From 29a4d4494b9848374a28ef4a62cc587cd572ee60 Mon Sep 17 00:00:00 2001 From: oxmc <67136658+oxmc@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:38:26 -0800 Subject: [PATCH] v 14.6 --- Bugs.txt | 252 + Changelog.txt | 3244 ++++++++ FreeFileSync/Build/Resources/Gtk2Styles.rc | 16 + FreeFileSync/Build/Resources/Gtk3Styles.css | 34 + .../Build/Resources/Gtk3Styles.old.css | 43 + FreeFileSync/Build/Resources/Icons.zip | Bin 0 -> 454288 bytes FreeFileSync/Build/Resources/Languages.zip | Bin 0 -> 543163 bytes FreeFileSync/Build/Resources/bell.wav | Bin 0 -> 102500 bytes FreeFileSync/Build/Resources/bell2.wav | Bin 0 -> 87678 bytes FreeFileSync/Build/Resources/cacert.pem | 3511 +++++++++ FreeFileSync/Build/Resources/ding.wav | Bin 0 -> 67340 bytes FreeFileSync/Build/Resources/fail.wav | Bin 0 -> 35092 bytes FreeFileSync/Build/Resources/fail2.wav | Bin 0 -> 50454 bytes FreeFileSync/Build/Resources/gong.wav | Bin 0 -> 230274 bytes FreeFileSync/Build/Resources/harp.wav | Bin 0 -> 182060 bytes FreeFileSync/Build/Resources/notify.wav | Bin 0 -> 54890 bytes FreeFileSync/Build/Resources/notify2.wav | Bin 0 -> 114220 bytes FreeFileSync/Build/Resources/remind.wav | Bin 0 -> 59894 bytes FreeFileSync/Source/Makefile | 135 + FreeFileSync/Source/RealTimeSync/Makefile | 68 + FreeFileSync/Source/RealTimeSync/app_icon.h | 27 + .../Source/RealTimeSync/application.cpp | 223 + .../Source/RealTimeSync/application.h | 27 + FreeFileSync/Source/RealTimeSync/config.cpp | 221 + FreeFileSync/Source/RealTimeSync/config.h | 40 + .../Source/RealTimeSync/folder_selector2.cpp | 191 + .../Source/RealTimeSync/folder_selector2.h | 52 + .../Source/RealTimeSync/gui_generated.cpp | 299 + .../Source/RealTimeSync/gui_generated.h | 110 + FreeFileSync/Source/RealTimeSync/main_dlg.cpp | 512 ++ FreeFileSync/Source/RealTimeSync/main_dlg.h | 75 + FreeFileSync/Source/RealTimeSync/monitor.cpp | 280 + FreeFileSync/Source/RealTimeSync/monitor.h | 26 + .../Source/RealTimeSync/tray_menu.cpp | 314 + FreeFileSync/Source/RealTimeSync/tray_menu.h | 24 + FreeFileSync/Source/afs/abstract.cpp | 501 ++ FreeFileSync/Source/afs/abstract.h | 580 ++ FreeFileSync/Source/afs/abstract_impl.h | 154 + FreeFileSync/Source/afs/concrete.cpp | 59 + FreeFileSync/Source/afs/concrete.h | 26 + FreeFileSync/Source/afs/ftp.cpp | 2757 +++++++ FreeFileSync/Source/afs/ftp.h | 41 + FreeFileSync/Source/afs/ftp_common.h | 113 + FreeFileSync/Source/afs/gdrive.cpp | 4118 +++++++++++ FreeFileSync/Source/afs/gdrive.h | 44 + FreeFileSync/Source/afs/init_curl_libssh2.cpp | 155 + FreeFileSync/Source/afs/init_curl_libssh2.h | 51 + FreeFileSync/Source/afs/native.cpp | 758 ++ FreeFileSync/Source/afs/native.h | 25 + FreeFileSync/Source/afs/sftp.cpp | 2209 ++++++ FreeFileSync/Source/afs/sftp.h | 55 + FreeFileSync/Source/application.cpp | 768 ++ FreeFileSync/Source/application.h | 35 + FreeFileSync/Source/base/algorithm.cpp | 1886 +++++ FreeFileSync/Source/base/algorithm.h | 111 + FreeFileSync/Source/base/binary.cpp | 79 + FreeFileSync/Source/base/binary.h | 20 + FreeFileSync/Source/base/cmp_filetime.h | 93 + FreeFileSync/Source/base/comparison.cpp | 1196 +++ FreeFileSync/Source/base/comparison.h | 60 + FreeFileSync/Source/base/db_file.cpp | 1047 +++ FreeFileSync/Source/base/db_file.h | 89 + FreeFileSync/Source/base/dir_exist_async.h | 159 + FreeFileSync/Source/base/dir_lock.cpp | 560 ++ FreeFileSync/Source/base/dir_lock.h | 59 + FreeFileSync/Source/base/file_hierarchy.cpp | 575 ++ FreeFileSync/Source/base/file_hierarchy.h | 1493 ++++ FreeFileSync/Source/base/icon_loader.cpp | 315 + FreeFileSync/Source/base/icon_loader.h | 34 + FreeFileSync/Source/base/lock_holder.h | 54 + FreeFileSync/Source/base/multi_rename.cpp | 198 + FreeFileSync/Source/base/multi_rename.h | 23 + FreeFileSync/Source/base/norm_filter.h | 67 + FreeFileSync/Source/base/parallel_scan.cpp | 465 ++ FreeFileSync/Source/base/parallel_scan.h | 54 + FreeFileSync/Source/base/path_filter.cpp | 318 + FreeFileSync/Source/base/path_filter.h | 258 + FreeFileSync/Source/base/process_callback.h | 91 + FreeFileSync/Source/base/soft_filter.h | 111 + FreeFileSync/Source/base/speed_test.cpp | 226 + FreeFileSync/Source/base/speed_test.h | 47 + .../Source/base/status_handler_impl.h | 559 ++ FreeFileSync/Source/base/structures.cpp | 380 + FreeFileSync/Source/base/structures.h | 393 + FreeFileSync/Source/base/synchronization.cpp | 2994 ++++++++ FreeFileSync/Source/base/synchronization.h | 103 + FreeFileSync/Source/base/versioning.cpp | 615 ++ FreeFileSync/Source/base/versioning.h | 114 + FreeFileSync/Source/base_tools.cpp | 300 + FreeFileSync/Source/base_tools.h | 28 + FreeFileSync/Source/config.cpp | 2451 +++++++ FreeFileSync/Source/config.h | 283 + FreeFileSync/Source/ffs_paths.cpp | 97 + FreeFileSync/Source/ffs_paths.h | 29 + FreeFileSync/Source/icon_buffer.cpp | 467 ++ FreeFileSync/Source/icon_buffer.h | 57 + FreeFileSync/Source/localization.cpp | 451 ++ FreeFileSync/Source/localization.h | 39 + FreeFileSync/Source/log_file.cpp | 673 ++ FreeFileSync/Source/log_file.h | 40 + FreeFileSync/Source/parse_lng.h | 742 ++ FreeFileSync/Source/parse_plural.h | 475 ++ FreeFileSync/Source/return_codes.h | 57 + FreeFileSync/Source/status_handler.cpp | 47 + FreeFileSync/Source/status_handler.h | 176 + .../Source/ui/abstract_folder_picker.cpp | 392 + .../Source/ui/abstract_folder_picker.h | 19 + FreeFileSync/Source/ui/app_icon.h | 30 + FreeFileSync/Source/ui/batch_config.cpp | 197 + FreeFileSync/Source/ui/batch_config.h | 22 + .../Source/ui/batch_status_handler.cpp | 433 ++ FreeFileSync/Source/ui/batch_status_handler.h | 86 + FreeFileSync/Source/ui/cfg_grid.cpp | 782 ++ FreeFileSync/Source/ui/cfg_grid.h | 172 + FreeFileSync/Source/ui/command_box.cpp | 202 + FreeFileSync/Source/ui/command_box.h | 56 + FreeFileSync/Source/ui/file_grid.cpp | 2309 ++++++ FreeFileSync/Source/ui/file_grid.h | 81 + FreeFileSync/Source/ui/file_grid_attr.h | 105 + FreeFileSync/Source/ui/file_view.cpp | 857 +++ FreeFileSync/Source/ui/file_view.h | 163 + FreeFileSync/Source/ui/folder_history_box.cpp | 139 + FreeFileSync/Source/ui/folder_history_box.h | 91 + FreeFileSync/Source/ui/folder_pair.h | 240 + FreeFileSync/Source/ui/folder_selector.cpp | 304 + FreeFileSync/Source/ui/folder_selector.h | 82 + FreeFileSync/Source/ui/gui_generated.cpp | 6232 ++++++++++++++++ FreeFileSync/Source/ui/gui_generated.h | 1275 ++++ FreeFileSync/Source/ui/gui_status_handler.cpp | 716 ++ FreeFileSync/Source/ui/gui_status_handler.h | 129 + FreeFileSync/Source/ui/log_panel.cpp | 545 ++ FreeFileSync/Source/ui/log_panel.h | 43 + FreeFileSync/Source/ui/main_dlg.cpp | 6497 +++++++++++++++++ FreeFileSync/Source/ui/main_dlg.h | 370 + FreeFileSync/Source/ui/progress_indicator.cpp | 1746 +++++ FreeFileSync/Source/ui/progress_indicator.h | 110 + FreeFileSync/Source/ui/rename_dlg.cpp | 437 ++ FreeFileSync/Source/ui/rename_dlg.h | 20 + FreeFileSync/Source/ui/search_grid.cpp | 145 + FreeFileSync/Source/ui/search_grid.h | 19 + FreeFileSync/Source/ui/small_dlgs.cpp | 2446 +++++++ FreeFileSync/Source/ui/small_dlgs.h | 78 + FreeFileSync/Source/ui/sync_cfg.cpp | 1859 +++++ FreeFileSync/Source/ui/sync_cfg.h | 66 + FreeFileSync/Source/ui/tray_icon.cpp | 198 + FreeFileSync/Source/ui/tray_icon.h | 50 + FreeFileSync/Source/ui/tree_grid.cpp | 1218 +++ FreeFileSync/Source/ui/tree_grid.h | 179 + FreeFileSync/Source/ui/tree_grid_attr.h | 65 + FreeFileSync/Source/ui/triple_splitter.cpp | 237 + FreeFileSync/Source/ui/triple_splitter.h | 82 + FreeFileSync/Source/ui/version_check.cpp | 358 + FreeFileSync/Source/ui/version_check.h | 35 + FreeFileSync/Source/version/version.h | 10 + License.txt | 980 +++ libcurl/curl_wrap.cpp | 411 ++ libcurl/curl_wrap.h | 79 + libssh2/libssh2_wrap.h | 247 + wx+/app_main.h | 17 + wx+/async_task.h | 162 + wx+/bitmap_button.h | 150 + wx+/choice_enum.h | 117 + wx+/color_tools.h | 219 + wx+/context_menu.h | 163 + wx+/darkmode.cpp | 102 + wx+/darkmode.h | 28 + wx+/dc.h | 316 + wx+/file_drop.cpp | 75 + wx+/file_drop.h | 45 + wx+/graph.cpp | 800 ++ wx+/graph.h | 346 + wx+/grid.cpp | 2440 +++++++ wx+/grid.h | 404 + wx+/image_holder.h | 72 + wx+/image_resources.cpp | 340 + wx+/image_resources.h | 24 + wx+/image_tools.cpp | 506 ++ wx+/image_tools.h | 144 + wx+/no_flicker.h | 158 + wx+/popup_dlg.cpp | 398 + wx+/popup_dlg.h | 103 + wx+/popup_dlg_generated.cpp | 124 + wx+/popup_dlg_generated.h | 89 + wx+/rtl.h | 112 + wx+/std_button_layout.h | 142 + wx+/taskbar.cpp | 19 + wx+/taskbar.h | 42 + wx+/toggle_button.h | 89 + wx+/tooltip.cpp | 120 + wx+/tooltip.h | 35 + wx+/window_layout.h | 84 + wx+/window_tools.h | 273 + xBRZ/src/xbrz.cpp | 1363 ++++ xBRZ/src/xbrz.h | 78 + xBRZ/src/xbrz_config.h | 35 + xBRZ/src/xbrz_tools.h | 248 + zen/argon2.cpp | 977 +++ zen/argon2.h | 20 + zen/base64.h | 176 + zen/basic_math.h | 342 + zen/build_info.h | 34 + zen/crc.h | 110 + zen/dir_watcher.cpp | 158 + zen/dir_watcher.h | 72 + zen/error_log.h | 127 + zen/extra_log.h | 84 + zen/file_access.cpp | 768 ++ zen/file_access.h | 99 + zen/file_error.h | 50 + zen/file_io.cpp | 345 + zen/file_io.h | 181 + zen/file_path.cpp | 200 + zen/file_path.h | 60 + zen/file_traverser.cpp | 79 + zen/file_traverser.h | 43 + zen/format_unit.cpp | 236 + zen/format_unit.h | 44 + zen/globals.h | 308 + zen/guid.h | 54 + zen/http.cpp | 555 ++ zen/http.h | 60 + zen/i18n.h | 115 + zen/json.h | 616 ++ zen/legacy_compiler.cpp | 28 + zen/legacy_compiler.h | 81 + zen/open_ssl.cpp | 871 +++ zen/open_ssl.h | 40 + zen/perf.h | 103 + zen/process_exec.cpp | 267 + zen/process_exec.h | 28 + zen/process_priority.cpp | 124 + zen/process_priority.h | 36 + zen/recycler.cpp | 106 + zen/recycler.h | 34 + zen/resolve_path.cpp | 231 + zen/resolve_path.h | 31 + zen/ring_buffer.h | 255 + zen/scope_guard.h | 113 + zen/serialize.h | 437 ++ zen/shutdown.cpp | 105 + zen/shutdown.h | 26 + zen/socket.h | 286 + zen/stl_tools.h | 397 + zen/stream_buffer.h | 207 + zen/string_base.h | 682 ++ zen/string_tools.h | 1019 +++ zen/string_traits.h | 186 + zen/symlink_target.h | 97 + zen/sys_error.cpp | 281 + zen/sys_error.h | 86 + zen/sys_info.cpp | 296 + zen/sys_info.h | 43 + zen/sys_version.cpp | 101 + zen/sys_version.h | 37 + zen/thread.cpp | 37 + zen/thread.h | 528 ++ zen/time.h | 421 ++ zen/type_traits.h | 198 + zen/utf.h | 369 + zen/zlib_wrap.cpp | 245 + zen/zlib_wrap.h | 44 + zen/zstring.cpp | 315 + zen/zstring.h | 110 + zenXml/zenxml/cvrt_struc.h | 202 + zenXml/zenxml/cvrt_text.h | 251 + zenXml/zenxml/dom.h | 270 + zenXml/zenxml/parser.h | 576 ++ zenXml/zenxml/xml.h | 405 + 268 files changed, 103575 insertions(+) create mode 100644 Bugs.txt create mode 100644 Changelog.txt create mode 100644 FreeFileSync/Build/Resources/Gtk2Styles.rc create mode 100644 FreeFileSync/Build/Resources/Gtk3Styles.css create mode 100644 FreeFileSync/Build/Resources/Gtk3Styles.old.css create mode 100644 FreeFileSync/Build/Resources/Icons.zip create mode 100644 FreeFileSync/Build/Resources/Languages.zip create mode 100644 FreeFileSync/Build/Resources/bell.wav create mode 100644 FreeFileSync/Build/Resources/bell2.wav create mode 100644 FreeFileSync/Build/Resources/cacert.pem create mode 100644 FreeFileSync/Build/Resources/ding.wav create mode 100644 FreeFileSync/Build/Resources/fail.wav create mode 100644 FreeFileSync/Build/Resources/fail2.wav create mode 100644 FreeFileSync/Build/Resources/gong.wav create mode 100644 FreeFileSync/Build/Resources/harp.wav create mode 100644 FreeFileSync/Build/Resources/notify.wav create mode 100644 FreeFileSync/Build/Resources/notify2.wav create mode 100644 FreeFileSync/Build/Resources/remind.wav create mode 100644 FreeFileSync/Source/Makefile create mode 100644 FreeFileSync/Source/RealTimeSync/Makefile create mode 100644 FreeFileSync/Source/RealTimeSync/app_icon.h create mode 100644 FreeFileSync/Source/RealTimeSync/application.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/application.h create mode 100644 FreeFileSync/Source/RealTimeSync/config.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/config.h create mode 100644 FreeFileSync/Source/RealTimeSync/folder_selector2.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/folder_selector2.h create mode 100644 FreeFileSync/Source/RealTimeSync/gui_generated.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/gui_generated.h create mode 100644 FreeFileSync/Source/RealTimeSync/main_dlg.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/main_dlg.h create mode 100644 FreeFileSync/Source/RealTimeSync/monitor.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/monitor.h create mode 100644 FreeFileSync/Source/RealTimeSync/tray_menu.cpp create mode 100644 FreeFileSync/Source/RealTimeSync/tray_menu.h create mode 100644 FreeFileSync/Source/afs/abstract.cpp create mode 100644 FreeFileSync/Source/afs/abstract.h create mode 100644 FreeFileSync/Source/afs/abstract_impl.h create mode 100644 FreeFileSync/Source/afs/concrete.cpp create mode 100644 FreeFileSync/Source/afs/concrete.h create mode 100644 FreeFileSync/Source/afs/ftp.cpp create mode 100644 FreeFileSync/Source/afs/ftp.h create mode 100644 FreeFileSync/Source/afs/ftp_common.h create mode 100644 FreeFileSync/Source/afs/gdrive.cpp create mode 100644 FreeFileSync/Source/afs/gdrive.h create mode 100644 FreeFileSync/Source/afs/init_curl_libssh2.cpp create mode 100644 FreeFileSync/Source/afs/init_curl_libssh2.h create mode 100644 FreeFileSync/Source/afs/native.cpp create mode 100644 FreeFileSync/Source/afs/native.h create mode 100644 FreeFileSync/Source/afs/sftp.cpp create mode 100644 FreeFileSync/Source/afs/sftp.h create mode 100644 FreeFileSync/Source/application.cpp create mode 100644 FreeFileSync/Source/application.h create mode 100644 FreeFileSync/Source/base/algorithm.cpp create mode 100644 FreeFileSync/Source/base/algorithm.h create mode 100644 FreeFileSync/Source/base/binary.cpp create mode 100644 FreeFileSync/Source/base/binary.h create mode 100644 FreeFileSync/Source/base/cmp_filetime.h create mode 100644 FreeFileSync/Source/base/comparison.cpp create mode 100644 FreeFileSync/Source/base/comparison.h create mode 100644 FreeFileSync/Source/base/db_file.cpp create mode 100644 FreeFileSync/Source/base/db_file.h create mode 100644 FreeFileSync/Source/base/dir_exist_async.h create mode 100644 FreeFileSync/Source/base/dir_lock.cpp create mode 100644 FreeFileSync/Source/base/dir_lock.h create mode 100644 FreeFileSync/Source/base/file_hierarchy.cpp create mode 100644 FreeFileSync/Source/base/file_hierarchy.h create mode 100644 FreeFileSync/Source/base/icon_loader.cpp create mode 100644 FreeFileSync/Source/base/icon_loader.h create mode 100644 FreeFileSync/Source/base/lock_holder.h create mode 100644 FreeFileSync/Source/base/multi_rename.cpp create mode 100644 FreeFileSync/Source/base/multi_rename.h create mode 100644 FreeFileSync/Source/base/norm_filter.h create mode 100644 FreeFileSync/Source/base/parallel_scan.cpp create mode 100644 FreeFileSync/Source/base/parallel_scan.h create mode 100644 FreeFileSync/Source/base/path_filter.cpp create mode 100644 FreeFileSync/Source/base/path_filter.h create mode 100644 FreeFileSync/Source/base/process_callback.h create mode 100644 FreeFileSync/Source/base/soft_filter.h create mode 100644 FreeFileSync/Source/base/speed_test.cpp create mode 100644 FreeFileSync/Source/base/speed_test.h create mode 100644 FreeFileSync/Source/base/status_handler_impl.h create mode 100644 FreeFileSync/Source/base/structures.cpp create mode 100644 FreeFileSync/Source/base/structures.h create mode 100644 FreeFileSync/Source/base/synchronization.cpp create mode 100644 FreeFileSync/Source/base/synchronization.h create mode 100644 FreeFileSync/Source/base/versioning.cpp create mode 100644 FreeFileSync/Source/base/versioning.h create mode 100644 FreeFileSync/Source/base_tools.cpp create mode 100644 FreeFileSync/Source/base_tools.h create mode 100644 FreeFileSync/Source/config.cpp create mode 100644 FreeFileSync/Source/config.h create mode 100644 FreeFileSync/Source/ffs_paths.cpp create mode 100644 FreeFileSync/Source/ffs_paths.h create mode 100644 FreeFileSync/Source/icon_buffer.cpp create mode 100644 FreeFileSync/Source/icon_buffer.h create mode 100644 FreeFileSync/Source/localization.cpp create mode 100644 FreeFileSync/Source/localization.h create mode 100644 FreeFileSync/Source/log_file.cpp create mode 100644 FreeFileSync/Source/log_file.h create mode 100644 FreeFileSync/Source/parse_lng.h create mode 100644 FreeFileSync/Source/parse_plural.h create mode 100644 FreeFileSync/Source/return_codes.h create mode 100644 FreeFileSync/Source/status_handler.cpp create mode 100644 FreeFileSync/Source/status_handler.h create mode 100644 FreeFileSync/Source/ui/abstract_folder_picker.cpp create mode 100644 FreeFileSync/Source/ui/abstract_folder_picker.h create mode 100644 FreeFileSync/Source/ui/app_icon.h create mode 100644 FreeFileSync/Source/ui/batch_config.cpp create mode 100644 FreeFileSync/Source/ui/batch_config.h create mode 100644 FreeFileSync/Source/ui/batch_status_handler.cpp create mode 100644 FreeFileSync/Source/ui/batch_status_handler.h create mode 100644 FreeFileSync/Source/ui/cfg_grid.cpp create mode 100644 FreeFileSync/Source/ui/cfg_grid.h create mode 100644 FreeFileSync/Source/ui/command_box.cpp create mode 100644 FreeFileSync/Source/ui/command_box.h create mode 100644 FreeFileSync/Source/ui/file_grid.cpp create mode 100644 FreeFileSync/Source/ui/file_grid.h create mode 100644 FreeFileSync/Source/ui/file_grid_attr.h create mode 100644 FreeFileSync/Source/ui/file_view.cpp create mode 100644 FreeFileSync/Source/ui/file_view.h create mode 100644 FreeFileSync/Source/ui/folder_history_box.cpp create mode 100644 FreeFileSync/Source/ui/folder_history_box.h create mode 100644 FreeFileSync/Source/ui/folder_pair.h create mode 100644 FreeFileSync/Source/ui/folder_selector.cpp create mode 100644 FreeFileSync/Source/ui/folder_selector.h create mode 100644 FreeFileSync/Source/ui/gui_generated.cpp create mode 100644 FreeFileSync/Source/ui/gui_generated.h create mode 100644 FreeFileSync/Source/ui/gui_status_handler.cpp create mode 100644 FreeFileSync/Source/ui/gui_status_handler.h create mode 100644 FreeFileSync/Source/ui/log_panel.cpp create mode 100644 FreeFileSync/Source/ui/log_panel.h create mode 100644 FreeFileSync/Source/ui/main_dlg.cpp create mode 100644 FreeFileSync/Source/ui/main_dlg.h create mode 100644 FreeFileSync/Source/ui/progress_indicator.cpp create mode 100644 FreeFileSync/Source/ui/progress_indicator.h create mode 100644 FreeFileSync/Source/ui/rename_dlg.cpp create mode 100644 FreeFileSync/Source/ui/rename_dlg.h create mode 100644 FreeFileSync/Source/ui/search_grid.cpp create mode 100644 FreeFileSync/Source/ui/search_grid.h create mode 100644 FreeFileSync/Source/ui/small_dlgs.cpp create mode 100644 FreeFileSync/Source/ui/small_dlgs.h create mode 100644 FreeFileSync/Source/ui/sync_cfg.cpp create mode 100644 FreeFileSync/Source/ui/sync_cfg.h create mode 100644 FreeFileSync/Source/ui/tray_icon.cpp create mode 100644 FreeFileSync/Source/ui/tray_icon.h create mode 100644 FreeFileSync/Source/ui/tree_grid.cpp create mode 100644 FreeFileSync/Source/ui/tree_grid.h create mode 100644 FreeFileSync/Source/ui/tree_grid_attr.h create mode 100644 FreeFileSync/Source/ui/triple_splitter.cpp create mode 100644 FreeFileSync/Source/ui/triple_splitter.h create mode 100644 FreeFileSync/Source/ui/version_check.cpp create mode 100644 FreeFileSync/Source/ui/version_check.h create mode 100644 FreeFileSync/Source/version/version.h create mode 100644 License.txt create mode 100644 libcurl/curl_wrap.cpp create mode 100644 libcurl/curl_wrap.h create mode 100644 libssh2/libssh2_wrap.h create mode 100644 wx+/app_main.h create mode 100644 wx+/async_task.h create mode 100644 wx+/bitmap_button.h create mode 100644 wx+/choice_enum.h create mode 100644 wx+/color_tools.h create mode 100644 wx+/context_menu.h create mode 100644 wx+/darkmode.cpp create mode 100644 wx+/darkmode.h create mode 100644 wx+/dc.h create mode 100644 wx+/file_drop.cpp create mode 100644 wx+/file_drop.h create mode 100644 wx+/graph.cpp create mode 100644 wx+/graph.h create mode 100644 wx+/grid.cpp create mode 100644 wx+/grid.h create mode 100644 wx+/image_holder.h create mode 100644 wx+/image_resources.cpp create mode 100644 wx+/image_resources.h create mode 100644 wx+/image_tools.cpp create mode 100644 wx+/image_tools.h create mode 100644 wx+/no_flicker.h create mode 100644 wx+/popup_dlg.cpp create mode 100644 wx+/popup_dlg.h create mode 100644 wx+/popup_dlg_generated.cpp create mode 100644 wx+/popup_dlg_generated.h create mode 100644 wx+/rtl.h create mode 100644 wx+/std_button_layout.h create mode 100644 wx+/taskbar.cpp create mode 100644 wx+/taskbar.h create mode 100644 wx+/toggle_button.h create mode 100644 wx+/tooltip.cpp create mode 100644 wx+/tooltip.h create mode 100644 wx+/window_layout.h create mode 100644 wx+/window_tools.h create mode 100644 xBRZ/src/xbrz.cpp create mode 100644 xBRZ/src/xbrz.h create mode 100644 xBRZ/src/xbrz_config.h create mode 100644 xBRZ/src/xbrz_tools.h create mode 100644 zen/argon2.cpp create mode 100644 zen/argon2.h create mode 100644 zen/base64.h create mode 100644 zen/basic_math.h create mode 100644 zen/build_info.h create mode 100644 zen/crc.h create mode 100644 zen/dir_watcher.cpp create mode 100644 zen/dir_watcher.h create mode 100644 zen/error_log.h create mode 100644 zen/extra_log.h create mode 100644 zen/file_access.cpp create mode 100644 zen/file_access.h create mode 100644 zen/file_error.h create mode 100644 zen/file_io.cpp create mode 100644 zen/file_io.h create mode 100644 zen/file_path.cpp create mode 100644 zen/file_path.h create mode 100644 zen/file_traverser.cpp create mode 100644 zen/file_traverser.h create mode 100644 zen/format_unit.cpp create mode 100644 zen/format_unit.h create mode 100644 zen/globals.h create mode 100644 zen/guid.h create mode 100644 zen/http.cpp create mode 100644 zen/http.h create mode 100644 zen/i18n.h create mode 100644 zen/json.h create mode 100644 zen/legacy_compiler.cpp create mode 100644 zen/legacy_compiler.h create mode 100644 zen/open_ssl.cpp create mode 100644 zen/open_ssl.h create mode 100644 zen/perf.h create mode 100644 zen/process_exec.cpp create mode 100644 zen/process_exec.h create mode 100644 zen/process_priority.cpp create mode 100644 zen/process_priority.h create mode 100644 zen/recycler.cpp create mode 100644 zen/recycler.h create mode 100644 zen/resolve_path.cpp create mode 100644 zen/resolve_path.h create mode 100644 zen/ring_buffer.h create mode 100644 zen/scope_guard.h create mode 100644 zen/serialize.h create mode 100644 zen/shutdown.cpp create mode 100644 zen/shutdown.h create mode 100644 zen/socket.h create mode 100644 zen/stl_tools.h create mode 100644 zen/stream_buffer.h create mode 100644 zen/string_base.h create mode 100644 zen/string_tools.h create mode 100644 zen/string_traits.h create mode 100644 zen/symlink_target.h create mode 100644 zen/sys_error.cpp create mode 100644 zen/sys_error.h create mode 100644 zen/sys_info.cpp create mode 100644 zen/sys_info.h create mode 100644 zen/sys_version.cpp create mode 100644 zen/sys_version.h create mode 100644 zen/thread.cpp create mode 100644 zen/thread.h create mode 100644 zen/time.h create mode 100644 zen/type_traits.h create mode 100644 zen/utf.h create mode 100644 zen/zlib_wrap.cpp create mode 100644 zen/zlib_wrap.h create mode 100644 zen/zstring.cpp create mode 100644 zen/zstring.h create mode 100644 zenXml/zenxml/cvrt_struc.h create mode 100644 zenXml/zenxml/cvrt_text.h create mode 100644 zenXml/zenxml/dom.h create mode 100644 zenXml/zenxml/parser.h create mode 100644 zenXml/zenxml/xml.h diff --git a/Bugs.txt b/Bugs.txt new file mode 100644 index 0000000..4226b3a --- /dev/null +++ b/Bugs.txt @@ -0,0 +1,252 @@ +When manually compiling FreeFileSync, you should also fix the following bugs in its library dependencies. +FreeFileSync generally uses the latest library versions and works with upstream to get the bugs fixed +that affect FreeFileSync. Therefore it is NOT RECOMMENDED TO COMPILE AGAINST OLDER library versions than +the ones mentioned below. The remaining issues that are yet to be fixed are listed in the following: + + +----------------- +| libcurl 8.16.0| +----------------- +__________________________________________________________________________________________________________ +/lib/ftp.c +https://github.com/curl/curl/issues/1455 + ++ static bool is_routable_ip_v4(unsigned int ip[4]) ++ { ++ if (ip[0] == 127 || //127.0.0.0/8 (localhost) ++ ip[0] == 10 || //10.0.0.0/8 (private) ++ (ip[0] == 192 && ip[1] == 168) || //192.168.0.0/16 (private) ++ (ip[0] == 169 && ip[1] == 254) || //169.254.0.0/16 (link-local) ++ (ip[0] == 172 && ip[1] / 16 == 1)) //172.16.0.0/12 (private) ++ return false; ++ return true; ++ } + + +- if (data->set.ftp_skip_ip) ++ bool skipIp = data->set.ftp_skip_ip; ++ if (!skipIp && !is_routable_ip_v4(ip)) ++ { ++ unsigned int ip_ctrl[4]; ++ if (4 != sscanf(control_address(conn), "%u.%u.%u.%u", ++ &ip_ctrl[0], &ip_ctrl[1], &ip_ctrl[2], &ip_ctrl[3]) || ++ is_routable_ip_v4(ip_ctrl)) ++ skipIp = true; ++ } ++ ++ if (skipIp) + +__________________________________________________________________________________________________________ +/lib/ftp.c +https://github.com/curl/curl/issues/4342 + +- result = ftp_nb_type(conn, TRUE, FTP_LIST_TYPE); ++ result = ftp_nb_type(conn, data->set.prefer_ascii, FTP_LIST_TYPE); + +__________________________________________________________________________________________________________ + + +---------------------- +| libssh2 1.11.2_DEV | +---------------------- +__________________________________________________________________________________________________________ +src/session.c +memory leak: https://github.com/libssh2/libssh2/issues/28 + +-if (session->state & LIBSSH2_STATE_NEWKEYS) ++//if (session->state & LIBSSH2_STATE_NEWKEYS) + +__________________________________________________________________________________________________________ +move the following constants from src/sftp.h to include/libssh2_sftp.h: + + #define MAX_SFTP_OUTGOING_SIZE 30000 + #define MAX_SFTP_READ_SIZE 30000 +__________________________________________________________________________________________________________ + + +------------------- +| wxWidgets 3.3.1 | +------------------- +__________________________________________________________________________________________________________ +/include/wx/settings.h +Support changing system colors for proper dark mode support: + ++ struct wxColorHook ++ { ++ virtual ~wxColorHook() {} ++ virtual wxColor getColor(wxSystemColour index) const = 0; ++ }; ++ WXDLLIMPEXP_CORE inline std::unique_ptr& refGlobalColorHook() ++ { ++ static std::unique_ptr globalColorHook; ++ return globalColorHook; ++ } + +class WXDLLIMPEXP_CORE wxSystemSettings : public wxSystemSettingsNative +{ +public: ++ static wxColour GetColour(wxSystemColour index) ++ { ++ if (refGlobalColorHook()) ++ return refGlobalColorHook()->getColor(index); ++ ++ return wxSystemSettingsNative::GetColour(index); ++ } + +__________________________________________________________________________________________________________ +/src/aui/framemanager.cpp: +Fix incorrect pane height calculations: + +- // determine the dock's minimum size +- bool plus_border = false; +- bool plus_caption = false; +- int dock_min_size = 0; +- for (j = 0; j < dock_pane_count; ++j) +- { +- wxAuiPaneInfo& pane = *dock.panes.Item(j); +- if (pane.min_size != wxDefaultSize) +- { +- if (pane.HasBorder()) +- plus_border = true; +- if (pane.HasCaption()) +- plus_caption = true; +- if (dock.IsHorizontal()) +- { +- if (pane.min_size.y > dock_min_size) +- dock_min_size = pane.min_size.y; +- } +- else +- { +- if (pane.min_size.x > dock_min_size) +- dock_min_size = pane.min_size.x; +- } +- } +- } +- +- if (plus_border) +- dock_min_size += (pane_borderSize*2); +- if (plus_caption && dock.IsHorizontal()) +- dock_min_size += (caption_size); +- +- dock.min_size = dock_min_size; + ++ // determine the dock's minimum size ++ int dock_min_size = 0; ++ for (j = 0; j < dock_pane_count; ++j) ++ { ++ wxAuiPaneInfo& pane = *dock.panes.Item(j); ++ if (pane.min_size != wxDefaultSize) ++ { ++ int paneSize = dock.IsHorizontal() ? pane.min_size.y : pane.min_size.x; ++ if (pane.HasBorder()) ++ paneSize += 2 * pane_borderSize; ++ if (pane.HasCaption() && dock.IsHorizontal()) ++ paneSize += caption_size; ++ ++ if (paneSize > dock_min_size) ++ dock_min_size = paneSize; ++ } ++ } ++ ++ dock.min_size = dock_min_size; + +__________________________________________________________________________________________________________ +/src/gtk/menu.cpp + +-g_signal_connect(m_menu, "map", G_CALLBACK(menu_map), this); ++g_signal_connect(m_menu, "show", G_CALLBACK(menu_map), this); //"map" is never called on Ubuntu Unity, but "show" is + +__________________________________________________________________________________________________________ +/src/gtk/window.cpp +Backspace not working in filter dialog: http://www.freefilesync.org/forum/viewtopic.php?t=347 + + void wxWindowGTK::ConnectWidget( GtkWidget *widget ) + { +- static bool isSourceAttached; +- if (!isSourceAttached) +- { +- // attach GSource to detect new GDK events +- isSourceAttached = true; +- static GSourceFuncs funcs = +- { +- source_prepare, source_check, source_dispatch, +- NULL, NULL, NULL +- }; +- GSource* source = g_source_new(&funcs, sizeof(GSource)); +- // priority slightly higher than GDK_PRIORITY_EVENTS +- g_source_set_priority(source, GDK_PRIORITY_EVENTS - 1); +- g_source_attach(source, NULL); +- g_source_unref(source); +- } + + g_signal_connect (widget, "key_press_event", + G_CALLBACK (gtk_window_key_press_callback), this); + +__________________________________________________________________________________________________________ +/src/unix/sound.cpp +Fix crackling sound at the beginning of WAV playback: +See: http://soundfile.sapp.org/doc/WaveFormat/ => skip 8 bytes (Subchunk2ID and Subchunk2 Size) + +- m_data->m_data = (&m_data->m_dataWithHeader[data_offset]); ++ m_data->m_data = (&m_data->m_dataWithHeader[data_offset + 8]); + +__________________________________________________________________________________________________________ +/src/common/bmpbndl.cpp +DoGetPreferredSize()'s lossy "GetDefaultSize()*scaleBest" calculation can be 1-pixel-off compared to real image size => superfluous + ugly-looking image shrinking! + + wxSize wxBitmapBundleImplSet::GetPreferredBitmapSizeAtScale(double scale) const + { ++ //work around DoGetPreferredSize()'s flawed "GetDefaultSize()*scaleBest" calculation ++ for (const auto& entry : m_entries) ++ if (entry.bitmap.GetScaleFactor() == scale) ++ return entry.bitmap.GetSize(); ++ + return DoGetPreferredSize(scale); + } + +__________________________________________________________________________________________________________ +/src/gtk/button.cpp +now this is absurd: if we add wxTranslations for "&OK/&Cancel", wxWidgets will detect "wxIsStockLabel()", +throw away the label and do gtk_button_set_label(wxGetStockGtkID(wxID_OK/wxID_CANCEL)) instead, +which will result in *untranslated* confirmation buttons everywhere! +https://github.com/wxWidgets/wxWidgets/blob/6561ca020048de57ec28fec5b27f80b00d445cdd/src/gtk/button.cpp#L253 + +#ifndef __WXGTK4__ + wxGCC_WARNING_SUPPRESS(deprecated-declarations) +- if (wxIsStockID(m_windowId) && wxIsStockLabel(m_windowId, label)) +- { +- const char* stock = wxGetStockGtkID(m_windowId); +- if (stock) +- { +- gtk_button_set_label(GTK_BUTTON(m_widget), stock); +- gtk_button_set_use_stock(GTK_BUTTON(m_widget), TRUE); +- return; +- } +- } + wxGCC_WARNING_RESTORE() +#endif + +__________________________________________________________________________________________________________ +/src/common/toplvcmn.cpp +wxTopLevelWindow::Destroy() uses wxPendingDelete for deferred deletion during next idle event. +There might not be a next idle event! E.g. on GTK2 a hidden window doesn't receive idle events. +Reproduce on GTK2+KDE for toplevel window: Hide(); wxTheApp->Yield(); Destroy(); => process not exiting! https://freefilesync.org/forum/viewtopic.php?t=11935 + +- for ( wxWindowList::const_iterator i = wxTopLevelWindows.begin(), +- end = wxTopLevelWindows.end(); +- i != end; +- ++i ) +- { +- wxTopLevelWindow* const win = static_cast(*i); +- if ( win != this && win->IsShown() ) +- { +- // there remains at least one other visible TLW, we can hide this +- // one +- Hide(); +- +- break; +- } +- } ++ Hide(); ++ wxWakeUpIdle(); +__________________________________________________________________________________________________________ diff --git a/Changelog.txt b/Changelog.txt new file mode 100644 index 0000000..8010574 --- /dev/null +++ b/Changelog.txt @@ -0,0 +1,3244 @@ +FreeFileSync 14.6 [2025-12-02] +------------------------------ +Write sync statistics to stdout as JSON for .ffs_batch +Removed precompiled 32-bit bundle (Linux) +Avoid redundant window centering before finishing layout +GTK3-based build (Linux) +Dark mode support with GTK3 (Linux) +Stream errors to stderr instead of stdout (Linux) +Installer supports dark mode (Windows) +Added "New Window" dock menu item (macOS) + + +FreeFileSync 14.5 [2025-10-03] +------------------------------ +Quotation not needed anymore for external application macros +Unambiguous license key file extension +Fixed crash when resizing config panel during comparison +Fixed log file viewing when config name contains special characters +Dedicated installer for x86_64-only operating system (Linux) + + +FreeFileSync 14.4 [2025-07-26] +------------------------------ +Fixed FTP login error "dh key too small" +Updated all 3rd party libraries to latest versions + + +FreeFileSync 14.3 [2025-03-27] +------------------------------ +Support internationalized domain names (IDN) for (S)FTP and email +Log performance statistics for file content comparison +Support installation using Ptyxis terminal (Linux) +Support pausing countdown towards system shutdown +Support KDE Plasma 6 service menu (Linux) +Fixed crash on app exit when called by Cron (Linux) + + +FreeFileSync 14.2 [2025-02-20] +------------------------------ +Fixed crash when closing progress dialog after sync (Windows) + + +FreeFileSync 14.1 [2025-02-19] +------------------------------ +Further dark mode improvements +Fixed blurry icons due to image resizing glitch +Fixed RealTimeSync process not exiting while in taskbar (Linux) +Improved file icon loading performance +Improved extension handling for multi-file renaming +Mitigate icon size rendering bug for notification emails +Close popup dialogs using Ctrl+Enter while ignoring keyboard focus +Resume from system tray via single mouse click +Avoid white flash when resuming progress dialog from system tray (Windows) +Increased progress indicator UI update frequency + + +FreeFileSync 14.0 [2025-01-17] +------------------------------ +Dark mode support (Windows 10 20H1, macOS 10.14 (Mojave), Linux) +Fixed dock icon progress percentage divergence (macOS) +Prevent "App Napp during comparison/synchronization (macOS) +Enhance EINVAL error message for unsupported characters +Support running with background priority (Linux) +Fixed installer access denied when creating shell links (Windows) +Improved size and date formatting for file listing (macOS) +Improved context menu customization grid +Reduced peak memory consumption by 12% +Automatically set appropriate text color for config panel background +Revived and updated Italian translation + + +FreeFileSync 13.9 [2024-12-07] +------------------------------ +Fixed CURLE_SEND_ERROR: OpenSSL SSL_write: SSL_ERROR_SYSCALL, errno 0 +Added comparison and sync context menu options for multiple folder pairs +Show file include/exclude filter directly in tooltip +Fixed file not found error when cancelling file up-/download +Fixed showing cancelled config log status after nothing to sync +Updated translation files + + +FreeFileSync 13.8 [2024-11-04] +------------------------------ +Support raw IPv6 server address for (S)FTP +RealTimeSync: Fixed scrollbar when adding/removing folders +Don't set sync direction for partial folder pairs +Uniquely identify partial folder pairs in error message +Fixed network login prompt not showing in Windows 11 24H2 + + +FreeFileSync 13.7 [2024-06-23] +------------------------------ +Support copying symlinks between SFTP devices +Fixed input focus not being restored after comparison/sync +Fixed log file pruning not considering selected configuration +Show startup error details when running outside terminal (Linux) + + +FreeFileSync 13.6 [2024-05-10] +------------------------------ +Compact parent path display for medium/large row sizes +Fixed crash when mouse inputs are queued due to system lag +Don't steal focus from other app when sync progress dialog is shown +Fix crackling sound at the beginning of WAV playback (Linux) +Prevent middle grid tooltip from covering sync direction +Disable Nagle algorithm for SFTP connections + + +FreeFileSync 13.5 [2024-04-01] +------------------------------ +Wrap file grid folder paths instead of truncate +Fixed sync operation arrows for RTL layout +Fixed FTP hang during connection (libcurl regression) +Consider user-defined file time tolerance for DB comparisons +Don't log folder pair paths if nothing to sync + + +FreeFileSync 13.4 [2024-02-16] +------------------------------ +Ignore leading/trailing space when matching file names +Work around wxWidgets system logger clearing error code +Fixed registration info not found after App Translocation (macOS) +Avoid modal dialog hang on KDE when compiling with GTK3 +Change app location without losing Donation Edition status (macOS) + + +FreeFileSync 13.3 [2024-01-07] +------------------------------ +Completed CASA security assessment for Google Drive +Use system temp folder for auto-updating +Ignore errors when setting directory attributes is unsupported +Save GUI sync log file even when cancelled +Fixed Business Edition install over existing installation +Updated code signing certificates (Windows) + + +FreeFileSync 13.2 [2023-11-23] +------------------------------ +Complete high-DPI/Retina display support (macOS) +Prevent files from being moved to versioning recursively +Fixed tooltip line wrap bug for moved files (Windows) +Return first FTP parsing error when trying multiple variants +Allow file times from the future for Linux-style FTP listing +Fixed setting modification times on certain storage devices (Windows) +Fixed bogus "Sound playback failed" error message (macOS) +Fixed rename dialog text selection wobble (macOS) + + +FreeFileSync 13.1 [2023-10-23] +------------------------------ +Keep comparison results when only changing cloud connection settings +Sync button: indicate if database will be used +Remove leading/trailing space during manual file rename +Set environment variable "DISPLAY=:0" if missing (Linux) +Support dropping ffs_gui/ffs_real config on RealTimeSync directory input field + + +FreeFileSync 13.0 [2023-09-12] +------------------------------ +Rename (multiple) files manually (F2 key) +Configure individual directions for DB-based sync +Detect moved files with "Update" sync variant (requires sync.ffs_db files) +Update variant: Do not restore files that were deleted on target +Distinguish file renames from file moves and simplify grid display +Fixed ERROR_NOT_SUPPORTED when copying files with NTFS extended attributes +Fixed error during process initialization while connecting with quick launch +Avoid redundant file reopen when setting file times during copy +Set working directory to match FFS configuration file when double-clicking (Linux) + + +FreeFileSync 12.5 [2023-07-21] +------------------------------ +Merge logs of individual steps (comparison, manual operation, sync) +Show total percentage in progress dialog header +Log and report errors during cleanup or exception handling +Skip folder traversal if existence check fails for other side of the pair +Automatically adapt batch options to prevent hanging a non-interactive process (Windows) +Support path lists for external applications: %item_paths%, %local_paths%, %item_names%, %parent_paths% +Create directory lock files with hidden attribute +Don't clear other side when right-clicking file selection +Fixed passive FTP when using different IP than control connection +Work around FTP servers silently renaming unsupported characters of temporary file + + +FreeFileSync 12.4 [2023-06-20] +------------------------------ +Show dynamic error and warning count in progress dialogs +Show process elevation status in title bar (Administrator, root) +Fixed libcurl bug CURLE_URL_MALFORMAT for numerical host name +Don't discard config panel last log after no changes found +Set taskbar relaunch command to launcher executable (Windows) +Fixed Btrfs compression not being applied during copy (Linux) +Run on file systems with buggy GetFinalPathNameByHandle() implementation, e.g. Dokany-based +Save selected view mode (F11) in batch config file + + +FreeFileSync 12.3 [2023-05-17] +------------------------------ +Add custom notes to sync configurations +Highlight comparison and sync buttons +Show sync stats in config panel tool tip +Update config panel sync info even if cancelled +Support FTP listing format missing owner/group +Fixed "Class not registered" error during installation +Propagate process priority of launcher executable +Fixed config panel metadata being reset after renaming +Fixed config panel keyboard cursor after deletion/rename +Improved small icon resolution for high-DPI monitors + + +FreeFileSync 12.2 [2023-04-02] +------------------------------ +Fixed temporary access error when creating multiple folders in parallel +Log failure to copy folder attributes as warning only +Enable UTF-8, even if FTP server does not advertize in FEAT (vsftpd) +Fixed drag and drop for non-ASCII folders (macOS) +Explicitly detect MTP path without existence check +Fixed crash when parsing SFTP package from stream +Revert back to GTK2 build due to GTK3 hangs on KDE (Linux) +Fixed missing COM initialization for MTP path parsing + + +FreeFileSync 12.1 [2023-02-20] +------------------------------ +First official build based on GTK3 (Linux) +Allow cancel during folder path normalization (e.g. delay during HDD spin up) +Fixed slow FTP comparison performance due to libcurl regression +Open terminal with log messages on startup error (Linux) +Preserve changed config during auto-update +Save config during unexpected reboot (Linux) +Preserve config upon SIGTERM (Linux, macOS) +Fixed progress dialog z-order after switching windows (macOS) +Removed packet size limit for SFTP directory reading +Mouse hover effects for config and overview grid +Always update existing shortcuts during installation (Windows, Linux) +Fixed another "Some files will be synchronized as part of multiple base folders" false-negative + + +FreeFileSync 12.0 [2023-01-21] +------------------------------ +Don't save password and show prompt instead for (S)FTP +Fast path check failure on access errors +Support PuTTY private key file version 3 +Respect timeout during SFTP connect +Removed 20-sec timeout while checking directory existence +Avoid hitting (S)FTP connection limit for non-uniform configs +Fixed middle grid tooltip icon not always showing (Linux) +Optimized file accesses when checking file path existence +Fixed overview navigation marker not always showing on main grid +Clear all grid selections after view filter toggle +Fixed mouse selection starting on folder group +Don't require sudo during non-root installation (Linux) +Stricter type checking when deleting file/folder/symlinks +Succinct error messages when path component is not existing + + +FreeFileSync 11.29 [2022-12-16] +------------------------------- +Fixed crash after 1-byte file copy from MTP device +Fixed incorrect installer z-order during auto-update (macOS) +Compress copied file only if target folder is marked as NTFS-compressed (Windows) +Show install errors without requiring access to "System Events" (macOS) +Fall back to creation time if modification time is missing on MTP device +Copy/paste filter config via operating system clipboard +Show FreeFileSync startup error message when called from RealTimeSync +Avoid server round trip when preparing summary email +Show path conflict warning aggregated into groups +Don't assume path conflict if single write and multiple ignored items +Fixed CTRL + Insert clipboard copy for some text controls (Windows, Linux) + + +FreeFileSync 11.28 [2022-11-16] +------------------------------- +Recover from corrupted database file +Save database files pair-wise as a transaction +Fixed FTP access for Xiaomi "File Manager" +Fixed filter full path detection for root directory (Linux/macOS) +Fixed recycle bin double initialization bug (Windows) +Fixed incorrect case-insensitive string comparison for i and ı +Round progress percentage numbers down + + +FreeFileSync 11.27 [2022-10-17] +------------------------------- +Fixed "Some files will be synchronized as part of multiple base folders" false-negative +Fixed "Unexpected size of data stream" for Google Drive +Fixed crash when downloading empty file from Google Drive +RealTimeSync: fixed ffs_batch not accepted as valid configuration +Fixed top buttons vertical GUI layout +Fixed progress dialog font on Ubuntu MATE +Support cut/copy/paste for filter settings +Fixed free disk space calculation if target folder not yet created + + +FreeFileSync 11.26 [2022-10-06] +------------------------------- +Faster file copy for SSD-based hard drives (Linux, macOS) +Don't fill the OS file cache during file copy (macOS) +Removed redundant memory buffering during file copy +Fixed ERROR_FILE_EXISTS on Samba share when copying files with NTFS extended attributes +Show warning when recycle bin is not available (macOS, Linux) +Customize config item background colors +Fixed macOS menu bar not showing after app start +Fixed normalizing strings with broken UTF encoding +Fixed sound playback not working (Linux) +Don't allow creating file names ending with dot character (Windows) + + +FreeFileSync 11.25 [2022-08-31] +------------------------------- +Fixed crash when normalizing Unicode non-characters +Fixed crash when accesssing Google Drive +Fixed regession for decomposed Unicode comparison +Fixed "exit code 106: --sign is required" error on macOS +Reset icon cache after each comparison + + +FreeFileSync 11.24 [2022-08-28] +------------------------------- +Enhanced filter syntax to match files only (append ':') +Fixed "Some files will be synchronized as part of multiple base folders": no more false-positives +Detect full path filter items and convert to relative path +Auto-detect FTP server character encoding (UTF8 or ANSI) +Cancel grid selection via Escape key or second mouse button +Apply conflict preview limit accross all folder pairs +Require config type and file extension to match +Fixed view filter panel vertical layout +Strict validation of UTF encoding + + +FreeFileSync 11.23 [2022-07-23] +------------------------------- +Format local file times with no limits on time span +Deferred child item failure when traversing MTP folder +Fixed occasional wrong thumbnail orientation for MTP +Support additional image formats for MTP preview (e.g. CR2) +Fixed folder pair window being squashed after text size increase +Fixed wrong folder pair order when loading config (Linux) +Fixed some images being stretched on high-DPI monitors +Fixed config panel tab text being mirrored in RTL layout +Fixed parsing file times one second before Unix epoch (Gdrive, FTP) + + +FreeFileSync 11.22 [2022-06-23] +------------------------------- +Allow to change default log folder in global settings +Fixed sort order when items existing on one side only +Consider HOME environment variable for home path (Linux) +Fixed config selection using shift and arrow keys +Start comparison, then sync by only pressing Enter after startup +Fall back to default path when failing to save log file +Improved relative config path handling in portable mode + + +FreeFileSync 11.21 [2022-05-17] +------------------------------- +Support volume GUID as path: \\?\Volume{01234567-89ab-cdef-0123-456789abcdef} (Windows) +Avoid Two-Way conflict when changing folder name upper/lower-case +List hidden warning messages in options dialog +Fixed buffer overflow while receiving SFTP server banner +Create crash dumps even if FFS-internal crash handling doesn't kick in +Log time when error occured, not when it is reported +Swap sides: Require confirmation only after comparison +Updated translation files + + +FreeFileSync 11.20 [2022-04-17] +------------------------------- +Fixed broken icon scaling on high-DPI displays +Fixed user language set to English after update + + +FreeFileSync 11.19 [2022-04-16] +------------------------------- +Improved performance for huge exclusion filter lists: linear to constant(!) time +Support sync with Google Drive starred folders +Access "My Computers" (as created by Google Backup and Sync) if starred +Western Digital Mycloud NAS: fixed ERROR_ALREADY_EXISTS when changing case +Added per-file progress for "copy to" function +Have filter wildcard ? not match path separator +Work around WBEM_E_INVALID_NAMESPACE error during installation +Fixed login user incorrectly displayed as root (macOS) +Save Google Drive buffer before system shutdown + + +FreeFileSync 11.18 [2022-03-07] +------------------------------- +Add comparison time to sync log when using GUI +Added user-configurable timeout for Google Drive +Consider port when comparing (S)FTP paths for equality +Fixed SFTP key file login error on OpenSSH_8.8p1 +Add error details for NSFileReadUnknownError (macOS) +Disable new config button when already at default +Use user language instead of region locale during installation + + +FreeFileSync 11.17 [2022-02-04] +------------------------------- +Show per-file progress in percent when copying large files +Log app initialization errors +Fixed uncaught exception after installation +Defer testing for third-party buggy DLLs until after crashing +Consider ReFS 128-bit file ID failure states (Windows) +Refer to volume by name: support names including brackets +Support local installation with non-standard home (Linux) + + +FreeFileSync 11.16 [2022-01-02] +------------------------------- +Allow to select and remove invalid config file +Migrated all HTTPS requests to use libcurl (Linux, macOS) +Set keyboard focus on config panel after startup +Added computer name to log file trailer +Context menu instead of confirmation dialog for swap sides +Fixed config selection lost after auto-cleaning obsolete rows +Install app files with owner set to root (Linux) +Don't override keyboard shortcut "CTRL + W" (macOS) +Migrated key conversion routines deprecated in OpenSSL 3.0 +Boxed app icon to fit OS theme (macOS) +Fixed manual retry after automatic update check error +Fixed missing ampersands in middle grid tooltip + + +FreeFileSync 11.15 [2021-12-03] +------------------------------- +Play sound reminder when waiting for user confirmation +Enhanced crash diagnostics with known triggers +Defer reporting third-party incompatibilities until after crashing +Fixed Server 2019 not being detected for log file +Use native representation for modified config (macOS) +Improved WinMerge detection for external app integration + + +FreeFileSync 11.14 [2021-09-20] +------------------------------- +Authenticate (S)FTP connections using OpenSSL 3.0 +Fixed E_NOINTERFACE error after synchronization +Preempt crashes due to Nahimic Sonic Studio 3 +Hide main window when minimizing progress window (macOS) +Avoid second dock icon when minimizing progress window (macOS) + + +FreeFileSync 11.13 [2021-08-17] +------------------------------- +Manage default filter settings via GUI +Support arbitrary location for local app installation (macOS) +Fixed ERROR_FILE_NOT_FOUND masking real file access error (Windows) +Copy full file paths to clipboard (CTRL + C) +Preserve clipboard contents until after program exit +Always enable external command if independent of file items +Support installation without Rosetta2 on ARM64 (macOS) + + +FreeFileSync 11.12 [2021-07-15] +------------------------------- +Native ARM64 build to support Apple silicon M1 (macOS) +Non-intrusive mouse highlight on file grid +Fixed /lib/i386-linux-gnu/libgcc_s.so.1: version `GCC_7.0.0' not found +Parse file times with no limits on time span (e.g. year 0, year 3000) +Show folder icon during drag and drop (Windows) +Show user name for (S)FTP display paths +Fixed FTP connection lost error with TLS 1.3 +Present file sizes in powers of 1000 bytes (Linux, macOS) + + +FreeFileSync 11.11 [2021-06-11] +------------------------------- +Fixed Shared Drive synchronization with Google Drive +Directly open exported file list (.CSV) as temporary file +Avoid EIO error for F_PREALLOCATE (macOS) +Watch socket using "poll" instead of "select" (Linux, macOS) +Fixed user-specific time/date format (Windows) +Fixed system_profiler not found error (macOS) + + +FreeFileSync 11.10 [2021-05-09] +------------------------------- +Fixed comparison results cleared after mouse-scrolling the first folder pair +Stricter base folder existence checks before synchronization +Disable all file pairs when base folder status cannot be determined +Fixed sync statistics if base folder existence test failed +Work around glitch in grid scrollbar size calculation +Fixed folder drag and drop failing after locale conflict (macOS) +Fixed incorrect MIME permissions after installation (Linux) +Stricter server response validation during update check +Fixed incomplete item path in log if source item is missing +Fixed installation error when running ConEmu +Support starting FreeFileSync as root login user (Linux) + + +FreeFileSync 11.9 [2021-04-01] +------------------------------ +Save different layouts depending on screen resolution +Fixed large file icon scaling quality (Windows) +Fixed broken default filter excluding DocumentRevisions (macOS) +Don't immediately exit terminal when installer error is showing (Linux) +Explicitly set file permissions when installing missing directories (Linux) +Support installation using noexec temp directory (Linux) +Don't fail installation if root is the only user (Linux) +Added automatic socket close on execv (Linux, macOS) +Fixed Google Drive login hanging after authentication (Linux) +Correctly generate and parse Windows epoch time (Windows, macOS) + + +FreeFileSync 11.8 [2021-03-03] +------------------------------ +Fixed unexpected file size error when copying to (S)FTP, and Google Drive + + +FreeFileSync 11.7 [2021-03-01] +------------------------------ +Detect moved files on FTP (if server supports MLSD) +Allow installation only for current or all users (Linux) +Added application uninstaller: uninstall.sh (Linux) +Use login user config path when running as root (macOS, Linux) +Fixed detection of moved files with unstable device IDs (macOS, Linux) +Strict checking for duplicate file IDs +Avoid EINVAL invalid argument error when using F_PREALLOCATE (macOS) +Restore input focus after closing log panel +Double-click on file to open Google Drive web interface +Fixed alpha channel image scaling glitch +Fixed recycle bin folders being created recursively +Fixed thread count status message fluctuation +Don't quit FreeFileSync when parent terminal is closed (SIGHUP) +Fixed "Operation not supported" error when setting directory locks +Show folder picker despite SHCreateItemFromParsingName() error +Work around "OLE received a packet with an invalid header" error + + +FreeFileSync 11.6 [2021-02-01] +------------------------------ +New FreeFileSync installer (Linux) +New auto-updater for the Donation Edition (macOS, Linux) +Support reading FTP file symlinks +Added context menu option "Edit with FreeFileSync" (Linux, KDE) +Support starting via symlink (macOS) +Command line support with "freefilesync" symlink in /usr/local/bin (macOS) +Fixed starting via symlink found by PATH (Linux) +Preserve keyboard focus when starting sync via F9 +Don't show relative parent path if folder does not exist +Added high-resolution application icons (Linux, macOS) +Work around "500 'HELP' command unrecognized" FTP error +Fixed menu bar icon not being removed immediately (macOS Big Sur) +Don't allow folder names ending with dot character (Windows) +Mitigate ERROR_ALREADY_ASSIGNED: Local Device Name Already in Use [Wnetaddconnection2] +Fixed startup failure when app folder contains back quote char (macOS) +Fixed network card not found error on virtual machine (KVM Linux) +Fixed RTL layout direction in popup dialogs + + +FreeFileSync 11.5 [2021-01-02] +------------------------------ +New configuration context menu option to delete from disk +Start auto retry delay at time of error instead of reporting +Added error details to status message before retry +Improved color scheme to better integrate with system colors +Keep partial SFTP results after network failure +Fixed incorrect panel font (macOS Big Sur) +Fixed SFTP retry not working after network drop +Fixed crash on exit with floating panels (macOS Big Sur) +Fixed auto-close option not being remembered +Fixed installer high-DPI scaling issues +Fixed mouse hover issues with grid column header +Fixed menu bar icons not showing (Linux) +Removed redundant GUI layout recalculations +Keep correct panel sizes after log panel maximize +Support modern folder picker in installer +Don't raise progress dialog after sync when resuming from systray + + +FreeFileSync 11.4 [2020-12-04] +------------------------------ +New progress graph "this one sparks joy" +Remember progress dialog size +New config file context menu option "Show in file manager" +Work around libcurl performance bug during FTP upload +Only log modification time errors after comparing by size or content +Smaller icon size for efficient screen layout (Linux) +Use system-native recycle bin icon +Fixed DeviceIoControl(IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS): ERROR_MORE_DATA +Support MTP devices lacking a friendly name +Fix grid scrolling with small mouse rotations (macOS) +Faster mouse scrolling on high-DPI resolution displays +Keep previous windows size when maximized during auto-exit + + +FreeFileSync 11.3 [2020-11-01] +------------------------------ +Enhanced main grid color scheme +Mouse-highlight for file selection +Added file create/delete indicators +Show file list tooltip for missing items +Click folder name and scroll to group start +Log failure to create application default config folder +Added tooltips and fixed help link context menu +Fixed tooltip not updated when scrolling (macOS, Linux) +Move error dialogs to foreground during batch sync +Align context menu popup positions +Updated translation files + + +FreeFileSync 11.2 [2020-10-02] +------------------------------ +Improved grid layout with file icons hidden +Improved rendering of inactive and disabled grid items +Remember last user-selected paths for file and folder pickers +Fixed folder name hidden in "item name" view type +Fixed determination of unsupported trash folder (Linux) +Fixed copying broken symlinks (macOS) +Fixed default action when pressing Enter in popup dialogs +Fixed default popup dialog size (macOS) +Use localized start of week for %WeekDay% (Linux, macOS) +Swap sides using CTRL+W instead of F10 +Show confirmation dialog before swapping sides + + +FreeFileSync 11.1 [2020-08-31] +------------------------------ +New file group layout on main grid (reloaded) +Alternate colors for main grid folder groups +Added file group context menu +Quick selection of items in folder group +Fixed FTP access errors with Explicit SSL/TLS +Fixed Google Drive error when double quotes in file name +Fixed RTL layout bug with number input control +Fixed grid column default sizes +Fixed grid rendering performance during mouse scrolling +Update all config files transactionally +Respect user-preferred number/time format (Linux) +Fixed floating panels not being resizable (Linux) +Instantly open selection context menu on right mouse button down +Further improved high DPI support +Updated deprecated system API calls (requires macOS 10.10 or later) +Fixed crash when accessing Nexis storage (macOS) +Avoid buffer flush when aborting native file output +Clear preview after folder history selection +Pre-allocate target file without setting size +Unified system error message formatting + + +FreeFileSync 11.0 [2020-07-21] +------------------------------ +Revised file layout on main grid +Skip download/upload when copying Google Drive files inside account +Support moving Google Drive files between shared drives and My Drive +Support copying Google Drive shortcuts between accounts +Support copying Google Docs, Sheets, Slides, etc. within account +Fixed parsing uninitialized Google Drive modification time +Fixed Google Drive file already existing check running too late +Ignore slash/backslash differences during manual search +Avoid creating orphan database entry if one DB file fails to load +Limit modification time error count for log file warning message +Support copying WSL symlinks +Avoid duplicate MTP/Google Drive item creation from multiple threads +Fixed TMPDIR not found during startup (macOS) +Added sync variant icons +Avoid redundant icon format conversions +Buffer high-DPI image scaling results +Improved MTP thumbnail scaling performance +Avoid race condition during parallel file icon rendering (Linux) +Allow creating folder name with leading/trailing spaces +Start supporting GTK3 (Linux) + + +FreeFileSync 10.25 [2020-06-18] +------------------------------- +New file tree layout for main grid +Support Google Drive Shared Drives +Support Google Drive Shortcuts +Prioritize item name rendering if lacking horizontal space +Report "out of memory" during startup instead of crashing +Fixed excess memory consumption when loading variable-size data blocks +Fixed VERSION_ID missing on Arch Linux +Fixed IWbemServices::ConnectServer error during auto-update +Fixed row being skipped during main grid page up/down +Fixed MSSearch files not found when using Volume Shadow Copy +Allow creating folder names with trailing dot +Improved sort by full path speed and folder ordering +Report detailed error when failing to parse FTP MLSD +Sort by path component names instead of relative path +Support access to MEGAcmd FTP server +Fixed Google Drive error when removing last parent of shared item +Fixed Google Drive owned+shared files being unlinked instead of deleted +Fixed Google Drive change notification evaluation for item without parents +Support double-click/"Browse directory" for (S)FTP/Google Drive (Linux) + + +FreeFileSync 10.24 [2020-05-17] +------------------------------- +Increased SFTP buffer sizes for faster upload/download +New %WeekDay%, %WeekDayName", and %MonthName% macros +Support Linux systems without lsb_release +Don't exclude desktop.ini by default +Merge error messages of failed error handling +Added ".DocumentRevisions-V100" to default exclude filter (macOS) +Fixed deletion error not reported during versioning +RealTimeSync: don't block when command fails with exit code > 0 +Visualize error status in macOS Dock and Windows Superbar +Show error code constants for Windows Shell errors +Suppport ProFTPD with "MultilineRFC2228 on" +SFTP option to enable/disable zlib compression + + +FreeFileSync 10.23 [2020-04-17] +------------------------------- +Run "on completion" commands on console (no need for "cmd.exe /c") +Check exit code and report errors for external applications +Report stream output of failed command line calls (macOs, Linux) +Use Unicode symbols compatible with older macOS +RealTimeSync: invoke command using cmd.exe instead of ShellExecute (Windows) +Avoid hitting log file length limitations for aggregated jobs +Fix OpenSSL failing on HTTP 1.0 response without Content-Length +Don't allow creating folder names ending with space or dot +Support base folders with trailing blanks +Show system error descriptions on Volume Shadow Copy errors +Raise exit code if saving log file or sending email failed +Report all documented MTP error descriptions +Updated default exclude filter (macOS/Linux) +Added image outlines for improved dark mode support +Work around WBEM_E_INVALID_CLASS error during installation +Align file path rendering with app layout direction +Play sound notification also when "cancel on first error" is set +Cleaner file path formatting (macOs, Linux) +Added instructions when failing to start due to missing GTK2 (Ubuntu) +RealTimeSync: distinguish drive unmount from folder change notification +Avoid blocking command scripts waiting for user input +Updated translation files + + +FreeFileSync 10.22 [2020-03-18] +------------------------------- +Fixed upper-case conversion bug for non-ASCII strings + + +FreeFileSync 10.21 [2020-03-17] +------------------------------- +Preselect last-used email address +Select log file format (HTML or plain text) +Aggregate email notifications when hitting sending limits +Show code literals in system error messages +Limit conflict item count for log file warning message +Show log icon error indicator even if error occurred after sync +Disable background drag & drop when showing modal dialog +Hide dummy model, vendor names in log files +Fixed ANSI encoding used for log file time formatting +Reduced memory consumption for large number of log messages +Correctly parse lock files despite corrupted trail data +Show emoji instead of Unicode icon in email subject +Fixed IWbemServices::ConnectServer error after sync +Fixed aggregate email logs incomplete truncation + + +FreeFileSync 10.20 [2020-02-14] +------------------------------- +Send email notifications after sync (Donation Edition) +Generate log files in HTML format +Detect sync database consistency errors +Start log file with preview of first 25 errors/warnings +Mitigate lock file data corruption +Print Windows error codes in hexadecimal +Fixed missing MTP and network links in folder picker (Linux) +Display versioning and log folder path history +Display and log all config names for merged configurations +Run post-sync command synchronously and log exit code +Fixed crash on Bitvise SFTP servers with zlib delayed compression +Show actual time out used in failure message +Show detailed error message when failing to test sound files +Fixed timeout for long-running FTP uploads by sending keep-alives +Use Donation Edition on unlimited number of virtual machines +Ignore accidental clicks in empty space of configuration panel + + +FreeFileSync 10.19 [2019-12-27] +------------------------------- +Unified rendering of disabled grid layouts +Count moved file pair as one update in view filter buttons +Fix command button default sizes (Windows) +Added %item_name%, %item_name2% context menu macros +Support deleting references to shared Google Drive files +Trash Google Drive files only when having single parent +Fixed high DPI scaling issue on image borders +Preserve system date format for RTL languages +Fall back to folder path if resource archives are missing + + +FreeFileSync 10.18 [2019-11-19] +------------------------------- +Save/load database files in parallel +Show item count for each view filter category +Group config history items via background colors +Allow grid sort by category and sync action +Reduced file accesses for faster start up +Buffer redundant database loads +Fix ibus initialization hang on Ubuntu 19.10 +Defer showing progress panel for short-lived tasks +Calculate stable scrollbar dimensions on GTK2 +Log mod time errors even when sync is cancelled +Show progress and errors when updating sync directions +Detect MLSD support despite invalid FTP FEAT response +Improved GUI responsiveness during config load +Added Vietnamese translation + + +FreeFileSync 10.17 [2019-10-17] +------------------------------- +Support PuTTY private key files for SFTP login +Enable zlib compression for SFTP servers if supported +Update last sync time despite differences if nothing to do +Reduce graph total time update interval +Remember folder history not just for first folder pair +Allow unprivileged symlink creation in Windows Developer Mode +Integrate latest libcurl FTP bug fixes +Detect common invalid SFTP key file formats +Fixed startup crash caused by corrupted HDD properties +Allow SFTP access via Ed25519 key in PKIX format + + +FreeFileSync 10.16 [2019-09-16] +------------------------------- +Redesigned progress indicator graphs +Avoid needless HTTP delay prior to Google Drive upload +Skip redundant CWDs during FTP metadata updates +Fixed MLSD 501 syntax error on Serv-U FTP server +Check FTP server status using FEAT/HELP instead of root folder +Avoid redundant TYPE changes during FTP directory listing +Access FTP files by full path and avoid CWDs +Support FTP home paths with non-ASCII chars +Work around libcurl bug failing to buffer FTP TLS authentication +Skip redundant FTP SIZE check before downloading file +Use ISO 8601 week of the year definition for %week% macro +Show login prompt for disconnected NAS share +Force icon resolution to 96 DPI in GTK2 build (Linux) +Notify missing full disk access permission (macOS) +Fixed accessibility issue with progress graph colors +Use short naming convention when deleting abandoned folder lock +Detect endless folder lock recursion on buggy file systems +Fixed Google Drive parsing error for invalid file time + + +FreeFileSync 10.15 [2019-08-15] +------------------------------- +Redesigned progress indicator stats +Fixed crash when progress dialog is closed right before showing error +Consider fail-safe file copy when creating sync.ffs_db files +Prepare support for GTK3 GUI framework (Linux) +Support sound output via SDL (Linux) +Shrink standard system icons if needed (Linux) +Add Windows Defender exclusions asynchronously +Fixed main dialog out-of-screen position on startup (macOS) +Activated CDN for all web accesses +Redirect error dialog to stderr during sound playback (Linux) +Updated translation files + + +FreeFileSync 10.14 [2019-07-14] +------------------------------- +Warn if versioning folder paths differ only in case +Fixed empty HTTP response during update check (macOS/Linux) +Warn if Donation Edition is active on unexpected number of machines +Use subdomain for application update checks +Consider cache control for HTTP GET requests +Access all web endpoints over TLS +Fixed character encoding issue in update reminder (macOS/Linux) + + +FreeFileSync 10.13 [2019-06-13] +------------------------------- +Allow to rename configurations via context menu +Work around hang on SMB network with broken FileFullDirectoryInformation +Work around SMB share returning empty item name +Detect and preempt keyman64.dll crash on exit +Manage notification sounds via global options dialog +Support 32-bit Debian Jessie and later releases +Work around silent failure to case-only rename on FAT drives (Windows 10) +Simplified installation folder structure +Update main grid scrollbars when resizing columns on other side +Preserve input focus when clicking on grid column label +Buffer result of process path normalization +Mirror middle grid icons for RTL layout (Linux) +Force LTR layout until wxWidgets supports RTL (macOS) +Fixed pair scrolling mismatch when grid height is exceeded by one row +Fixed startup failure due to missing /etc/machine-id (Linux) + + +FreeFileSync 10.12 [2019-05-12] +------------------------------- +Show sync start time and date in progress dialog title +Added duration of comparison to log +Show all total times in full HH:MM:SS format +Added sync start time to log file header +Add Windows Defender exclusions to fix CURLE_PARTIAL_FILE +New RealTimeSync option to hide console window +Support launching through symlink (Windows) +Dropped support for Windows XP, Server 2003, and Vista +Reduced installation size by 25% + + +FreeFileSync 10.11 [2019-04-11] +------------------------------- +Last FreeFileSync version supporting Windows XP and Vista +Fixed crash on multi-monitor set up +Fixed dialogs not showing after opening UAC prompt +Support launching through symlink (Linux) +Added example desktop starter files (Linux) +Fixed misleading error when determining file permissions support +Updated wxWidgets, libcurl, libssh2, VS, GCC, Xcode + + +FreeFileSync 10.10 [2019-03-10] +------------------------------- +New option: synchronize selection +Dynamically disable unsuitable context menu options +Support MTP devices without move command +Fall back to copy/delete when implicitly moving to different device (e.g. symlink) +Fixed incorrect statistics after parallel move +Fixed menu button not triggering context menu +Fixed crash on focus change while message popup is dismissed +Fixed crash when trying to shrink empty image +Fixed invisible dialogs when monitor is turned off in multi-monitor setup +Work around GetFileInformationByHandle error code 58 on WD My Cloud EX +Changing deletion handling now correctly triggers updated config +Support root-relative FTP file paths (e.g. FreeNAS) +Move and rename MTP items as a transaction +Exclude AppleDouble files (._) via default filter on macOS +Support home path for FTP folder picker +Use server default permissions when creating SFTP folder +Use native OpenSSL AES-CTR rather than libssh2 fallback +Added context information for cloud connection errors +Updated translation files + + +FreeFileSync 10.9 [2019-02-10] +------------------------------ +Added FTP, SFTP, Google Drive support for Linux +FreeFileSync Donation Edition available for Linux +Compress file stream during Google Drive upload +Navigate beyond access-denied parents in SFTP folder picker +Fixed unexpected stream size error during FTP upload +Support native recursive deletion for Google Drive +Support native recursive deletion for MTP +Deterministically save Google Drive state during exit +Work around missing TMPDIR variable (Linux) +Support SFTP servers returning large package sizes during folder reading +Start with home path when using SFTP folder picker +Aggregate device authentication prompts during comparison +Clean up temp file after unexpected stream size error +Work around FTP servers not supporting HELP command +Support parsing path by volume name when volume is missing +Parse and streamline Google Drive error messages +Load next item after deleting from config history +Avoid redundant Google Drive syncs after file/folder creation +Avoid duplicate MTP item creation by multiple threads + + +FreeFileSync 10.8 [2019-01-15] +------------------------------ +Support synchronization with Google Drive +Don't reset sync directions when changing versioning or deletion handling +Save last sync time before shutting down system +Support MTP devices that accept modTime only during file creation +Avoid dependency on file id to detect duplicate folders (buggy network drivers) +Check if path exists before creating duplicate MTP folder +Check for empty MTP item name during folder traversal +Check if multiple MTP items are referenced by the same path +Fixed sync config GUI distortion when toggling auto retry (Linux, macOS) +Fixed FreeFileSync sort order in Windows Uninstall Programs +Fixed log override path being squashed on high DPI +Fixed volume serial not considered when file id is missing + + +FreeFileSync 10.7 [2018-12-12] +------------------------------ +Correctly resolve ambiguous paths in (S)FTP folder picker +Fixed path alias check to not rely on volume serial number +Check already existing move target by ID instead of path (Linux, macOS) +Use native image conversion routines in installer +Added base folder info for unresolved conflicts message +Avoid silent failure when setting epoch modTime (Windows) +Fixed RealTimeSync failing to start FreeFileSync batch (macOS) +Support command arguments and exit code with launcher (macOS) +Consider UTF encoding when trimming long temp name during file copy +Exclude failed item paths containing backslash in names (Linux) +Fixed RealTimeSync GUI distortion after drag & drop (Linux) +Fixed parsing locale with unexpected format (Linux) + + +FreeFileSync 10.6 [2018-11-12] +------------------------------ +Detect and skip traversing folder path aliases +Report conflict when names differ only in Unicode normalization +Unified 32 and 64 bit into single package (Linux) +Notarized application package (macOS) +Save configuration files in user-specific paths (Linux) +Use XDG-style config file paths (Linux) +Fixed (fake) intermittent hangs during comparison (Linux, macOS) +Detect SMB mount points as separate devices (Linux) +Consider /mnt subfolders as device root paths (Linux) +Create missing default log folder upon first run +Don't consider final status for error/warning count +Discard invalid SFTP session after max channel determination +Fixed main dialog position not being remembered (Linux) +Fixed imprecise FTP times due to MLST parsing issue +Fixed application menu not being localized (macOS) +Fixed temp file name hitting file system length limitations +Fixed fatal errors not being written to console (Debian Linux) +Updated translation files + + +FreeFileSync 10.5 [2018-10-11] +------------------------------ +New file matching algorithm considering Unicode normalization +User-configurable timeout for FTP and SFTP connections +Ignore case sensitivity during filter matching (Linux) +Obsoleted old CHM manual in favor of PDF +Unicode-normalized and faster case-insensitive grid search +New button to save current view filter settings as default +Both slash and backslash can be used in filter expressions +Improved Unicode case conversion routines +Keyboard shortcuts for swap sides (F10) and view category (F11) +Don't steal input focus when closing progress dialog (macOS) +Fixed shutdown crash when accessing already destroyed state +Fixed file grid column order not being preserved +Fixed manual activation input fields being disabled (macOS) +Fixed FTP parsing error due to invalid folder time +Fixed statistics boxes background distortion (macOS) + + +FreeFileSync 10.4 [2018-09-09] +------------------------------ +Allow overriding log folder path for GUI and batch runs +Fixed RealTimeSync not triggering when using volume path by name +Fixed reading FTP folders including wildcard chars +Fixed image overlay graphics glitch (Linux) +Don't show error if versioning folder is not yet existing +Fixed crash when removing folder pair just before comparison (F5) +Fixed crash when parent folder of newly-moved file is deleted after comparison +Fixed statistics when folder containing moved files is found missing + + +FreeFileSync 10.3 [2018-08-07] +------------------------------ +New log panel showing details about the last operation +Show status of last syncs in configuration panel +Access log files via the configuration panel +Allow auto-retry and ignore errors during comparison +Show folder RealTimeSync is waiting on +New %logfile_path% macro for "on completion" command +Show errors and warnings count in log file header +Fixed crash when resizing panel during comparison +Fixed folders created hidden when source is a volume root path +Use steady clock while waiting in RealTimeSync +Fixed folder access error with Google Drive File Stream +Open global log folder path via options dialog +Limit global logs by age instead of size +Deprecated batch-level log files and LastSyncs.log + + +FreeFileSync 10.2 [2018-07-06] +------------------------------ +Limit number of file versions by age and count +Report not yet existing folders as warning instead of error +Improved comparison speed for high-latency traversals +Set up parallel file operations for versioning folder +Early clean up to avoid hitting (S)FTP connection limits +Support FTP servers with ANSI encoding +Fixed folder drag and drop for modal dialogs +Fixed progress graph glitch caused by unsteady system clock +Unbuffered folder lock file existence checking +Fixed macOS Donation Edition not being recognized after bundle rename +Updated translation files + + +FreeFileSync 10.1 [2018-06-03] +------------------------------ +Binary-compare multiple files in parallel +Copy file permissions when creating base folders +Fixed hang when scrolling file list (Windows) +Fixed file list mismatch when cancelling sync +Fixed delay when cancelling folder existence check +Fixed comparison and sync processing order to honor FIFO +Fixed startup delay when internet is offline (Linux, macOS) +Fixed crash when closing FreeFileSync via the macOS Dock +Support installation without admin rights (macOS) +Fixed bcrypt.dll not found on startup (Windows XP) +Respect Content-Length header for HTTP requests +Support parallel folder traversal on Ubuntu 16.4 +Fixed missing shared library dependencies (Linux) +Unified precompiled Linux binary packages + + +FreeFileSync 10.0 [2018-04-27] +------------------------------ +The installer is now ad-free! +Sync multiple files in parallel (Donation Edition) +Compare multiple files in parallel within a single folder tree +Aggregate worker threads per device during folder traversal +Reset GUI layout configuration for high DPI displays +Keep GUI responsive during synchronization +Remember maximum number of visible folder pairs +Fixed high DPI issues in installer +Don't delay errors by callback interval during comparison +Handle concurrent intermediate folder creation for versioning +Sync all folder level items before recursion (avoid CWDs) +Updated translation files + + +FreeFileSync 9.9 [2018-03-09] +----------------------------- +High DPI display support +Allow automatic retry at configuration level +Show error handling settings during sync +Avoid libpng.so dependency (Linux) +Fixed undefined behavior closing paused progress dialog +Check if buggy DLLs are loaded into address space (Windows) +Fixed FTP parsing error for Windows CE device +Workaround VSS provider implementation bug +Respect macOS user settings for date and thousands separator +Updated translation files + + +FreeFileSync 9.8 [2018-02-06] +----------------------------- +New option to auto-close progress dialog +Update last sync time if no differences found +Added 5 seconds countdown before shutdown/sleep +Preserve XML attribute creation order +Support HTTPS web accesses without redirect +Connect network share upon logon type not granted +Fixed invalid pointer error when reading MTP +Fixed temporary db file triggering RealTimeSync +Fixed runtime error during uninstallation +Continue status updates during sync cancellation +Log number of items found during comparison +Warn about outdated nviewH64.dll instead of crashing +Show default log file path when saving a batch job +Consider only full days for time since last sync + + +FreeFileSync 9.7 [2018-01-12] +----------------------------- +New configuration management panel +New column showing days since last sync +Support starting FreeFileSync via Windows Send To +Minimized memory operations for I/O buffer +Allow multiple config selections on Linux +New command line option -DirPair +Fixed Enter key not working for most dialogs (macOS) +Show only one warning about failed directory locks +Show correct synchronization time when resuming from system sleep +Don't resolve symlinks that are dropped via mouse +Detect and notify LCMapString compatibility mode bug +Fixed incorrect file permissions within macOS bundle +Fixed wrong results dialog panel selection (Linux) + + +FreeFileSync 9.6 [2017-12-07] +----------------------------- +New installation command line option /disable_updates +Fixed crash when closing main dialog during sync +Fixed RealTimeSync crash after recursive mutex locking +Improved file copy performance on macOS +Clean up obsolete files during installation +Don't use threads for running async command line (Linux) +Avoid main dialog flash after minimized sync +Disable file list export until after comparison +Directly close progress dialog during sync +Redirect escape key from main dialog to progress dialog +Fixed startup delay during consistency checks +Updated translation files + + +FreeFileSync 9.5 [2017-11-05] +----------------------------- +Allow to change error handling option on progress dialogs +Set up shutdown behavior during sync (summary, exit, sleep, shutdown) +Conditional execution of the post sync command line +Directly use native shutdown/sleep API (Windows and macOS) +Run post sync command even when fail on first error was set +Merged batch and GUI error handling options +Write post sync command to log file +Update GUI-specific options when saving as batch job +Progress graph area matches processed data ratio +Delete files permanently with Shift+Del +Apply correct quotation for CSV-exported folder list +Replace Unicode arrow chars with ASCII for variant description +Updated libcurl, OpenSSL to latest builds + + +FreeFileSync 9.4 [2017-10-05] +----------------------------- +Fixed copying files with locked byte ranges using VSS +Fixed wrong FTP working directory reuse in libcurl +Allow retry upon failure during online update check +Repackaged Donation Edition to reduce AV false positives (Norton) +Apply correct directory path encoding during FTP traversal +Fixed strict weak ordering for SFTP session ID sorting +Clean up read-only temporary files during failed sparse file copy +Fixed access denied file copy error for ADS while using BackupWrite +Workaround broken non-Windows SMB implementations reporting sparse support +Support hash characters in FTP directory listing +Prepared auto-updater to support new installer format +Refined installer error reporting +Streamlined sync config dialogs +Resized installer window dimensions + + +FreeFileSync 9.3 [2017-08-09] +----------------------------- +Support multiple connections per FTP folder traversal: N times speed up +Improved folder traversal time by 35% for FTP servers supporting MLSD +Use single CWD when changing FTP working directory +Maximize FTP input/output speed using prefetch/output buffers and async execution +Use larger socket buffer for significant FTP upload speed increase +Fixed out of memory error when copying large files via FTP +New popup dialog option to ignore all errors +Reduced memory peaks by enforcing streaming buffer size limits +Removed custom sync directions from config XML if not needed +Fixed EOPNOTSUPP error on GVFS-mounted FTP (Linux) +Prevent input focus stealing after manual comparison +Flash task bar after comparison if other app has input focus + + +FreeFileSync 9.2 [2017-07-03] +----------------------------- +Use direct copy instead of transaction to speed up versioning +Replaced file existing handling with use of unique temporary names +Support SFTP authentication via Pageant/SSH agent +New menu option to restore hidden panels individually +Fixed GTK button icon being truncated (Linux) +Fixed error dialog hiding behind progress dialog (macOS) +Round out FTP symlink deletion handling +Support four-digit year format on IIS FTP +Fixed FTP parsing error for epoch time on Windows server +Narrow contract for file system abstraction regarding existing files +Treat failure to load database as error rather than warning +Save root folder access for certain FTP path checks + + +FreeFileSync 9.1 [2017-05-24] +----------------------------- +Fixed crash when getting invalid data after item type check +Fixed copying symlinks pointing to network folders +Support resolving network paths in the NT namespace +Support FTP servers with broken MLST command (Pure-FTPd) +Fixed FTP access error on file names containing special chars +Include raw FTP server response in error message +Quickly check server connection using a single FEAT +Don't change working directory when sending a single FTP command +Support FTP Unix listings missing group name +Support RFC-2640-non-compliant FTP servers having UTF8 disabled +Support FTP servers returning non-routable IP in PASV response +Support IPv6 when establishing FTP connections +Start external application keyboard shortcuts with zero + + +FreeFileSync 9.0 [2017-04-16] +----------------------------- +Support synchronization via FTP (File Transfer Protocol) and FTPS (SSL/TLS) +Notify failure to set modification time as a warning instead of an error +Allow intermediate non-folder components when checking path status +Prevent file drop events from propagating to parent windows +Create Downloads folder if not yet existing when running auto-updater +Get all MTP input stream attributes as a single device access +Improved SFTP input stream copying time by 20% +Buffer (S)FTP sessions based on all login information +Finalize all installation steps before showing finished page +Updated translation files + + +FreeFileSync 8.10 [2017-03-12] +------------------------------ +Fully preserve case-sensitive file paths (Windows, macOS) +Support SFTP connections to local hosts +Warn if versioning folder is contained in a base folder +Use natural string sorting algorithm for item lists +Consider exclude filter settings for folder dependency checks +Fixed file not found error on case-sensitive SFTP volume +Fixed failure when creating MTP sub directories +Fixed crash when loading database file during comparison +Refactored UTF conversion routines +Use pipe symbol as filter separator instead of semicolon +Iterate over all matching SFTP connections available on a server (macOS) +Reduced folder matching time by 12%, average memory use by 11% +Added experimental FTP support + + +FreeFileSync 8.9 [2017-02-08] +----------------------------- +Detect when database file was copied and avoid "second part missing" error +Further reduced size of database files by 20% +Reduced amortized number of file operations during versioning +Added database file consistency checks to catch unexpected number of stream associations +Improved file I/O by detecting cross-device moves via path +Fixed path parsing failure when creating MTP directories +Implemented buffered stream I/O abstraction to prepare for FTP +Generalized file path handling for abstract file system implementations +Warn about outdated AvmSnd.dll before crashing during sound playback +Avoid libunity9 dependency for Ubuntu builds +Refactored OpenSSL and libssh2 initialization/shutdown +Case-insensitive grid sorting on Linux +Added 32-bit precompiled Debian/Ubuntu release + + +FreeFileSync 8.8 [2017-01-08] +----------------------------- +Distinguish file access failure from not existing during sync +Further optimized number of file I/O operations via file system abstraction +Report unexpected prompts for keyboard-interactive SFTP authentication +Mark followed directory symlinks on grid +Fixed parent path determination for UNC +Don't skip source files that cannot be accessed +Don't consider a symlink type for SFTP when comparing by content +Fixed invalid parameter error when setting file times on exFAT file system +Don't allow overwriting folder with equally named file when copying from main dialog +Fixed failure to create intermediate directories for Cryptomator/Webdav +Refactored file system abstraction layer for future FTP support +Fixed failure to change file name case on MTP devices +Fixed late failure for batch recycling when parsing of single item fails + + +FreeFileSync 8.7 [2016-12-06] +----------------------------- +New auto-updater feature for FreeFileSync Donation Edition +Download zip archive of portable FreeFileSync Donation Edition +New command line options to define parameters for silent installation +Support offline activation for portable Donation Edition +Use automatic keyboard-interactive SFTP authentication as fallback +Check for available SFTP authentication methods before login +Support cloud sync of portable edition installation files +Access donation transaction details from about dialog +Use width from flexible grid column when showing/hiding extra columns +Show item short names in middle column tooltip +Enhanced file category descriptions with modification times +Don't warn about missing recycle bin when only moving or updating attributes +Fixed crash when switching to main dialog during batch sync + + +FreeFileSync 8.6 [2016-10-25] +----------------------------- +Added SFTP support for OS X +Support SFTP authentication via public/private key +Remember configuration history scroll position +SFTP folder picker supports browsing hidden folders +Fixed failure to copy files with corrupted ADS +Signed application installer (OS X) +Increase config history default size to 100 items +Auto-close FreeFileSync processes before uninstallation +Simplified SFTP configuration syntax +Fixed update check sending incomplete keep-alive header +Detailed error reporting after failed web access +Suggest folder path macro substitutions also at inner positions +Transfer folder creation times (OS X) + + +FreeFileSync 8.5 [2016-09-16] +----------------------------- +Support multiple SSH connections per SFTP folder traversal: N times speed up +Support multiple SFTP channels per SSH connection: additional N times speed up +Fixed installer crashes by using correct DEP-compatibility +Fixed notification area icon being generated too often +Thread-safe SFTP uninitialization on shutdown +Thread-safe mini-dump creation during shutdown +Fixed case-insensitive migration of new csidl macro names +Reduced SFTP access serialization overhead +Buffer SFTP sessions independently from usage context +Detect and discard unstable SSH sessions +Pre-empt SFTP session disconnect via dedicated SFTP cleanup thread +Run SFTP tasks directly on worker threads without helper thread overhead + + +FreeFileSync 8.4 [2016-08-12] +----------------------------- +Mark temporary copies created by %local_path% read-only +Fixed crash when accessing Bitvise SFTP servers +Support nanosecond-precision file time copying (Linux) +Start maximized instead of in full screen mode (OS X) +Fixed crash while setting privileges during shutdown +Fixed crash when failing to clean up log files +Fixed EOPNOTSUPP error when copying file to GVFS Samba share (Linux) +Fixed default external applications command line (Linux) +Thread-safe translation access and change during app shutdown +Don't consider port and password when comparing SFTP paths +Updated translation files + + +FreeFileSync 8.3 [2016-07-08] +----------------------------- +Make temporary local copy for non-native file paths: %local_path% +Support selections from both grid sides at a time for external applications +New external application macros: %item_path%, %folder_path%, %item_path2%, %folder_path2% +Migrate external application commands to new macro syntax +Support reverse grid search (Shift + F3) +Don't condense empty sub folders on overview panel +Show changelog delta in update notification +Center modal dialogs after layout redetermination +Warn about portable installation into programs folder +Calculate default message dialog height depending on screen size +Don't substitute external applications path for empty base folder +Fixed prolonged tooltip time not being evaluated + + +FreeFileSync 8.2 [2016-05-30] +----------------------------- +Unified item path representation on main grid +New progress indicator control for binary comparison +Fixed crash on exit when accessing already destructed constant +Fixed crash when FreeFileSync is still running during OS shutdown +Fixed crash on startup due to missing root certificates +Work around start up crash on Windows installations missing certain patches +Fixed in-place progress panel height being trimmed +Support drawing arbitrary polygons with graph control +Apply POSIX file name normalization (OS X) +Normalize keyboard input encoding for all text fields (OS X) +Report errors when cleaning up old log files +Integrate external app WinMerge if installation is found + + +FreeFileSync 8.1 [2016-04-21] +----------------------------- +Follow shell links during drag and drop on main dialog (Windows) +Significantly improved main grid rendering performance +Log info about non-default global settings +Establish new network connections only when needed (Windows) +Show only a single login dialog per network share +Show login dialogs for the same network address one after another +Fixed endless recursion for paths containing certain Unicode characters (OS X) +Support using portable version without direct installation +Fixed access denied error when verifying read-only target file (Windows) +New global option for sound cue after comparison +Updated help file + + +FreeFileSync 8.0 [2016-03-15] +----------------------------- +Fine-tuned buffer sizes for 70% improved SFTP stream I/O speed +Support incomplete read/write operations while maximizing buffer saturation +Automatically check consistency of FreeFileSync installation +Fixed crash when using SFTP on CPUs without SSE2 support +Improved GUI responsiveness during SFTP I/O +Disabled automatic quote substitution for file filter (OS X) +Work around invalid parameter error on FAT drives for broken create times +Avoid filter mismatches by using precomposed UTF (OS X) +Fixed main dialog close button not being disabled during sync (OS X) +Don't create AppleDouble files if extended attributes are unsupported (OS X) +Set content format metadata when copying to an MTP device +Fixed F-keys not working in sync config dialog (Linux) +Revert to default button margin values (Linux) +Fixed crash when thumbnail loading fails on MTP device +Fixed main grids not scrolling in parallel during mouse selection +Revert to default scaling for non-dpi-aware apps +Integrate FreeFileSync online manual +Added Slovak translation + + +FreeFileSync 7.9 [2016-02-13] +----------------------------- +New comparison variant: compare by file size +Buffer SFTP read/write accesses for optimal packet sizes +Configure folder access time out via GlobalSettings.xml +Drag and drop config files anywhere on main dialog +Work around "argument list too long" file copy error (OS X) +Work around "invalid argument" file copy error (OS X) +Support case-change when syncing to case-sensitive SFTP (Windows) +Select between sync completion sounds gong/harp.wav +Set up sync completion sound file in GlobalSettings.xml +Validate monitoring data to avoid RealTimeSync crash +Updated help file +Updated translation files + + +FreeFileSync 7.8 [2016-01-01] +----------------------------- +Correctly resolve environment variables containing MTP paths +Support at and colon characters in SFTP user name +New context buttons for quick sync config changes +Report specific error during folder existence check when starting sync +Fail lately when traversing available MTP devices +Correctly handle SFTP time-out error when checking folder existence +Updated on completion command lines for log off/standby/shut down (Linux) +Support HTML POST redirection for update checks +Calculate UTC file times like Windows Explorer for MTP devices +Don't reuse timed-out SFTP sessions with thread affinity +Workaround SFTP session hang after unsupported statvfs command +Updated OpenSSL to 1.0.2e + + +FreeFileSync 7.7 [2015-12-01] +----------------------------- +Support variable drive letters for config history when using FreeFileSync portable +Skip non-storage functional objects at MTP device level +Log and show error messages without hanging when running as a service +Navigate between sync settings panels with arrow keys +Fixed volume shadow copy file path generation +Handle integer overflows when comparing file times +Ignore more than one file time shift +Reworked grid to support mouse highlight areas +Allow minute precision for file time shifts +Warn about unsupported MTP and SFTP paths in RealTimeSync +Strip superfluous mode parameters when creating a directory (Linux, OS X) +Correctly detect system language for English UK +Store program language by name to handle changing ids +Fixed crash during application exit after using SFTP + + +FreeFileSync 7.6 [2015-11-01] +----------------------------- +Create missing synchronization base folders only on demand +Improved main grid text search performance by 40% +Restore correct main dialog height after restart (Linux) +Default to standard main dialog size after unmaximize (Linux) +Prevent creation of irregular folder names (Windows) +Support MTP devices over WiFi with null modification times +Do not apply invalid vertical main dialog positions (OS X) +Support Yosemite full screen window mode (OS X) +Use buffered lock file I/O (Windows) +Correctly set up OpenSSL for multithreaded use +Added COM initialization for worker threads (Windows) +Forward focus to sync button after comparison +Streamlined file system abstraction layer interfaces + + +FreeFileSync 7.5 [2015-10-01] +----------------------------- +Detect moved files on source even for targets with no (SFTP) or unstable (FAT) file id support +Improved performance for detection of moved files by over 50% +Added folder picker to select SFTP paths +Support additional SFTP ciphers by building upon OpenSSL backend +Added 10-seconds time out when SFTP command is hanging indefinitely +Work around unexpected SFTP session termination on Synology servers +Fixed various libssh2 and OpenSSL memory leaks +Fixed FreeFileSync taskbar link reuse (Windows 7) +Avoid last error code being overwritten by certain C runtimes before evaluation +Run online update check asynchronously (Windows) +Check source item existence before cleaning target during versioning (Linux, OS X) +Check folder recursion limit to catch stack overflows +Doubled potential folder traversal recursion depth (Windows) +Consider child elements of excluded folders during database clean up + + +FreeFileSync 7.4 [2015-09-01] +----------------------------- +Switch between all folder pair configurations directly in the sync config dialog +Support macros, path by volume name for config files on command line +Support slash as path separator on command line (Windows) +Allow slash as path separator in filter dialog (Windows) +Discard SFTP connection after 20 seconds of idle time +Fixed file already existing error when changing file name case (OS X) +New keyboard shortcuts to open external applications +Fixed clipboard being cleared when opening sync config dialog (OS X) +Workaround wxWidgets bug breaking copy/paste shortcuts (OS X) +Fixed disabled button icons not being updated in the config dialog +Fixed launcher error messages not being shown (Windows XP) +Fixed launcher showing incorrect error about missing service pack (Windows XP) +Revised help file and consolidated into online help + + +FreeFileSync 7.3 [2015-08-01] +----------------------------- +New context menu option to copy selected files to alternate folder (create diffs) +Fill a folder pair by dropping two folders at a time from Explorer +Added option to set non-standard SFTP port +Prevent recursive creation of temporary recycle bin directories (Windows) +Retrieve grid column label colors from the system +Fixed detection of already existing files when moving (Linux) +Follow OS convention for preferences (OS X) +Prevent progress dialog from hiding behind main dialog (OS X) +Fixed config saved status not updating when changing certain settings +Support for high dpi display settings +Fixed crash when help viewer is open during exit (Windows) +Show manual deletion progress within comparison status panel +Further reduced number of file accesses during versioning +Fixed folder picker failing to select Desktop folder (Windows) + + +FreeFileSync 7.2 [2015-07-01] +----------------------------- +Support synchronization via SFTP (SSH File Transfer Protocol) +Detailed error reporting when checking folder existence +Synchronize MTP devices with no modification time support +Set focus to comparison button on startup +Fixed transactional stream clean up error if target file already existing +Fixed incomplete input stream clean up on fadvise failure (Linux) +Consider non-native paths for direct comparison after startup +Revised algorithm generating folder pair display name +Reduced number of file accesses during versioning +Stricter language file consistency checking +Resolved crash when running Windows 7 on CPUs without SSE2 +Improved Minidump creation handling stack overflows +Revised path formatting to always match native representation +Fixed about dialog layout for large font sizes +Support Minidump creation for Windows XP +Updated translation files + + +FreeFileSync 7.1 [2015-06-06] +----------------------------- +Avoid various access denied errors when synchronizing with admin rights (Windows) +Accept Explorer drag and drop from MTP devices +Support showing MTP files with Explorer +Support opening MTP files with default application +Preselect active MTP folder in folder picker dialog +Work around file not found error when copying alternate data streams +Fixed access denied error when copying file times (Linux) +Work around boost bug causing RealTimeSync to wake PC (Windows) +Fixed naming convention "replace" for versioning +Skip space pre-allocation if not supported (OS X) +Use faster space pre-allocation method (Linux) +Transactional error handling when closing file streams +Fully initialize system image list for medium and large icons (Windows) +Handle XP backwards-compatibility with 32-bit build (Windows 64-bit) +Work around hang due to unsupported AVX2 instructions (Vista 64-bit) +Fixed invalid argument exception during app launch (OS X) +Fixed binary comparison checking for wrong buffer size +Fixed GetLogicalProcessorInformation not found startup error (Windows XP SP2) +Support IP-based UNC paths with folder selector (Windows) +Use standard file permissions for application bundle (OS X) +Updated help file and added tips and tricks chapter + + +FreeFileSync 7.0 [2015-05-11] +----------------------------- +Support synchronization with MTP devices (Android, iPhone, tablet, digital camera) +Implemented file system abstraction layer +New database format supporting generic file ids +Pre-allocate disk space when writing file output stream +Late failure when moving multiple items to recycle bin +Keep UI responsive while loading/saving database file +Improved error reporting indicating failed item when moving to recycle bin +Pass correct thread id when creating Minidump (Windows) +Fixed directory icon loading resource leak (Linux) +Fixed RealTimeSync message provider exception safety issue (Windows) +Avoid locking issues by creating the log file after batch synchronization +Fixed RealTimeSync monitoring for items beyond subfolders (Linux) +Fall back to file extension during file icon load error +Show file icon by extension as temporary placeholder +Work around silent failure to copy file times to external drives (Linux) + + +FreeFileSync 6.15 [2015-04-07] +------------------------------ +Revert to log file naming convention without colon character +Prevent endless recursion when traversing into folder on corrupted file system +Fixed view filter button rendering issue for RTL languages +Fixed grid losing far scroll positions when increasing icon sizes +Flush file buffers before verifying file copy +Update existing items when retrying failed folder traversal +Harmonized bitmap file loading by removing format variance +Fixed invalid argument error when setting file times (Linux) +Fixed application hang when loading icon for named pipe (Linux) +Improved file copy read-ahead performance (Linux) +Use native file I/O for stream operations (Linux, OS X) +Fixed file copy creating zero-sized files (OS X) +Automatically create Minidump files during an application crash (Windows) +Check for missing service pack to help diagnose crash (Windows 7) +New menu item with download link after a version update +Work around C-function memory race condition when formatting time +Added Hindi language + + +FreeFileSync 6.14 [2015-02-10] +------------------------------ +New buttons allow changing the order of folder pairs +New keyboard shortcuts for rearranging folder pairs +Preserve comparison results when deleting a specific folder pair +Allow inserting new folder pairs into the middle of the list +Append status to log file names when warnings occur +Don't interrupt immediate comparison when starting a .ffs_gui file for slow devices +Work around wxWidgets bug eating up command keys in text boxes (Linux) +Fixed incorrect parameter error when checking recycle bin on drive mounted with Paragon ExtFS (Windows) +Use colon as time stamp separator in log file names +Refactored basic low-level file traversal routine +Optimized file icon startup procedure +Fixed occasional failure to set modification times on Samba shares (OS X) +Transfer creation times during file copy (OS X) +Support copying file times with nanosecond precision (OS X) + + +FreeFileSync 6.13 [2015-01-11] +------------------------------ +Fixed crash when failing to create log file during batch run +Show directory traversal errors as conflict category on grid +Improved file filter behavior for certain edge cases when updating the database +Fixed crash when task scheduler ends FreeFileSync after a certain time (Windows) +Don't show alternative folder paths if volume name is empty +Support silent installation for Inno Setup (Windows) +Fixed recursive yield when minimized into notification area (Linux, OS X) +Include ACLs when copying file and folder permissions (OS X) +New file copy routine including extended attributes (OS X) +Fixed failure to permanently delete directories containing symlinks +Copy extended attributes when creating new folders and symlinks (OS X) +Restore process umask after creating lock file (Linux, OS X) +Copy directory permissions by default (Linux, OS X) +Optimized construction of merged path filters +Exclude items subject to traversal errors when updating the database + + +FreeFileSync 6.12 [2014-12-01] +------------------------------ +New "Actions" menu bar entry with basic operations +Fixed crash after comparison while needlessly copying traversal results +Support update-checker URL redirection (Linux, OS X) +Merged installer translations into .lng files +Fully translated FreeFileSync context menu options and file types in Windows Explorer +More structured symlink handling options +Scroll to active selection in config list box on startup +Fixed delete key to remove items in config history panel (OS X) +Fixed language file parser showing incorrect row on error +Fixed crash during sync due to unsupported SSE instructions (Server 2003, XP 64-bit) +Fixed startup error due to invalid handle type +Always log folder pair paths even if there is nothing to sync +Updated translation files + + +FreeFileSync 6.11 [2014-11-03] +------------------------------ +Updated recycle bin access for Windows 10 +New command line option "-edit" to load configuration without executing +Case-insensitive command line argument evaluation +New Explorer context menu options for ffs_gui, ffs_batch files +Added sync variant to folder pair info in log file +Don't process and log folder pair if nothing to do except writing DB file +Fixed liblzma.5.dylib not found during startup (OS X 10.8) +Added version info to application bundles (OS X) +Fixed incorrect warning when configuration contains empty folder pairs +Replaced misleading inotify error message "No space left on device" (Linux) +Fixed FreeFileSync launcher blocking app folder move (OS X) +Updated default main dialog layout +Fixed async error evaluation when creating volume shadow copies +Keep user interface responsive while creating a volume shadow copy +Fixed error when starting asynchronously from a batch script +Show progress of writing log files +Fixed updated file being left deleted when copying permissions failed +New Project website: https://freefilesync.org/ + + +FreeFileSync 6.10 [2014-10-01] +------------------------------ +Fixed crash when accessing recycle bin in compatibility mode (Windows 7, 8) +Draw middle grid selection irrespective of focus column +Don't show parts of progress graph if nothing to sync +Break on missing directories before evaluating warnings +Ignore leading/trailing whitespace in search panel +Disable search panel during comparison +Disable shortkeys during comparison +Log folder pair only if files are synced +Fixed number separator formatting for English locale +Copying locked files now inactive by default +Show all affected folders when warning about a shared sub folder + + +FreeFileSync 6.9 [2014-09-01] +----------------------------- +Reuse FreeFileSync taskbar link when available (Windows 7) +Limit number of retries when creating temporary files +Fixed bitmap rendering issue for high-contrast color schemes +Revised and fixed unclear GUI texts +Updated deprecated system call when suspending idle (OS X) +Fixed retry when failing to determine recycle bin status +Added progress graph legend +Updated translation files + + +FreeFileSync 6.8 [2014-08-01] +----------------------------- +New comparison option to ignore file time shift in hours +Tentatively disabled DST hack affecting FAT file creation times +New menu option to reset GUI layout +File sizes ignore sync direction in overview panel +Sort by file name also sorts folder names +Main grid column "full path" includes file name +Always position comparison progress below main buttons +Fixed high-precision tick count calculations +Fully restart directory traversal on errors +Updated help file with steps to schedule a batch job (OS X) + + +FreeFileSync 6.7 [2014-07-01] +----------------------------- +Redesigned comparison progress statistics +Fixed crash when loading incompatible config file +Added "new" button to config panel +Avoid sync progress dialog repositioning +Resolved crash when loading sync settings for Arabic locale +Restored cancel button width +Help window not forced to float over main dialog (Windows) +Fixed overwriting old-format batch files +Harmonized view category sequence +Merged similar translation items +Fixed crash when scrolling help window without focus + + +FreeFileSync 6.6 [2014-06-01] +----------------------------- +Fixed large font size standard button layout +Fixed config dialog graphics glitch with large font sizes +Exit FreeFileSync launcher process during update +Exclude temporary files from RealTimeSync monitor +Implement correct standard button spacing (OS X) +Fixed SELinux compilation issue (Linux) +Installer adds RealTimeSync link to desktop (Windows) +Improved makefile (Linux, OS X) +Reduced binary file size (Linux) +Updated translation files + + +FreeFileSync 6.5 [2014-05-01] +----------------------------- +Support preview for RAW CR2 image files (Windows Vista and later) +Fixed startup exception when using task scheduler (Windows XP) +Correctly resolve SystemRoot NT path syntax for symbolic links +Fixed incorrect error codes being reported (Windows XP) +Fixed config dialog shortcut key presses getting lost (OS X) +Allow vertical layout for top button panel +Code cleanup: removed support for old database and XML config formats +Center sync progress dialog +Updated help file + + +FreeFileSync 6.4 [2014-04-01] +----------------------------- +Combined comparison, filter and sync config dialogs +Support alternate GlobalSettings.xml file via command line +Toggle between config panels with F6, F7, F8 +Show config status icons in notebook panel caption +Redesigned configuration dialog layouts +Fixed startup error after moving installation directory +Fixed retry on failure to resolve path by volume name +Resolved ERROR_ALREADY_EXISTS when creating temporary recycle bin subdirectory +Added "save as GUI job" button on main dialog +Added Bulgarian language + + +FreeFileSync 6.3 [2014-03-01] +----------------------------- +No wait time anymore while searching for recycle bin (Windows Vista and later) +Revised synchronization progress graph +Clean up "On completion" considering last usage +Fixed CTRL + C keyboard short cut in filter dialog (OS X) +Resolved static initialization order issues +Reduced disk accesses when resolving directory name +Added view filter labels +Updated translation files +Updated help file + + +FreeFileSync 6.2 [2014-02-01] +----------------------------- +New synchronization progress graph +Skip binary comparison for files excluded via time span or size +Fixed configuration panel ordering for entries starting with numbers +Filled gap after last grid column to cover full window width +Work around wxWidgets image button bug showing obsolete disabled state +Refined file existence checks to handle restricted permissions +Disable file filter button during comparison +Fixed mouse wheel grid scrolling for GTK2 (Linux) +Avoid dummy texts during progress dialog init (OS X) +Translated external application default commands in global settings +Support cancel while encoding extended time information +Highlight non-zero synchronization statistics + + +FreeFileSync 6.1 [2014-01-01] +----------------------------- +Released RealTimeSync for OS X +Handle errors loading reference batch config +Disable user mode exception swallowing for Windows 7 SP1 +Always exclude root nodes on manual selection when excluded items are hidden +Fixed showing duplicate custom "on completion" commands +Close old directory handle first before executing directory traversal fallback +Show negative batch synchronization result in log file name +Avoid file system race when creating temporary files +Transfer creation and modification times on folder creation +Fixed empty main dialog configuration migration issue on Mac OS X + + +FreeFileSync 6.0 [2013-12-01] +----------------------------- +Revised main dialog panel layout +Show arrow icon for shortcut files and symlinks +Execute the "on completion" command asynchronously +Resolved invalid grid background when context menu is shown +Set negative file time tolerance to disable file time check +Optimized sequence of steps when saving database files +Prevent temporary incorrect statistics after unexpected increase in workload +Fixed default height when mixing panels with and without caption on main dialog +New view filter button "show excluded items" +New keyboard shortcuts for file filter and sync settings +Removed libpng15.so dependency for openSUSE 13.1 +Updated help file +Updated translation files + + +FreeFileSync 5.23 [2013-11-01] +------------------------------ +Allow sorting root nodes on overview panel +Support retry on failure to resolve path by volume name +Copy high-precision modification times for files and symlinks +Align top panel height with comparison and sync buttons +Show lock owner while waiting on a locked directory +Resolved help file W3C validation issues +Fixed high-contrast accessibility issues in help +Fixed crash for CPUs without SSE2 when using VSS (Windows XP) +Prevent progress statistics timer overflow +Save RealTimeSync settings before forced exit due to shutdown or log off +Resolved contract violation error due to out of memory +RealTimeSync does not block system shutdown anymore +Added "select all" context menu option for progress log +Have progress log keyboard input ignore focus +Fixed category icon background color issues +Report error when reading active config file failed during save +Preload adjacent file icons on grid + + +FreeFileSync 5.22 [2013-10-01] +------------------------------ +New options for automatic retry after error +Improved compliance with Windows User Experience guidelines +Harmonized popup dialog layouts +Correctly show program menu when main dialog receives focus (OS X) +Revised configuration dialog layouts and designs +Fixed crash on startup for CPUs without SSE2 support (Windows XP) +Work around wxWidgets bug for sorted list boxes (Linux) +Updated and revised help file +Early parameter validation for filter and sync config dialogs +Fixed followed directory symlinks being incorrectly excluded +Automatically calculate best initial message box size +Progress graph and status icons support high-contrast color schemes +Include directory child-elements when manually setting filter +Allow manual filter for short name on overview panel +Don't treat file drops on directory input fields as URI (Linux) +Updated translation files + + +FreeFileSync 5.21 [2013-09-02] +------------------------------ +Detect moved/renamed files in mirror and custom variants +New database format for two way variant: old database files are converted automatically +Support double-clicking ffs_gui/ffs_batch files (OS X) +Integrated search panel (CTRL + F, F3) into main dialog +Merged variant names into top button labels +Hide dock icon while minimized to notification area (OS X) +New keyboard shortcuts: F5, F6, F7, F8, F9, F10 +Further reduced size of database files by 10% +Fixed Outlook *.ost files found missing on VSS snapshot volumes +Added include filter context menu option +Correctly scroll to search hits on different grid +Always remove .ffs_tmp files permanently +Fixed layout for buttons with text and graphics for RTL languages (Arabic, Hebrew) +Revised file filter parser: new syntax for excluding items in subdirectories +Improved configuration merge algorithm +Fixed crash when showing help due to wxWidgets 64-bit bug in help component (Windows 8) +Avoid progress dialog graph flicker during resize when too few samples are available +Progress status when deleting files not greyed-out anymore +Increased time-out to 20 seconds when checking for directory existence +Exclude broken symlinks via filter before showing error message +Follow symlinks when checking file/directory existence (Linux) +Consistently set batch error codes during startup phase +Updated translation files + + +FreeFileSync 5.20 [2013-08-03] +------------------------------ +Fixed crash on startup due to wxWidgets 64-bit bug in font enumeration (Windows 8) + + +FreeFileSync 5.19 [2013-08-02] +------------------------------ +Redesigned progress dialog including new items graph +New command line syntax: set directory names of a .ffs_gui/.ffs_batch externally +Explicit button on progress dialog to minimize to systray +Fixed progress graph labels being truncated (Debian, Ubuntu, openSUSE) +Resolved main dialog z-order issues during sync (OS X) +Reduced progress dialog layout twitching +Further improved comparison speed by 10% +Use proper config file path in file picker dialog (OS X) +Never interrupt when updating a file with fail-safe file copy after target was deleted +Prevent crash when closing progress dialog while paused (OS X) +Support external command lines starting with whitespace (Windows) +Show warning before starting external applications for more than 10 items +Start external applications synchronously if needed to avoid running out of system resources +Don't show hidden progress dialog when showing an error message in silent batch mode (OS X) +Correctly show file names containing ampersand characters in progress dialog +Adapt size of results dialog to fit contents +Correctly execute file move before parent directory will be deleted +Show a blinking system tray icon on errors instead of a modal dialog in RealTimeSync +Added installation size for Windows' Add/Remove Programs + + +FreeFileSync 5.18 [2013-07-02] +------------------------------ +Work around boost 1.54 bug "The procedure entry point GetTickCount64 could not be located in the dynamic link library KERNEL32.dll" (Windows XP) + + +FreeFileSync 5.17 [2013-07-02] +------------------------------ +Consider target file when updating followed file symlinks +Support moving files to recycle bin contained in followed directory symlinks +Move instead of copy updated files into versioning directory +Reduced memory peak when loading large database files after comparison +Check recycle bin existence only once per base folder and only if deletions occur (Windows) +Revised and enhanced error messages +Show moved files in same category as updated files +More pessimistic calculation of required disk space reducing false positives +Implemented platform-specific standard button ordering (Linux, OS X) +Set configuration panel primary orientation to vertical +Added new checks and error message strings for translation file parser +Revised middle grid inactive color and duplicate equality symbol +Skip XML comments while parsing config files +Redesigned confirmation popup dialogs +Standard button spacing conforms to operating system conventions +Shrink memory consumption of file hierarchy data structures +Don't show file deletion dialog if selection is empty +Fixed incorrect progress statistics if a file or directory is deleted externally after comparison +Focus grid cursor row after switching sides with keyboard direction keys +Improved localization process: find translation deltas more easily, better error reporting +Reset initiated grid selection when changing grid cursor +Improved sync progress dialog layout +Suppress dubious wxWidgets error message "locale 'es_AR' can not be set". (OS X) +Don't show busy cursor on synchronization results dialog +Log error message upon retry as type info only +Updated translation files + + +FreeFileSync 5.16 [2013-06-01] +------------------------------ +Integrated both category and sync action view into middle grid +Condensed folder pair display names on overview panel +Consider symlinks and junctions when copying locked files (Windows Vista) +Resolved failure to set directory lock within Windows XP as Virtual Box guest +Period resolves to working directory again +Fixed "DecodePointer could not be located in KERNEL32.dll" (Windows 2000) +Support closing progress dialog forcefully during sync (OS X) +Don't disable all child items if directory traversal fails for a single item only +Simplified deletion confirmation dialog (removed "delete on both sides") +Work around wxWidgets leaking memory on exit (OS X) +Avoid wxWidgets crash when deleting folder pair control (OS X) +Prevent wxWidgets corrupting stack when wxLocale is allocated statically (Linux) +Use GetUserDefaultLangID to determine installer default language +Avoid progress speed and remaining time jitter +Check existence only once for duplicate base directories +Detect invalid file symlinks pointing to directories (Windows) +Disable unsuitable buttons in pop up dialogs when checkbox is set +Copy folder attributes if source is a junction already on Windows XP instead of Vista +Mark failed UTF conversions with replacement character +Do not restore main dialog position outside visible screen area (multi monitor setup) +Support detection of moved files through symlinks +Reduced memory consumption when detecting moved files +Check for duplicate file ids when detecting renamed files +Redetermine volume id for followed directory symlinks +Removed "Compare_Complete.wav" +Don't accept file deletion confirmation in less than 50ms +Systematically resolved translation bugs +Added Serbian language + + +FreeFileSync 5.15 [2013-05-01] +------------------------------ +New menu option to activate/deactivate automatic update checking +Show status message while checking for program updates +Faster start up times through asynchronous config file checking +Automatically migrate configuration files to new format +New context menu options to copy and paste filter settings +Support file and folder names with trailing space or period characters +Do not show superfluous scroll bars for multiple folder pairs +Correctly show long file paths when moving to recycle bin failed (Windows Vista and later) +Status feedback before blocking while creating a Volume Shadow Copy +Do not show dummy texts while initializing progress dialog (OS X) +Allow to maximize filter dialog +New column for item count on overview panel +Allow CTRL + C to copy selection to clipboard on overview panel +Consider current view filter for file selection on overview panel +Work around silent failure to set modification times on NTFS volumes (Linux) +Avoid main dialog flash when closing progress dialog (Linux) +Do not show middle grid tooltip when dragging outside visible area +Reduced file accesses when loading XML files +Simplified structure of GlobalSettings.xml +Allow to change default exclusion filter via GlobalSettings.xml: "DefaultExclusionFilter" +Split filter entries over multiple rows in ffs_gui/ffs_batch XML files +Resolved failed assert during start up (ReactOS) +Create directory locks after one-time existence check +Show warning when locking directory failed +Reset main dialog layout to fix top panel default height being too small +New help file topic "Expert Settings" +Updated translation files + + +FreeFileSync 5.14 [2013-03-31] +------------------------------ +Do not process child elements when parent directory creation fails +Start comparison after pressing Enter in directory input fields +Lead grid is determined via keyboard input instead of input focus change +Ignore empty directory entries in RealTimeSync +Restored mouse cursor "snap to default button" +Implemented file icon support for sync preview (OS X) +RealTimeSync exit via menu working again +Restore main dialog even if "close progress dialog" is selected +Show full path when failing to create directory on not existing target drive +Middle grid tooltip shown correctly again (SUSE Linux/X11) +Prevent process hang when manually writing to directory history (Linux and OS X, wxWidgets 2.9.4) +Resolved crash after showing help dialog (OS X) +Properly handle non-ASCII characters for external commands (OS X) +Support UTF8 format restrictions on file systems like HFS (OS X) +Do not stretch small thumbnail icons (Linux) +Use 32x32 instead of 48x48 as medium icon size on Windows XP +Properly size non-jumbo icons in thumbnail view (Windows Vista and later) +Reduced GDI resources for file icon buffer (Windows) +Automatically check for updates weekly without showing pop up on first start +Restored program logo in systray progress indicator +Fit grid row label to match wide font sizes +Added macros %csidl_Downloads%, %csidl_PublicDownloads%, %csidl_QuickLaunch% (Windows Vista and later) + + +FreeFileSync 5.13 [2013-03-06] +------------------------------ +Prepared support for new build on Mac OS X +Time out for not existing directories after 10 seconds +Check directory existence in parallel +Inform about all missing directories via a single error message +Show remaining time considering relative error of 10% +Check for grid icon updates only when needed +Revised directory lock process detection +Implemented high resolution icons +Accessibility: fixed unreadable labels +More polished user interfaces +Fixed time stamp not being set on NFS/Samba shares (Linux) + + +FreeFileSync 5.12 [2013-02-04] +------------------------------ +Dynamic statistics adjustment during synchronization +Allow to save active view filter settings as default (context menu) +Stay responsive while checking recycle bin existence on slow disks +Reset option "Delete on both sides" upon each manual deletion +Added context menu to allow deletion of last used configurations +Support numpad add/subtract keys for overview tree +Revised external application integration +Call external applications for multiple selected items +Automatically schedule abandoned recycle bin temp directories (.ffs_tmp) for deletion +Binary comparison speed estimate considers errors and short-circuit evaluation +Use full time window of sync phase when calculating overall speed +Added Arabic language + + +FreeFileSync 5.11 [2013-01-06] +------------------------------ +New file versioning scheme: move to folder replacing existing files +Fixed high CPU consumption after longer syncs +Improved .ffs_batch configuration file handling +Allow to quick save .ffs_batch files on main dialog and program exit +Convert batch-exclusive settings when opening a .ffs_batch file on main dialog +Redesigned configuration dialog layout +Enhanced all file I/O error messages to show locking processes (Windows Vista and later) +Separator in CSV file now locale dependent +Avoid "Windows Error Code 2" for truly empty directories +Macro %month% resolves to decimal number +New macro %timestamp% +Revised sync progress graph +Fixed progress graph graphics glitch for RTL layout +Allow XML element values to contain non-escaped quotation marks +Updated help file +Updated translation files + + +FreeFileSync 5.10 [2012-12-03] +------------------------------ +Show synchronization log as a grid in results dialog +Improved grid scrolling performance (most noticeable on Linux) +Allow grid selection starting from outside of the grid +RealTimeSync: Support drag & drop on main dialog for *.ffs_real and *.ffs_batch files +Optimized memory consumption when generating log for millions of items +Optimized memory consumption when exporting to CSV file +Have grid row height match window default font size +Catch out of memory when copying huge lists into clipboard +Fixed failure to resume aborted sync after having FFS implicitly create target directory +Fixed horizontal mouse wheel scrolling direction for RTL languages (Hebrew) +RealTimeSync: Fixed drag and drop not working (Linux) +Set maximum size of LastSyncs.log in GlobalSettings.xml element +Show error when trying to copy a named pipe rather than hang (Linux) +Improved copy routine minimizing file accesses (Linux) +Copy file access permissions by default (Linux) +Fixed unexpected "File or Directory not existing" error during file copy (Linux) + + +FreeFileSync 5.9 [2012-11-03] +----------------------------- +Scroll grid under mouse cursor +Move files directly to recycle bin without parent "FFS 2012-05-15 131513" temporary folders +Offer $HOME directory alias in directory drop down list (Linux) +Support for tilde (~) character in input folder paths (Linux) +New environment variables for RealTimeSync: %change_action%, "%change_path% +Use Internet Explorer proxy settings for new version check (Windows) +Show proper error message after failed symlink creation +Start comparison upon double-clicking config list +New batch return code: "Synchronization completed with warnings" +Hide files that won't be copied by default if direction "none" is part of the rule set (e.g. update variant) +Remember save config and folder picker dialog positions separately +New sync completion sound +Fixed sync completion sound not playing (Ubuntu) + + +FreeFileSync 5.8 [2012-10-01] +----------------------------- +New icon theme +Dynamic save button and dialog title show unsaved configuration +Exclude all folders if file size or time span filters are active +Added macros %csidl_Nethood%, %csidl_Programs%, %csidl_Startup% +Fixed crash on failed CRT parameter validation (Windows) +Update-checker handles moved web address +Fixed configuration conversion error when deleting into versioning folder +Avoid modal error dialogs in batch mode unless error handling is set to "pop up" +Set return codes in batch mode even if modal dialogs are shown +Disabled UAC virtualization for 32-bit user-mode process +Descriptive error message when setting invalid dates on FAT volumes + + +FreeFileSync 5.7 [2012-09-04] +----------------------------- +Modern directory selection dialog (Windows Vista and later) +New file versioning scheme appending revision number to files +New sync option to limit number of versions per file +Revised configuration format for *.ffs_gui/*.ffs_batch files: old format will be supported for some time +Fixed crash on invalid file modification times +Fixed zlib error on empty database stream +GlobalSettings.xml: added "MaxSize" parameter to "ConfigHistory" +Fixed occasional crash on GTK 2 (Linux) +Always show "items processed" in log file +Simplified configuration dialogs +Fixed password prompt not always coming up when connecting to a network share +Support environment variables everywhere: +on completion; +external applications; +RTS command +Harmonized external application macros: %item_path%, %item_folder%, %item2_path%, %item2_folder% +Updated translation files + + +FreeFileSync 5.6 [2012-08-02] +----------------------------- +Resize left and right grids equally +Allow to move middle grid position via mouse +Automatically resize file name columns +Do not follow reparse points other than symlinks and mount points +Warn if recycle bin is not available during manual deletion +Fixed error when saving log file into volume root directory +Show files which differ in attributes only in the same category as "equal" files +Apply hidden attribute to lock file +Fixed potential "access denied" problem when updating the database file +Show errors when saving configuration files during exit (ignore for batch mode) +Mark begin of comparison phase in the log file +More detailed tooltip describing items that differ in attributes only +Added Scottish Gaelic translation + + +FreeFileSync 5.5 [2012-07-01] +----------------------------- +New database format for variant: old database files are converted automatically +Tuned performance for variant when saving database for millions of files: > 95% faster +Support partial database updates for variant respecting current filter +Reduced size of database files by 30% +Fine-tuned algorithm to avoid certain conflicts after changing comparison settings +Lower peak memory consumption when reading database participating in multiple sync jobs +Refined symlink categorization and variant handling +Always save log of last syncs to %appdata%\FreeFileSync\LastSyncs.log (128 kB limit) +"Save" and "Save As" menu options +Properly show status message after save configuration +Avoid issues applying file modification time on certain NAS +Refined last-used configuration handling +Avoid race-condition: database file is only read if directory is existing +Protect against temporary network drop between comparison and synchronization +Rearranged statistics panel to save vertical space when vertically aligned +Removed limitation for number of conflicts shown in the warning message and log +Consider both global and local filter when estimating whether folder could contain matches +Updated translation files + + +FreeFileSync 5.4 [2012-06-01] +----------------------------- +Copy all NTFS extended attributes +Improved statistics panel +Improved main grid +Support context menu for files in overview tree +Process double-clicks outside main grid +Allow quoted paths ending with backslash in command line: "C:\" +Fully localized number formatting (Windows) +Fixed deletion dialog header being trimmed (Linux) +Fixed exclusion via context menu (Linux) +Preserve row label width after comparison (Linux) +Updated help file +New batch mode return codes, see help file +Prefix custom deletion directory with job name +Use the same time stamp for log file and versioning +Handle folder drag and drop outside main grid +Avoid name clash having multiple folder pairs delete into the same versioning folder +Exit FreeFileSync automatically while upgrading to new version +Accessibility: Support high-contrast color schemes +Yet another UI design overhaul +Fixed "access denied" issue on OS X-hosted network shares +Support Citrix folder shares +Support Arch Linux (Chakra) +Updated translation files + + +FreeFileSync 5.3 [2012-05-02] +----------------------------- +Show which processes lock a file during synchronization (Windows Vista and later) +Use unbuffered copy to speed up copying large files (Windows Vista and later) +Preserve NTFS sparse files +Support referencing all logical volumes by name (including FreeOTFE virtual drives) +Fixed lag showing "Searching for directory" on comparison +New context menu filter option: exclude by short name +Use clicked-on row rather than anchor when determining action for shift-selection +Refresh grid after pressing "CTRL + A" +Add base folder pairs to CSV export +Show full path in tooltip if multiple folder pairs are used +Show child dialogs on same monitor as parent dialog on multiple monitor systems +Added statistics at beginning of batch log file +Fixed batch mode final speed statistic and reset graph after binary comparison +RealTimeSync: Automatically retry after 15 seconds if an error occurs +Show button images untrimmed (Linux) +Fixed problems with auto-closing progress dialog (Linux) +Fixed unresponsive progress dialog and systray icon (Linux) +New option in GlobalSettings.xml: "LockDirectoriesDuringSync" +Added Lithuanian translation +Added Norwegian translation +Updated translation files + + +FreeFileSync 5.2 [2012-04-01] +----------------------------- +Fixed runtime error "Error comparing strings! (LCMapString)" (Windows 2000, XP) + + +FreeFileSync 5.1 [2012-03-31] +----------------------------- +New category for time span filter: last x days +Fixed "Error loading library function: GetVolumeInformationByHandleW" if NTFS permissions are copied +Fixed command line issues: allow config name without extension, allow multiple directories instead of a config file +Reenabled global shortcut F8 to toggle data shown in middle grid +Unified error handling on failure to create log directory +Do not close batch creation dialog after save +Tree view: compress and filter root nodes the same way as regular folder nodes +Fixed wrong tooltip being shown if directory name changes +Date range selector does not trim year field anymore +Show action "do nothing" on mouse-hover for conflicts in middle grid +Fixed "Windows Error Code 59: An unexpected network error occurred" +New filter pattern: *\* matches all files in sub directories of base directories +Fixed "*?" filter sub-sequence +Fixed "Cannot convert from the charset 'Unknown encoding (-1)'!" +Support CTRL + A in filter dialog +Support large filter lists > 32 kByte +Allow to hide file icons +Avoid switching monitor when main dialog is maximized on multiple monitor systems +Improved huge XML file loading times by a factor of 3000, saving by a factor of 3 +Restore grid scroll position after repeated comparisons +Show log after sync when non-fatal errors occurred +Fixed crash in UTF8 conversion when processing a corrupted ffs_db file +Even more pedantic user interface fine-tuning +Compiles and runs on openSuse 12.1 +Fixed grid page-up/down keys scrolling twice (Linux, wxGTK 2.9.3) +Fixed unwanted grid scrolling when toggling middle column (Linux, wxGTK 2.9.3) +Fixed middle grid tooltip occasionally going blank (Linux) +Support single shift-click to check/set direction of multiple rows +Removed gtkmm dependency (Linux) +Installer remembers all settings for next installation (local installation only) +All executables digitally signed +Updated translation files + + +FreeFileSync 5.0 [2012-01-30] +----------------------------- +New grid control +New tree control +Revised Right to Left layout for Hebrew +Updated translation files + + +FreeFileSync 4.6 [2011-12-25] +----------------------------- +Execute user-defined command after synchronization +Option to automatically close synchronization progress dialog +Automatically adjust statistics during sync if changes happened after comparison +Fixed "DecodePointer could not be located in KERNEL32.dll" (Windows 2000) +Fixed "Windows Error Code 31: A device attached to the system is not functioning" +Mouse wheel will scroll list of folder pairs instead of toggle through directory history +No error message when scanning a single directory +Minimized disk accesses when deleting files +Less mouse-clicks required when overwriting configuration +Pause timers while showing error messages +Show error message for malformed external commands +Support detection of moved files over "subst" alias +New default font: Segoe UI (Windows Vista and later) +Save settings before forced exit due to shutdown or log off +Updated translation files + + +FreeFileSync 4.5 [2011-11-25] +----------------------------- +Fixed "Windows Error Code 50: The request is not supported" +Fixed "Windows Error Code 124: The system call level is not correct" +Fixed config load performance problem if network drive is not reachable +Support traversing truly empty directories (no ., ..) (Windows) + + +FreeFileSync 4.4 [2011-11-22] +----------------------------- +Fixed error copying files containing alternate data streams (Windows) + + +FreeFileSync 4.3 [2011-11-20] +----------------------------- +Detection of moved and renamed files +New database format for mode: a full sync is suggested before upgrading +Fixed overwrite symlink with regular file +Fixed synchronization result dialog GUI glitch (Windows XP) +Fixed macro %weekday% +RealTimeSync: Fixed support for manual volume unmount (Windows) +Added Croatian language +Updated translation files + + +FreeFileSync 4.2 [2011-11-02] +----------------------------- +Implemented workaround for compiler bug leading to uncaught exceptions (Windows 32 bit) +Shadow Copy Service: Native support for Windows7/Server 2008 +Fixed reference by volume name parsing issue +Rearranged synchronization progress dialog +More concise log message format +Fixed default file icon (Kubuntu) +Support for wxWidgets 2.9 series (Ubuntu/Kubuntu) +FAT 2 sec tolerance for files dated in the future +Honor DACL/SACL inheritance flags when copying NTFS permissions (Windows) +New option in GlobalSettings.xml: "RunWithBackgroundPriority" (Windows Vista and later) + + +FreeFileSync 4.1 [2011-10-09] +----------------------------- +Improved synchronization progress dialog +Show all available aliases in directory history list +Show password prompt when connecting to mapped network share +Removed busy cursor after program start up +RealTimeSync: atomically detect missing directories +Handle not existing reference by volume name as an invalid path +Improved start up responsiveness by checking dir/file existence asynchronously +Fixed loading incorrect directory name when using multiple folder pairs +Allow passing multiple configurations via command line +Allow passing multiple directory names via command line + + +FreeFileSync 4.0 [2011-09-25] +----------------------------- +Thumbnail list view +Option to specify comparison settings at folder pair level +Correctly update parent-child relationship when changing sync directions +Show history list for additional folder pairs +Switch between volume name and full path in directory history list +Perf: shrinked folder matching CPU time by over 70% +Show windows environment strings in directory history list +Show windows special folder IDs in directory history list +Fixed progress dialog going into background on heavy load +Support creating old 8.3 directories +Take over configuration name when creating new batch job +Remember batch-specific settings when loading a ffs_batch file from main dialog +Drag & drop ffs_batch files on main dialog to test and edit batch settings +Automatically resolve objects deleted externally after comparison +Date column context menu: manual time range selector +New categories for time span filter: today, this week, this month, this year +Respect both sides when sorting by relative path +Updated COM error message reporting resolving "Unknown error" +Smarter configuration merge algorithm +Correctly show existing folders on both sides when using include filter +Fixed network access using WebDrive +Update modification times during file copy to write current values to database +RealTimeSync: write name of changed file into environment variable "changed_file" +RealTimeSync: fixed network drop incorrectly being handled as a failure +Set default direction according to current configuration when deleting manually +Plenty of GUI improvements +Updated help file +Updated translation files + + +FreeFileSync 3.21 [2011-08-19] +------------------------------ +Fixed deleting to user-defined directory +Fixed crash when using include filter +New global option to disable transactional file copy + + +FreeFileSync 3.20 [2011-08-11] +------------------------------ +Scan multiple directories in parallel +Automatically resolve disconnected network maps +Fixed temporal hang when dropping large files on main dialog + mode: Fixed issue regarding directory names differing in case during first sync +Delete permanently if recycle bin is not available (Linux) +Keep FreeFileSync responsive when trying to access non-existent network folder +Support for Ubuntu Unity Launcher (Linux) +RealTimeSync: Failure notification if command line is invalid (Linux) + + +FreeFileSync 3.19 [2011-07-23] +------------------------------ +Exclude sub directories from synchronization which cannot be accessed during comparison +Warning if Recycle Bin is not available instead of deleting silently (Windows) +Adapted log message if missing recycler leads to permanent deletion (Windows) +Revert to per file recycle bin handling if creating temp recycler folder fails +Avoid orphaned deletion temp directories on network drives +Quick-select comparison and synchronization options via double-click +New right-click drop down menu on comparison and synchronization settings button +New database design: copying the database file does not lead to complications anymore +Full support for "retry" while comparing +Don't copy empty folders when filtering by time span +Allow loading/merging multiple configurations files via file open dialog +Allow loading/merging multiple configurations in last used config list +Fixed system shutdown interruption during batch mode +Allow saving log files in both silent and non-silent batch jobs +Reduced main dialog flicker when switching configurations +Database and lock files created by FreeFileSync do not trigger RealTimeSync anymore +Restrict maximum number of visible folder pairs to 6 (configurable via GlobalSettings.xml) +New macros: %day%, %hour%, %min%, %sec% + + +FreeFileSync 3.18 [2011-07-03] +------------------------------ +Launcher running synchronously and returning application error code +Fixed sort by file extension +Fixed drag and drop of SAMBA network folder +Render (all) invalid file dates correctly on GUI +Correct layout selection for RTL and LTR languages +Correct GUI status texts while waiting for directory lock +Properly set default directory when loading configuration +New XML framework: zen::Xml +Added Hebrew language +Added Danish language +Updated translation files + + +FreeFileSync 3.17 [2011-05-20] +------------------------------ +Filter files by size +Filter latest files by time span +Launcher automatically selecting 32/64 bit executable on start up +More detailed systray progress indicator +New database format for mode: a full sync is suggested before upgrading +Update database at individual file level (support for partial and aborted syncs) +New translation file format +Dynamically load existing translation files +Correct translation plural forms +Improved directory locking strategy +Restructured installation package +One button-click synchronization +Fixed CSV character encoding +Put CSV values in quotes if they contain semicolons +Explicit button and settings for "Custom" sync variant -> old configurations need to be migrated +Keyboard shortcuts also on middle grid +Minimize progress dialog by clicking on taskbar +Render invalid file dates correctly on GUI +Process user-defined commands via shell execution (FFS and RTS) +Allow base directory names having trailing white-space +Added Ukrainian language +Updated translation files + + +FreeFileSync 3.16 [2011-04-21] +------------------------------ +Fixed file copy issues on SAMBA shares +Small GUI fixes + + +FreeFileSync 3.15 [2011-04-19] +------------------------------ +Overwriting a file as fully transactional operation +Optimized synchronization speed (non-cached volumes, e.g. memory sticks in particular) +Volumes can be specified by name: []\ (use case: variable drive letters, RealTimeSync) +Copy NTFS compressed, encrypted and sparse file attributes +Copy NTFS compressed and encrypted directory attributes +Copy NTFS alternate data stream +Improved performance: CSV export, copy to clipboard, sync log display +Improved color theme support +Fixed crash on certain system text color settings +Fixed progress numbers for manual deletion +Allow aborting manual deletion via escape key +Use relative name for file tooltip +Automatically redirect arrow keys to main grid +More tolerant directory creation (operation not supported/wrong parameter) +More tolerant file move: ignore existing files (user-defined deletion directory) +Added macro %weekday% + + +FreeFileSync 3.14 [2011-03-20] +------------------------------ +New keyboard shortcuts: F5: compare F6: synchronize +Skip to next folder pair if fatal error occurred (instead of abort) +Reload last selected configuration on start up +Abort with error when copying to empty directory field +Full log information after comparison (including file transfer) +Check read access for source file before overwriting target +Fixed possible application crash after comparison +Fixed possible network freeze when comparing +Maximum number of log files can be specified +Don't condense white-space when loading XML configuration +RealTimeSync: Put executable name in quotes when parsing *.ffs_batch file +Large program icons - 256 x 256 +Handle daylight saving time(DST) on FAT network shares +Skip DST handling if drive does not support accurate file times +Many small GUI/usability fixes +Added Korean translation + + +FreeFileSync 3.13 [2011-01-16] +------------------------------ +Implemented Advanced User Interface to allow user specified layout customizations +Process case sensitive file/directory/symlink names +Synchronize name/attributes only avoiding full copy if appropriate +Prevent hibernation/sleep mode during comparison and synchronization (Windows) +New database format: single file for FreeFileSync 32 and 64 bit versions + - full sync suggested before migrating to v3.13 + - old sync.x64.ffs_db files may be deleted +Improved algorithm to calculate remaining time +Allow resizing window containing multiple folder pairs +Show folder short names in column file name +Correctly report message "nothing to sync" in batch mode +Removed libjpg-8 dependency (Linux) +Fixed loading correct maximized position on multi-screen desktop +RealTimeSync: Removed blank icons in ALT-TAB list during execution of command line +Show RealTimeSync job name as systray tooltip +Last used configurations as sorted list without size limitation +Remove redundant configuration when merging multiple ffs_gui/ffs_batch files +Warning if folder is modified that is part of multiple folder pairs +Aggregated warning messages for all folder pairs instead of one per pair +Added privilege to access restricted symlink content +Added Greek translation + + +FreeFileSync 3.12 [2010-11-28] +------------------------------ +Allow empty folder pairs without complaining +Automatically exclude database and lock files from all (sub-)directories (not only from base) +Resize grid columns on both sides in parallel +Fixed tooltip foreground text color (Linux) +Search via CTRL + F and F3 now as global hotkeys +Fully portable use of directory locking (Windows/Linux, 32/64 bit) +RealTimeSync: Treat missing network path the same as missing local path +Show current job name during synchronization (batch/gui) +Allow copying dereferenced (=followed) directory Symlinks over network share +Fail to copy Symlinks (=direct) over network share instead of silently creating empty folder (Windows XP) +Copy NTFS junctions as Symlinks (avoiding permission checks) +RealTimeSync: ignore request for device removal on network mapped drives +Support for copying SELinux security contexts +Fixed moving buttons in synchronization dialog +Allow deleting currently selected item from list of last used folders (not before wxWidgets 2.9.1) +Avoid losing focus after manually deleting a file +Preserve custom changes to sync directions after manually deleting a file +Handle empty tooltips correctly (Linux) +Updated translation files + + +FreeFileSync 3.11 [2010-09-20] +------------------------------ +Fixed migration issue: reasonable default value for number of folder pairs +Better message box background color + + +FreeFileSync 3.10 [2010-09-19] +------------------------------ +Automatically solve daylight saving time and time zone shift issues on FAT/FAT32 (finally) +Instantly resolve abandoned directory locks associated with local computer +Show expanded directory name as tooltip and label text (resolves macros and relative paths) +Do not copy relative file attributes for base target directories that are created implicitly +Move dialogs by clicking (almost) anywhere +RealTimeSync: ignore request for device removal on Samba shares +Added UTF-8 BOM for CSV export +Correctly handle window position on multi-screen desktop +Disabled warning "database not yet existing" +RealTimeSync: replaced delay by minimum idle time +Maximum number of folder pairs configurable via GlobalSettings.xml (XML node ) +Added tooltips to display long filenames on main grid +Keep application responsive when deleting large directories +Vista/Windows 7: harmonize modification times shown on main grid with Windows Explorer +Changed background color to avoid unreadable texts in combination with certain color themes +Toggle middle grid comparison result/sync preview with right mouse button click +Further GUI enhancements/polishment/standard conformance +Updated translation files + + +FreeFileSync 3.9 [2010-08-10] +----------------------------- +Advanced locking strategy to allow multiple processes synchronize the same directories (e.g. via network share) +Merge multiple *.ffs_batch, *.ffs_gui files or combinations of both via drag & drop +Copy file and folder permissions (requires admin rights): + - Windows: owner, group, DACL, SACL + - Linux: owner, group, permissions + - correctly handle Symbolic Links + - new option in global settings +Compare by content evaluates Symbolic Links +32-Bit build compiled with MinGW/GCC to preserve Windows 2000 compatibility +RealTimeSync: Handle requests for device removal (USB stick) while monitoring +Sort by file size: group symlinks before directories +Added macros %week%, %month%, %year% for creating time-stamped directories +Touch database file when changes occurred only +Moved settings "file time tolerance" and "verify copied files" to GlobalSettings.xml +Updated translation files + + +FreeFileSync 3.8 [2010-06-20] +----------------------------- +New options handling Symlinks: ignore/direct/follow => warning: new database format for mode +Fixed crash when starting sync for Windows XP SP2 +Prevent tooltip from stealing focus +Show associated file icons (Linux) +Run folder existence checks in separate thread (faster network share access) +Write mode database file even if both sides are already in sync +Don't raise status dialog to the top after synchronization +Embedded version information into executable (Windows) +Migrated compiler to Visual C++ 2010 (Windows) +Avoid losing manual changes when excluding via context menu +Adjusted update-checker web-address +Updated translation files + + +FreeFileSync 3.7 [2010-05-16] +----------------------------- +RealTimeSync: Trigger command line only if all directories are existing +Allow for drag and drop of very large files +Batch modus: New "Switch" button opens GUI modus when warnings occur +Support copying old 8.3 filenames correctly +Handling of Symbolic Links configurable via GUI +Fine tuned calculation of remaining disk space for custom deletion directories +Save default config files only if actually changed +NSIS installer: Support for /D and /S switches +Fixed resource loading if installation folder is not working directory (Linux build) +Consolidated batch creation dialog + mode: Detect conflict when a directory shall be deleted while new sub-elements are to be copied +Automatically mark left behind temporary files (*.ffs_tmp) for deletion with next sync +New Project website: freefilesync.sourceforge.net +A lot of small GUI fixes +Updated translation files + + +FreeFileSync 3.6 [2010-03-31] +----------------------------- +Fixed occasional crash when starting FreeFileSync + + +FreeFileSync 3.5 [2010-03-27] +----------------------------- +Allow mode syncs between 32 bit, 64 bit, Windows and Linux builds +Show progress indicator in window title +Support for progress indicator in Windows 7 Superbar +Reduced progress indicator flicker +Prevent silent batch mode from taking keyboard focus +Improved error messages (loading/saving/copying files) +Improved environment variable tolerance: strip blanks and double-quotes +RealTimeSync: Fixed crash when double-clicking systray icon +Allow aborting all operations via Escape key +Added British English translation + + +FreeFileSync 3.4 [2010-03-04] +----------------------------- +Performance: Reduced Recycle Bin access time by 90% +Recycle Bin support for Linux +Performance: Reduced binary comparison sequential read time (by up to 75% for CD/DVD access) +Improved synchronization sequence to avoid disk space shortage: overwrite large files by small ones first +Fixed problems with file renaming on Samba share +New free text grid search via shortcuts CTRL + F and F3 +Show number of processed files at end of synchronization +New optional grid column: file extension +New comparison category icons +Fixed handling sync-config of first folder pair +Allow moving main dialog by dragging client area instead of title bar only +Enhanced help file: Run RealTimeSync as Service +Prefix log files with name of batch job +Fixed GUI right-to-left mirroring for locales Hebrew and Arabic +Portable version: save configuration in installation folder +Many small GUI enhancements +Updated translation files +New Linux .deb package: ppa:freefilesync/ffs + + +FreeFileSync 3.3 [2010-02-02] +----------------------------- +New installer package for portable/local/32/64-bit versions +Built-in support for very long filenames: apply \\?\-prefix automatically +New button for synchronization preview: show equal files +RealTimeSync: Respond to directory or volume arrival, e.g. USB stick insert +Start comparison automatically when double-clicking on *.ffs_gui files +Visual progress indicator for sys-tray icon +Fixed string comparison for 'ß' and 'ss' (all Windows versions) +Fixed general string comparison for Windows 2000 +Significantly faster file icon loading +Applied new IFileOperation interface for recycle bin (Windows >= Vista) +Patched mode to handle FAT32 2-second file time precision +Play optional sound after comparison: "Compare_Complete.wav" +Allow environment variables for log file-directory +Enhanced conflict reporting +Added Swedish translation +Updated translation files + + +FreeFileSync 3.2 [2009-12-13] +----------------------------- +Native Windows 64-Bit version (including Volume Shadow Copy Service) +Harmonized filter handling: global and local file filters +Unified handling of first folder pair: all pairs now semantically equal +Use environment variables within directory names (e.g. %USERNAME%) +New keyboard shortcuts to set sync-direction: ALT + +Allow copying to non-encrypted target directory +Fixed sort by filename +Fixed GDI resource leak when scrolling large grids +Fixed string comparison for 'ß' and 'ss' (Windows >= Vista) +Faster file icon loading +Remove elements in folder drop down list via DEL key +New integrated help file +Play optional sound after synchronization: "Sync_Complete.wav" +Several GUI/usability improvements +Created package for PortableApps.com +Added Finnish translation +Updated translation files + + +FreeFileSync 3.1 [2009-10-26] +----------------------------- +Support for multiple data sources in Automatic mode +Copy file and folder create/access/modification times when synchronizing +Progress dialog can be minimized to systray (Batch and GUI mode) +Allow switching between silent/non-silent batch mode interactively +Some GUI improvements + + +FreeFileSync 3.0 [2009-10-15] +----------------------------- +New synchronization mode: +Consolidated batch mode error handling +Fixed crash when comparing multiple pairs by content +Fixed calculation of remaining objects +Fixed swapping grids +Show scanned files when traversing with filter enabled +New default filter values +New macros %time%, %date% for creating time-stamped directories +Avoid corrupted data when program is terminated unexpectedly +Prevent deletion when source-directory (temporarily) is not accessible +Native Unicode support for Linux build +Added Romanian translation +Added Turkish translation +Updated translation files + + +FreeFileSync 2.3 [2009-09-27] +----------------------------- +New filter and sync configuration at folder pair level +Improved sorting: sort across multiple folder pairs + stable sorting in middle grid + consolidated sorting of sync-direction +Open external applications via context menu(customizable) +Removed performance penalty when using include filters +Improved filter syntax for strings beginning with wildcards +Default handling for conflict files now configurable +New option to show all hidden dialogs again +Fixed issue with macros %nameCo, %dirCo +New option in *.ffs_gui/ffs_batch files: Verify copied files +Use Windows Volume Shadow Copy for shared and locked files(new) +More detailed information in *.cvs export +Use current working directory to save global configuration (portable version) +Respect sub directories when manually changing sync-direction +Allow import of batch configuration into GUI mode +Some small GUI improvements +New shortcuts: SPACE: (de-)select rows; ENTER: start external application +Performance improvements: Reduced CPU time by 28%, (peak) memory consumption by 20% +Added Traditional Chinese translation +Updated translation files + + +FreeFileSync 2.2 [2009-08-16] +----------------------------- +New user-defined recycle bin directory +Possibility to create synchronization directories automatically (if not existing) +Support for relative directory names (e.g. \foo, ..\bar) respecting current working directory +New tooltip in middle grid showing detailed information (including conflicts) +Status feedback and new abort button for manual deletion +Options to add/remove folder pairs in batch dialog +Added tooltip showing progress for silent batch mode +New view filter buttons in synchronization preview +Revisioned handling of symbolic links (Linux/Windows) +GUI optimizations removing flicker +Possibility to create new folders via browse folder dialog +Open files with associated application by special command string +Improved warning/error handling +Auto-adjust columns automatically or manually with CTRL + '+' +New macros for double-click command line: %name, %dir, %nameCo, %dirCo +Fixed runtime error when multiple folder pairs are used +New tool 'RealTimeSync': Watch directories for changes and start synchronization automatically +Improved XML parsing, fault tolerance and concept revisioned +More detailed statistics before start of synchronization +Removed superfluous border for bitmap buttons (Linux only) +Added Czech translation +Updated translation files + + +FreeFileSync 2.1 [2009-07-03] +----------------------------- +Fixed bug that could cause FreeFileSync to crash after synchronization +Compiled with MS Visual C++ 2008 using static runtime library + + +FreeFileSync 2.0 [2009-06-30] +----------------------------- +Copy locked files using Windows Volume Shadow Copy +Load file icons asynchronously for maximum display performance +Handle include filter correctly when comparing +Display optional summary window before starting synchronization +Adjust sync direction properly when switching sides +Info about sync variant on main dialog +Issue a warning message for each conflict type when comparing +Save default configuration in user application path (Installer based version) +Limit main dialog minimum size +Update grid row labels while scrolling +Right-click selects cell before opening context menu +New context menu options to manually assign a sync-direction +Moved sync-preview switch into middle grid's context menu +Possibility to remove top folder pair +Fixed calculation of row total in sync preview +File icons configurable for each side +Many small GUI improvements +Compiled successfully with GCC 4.4.0 and MS Visual C++ 2008 +Added Russian translation +Updated translation files + + +FreeFileSync 1.19 [2009-06-01] +------------------------------ +New synchronization preview +Sync-direction can be adapted manually +New category type "conflict" +New check for unresolved conflicts +Improved overall GUI layout +New check for erroneous file modification dates +Optional pop up to notify on changed configuration +Files with invalid dates (e.g. year 30.000) do not result in a program abort anymore +Replaced column "full name" by "full path" to be combined with "filename" +Apply filtering WHILE comparing (if activated) and avoid traversing excluded directories +New filter paradigm: use relative instead of absolute names +New option "ignore DST +/- 1-hour" to correctly handle daylight saving changes +Sync preview statistics now on main dialog +Show only relevant synchronization options +File icon display configurable via grid column context menu +Updated translation files + + +FreeFileSync 1.18 [2009-05-10] +------------------------------ +Linux build officially released: all major problems solved! +New statistic: remaining time +New statistic: bytes per second +Automatically check for program updates every week +Finally got rid of scroll bar in middle grid for Linux build +Fixed issue with file icon display +Fixed overlapping grid cells +Alternate log file directory configurable via GUI +Added drag & drop support for batch job assembly +Simplified filter usage: - matches "\*" as well as "\" + - only distinct filter entries are considered +Platform dependent line breaks in configuration *.xml files +"Significant difference check" runs at folder pair level +Sorting runs at folder pair level +New check for sufficient free disk space (considering recycle bin usage) +New optional grid column: directory +New sort by directory name +Reduced memory consumption by 10% +A lot of smaller improvements +Added Brazilian Portuguese translation +Updated translation files + + +FreeFileSync 1.17 [2009-04-05] +------------------------------ +Full support for Windows/Linux symbolic links: + - traverse, copy, delete symbolic links + - handle broken symbolic links + - new options in GlobalSettings.xml: TraverseDirectorySymlinks, CopyFileSymlinks +New menu option: "Check for new version" +Copy folder attributes and security settings when implicitly creating folders +Maximum file time difference now fully configurable +New history of last selected folders +Fixed "Year-2038-Problem" for time_t +Upgraded to wxWidgets 2.8.10 +Individual folder pairs can be selected for removal +Performance: Reduced CPU time by 9%, memory consumption by 36% +Support for cancellation when copying and comparing large files +Smooth progress indicators when copying and comparing large files +Support for Shift-PageUp/PageDown +Support for Home/End and Shift-Home/End +Alternative log file directory configurable via *.ffs_batch Xml +Show explorer file icons in grid (windows only) +Fixed compilation issues for Linux build +Fixed grid alignment issue in Linux build +Enhanced error messages for Linux build +Optimized traversing algorithm for Linux build +Fixed graphical misalignment with multiple folder pairs +Added Slovenian translation +Added Hungarian translation +Added Spanish translation +Updated translation files + + +FreeFileSync 1.16 [2009-03-13] +------------------------------ +Support for \\?\ path prefix for unrestricted path length (directory names > 255 characters) (windows only) +Copy files even if target folder does not exist +Fixed occasional error when switching languages +Added sys-tray icon for silent batch mode (pause, abort, about) +Support for numeric DEL-key +Avoid endless loops with Vista symbolic links (don't traverse into symbolic links - configurable) +New functionality for loading batch files (load button or drag & drop to main/batch window) +New options for batch file error handling: "pop up, ignore errors, exit with returncode < 0" +New option to reset all warning messages +Allow marking both sides of the main grid via CTRL + mouse-click +Allow manual deletion of files on both or one side only (respecting selections on both sides) +Special recycler option for manual deletion +New optional grid column: Full name +Fixed locale related issue when comparing. Big thanks to Persson Henric for providing support! +New check if more than 50% of files will be overwritten/deleted +Save memory by clearing old results before re-comparing +Usability improvements: + - name of config file in window title + - refresh view filters on configuration load + - default to ascending sort when changing column + - maximum length of config file history customizable through xml + - new "load configuration" button + - check/uncheck option for middle grid + - support for CTRL + A (select all) + - enhanced error messages (windows only) +Updated translation files + + +FreeFileSync 1.15 [2009-02-22] +------------------------------ +Fixed performance bottleneck in batch mode (non-silent) +Improved performance of comparison by another 10% +Configure column settings by right-click context menu +Remember column positions on main grid +Hide/Show individual columns +Added "sort by comparison result" +Sort file list by relative name after comparison (GUI mode only) +Removed Windows registry usage for portable version +Restored line breaks in status texts for better readability +Revised German translation. Thanks to «Latino»! +Created custom button control to finally translate "compare" and "synchronize" +Allow manual setup of file manager integration (Windows and Linux) +Added Step-By-Step guide for manual compilation (Windows and Linux) +Added checkboxes to manually select/deselect rows +New option: Treat files with time deviation of less-equal 1 hour as equal (FAT/FAT32 drives only) +Added Polish translation +Added Portuguese translation +Added Italian translation +Updated translation files + + +FreeFileSync 1.14 [2009-02-01] +------------------------------ +Massive performance improvements: +- comprehensive analysis and optimization of comparison functionality +- new, fast directory traversing algorithm +- improved folder hierarchy compare algorithm +- lazy evaluation of formatted date strings +- new high-performance string class +=> reduction of CPU time by more than 90%! +Folder attributes are copied during synchronization +Sorting now case-insensitive (Windows-only) +Allow column positioning on main grid +Many small fixes +Added Chinese translation +Updated translation files + + +FreeFileSync 1.13 [2009-01-06] +------------------------------ +Automatically detect daylight saving time (DST) change for FAT/FAT32 drives +Added directory dependency check when synchronizing multiple folder pairs +New synchronization option: "update" +Reduced status screen flicker when comparing and synchronizing +Fixed bug when sorting by filename +Further GUI improvements +Updated translation files + + +FreeFileSync 1.12 [2008-12-23] +------------------------------ +Significantly improved speed of all sorting algorithms +Keep sorting sequence when adding or removing rows +'Sort by relative path' secondarily sorts by filename and respects folders +Allow adding multiple files/folders to exclude filter via context menu +Exclude full relative path instead of short filenames via context menu +Fixed possible memory leak when canceling compare +New option to manually adjust file modification times (To be used e.g. for FAT32 volumes on DST switch) +Handling of different types of configuration (GUI, batch, global) +Enhanced exception handling +Multiple GUI improvements +Added Dutch translation +Updated translation files + + +FreeFileSync 1.11 [2008-11-23] +------------------------------ +Support for multiple folder pairs +Optimized performance of multiple pairs to scan each folder just once +Enhanced batch file format +New context menu option to add files, file types or directories to exclude filter +Reworked file filter dialog +Updated translation files + + +FreeFileSync 1.10 [2008-11-09] +------------------------------ +Transformed configuration file format to XML +Exchanged batch files with shell links for full Unicode support (Windows-only) +Improved filter usage: ignore leading/trailing white-space, upper/lower-case (Windows-only) chars +Removed screen-flicker when clicking on compare: +Added elapsed time to compare status +Calculate height of middle grid independently of OS window layout +Multiple GUI improvements +Added Japanese translation +Updated translation files + + +FreeFileSync 1.9 [2008-10-26] +----------------------------- +Fixed wxWidgets multithreading issue that could cause synchronization to hang occasionally +Fixed issue with %1 parameter +Fixed issue with recycle bin usage in Unicode mode +Added uninstaller +New installer option to associate *.ffs files with FreeFileSync +Transformed language files to Unicode (UTF-8) +Delete elements in configuration history list via DELETE key + + +FreeFileSync 1.8 [2008-10-19] +----------------------------- +Enhanced status bar information +Enhanced log file information +Enhanced progress information +Added Unicode support +Program now waits until work is completed when abort is triggered during synchronization +Added French translation +Updated German translation + + +FreeFileSync 1.7 [2008-10-12] +----------------------------- +Display only those view filter buttons that are actually needed +Compare by size and date: last write time may differ by up to 2 seconds (NTFS vs FAT32) +Fixed minor issue with trailing path separator when creating batch jobs +Fixed minor issue with window sizes not being remembered in some special situation +Further improved Unicode compliance +Updated German translation + + +FreeFileSync 1.6 [2008-10-05] +----------------------------- +Significantly improved speed of filtering files and view (< 10 ms for > 200,000 rows(!)) +Fixed minor grid mis-alignment under some special conditions +Enhanced status bar with centered texts +Flexible filter options depending on compare variant +Improved synchronization statistics +Fixed issue when trying to delete system folders +Usability improvements +Recycle Bin usage as command line parameter +New menu bar +Program language selectable from menu +UI-option to create sync jobs (batch files) for automated synchronization +Updated German translation + + +FreeFileSync 1.5 [2008-09-21] +----------------------------- +Improved speed of comparison by file content +Simplified and optimized calculation of accumulated file sizes +Added right-click context menu to main dialog +New installer for Windows +Improved usability of filtering and selecting rows +Solved possible issue with different file time precisions in multi-OS environments +Updated German translation + + +FreeFileSync 1.4 [2008-09-14] +----------------------------- +Implemented generic multithreading class to keep "compare by content" and "file synchronization" responsive +Added status bar when comparing files (with additional status information for "compare by content") +Some further speed optimizations +Added option to skip error messages and have them listed after synchronization +Restructured loading of configuration files +The result grid after synchronization now always consists of items that have not been synchronized (even if abort was pressed) +Added "remaining files" as sync-progress information +Updated German translation + + +FreeFileSync 1.3 [2008-09-07] +----------------------------- +Maintain and load different configurations by drag&drop, load-button or command line +New function to delete files (or move them to recycle bin) manually on the UI (without having to re-compare): + Deleting folders results in deletion of all dependent files, subfolders on UI grid (also no re-compare needed) + while catching error situations and allowing to resolve them +Improved manual filtering of rows: If folders are marked all dependent subfolders and files are marked as well +(keeping sort sequence when "hide filtered elements" is marked) +Comprehensive performance optimization of the two features above (manual filtering, deletion) for large grids (> 200,000 rows) +Improved usability: resizable borders, keyboard shortcuts, default buttons, dialog standard focus +Main window will remember restored position even if maximized +Updated sources to become more Linux and Unicode friendly +Updated German translation + + +FreeFileSync 1.2 [2008-08-31] +----------------------------- +New progress indicator and status information when synchronizing: + ->available for command line mode and UI mode: Status update and final error report +New progress information when comparing directories +Multithreading for copying of files to keep program responsive +Optimized all status dialogs and progress indicators for high performance: practically NO performance loss +Possibility to abort all performance critical operations (comparison, synchronization) at any time +New options in case of an error: "Continue, retry, abort" for UI and command line +New command line option "-skiperrors" to continue synchronization despite errors +Enhanced log file (-silent mode) to include all errors during compare and synchronization +Do not synchronize folders that have been deleted externally (but show an error message) +Manually filter out ranges from synchronization instead of just single rows +Some UI improvements +New option to use Recycle Bin when deleting or overwriting files +New synchronization sequence: first delete files, then copy files to avoid disc space shortages +Added different return values when used in command line mode to report success or failure +Updated German translation + + +FreeFileSync 1.1 [2008-08-24] +----------------------------- +Some further speed optimizations (sorting) +Written custom wxGrid class to avoid mapping of data to UI: huge performance increase (especially with formatted grids > 100,000 items) +Filter files to include/exclude them from synchronization +Minor UI and text adaptions +Allow direct keyboard input for directory names +Added possibility to continue on error +Added indicator for sort direction +Simplified code concerning loading of UI resources +Prepared code to support Unicode in some future version +Updated German translation + + +FreeFileSync 1.0 [2008-08-10] +----------------------------- +Initial release diff --git a/FreeFileSync/Build/Resources/Gtk2Styles.rc b/FreeFileSync/Build/Resources/Gtk2Styles.rc new file mode 100644 index 0000000..b3cd5a5 --- /dev/null +++ b/FreeFileSync/Build/Resources/Gtk2Styles.rc @@ -0,0 +1,16 @@ +style "no-inner-border" +{ + GtkButton::inner-border = {0, 0, 0, 0} /*remove excessive borders on Gnome*/ + /*GtkButton::focus-padding = 0 => keep default: minor difference + looks better on KDE */ +} + +class "GtkButton" style "no-inner-border" + + +style "no-scrollbar-spacing" +{ + /* see wx+/grid.cpp: implementation assumes no spacing! */ + GtkScrolledWindow::scrollbar-spacing = 0 +} + +class "GtkScrolledWindow" style "no-scrollbar-spacing" diff --git a/FreeFileSync/Build/Resources/Gtk3Styles.css b/FreeFileSync/Build/Resources/Gtk3Styles.css new file mode 100644 index 0000000..ca82fe1 --- /dev/null +++ b/FreeFileSync/Build/Resources/Gtk3Styles.css @@ -0,0 +1,34 @@ +/* CSS format as required by CentOS (GTK 3.22.30) + pkg-config --modversion gtk+-3.0 + + https://docs.gtk.org/gtk3/css-overview.html + https://docs.gtk.org/gtk3/css-properties.html */ +* +{ + /* see wx+/grid.cpp: spacing wouldn't hurt, but let's be consistent */ + -GtkScrolledWindow-scrollbar-spacing: 0; +} + +button +{ + padding: 4px 5px; /*remove excessive inner border from bitmap buttons*/ + min-width: 0; + min-height: 0; + /*border-radius: 5px;*/ +} + +entry +{ + padding: 2px 5px; /*default is too small for text input*/ +} + +combobox entry +{ + padding: 0 5px; +} + +spinbutton entry +{ + padding: 0 5px; + /*margin-right: -50px; possible hack! but not needed right now */ +} diff --git a/FreeFileSync/Build/Resources/Gtk3Styles.old.css b/FreeFileSync/Build/Resources/Gtk3Styles.old.css new file mode 100644 index 0000000..ad11061 --- /dev/null +++ b/FreeFileSync/Build/Resources/Gtk3Styles.old.css @@ -0,0 +1,43 @@ +/* CSS format as required by Debian (GTK 3.14.5) + pkg-config --modversion gtk+-3.0 + + https://docs.gtk.org/gtk3/css-overview.html + https://docs.gtk.org/gtk3/css-properties.html */ +* +{ + /* see wx+/grid.cpp: spacing wouldn't hurt, but let's be consistent */ + -GtkScrolledWindow-scrollbar-spacing: 0; +} + +GtkButton +{ + padding: 4px 5px; /*remove excessive inner border*/ + /* min-width: 0; => Debian: Error code 3: Gtk3Styles.css:13:10'min-width' is not a valid property name [gtk_css_provider_load_from_path] + min-height: 0; */ +} + +GtkPaned +{ + border: 10px solid #d0d0d0; /*hack wxAUI panel splitter: not sure why "color" and "background-color" are not working*/ +} + +GtkEntry +{ + padding: 2px 5px; /*fix excessive padding for text input fields*/ +} + +GtkComboBox GtkEntry +{ + padding: 4px 5px; +} + +GtkSpinButton /*GtkEntry*/ +{ + padding: 4px 5px; +} + +.tooltip /* why not GtkTooltip!? */ +{ + color: white; + background-color: #343434; /*fix "Adwaita" theme glitch (Debian): background is *light grey*, while text color is white!*/ +} diff --git a/FreeFileSync/Build/Resources/Icons.zip b/FreeFileSync/Build/Resources/Icons.zip new file mode 100644 index 0000000000000000000000000000000000000000..850025aa1b7c059685ed2baf4e3af6ee4ffbf496 GIT binary patch literal 454288 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

&bCYKt#_KWuw`rmN+C!@mz5 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�^WLb9L?$wn77G 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 w74)zz}wjh4xr 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


?b^&!^l#43o8 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 z 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 bN-6TUNqM8|kmCj6B>vwRhC4+&=+FNFs~JJ5 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/Languages.zip b/FreeFileSync/Build/Resources/Languages.zip new file mode 100644 index 0000000000000000000000000000000000000000..b415e90a58e039c201244736c5b450d3353f52fd GIT binary patch literal 543163 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

EbD#2CSmYKwh9vBB84N)Mjz_h|K0FE{ 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≪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

iU_KIUG=#nTyC59 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/bell.wav b/FreeFileSync/Build/Resources/bell.wav new file mode 100644 index 0000000000000000000000000000000000000000..5c3e245ac06e918d1cba2ff948267d6cb5472c23 GIT binary patch literal 102500 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 z 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/bell2.wav b/FreeFileSync/Build/Resources/bell2.wav new file mode 100644 index 0000000000000000000000000000000000000000..2a7610f6ece72a2e6fc284708280d5c96dbb68a1 GIT binary patch literal 87678 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ξ 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%0D_&@P=leb2S7}u?O?jx@U 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/fail.wav b/FreeFileSync/Build/Resources/fail.wav new file mode 100644 index 0000000000000000000000000000000000000000..50a8d08a3b064c2f27ae65c7a6d469173ee11511 GIT binary patch literal 35092 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/fail2.wav b/FreeFileSync/Build/Resources/fail2.wav new file mode 100644 index 0000000000000000000000000000000000000000..1b6a8c38ea281e6e7b33fac7a6460b43d9a44459 GIT binary patch literal 50454 zcmXVY19&7$_jkL;Gf5_xOtiN5u5Eko+O}=qy=&XHZ7XZr-e{)XRn_0Q@ArTD$t1g( zX;z*3>7-fRTD3gaD5_P}ziaj!Fg(RaQ4|f^#SRoT`3yxdl#lA(Wq6lgS1C%jObc5q z{MJs1l$UZ+5%B7v9F&U^U}Ipz7J+vm`1)^C;r-$NeK+9Muq^mO@JdlU#lq(tlpD7H z_T+?rGn57IN3a?Hx2f>6Vrj4+6~1Hm>HqJQrjTWWeR$y0QSkW)ID`MriH1G=cNG19 zU*!Kj5r%(<;IlG(8o~bwaEGt($!Y|-26_4E zL+@o=K$10yJuXzXZ*UxRjI-Ajn{eN#sko<+ss62Ol~2nZly2%ct-F3pPt{xKO|%CJ zBm2SyvL~+7^l%T!EpM0K%QNL&Qls$qthJf@GYVy7q+d#3lioI+O5dFpnf@`fo?P_IOuCit zWuZ>_AEcD_hs1(bQ*#kIM?bgC@IH##?$;w8xt0lps)4cT!mF(*%yw=(zl)p8thKJ9 z5k_HM(N^m#jZtuaCz#!gyPBmmlbcBqQU~d!R7@@|UzVPR7l#^TRmkj{!DOiEeKUGy zE|R~|g(7>TXnFM9%ad|NMhJf5LkF!r!XNjs$U{+K-z;ZG{vRqE=|!$naoi`t5T6MQ z*N-ZX8=4{GftefECO1e8@(H=jCVHHo3%tL~zd?tnYcm zPniC>`1;f%{2G6-elmObKSEFlu~q2V4<+*c_cmnRF?E!m z(QUgt%_A!KWM?U%5M7_#z-_IZjE%o5To8PGFQ%LI6Kyi@nk=44W?GZ2(c~(+ZA9pd zMrjANHF{NJnh|FN;Vvvx?#rEJB)ySDIYIs?wU=guAB3pzZB<~yzLbRDDb16DF|(X) zn0R~%O{2yOMO_=c8@;1kW5vD>kUs2cdv(ueZ(fhrVRHXaI=+FAS__zR{7NCO5W(?u z3~{0UrfO!#-AFDgztxgpG{j6Y+UZ5~DSEsy&iKc08HsTJ9Z+kq{7U{QBl(RyKz=U$ zC9MkYlI|F_g^Q6Fg58pK$2X50X&Xx|MBVX6`kUxk?{ zd4nI%{h)JMPAs5Yu;}C`v{l?%PTJ#_=4j(zy^vm4KdJ|eJjPqSfL>o4r5;uaDXRQU z{w+V27t8tOqf#!Zw-nH)vQa)dzGBkTU?u-V2d3ljSG0~AAbfIqywTnyS9fs*2+Xa8+uM7O>d-6*7mAj zl*Worp_B~yJJi`WFwW{p3DRKo5S8pEv6m9RBs7e<@4CW1!n4pg>pi#8G0M}>o8_8k z+sz)cKH%b{0`;AV<$v%M`10&LDhM+v9?eI~@lI0Na#^{^1ysj$7=83|`UL%lUeJg( zX6Yxjw<@i+S6qq?*PJfDl~=+j`$vkG>M4atqGNINfrMd+1>>rD(|Oh!iTaQRY>Iuo zyOsBqn{ovBvs6Cv33swgx-WNzpUWe56#a%MC<)a-u{a<3O=7Hpq%MAFPB!N0rS)q1 zZe7z|#wp#aSJWD-bChW1qnrxk`=Y#Dt{~r#s!C(z4`z1J7d0jLCovdLMJ}@4pl+jH zcr=|yEb02@8SClcyeW8@7vuzOnGZ_%$qMX921(k`j0(nQIWugIt5v+_o{x%>^D^JnR)9>*p5_5@BQE(uQe zH*tPs*5g$4%*w?tcBFXDdKS5c+m5lntPCu{ijl~);YRQ|`0;FjK1d{#j7lR7{lsg? zZjz07@Gi59F+i8JFIpjejJ{DHqRU!&t%tf*VUi&`Jl94%BAI`X?Ne) z?1|?R_}JQ>e!R^(hz1dz8DmRzZTGBnS9EOWo6%FrO1QRDR1WqXSBG!IHD~@<&+s6G z&E}{C=ExM%jqJd~kjMP3C+erP>zWJDLKi(rU!c8GIkmL1NA4$gkXyr+TYfJclqhu_ z`DlOSe;=%o7!gR0C~C{1%Hb`z0^LU_>GXL1@;r845NosNtS|U89%Y@UU$9~BD%Y7U zO`9YI=GGmvBPxSM(tu#>$Ir}-#wMs`i?p3uhL#(SoT`=8`l<_*yK)OTM#hpQsZhoD zNPeX{IxD0{RETet_&EMkR5oW}HZO_Bv#nxWB|Gcx;fZ%2wzuUQ(<{gt+=(=z7BFSF z8(b{cjLA;Xq%YcSUNlFdrZ@w8$vV6Z4KOPhf9Z>~F4{lZO)ac_*Z$QMwV>Ks*)9Ji zf09zA6VfhehSX86ZMe!CTitC1MSEB1AMa$j)Qbv75H+08Jf z7UK|!qyub2t}9oU?L}9z>f$tWpt;-Zg$m+ruti}9dSUF*FT(T9sa4aKX-~B`+IFq5 zHc7pq3Fukafq%*2v5~G)y1HD9*aUr}AZ8fJEll602E3L9N zMth(|=u!GJt(-PQouTZI-$~6SHT)nfNY|B`M6pebx)Yx(v1|OPs20xUY!`AIX7vcB zt2o_x%01cbcIFq;nSoYIoPktWtzXd9*^TTQrVd@ks)V1L1Y7ct6^1 z4l@SpTeXr}A#JF3Ut{$QZIrf8-Ki{xI+7axJ6tB*FKfrD&KI_Ht`>cU?8gPj0c#iCg>AwWB?4X{LvK6L&hT` z$sA!+;#b?KOVz{bR5eB&q#Th=sh#vGoKqU57PXq% z)yR_pSHiP^=x^_O&(*coAshZ-MRGN5e>>N@UOPwHYYNfqMCv{1M_8*qb%MUi1e2GL*WWsMicK=Yyb3a~dk_F0;#(6x|}eYA`V_XC&<$!kx6%;)3KEb&T6*JMD~hk8l-qqzm=g zEUOo;fySaMc#M^guE#87UeZme4x|%$WTfgd^&5I8BZv9iY=yePJ^ya3)rV@c)nqkJ z?WFEi&#L3qpGty~U#=}p3}=O3tEa6w_EV9?0(*mh0zdpgPqNsDeuq`mpDdv#@rm{+ z&IA{Bth5c~qZp4R;Ob~8I)aN?hpDr)kC{!^rq+;GsHs`P*rBi2Qw$o&=v74HNvMY@ z=_|BZYL@au(Ud5aD7#?QtSL_gRJ1ERSSgPC3D>=1?9KT8@&Cs3^UblhVZQ)|uYu26 zJ=vyWFGtvU)T!BXh=s0m~{a+*Y+tzXlZ8hc@u zmBS`7%^8NQ?N;9^%i)f{RX!`{l)5mldAW_`lD^9Q%#UoO`>lUxU|sy{*in%Wfzu^W zNq9EOM_SYQ`L(w1j-}3rjtjPXe1E2r^&TxXJDa7^1ANt*PA_7%GP~(aD-|DrD@@le zY2EbIMuzznd2weHG`HyWv<@(~ZYuYbJ<0$;W<%u+>0halv|281e4w8=(j!;J?TSwf ztnm-7`=U}?6rQ68kXrk>iI z5zS*K#8-WHNebtkN~j(x&Q_jkCrzqqp%KaNz~5CLFm4)b96yh%QRq z0TWe`wn(Xp6CGhIxV}X-4{QnUjSt6ki10Z7;%`w6$!}c2x=r8UW{TtNJsmNQe{J9S z{me4!9a5os6f~Nc4!nyzw`zkX60pwT9B3n4b(W@RZS^zy2mPEr4KPw(9ciiBNG)A0 zuMSi8%S0L{m6jSxU*zG&HM);uW90X^@(INf3I@`n7PyOw-{?WahPT29Kg&GjTiL$Y zi#UAtokABj!+L@Gffn&pd#fKX_u%qY4yrV@z-mIyqWtC`z@S06<2Q6kf2JSOcj*`5 z9K!lzy_Y^;8>7}$3d&u8U8YEL$17c1e%|Wuo(VAK0q0#Vi*-jVRKJ zN@0)kyTrq`X|_mlIk$m6L>$NmOs<07)zHn+fX4D$$<{w)8g7s3n{$l(MuL&uK>9I# zs6JM|saG&o8kdccuzC;Cd}=Bn@8Qw}X|%ju+eUf|Jv=$1=Lb@Pj{@amyb+b1r}*-8 zH6o)@_!%iiKVoBrvtk|FIB^tzfZ1$aMAeLoT1Tyde#w}C6wF!!tQyufau?IM8{*Id zbFS$yYZ&8o8n%x5E2yo#4F_lqUhRjX$erXkd7wN^ZI9})4rgR!>sVVn8$T;Hf7Ed| zW6NN8YcG<`C+ImDM`z=@2uH+|;$WdT_YZxX5**pSQCYs7|CI`fBunySM`8_F+7axUJmQ zH!=jcK?8V75vYM#&uFb5(3Wb3wp1?yTyMOg>M43&t$^B0Ss{yZUzyX2;ApOglZ$*C z(D$Z=+8Vey|p3t73#@3*(lbxuvxyN{F3^)6u zF}O8(M;PlpnMPb>0N#xbLiPMx@25Edncdgs>IID9MlGoBSF{W2QRSliQraZ7R8ANL z={dG7-WJh6;!t2yoI9HFRdF2WeAEM!)68p5MpwxYI)V!bg~VK90bw#%nYjpS*BSGt z@e*cNKEU4D$PwT>cgQ3{k)C)x+G}nw<^uw+td-JcX?A_Oeiz1i0lgdGvT@2<`HnPF zIwH^17g?nWLHwwp`O@rz7g!mzpIgC~629{vxpHg* z{e{HfbaQ~&)!b{=N113Gz6;ntH_1z)$Pw(rEP4uSBOLb#xvSCNlXg38k5T%$ukwsX`TD zuCiyj(!7^1#4TlxP+v$VoDG#X&lp#YT;^Nz7@(GZL?sDUDv2cow4|kQ-3p-pMp{0N zgF4zrWz;>&D5bg*3&&@*yJk_kr#Q~NC9+mbRNVX6H_^=^f4g(qXK)K)eL81GnA6SI z=o?9-v$3nW;rwBq<7cr8=q1*5+z{n8#~Rg)NruyWZ$3ij@GN2wpLGzf`Z(%smIURb zpY~B*rS?@Dt0`)xa#uO6EK(XNWt2YZdSj<`hDXln5gVhG*miMmVxC1E^?IGH1Uoa9 zQ0R!U+Sq6g!#AuX#?KAqlZ0>lHO|8((b=qOpb#xJ!unI4Gd99VAA)+|1TvM3AT%k4 zlToah&1kA0(`swFdPr^mziS?9?6{1H+5LYGJ zarU}(7iAfBjITytw2mlL0roeSTUaW@3um||%t2}j>4g>>74@;OD(%pg=^u@nXd3QL zUXur;2^oc_qN(N*;|QSsyjojzrgBHFA#-wsTwY!yUsTrW4M;YwuH&<}o}Y@MXHw0m)fQu|Cu>ML>20N31E_1%OKL4unA&aC zu{bNrx@NtmuF>t78H`D%Q!}h+Q2b2ufaygBs!iTlQ|SB5Fm?$0h51Y`rJ}8UaBVxx zN@gu{t63SnMT4=9`;+?w5sRD$eRcxwf<78P-K7TQ(cuB1q|n$f4K zj&d)l;<$p*U3;O<(DE2ASVd}E*R7sbF~CWkjKSIqWhLk&)1*F9Pbr7AG5jkO6Dpfk zBeQizhxGPoeg2I4efU?#&%(dl8P|+~&Nm4a3oI@%t7M77v$K!(Q{pw$14id7Q(+c3 z?)XOfulg@V3~}WbR!|pEl-bv;hTmJ=m{@KWw}rg{oNq7MXYAG|>QP37DVv>83Dm+2 z>!!*ne@iXGY`A>*VR(XcMH&NI@YSsA86VO{{yF*k)9>kjk~6-jJ;YyenYm{c8dTs@ zHjlrIpqX3M9{L?CukE6@s(-luy6>W+CD*~qL=EsI>o>EKe=AHAZgYP#Kdm6CgcrdK zn_?wXd8xJ5Y?2%AHygrQ*-s0p&(xo)p?*@kLoKZ;zY0$a&CkM_4>I3m-pO2***LRA zW@2XBP=52BU5RUzL&|kH@ild-z)pgA8EE45xEW$G z`*Yh4VI13*YC^W-Qe>r-lWqi@s1@^pnnif@RPU=jRKw~T?H{-&pY*#LQXWYcL&;gK zGgoJB&qATc;dtqBIB&Q{s8JRUr5TJZAzDw&OfC{ki&!DnwfdUdkxs|i%Xs@nY7yl; zdu#P4| zbT&rnsp>U3K?;Z3hsK5Khi6D9{xZ)@-V%a`P<;m))T<;v3osZ9Dimmy+%S4R>1A>l5pGYn`;tAUN@0?(=* zb=m4e)}z;;+1A$B?dIMh9A1#Or12lTbR+G_QPaz(x-y$JK+ z@}V|aTQh5Aw#>Yrxgl#tIH*5nxA+1gEnpScZLZSoTCy5eR;zck@A?$L zj_dRc^@02@+&MHkt9aIgEN|#qXlHn&lqGrPENPycsdr(CXMJ4z#HtA$Vi=EI$fQ!O z#mqgigqx2T6d4s!#og3qvU%y|R5N-8dx_TtPW;KgVh7V@t!a1;>W%)ONuaru#Cgzn z;0g5st@qZS>lKYCqm>?`wN<)HCqo%o=~**Ev%^=UYjPW9BCL58WWPj*Cx^q@5B7*J z7!*@BCI#X=-Zerl`n5HKIUq*4fB1?<_VYPi7ld-mOiL#nsV_`7K2^9c4CMv3K6M<{ z#f`=YBg{rGu5Vw zBwY@R;mB}H`4Nh>XGHf&KAwGGa^={f?pfSstDg0p*=!5DqazAL6!b*c&$4HLlXW4h zsiEuveylK-|HKTjilPhpdhmlr8D|k^O{Q*ATS1@dW{g&2Br?gd)m7+==rK2)a`J!~x-%~AJ_hOeM_sN!=c*xH= z9@I@E)%;|(fOYtmwp`t<6j2%|rt(ESrsdRCt%lZ3J)xY^8d6`J?PBTVQ7K=7m7)gQ z8_{3TV>FzK6vjEDywANG-DT~O+$QT0=;vKQS06xUXVcjI>=GuODsEi^Ed9vznLXh7 zchy<_hW1_?1Wr|M^PDM}*UZXhZ=*UeiQMW##RjuugjNPzhGlv(Xzkh6WaYHHM$4qe zxpKsAN-UAoG(biC5jRi~cppAOXBV@aOFXST4W0dk+%%1evDvIiZqq(KQLHcS;~z0C zt?y=S{Sh$olG+Bnpg9-`_z#)^PS$i28^a90@lF4t-`Cgawe)wOeI9_BSspl6UpF}>Pi<)gArD`8&7*RA{1cB-D$9M>>6>q`NjoYAi8lJV564l{PN zIm#HW&(-YO1NDNsO&zHwsvDFTMN~%UJLw^=fiX)H>Lh-M6C-T4rc|mKFz@5V%sNm( z>$}go3OF_iG+To@N1Bn%F@OW zV-D2z`S=GuhQokGI~hoO1}^FrrH4`)oR0f)cln1jSiY?vWR|;=W4k5z5+28l@buzy zk`cxiLnc4CqmG511>RNew)Oy*+gfgZ({;VKc>?s5a!d)Rj;7utK7|>3@D!Ht^HZ z!|g$fKBUc}Go2xSAbxiI>6i%LA=@zKI-( zCtr~!N;M=}S{n|AH-}=RWTORl&6_)5Bo0q38z*_c3Vy4Qk){!&j@5~~ZJXpwbnS7R z7aMSO>6U;jldV0}4Q3TLhab-`;&QXI=@=@fb%$IgVG?iotSuxBr=e%&Q*hQcf~PrA z9|S24OPdcWu}zIqO3A&Yx#4Xgob@iNOvng*mA|2U;_`?#f$j+{;{*Qo&S6XibE^7M zU23+Y=ZG_0wY?*~72QqknCn9~wmy(=R*>1lr3x>A^^X&Jaq0AIaH2{ANXaX$7?dYO1nb9w+@1eiND&niq0}--lO7mE=n*LnzVo4vP62 zmpwLHWNYVYuD|sa4Z*#s+gv^SPFF3@A9t#AlFMdoSaioP0@Vu$iW@=II8$HQ$QHSt7NOPu9d7%fvjB_1Jg&Pf68Kv;`}1tq(H8uh@|U*`;mVfuh}?j0j_1WWBb^6cdXAJ zaoXG6#o0EpHc;~#fY<7z@-Uy+XWU+HEW4Nh85vTyS*OQ?`iGP!zuS|sC4 z$6pLVb57_T@T~T5PmW@Ee!Q~7Kx*t()qxnSj4VnYS6c=ZX|#ULnXGE zYmH_`6QdjO#HYqLV>>83Z?)-ad7$+_!;eFutZrGkv+`yQ%Sy`{9m1iBfE6U|2sO;{ zD(X{w)uaW9eF8b6O1m7wKKiaToRXNaLYjT1>$1CsyQ#CaP2}IvLo6q`h^N9_T}J<5 zmat>ktIQyJjWrq%GhgdBwRYNEtq~xTXmf!%#N1)z)w8Q#q;H|YS-Uf*XXeZ575W@5 zF7rxN<(phe?jc>2=4joh(T>Lc7s36>!;*IdXGTx(JP^+^igngnP2b~Mfmh;pC%A_> z*V$tDqYwg{hAZG?vfpY+Pi0;*^_c{C3jg3s<_O~uXsFr2YaXv(fjTflwL;C!?aK0e4(co2fn( z$eV(cSIRMEEQ|vOXvwd@V-LysU>xpMZY$4}E6RLuqAMzo)O$$aM|-Rok}x@WKvL8A zw*LL@apFPdrB%opMJ2L>h1vF}&JC^~&Q$w0fn!&IQZmx4Vus9ecoErbRi)ZdZ>+1N zB<^Mo(r2k3#fHS#$?xE3{@f{O4Cj1pJqJBd3E{Z*2wIZL0Oxh~YGrj9q#-7NA~{)# zm41ZXP^b56ugMKT@w|(k5MMFD8yp+kEV8?6lW>M^N8TVm-axKUZ<)#55dIcFoqxf- zWZyFy9iR(SWvzN-Hy)3l;PIrawZ}SU)wcFSDxp4Jhu)f+5oz=U1^k${S=*wm(gtfK zv@~_UI#?~BK2~Zei{!gfe(6ehl(br%j;r%6+|B*P0*!);;|IiUiWJP@|( zwo!kq&yX7_f{q)Fbf=bI-47mOK{$SY?VaY(-vfU>2z=><{8%~{J`(zzwIb_9=#)|d zm*8u<1OC*wWAW*MyD`}!*Sm^{BbZ#)2;2>iA@!+2%mHA_{cQi(9gZ*dX!|yCHJ^)J zPBkEh(Qi=JUZ7Wy?8|3W1#NC5Bs9jLV&)Hh7r2=`%sEOeq1IHB)JsZtB_2ir1^VzD zzy?>sJ3?t$8KL7!HT*X}(>>B(HqbD5JU%^kT2yThYahbpq#J_bTifbR{iZLo`}s+t zWP59maLlsjv1xof%Tujze{-ra(5o zm6b{}rL58czF$^$E9I3za#v|U_-!a!N>zX2=KM=ngQ$_i*yu{VJm{+j&84U{R`6dW z3@gVb(_?IhZ08-NoN`dEB}d6EVP#mZlvlTaqds2wCMATwWX%r6Dz#96tK<4N>PFnc z;N#%;K;D>^5r4Vni^tg*dJniu2d$e>n=f$b!XDd8dqc--`xIMQp#sZNQ7}>->6`Qj zIQnPv1KNN+B!#?(*-#Q4GAymGdK&!e+u;e}^`Kf_kPgd-l^LoC$pRU4gGS+fSp~Cx zgjTBQWVM*hS1I;O@KPd|7#%-9x~;FTv#D5(E6Dih!_+tG0`S!PTxVf}xWLBQciU=+ zI_G1GT4^v7UKocB&YWZVQ5i@ntu^a{k3Z5#2bMSq@~kVBXYw$)qMQx#1JfXZR!bSG z^ii@Yo#fV1pYW|v-tZcEv(be7&ic+ zjgh-ctHVXY^~1NrBcuXyf|95{gnYqMWuv?o(8)ukrrC$?V;kUI5dA!$1V0Dk;?rUt zMmF_)w9n(e(RHmKpdXeZr!5E5iR&ml6erte+U|(?gcocFx|p>Lw?!?@%|-#^I-rsk zdN+Ns&KnECf%{=p0$;2LJc%4ibx0cymqtssq!Dsg#S8k{NI0|2Qe{w)fWIPz2{@kk zipF#djEz?UjyQjGhlpA3JN8bHqxc2tfPz=ymL#64!Ia~k@*$y!xJW3#7ia6x)2&~4 zEGlP~GMeb4wKuA+UeMxz-+S>h`~f$?^^s`|(FH9*Jq`-qS*4o#TJ5EM(*}S$OM|{N z)R<`gi$_rX*l2;V<+P8rm$LtB8z$O>xm+8zJF|lRMKz~hSv9Tupr`By->)EHNjcIN zT)6RM0I5e>l0jr4*+ULOI_($mR0i^sIq-dag^Yw6y#dti+?D~#x&$6t3RwoJrVS|F zY-t_@O`$y`_Im0A^u{{TgJ`KOizGK<1$( z#t^N8azFyE0$X2Dmga_kgiKJDzhpd1uaN#Nt#8_~KONIjLU+vqb}6=Xt{;Ufmv~le zWPvr=#F%4_A@q3ABA%K)dW(49T_o~-)cMGI-U5#9oZl*CHiz_McXKD?+wM>;>H73K zs-txSl4Fa&v#O+5)dz!qGSPSo{?ZD4oK{XfAm@}mh3HWItZSLWGNUr@WxUF8Wq!zL zoiQT4eAMYhYcq}EbDX)|OIMo5uT;qdvah|JOHwbNex*_Ylx*}*-E?wIpyftC4pWgFtp zCeFd5bf?OM+lisNhdmiy8<~`58sFFNzZ^ha1^8_4_Zs8 zCy*3)NFwn>$mlW#4>|07ptSZLJVkT%Y~JFL=Oe~?+SqTi{jF}0KYL=G zV4CswgtI~$J|}yH(n)nP12|?PwUatWm7~1YEG(P1jCYXJ^?_%9AF_pMfR|gT=M|?i z4|Ii-kj^l}H^cV=SYu!mbfXoMsR!NCEFsZ0_uteP``zF zZb#(v$dHnA&eGwDwz>pgZrg&XF8A-i**U zshW~QrL_`zZlkZkfgAEt&9Cf}Q21B4reu>76}#F^wX3np7ins^duUD8%Pd;CYRz`J z;%vzWll#R_h|t9|)H1x5{9=yV9G=gm;P^ag!G2T(6m5v7{Ru+GODA3^)Q59oR~ zyhZ`K28B|8qz%!21JZf`?nIV+1RTk|@)miz+)++ePvQBtGf_8#U6T@mi=z5ChB0Sw zRot9f&O4l$o_xOjp6t#=LJwxEbrX2_C%QRTRro3h!ZXM_OvFDxTdb|uH438~q!T=) zXQUUdW#-Xes4LY$nxr2x1<>l(;>)O-IYTc1>DPFb1+0xghh7BZwiEO*OqHrh9i_%< z59^|1LG;1|S5j7>Qe;Ovq6y^o-_yOsyRNFf7rx0JyW>39nA!p7w~CymUNK3Wi@VI6 zrba{eM0YdE9B+0%^l`4^U&CKu-7-sFzRiRkYar+wkf}u`gN~gdAvK;p?fM#XCEmXajHrhGBWpkbsFR>G-Bjh4!OC4i^ z{3GEnv4Jp+8_wLL4Cn{jN3Eh8Gm{wy6QK87zj0pl%4lq~h5SMhIETT|@sktu5k~t8 zEUbw9MA{||mHJDQr1{c9$SPy0C8V`C$pf@BtF5Due_8zVVDY#@5#Ma9=tRgf-60j( zfGyIM;<@Fn?p!Hmu=#1tsz)8CZ?mU)RY(zAgPW=|4d_^^H~8SgtTWbgYrXZ6EX6VC zq;Vhev-9*!y^+z}xCP93k(!`9gKBm#{0gecSLuPgOUbF`0jyq9sVf(e(!yKRsnl0z z(U`^wsR;$*s``eD)v3vVrW#T+`KyjOp4Z+!o?DK_LRsjdinRJtCi9)IY-?vHwtC`j z?kZrVrT820Cmp-38&KQofe*h7G3H)K<^NHK0v?ZpJZV|gsm@m_DfvJ--=kbp&MU`( zr+$*N%QK`Hs3?rI0dkMUdC5B@u4_W6;KS$?PeY*(^%g%O!gl&yM+Z#m$#A6Q zPQ41AZ)f=`Bn2PBxH%q%{IT>zbI}RT+WyAzNeKr6PXBvX6=65cQb}|k?w6R~dB@em zmD90XSOt8#3tDA%MukXUYAKVAJI1-WK1@YQAhD2q?r4M|!&ed%h1{?bp0;{ecS#)d zlMI2(dy07p6#nJ<4@mk9QdcV%;90zq?f{Qj3%wk9q_5$(~=nnxs>ZLV+8O5_L0$~4Wv*+G7@)1Ukpu?z+s4y)`jndXG_!N zkqQkCbywv)*)dbfwSO=se)MAF6jo5?PmvKMwa-ML-Q?bn6#Yzx}5K3Wf{dCXRBj4)2@Ao>L# zH--L67N9?d4XXJay`1sO*be;(y`c|etTtV_E^UJTgIVFG&{ecc*$sJt0dU+@@MK#; z-`RR?zdA`7FBejV85Ni%&fNZ}_+|+Y;`7Cv^b*_O>{#G+(;ySogA0kL98F-%=5vbn z*+LRmmibL}r#4X2==IEEaA*&(cbRH*LF)rLVX*p1Xl4GOG=i4qp`b99hSYc}C=B(L zbxJ8!Rc}C7X%QINd5!bH1%GN?wd-mu%;H&cFy&e~JyXcfJ)jFv6QL*Bq^dBOc?LQ14}e&6nSwq<9Ri4~ z+TEcH(HJai`7;<%xZ=So6H%)d}s zT$+4?T--7870}u)b0=i^%j=oYi8M>kV;lp&y1Ti;d}n4ym5~Ddh$kQm8Vz-~He{Y{ zfFa+>nQ{;u^itrVk5#*vt(g}N6!|M|Y4Bcrqu4JI$DHr^ru0UVK(<)(7?qE)7jYWS zI?gWkH9~i`9OU@!fe+Hb?2T?>-uhs*p=wcQ06~wz5$K9>Q}3f+(9ar9$ZUSbX?Pf9 z%tDZkbsI&Y6Yrgx53IPmEZ}M(2A%TvLq+#3$09Xgs*OFi(os?_t*hALeI0d-uRyu=wFP^lQa#XXI6{m5Bpi^idZVZXtYDfnq>>}A{H)DG$}Scj)5>mXY< zNKuTM%st1e$ggo!0tprmRP-l#Zi=}Xfn=F>NcQifZnF!7@wSTgE4JL?QBI{NfTPtL z`Zt!M8IXwHXRV@2&>d+4UGWD0|If2d2)<6Z=phcVP1GPmy_ zm)*{2X)HD3VcnUBIQ$t=Xo&Hzwq8kwjFD3+C2f^*$_wQzIYMo0L{Wo8A1E~M0=8~iK<$67?W>i>uoP#e((o>oIE9s0f{W6O46(8 zV~{2s1g^p)SO*z%I%KQ{8*7XcfY**0Ta2~FCP0u2!Rzm;H-NNw7;=t+Tt?m_|D|kD zQk8egJ2l0;LfsO-dv5vX#9ax@kE>X`D4Iv&dMyTHZ7EeN&S^D?@+CX6HJ`HQP#|3)h)BM)iWERbTLuqYw?N{ZF(Mr;ssZ37JTW zkbl8zt!?r~SgQ*?Ux&1nu%|U)Gvo-Wi^zV;d5LY`k#lOcp&oP-VKxc;}`BfAoU8vlw zDD<%D_6d$Ods|y>VFvq&8banm^6@Stf3Z0c7X=J=lg`h4rSm`v;34?WU(LD3bo~zW zpuN+M>Q4Z*90!(K0zEcN=xDAf{|=MT?9kt#PO$wOIvO^WmMD`sX-|cxb0GF>T%ou_ z(YqpcxvGc_nc<`gx?_Gs`M~cl!0!-y*z-Dw-C>_DuH&I6jm~ZLgAR_l=1Fri{IQD} z)@4|SHqce*et;RAWEX00vc_wT1z*dj-PZoq?XWsd(`RZeAc=8TIvM^FsuTK}wIgd% z)`P4C;e+ZMT#n!8Djt;*_c^|OaCsmj`kSwu^DrMz8+am`gRbJ&)(FPSw-nWU;w+o5zb{zfvoCFU>OaqWn?_&Q6reI32Hqhk1|Ke1Dfh@ z^#~+!Dl3iUF~GE@g&T(5;R~T*p^KqwQj*@-njx%nKZxoV#{?$^t-z5ON91_dcfq3D zlj`W4xfX3A-RYKGyja=3#S!P+>zHdlFOKA2F%_s!I0$0}fx7z?qdWX=x?k$4T-NHHgyaKocK^TyaLYTzI7f@{~|IN8|DDxn>P7>Df|iYQu!(LxWARV%7=l~TnIl2x0A}q zB@_mDP-K2VqWn?i|$+UCgNXxP*PdHi7=p1AU_N3%`W!MW#UBq!p|z3Dgdz z3_nbC+TYoq+W*)}i!uis16(;fbd((em8OJHS0+pCNuD>F`#>jyh!He-T;@4-d|x{Yy>;Wj7eA`xS7)nN)Z6fPFCMBHYGG|U^zuH0nI)?m)dgB# zQ@1wq+nqHcK1b&cT#jEIzb&p%^b%ix=idT!tCCl+ettBXptIyG{e!z9zP6`3p1?S% zB6Al zz4JCY=56f8o1MFvH}Cy%&i6O8EHp_pLs|53%35S|&BbG48)&PS$$#a-sN3vPekq03 z$;d~WYv=Gi4cE`>Z@`H;srN*Me4bWbOVYY(0{pS<2p2h(yh%PIKaqdP5LJunMD?Xw zQAH^ib&5Q5c>cf+3pzc>9V^sCz6wR{hw z7~GcBwZM|XaRnUNmqt3_3&O-MeoGScj7WYXO{&!k2+i;cZ$ux zHbypPh)z#0o&FMMZPB!s|LXm_k!ngEpIVf?Meg$~&bTsoHA1%{hn@M!-*K zQF*x>oH4`DUC)I`As~^`KG7;x#M5lacST?EH*dk6@P-@9eu!2|fA{ZWYMs=~saZkK zQUAT<6X-Xd6A8~Vx6afvvc>g?S*u-@N2#Z%DVAj}kI(Omx=vd^(9iVqN+#tnB20=t zYP6Z>m}a4m5F-3qHg%x7MoS|4!Abj-d`}G5eQ*kGQ7$R1kh_f3rfUVYc4|3AMenaE z)T^_Ed;CXeBCmmF^EmxlTCOxtTIIApX|>X};SH`#+b0~S(>!Goh|EJX-U@eil{3CX z3|mm&YVcSNI8;|lS0;OPlbw34K86pU(N`c7C}`egu4tM=&m_b8Q&e?2>V3&>hCQ@E z=cngU%ZO*%Znd1+N&TpHfK#NqHcKTHMtXuis4HfPu1Y_d-Zq*k_A53X@qc&lg(gP} zrkm5o|C{(Pb6UID3FVhLci=~2hRi)Oqy>vPveKEM3bANSsVb&Awq(aA`vmJVquCH5 z(zQ2w6)G$Bg1XOa>F^etmoPs*O1O`x_AHwTO8ybh)+cb2QQ2qvs2!JpUVf5(p6+aY=#08YxbHcxT2?a+soO*`(rXxiJ7TK2i&-(QqQ83)vaS^^3Br*)F0$vi94-c?oPnop3w%3DY-XFLs4YeK+@Trh1NgKe~gC9%dsQ zBktd6nrgV>8HlH#G_-&lZJMi$YVi0x<*&#lTVVwQy9>K;@|>J(UdT zq&|nTbvfuEw@|^}$5!Qxh$=#YOPByfR~kDFdX|d+v3mBggQSPxJS++yPh11FZx_!9 zOHD&rD99c3QL|=0<_f#pIDcC&8t+gH@lY#{+VyI8yraWI!LFo={@SL-md} z7+#F^S`+oD+(nuIW#>_Gs?MgPQ3NEw+__u{x784qPB86iEh%ofU{Z`EH?wN15c8yq_v8*Q3tm*E2X zQ;IT7GouUgkKv5_jJ27vAdOVeWwo0+K>Y^|N(p^{UQ}<3dSR-}N*$#N(gf+FiLb|RL3XV{XLKZ5Gir;*MW;vG#0K#WJzz}={74v*acV-f zfMP3dsIGRB8>?r?uxWaKn#K>Z!i7ZnA@D&{M9&z{zguNUOucA zRDY|A_F3;t)FEc-#Xz6QEYncC41fl&jx<|J!T#O{W?2WZtyof2!R7eKJNZmp5w>3} zHJXmd$BZ{wlY zqt#HHR0s=o(omF1Wgalk=)#7fWE@ce9km}?9=*Aq2UUnD`ppvRh2v2#tSg$iF#o@KS{;UY7nDo;a(8uO(K=kUP%ko8MKQg(6wLVR^oKZ%Q?7TY!aVasm^?GrH7L;jL0xL zZk6w)tpG!iB=L~!#|*J-uorheblkGdH@~IFlg;#zaMB6-6!I^1+OX2F5JKe2`X*!> zUZtN>9{n$;-dg_#{akgNM>@Q^G;}(h5tY6WMvJ`IP^u@rhJxgTFdRP<@Y4)me}8^I z_ZVD+wy{LiVEb|}6v`Cutq@;0lP6Py_|d+j)+~nRT4uP>XBdW>m)P$(mpaSao0_j0 z&g(@{Q!c1x(8r+4`JL8j*-(#aL#);6qe`<~`3sIiFV&|OR21k-PvAQ*1~+6^#Ibi# zok);Bqb6XG>WRCdDqO?Yg3tCnsz-ad8Q9O2;CuKBepiT_B4p5CnsfQ;#kb118T_oQ z-Y=GqRBvSX*R;me9AgD*iv19((F4ppqK&=!X>i~!>t(em~^ia#m1MoN_=mg0wImH*H7gUlW8gd~clI7W`_@AHzx6 z4KejlVXSaZx{q4$Aa9?@frJb3nL|GJNb?_ZCG9z`^Aou`E{f&)7F-KYyHsFRjQ6@9;0i)d$A>Rk{ypRg_dZSan(ojFA1 z1TAd1cAmVz)UXV&eX{MfmN(~Sl2L>Hh-mndc36K)ydj5Cy{OydO=1$X(<=1fudu&! zD|=Aa{;W3EW}th$4&991==_;+GI>!qNWlGeU#x+(zm`<#l2iqbfdb-kxM*9#9WJ7> z(}5i;6xC(ZF?ZEa^SHT@4gtx@nQoFjp)0DRl+#lTA5C*?&mEPV5A5$PS&i+fpnel? zqMdR}-KsZ%-*hSEp}rEW^uO?g%uv=szj#b-sh!kbYsbKT8-o~f87icoaHG(uvn8TB zS_Vq`7HUQ{Mad`cg~RGMcbkpHTE^akrgJI1P1M0x)LNUGxwD0)#ChU!2Sr!N@|_y3 z4FhxQmHx({BN`Fyr|iqEL6gA%4WPo}PFSR9+B))z!NIg*F3@d3>Y9xn#!-B6zrl`@ z;AXCm{GzfpN*#QjR z?2T}gcx~zBog6M3-#o5Wu!Z}!<&R;p-W+>#puUePVJvI0+XmR0S-s{`OfSTX>$M8n zPpvbNg*s<=K$m5%(JKruNJ(#?InUPCShlCS-kctj!pJS)`_N$^mg(zy7>R#0}hd$sT6~U&O@Iu+@}`7_wY!Yjc0NS z6m3=(*0of$x=j`W+(hktoJ_ttS zmSgdY6{-mJ`Ep!!_)ji^#Z(ygJ{cVljpH84PYlcKZTx2onL)EwiYT62#s(}MbTq5Hrl^jlPAlAdB(@JdzqGAo) zs^{b(P=voyqww!;LpQNJR4OB&bZvuMzXk67BoO{4sW6Jo8|lk5$|kSh_@X6Ed2 zb!xgbhxcA+UR<`g+rd$uDz-{YUt*kkU7@w(L?QaP>5BD=eW2r#{e?ATPNm-?{>ZJp z1T(XRzK<|dIyK8M*zldY2S?Hqa2V>KPF5ayQEz#_d<}igTJk;QM^`|D=L}KaCCqAy!18chtrRxuTP%^ zPiYl?rMjP~;k*~f8b2~|dBUG?Bi}pwM&oYie*dTi^ao^V=A8MyE#~;*T<^^2xM00( zI!WiIni5s9zjMX>E)}om60VWN&kkPfm~2Jjkt;tR_Q64P3A?plL{IzH-(%J6hdioS^YkRefE zyA=}K>sAv0d;w#8N@6TQj1&rVajmtiq;o=_JzHmq28JBQKjxyg?RKl9vb~%2wh109 zswHt=JFAvdTd6PA{n|qGjrI^J#2T#^|7EhFC1N zh@Hj#5<03A3{$NAJat0t;|nA%NO&F@6o_}vv94y`kVo|p{46KQBJ>@j$ubWtqX|~p zVle%rC8|EM#1t(Vdiq+p5mM1%o2U8pAbgNJ5FNFXrV776Kr^uK@g9nC)%iBUTye9s z0(ycH$}~`ahX_--3$dwDJ>3&s6m1^M5Avf1-SZovnY!KJvJLQF4d+X=WGs<^j*kQj zxhq*YvW4wF2qIyp@wb3XW=3^8S}?l#rDJ)wlx%Dckvu! zT$-2#70+RgCH_uv?hM5;UP{aw_rw3i`N@3H@JinbGS_)^r(T=N$Q&~ruxzwWvyQWT zG%1Xq&Vl;OMC~8?STDf2Sq%ruPC1XVOnHf)x!_Yy1;x1*yeKT9)~kpXBYYkH3BOes z3RQ46>49h#S^g&X7QLm**-nnhkFd`}#gpoRrsRp)l| zOYjOxAU9nFwf#7v8`X$@#yl~WG-WjHG^R3X^jyOo@*4-cCxyT0f ziF#BWiSFfL#jTW*D@pmp970|GC)`mx_^CnzL`UDnUHB3^2ph4^4P0674||n8!ft0j zu#NdUQWoO4al0b`pG3=qVu{z{$A-`OiaF<*2N~Mv^OcN9bq zcCcc5NmHZ&=v_}nUBCcmb3=3_+q0S2uB?UMB&F%qjIZso_hsl^+^+a$aXCX9z3m){ z=Fx`t`e?PT(nRU47S`*LoeV*ysBwpJs__rwW;nwNiXgk|M#LWzP?uh>s7eh)FMZ)H z3aj6gXUbdf78fEi@yI;*V7~urK7{8&y2wh~K!e!`CD(WHhHwzo;frhpTJB-7y3jlh z=gLW|b=tVs{?p5b^2C=-C={O@cKbQ!8OtAuxoCiv)>ZF+n#VD zA3(%?K->$rS0k=4+Xx=WlTj|(EY^d4$J5Ffa)areV}&nQ*bz@97~>m~Rn zJ;`cXNu`0j0M*|bT7GEiuN!hR<&5u)SxxthFPX|YpSp^i{z!5bngjg?+-AP>fx`UoviQ67l?QM=COXFWs)lRE7f-Ooo5PEIot#EXF4|7S6iq4y^}sdH{#| zZ6O7<{W!ipw+e6VE$YhI*j`+9akrY&klFIrN%=2_9!4T@TfcvItT<8@;`Bg4!xyd;Yd`@zSV3YyVUc{(DG?{ay#r|N=L znxY-l7DI2I4E~@`rciaiCu|W`3v1AC=>Vl!Ufj3q*Z}CPKVrq$u`Iz=;U9_H)gjbn zliP9EGbFG%R3=<1bjx2CE8@%sW*Jm4K4=SRbnDirsy2_vO+7LUVqP2Xn(XGmrV_@n z^nPeo2I{4>>gr_l(Ra#=z`k)S2bD2uIqd?dS(CM^h}L?;Em1%mCNN+pE#-%SW8~nM zaWcrQCt`JCZV=c*vAnS*v0vb$ZBn+7Nv19KIi8+@Pod|~;x`Uf@=b81*hZOd8Lkpb zw2si0?@?dDDRh+#8tT)1n4ZR5rUj-crT|ouNz5ZdMQRb^v_ep9TtdbA8tB(`kcl0Y z+bZYaDW0x;gObA`C5W})8OjG8;zF)5myWD#b?jv{DS8G`(X;f@(49?=)c{*yl)99v zYEE}#_EiayaS8Dw;);iN`0Ke3+e(|N8ob0$^(MI6rNENCr#FLlbuHc2Xf)Tekk;Rp zES7_&d(2V86gWvopT(B~Hq)Wuc(2N#`_bw0WaqX0Ws2bH5TY^LR9&5P`cJOoP z;x9)lMDM0APM@ECKfP3RUo!*nx3SG(hsJcNh_6}p8hV{mHo=k zmm@?)W{GvTE9@T?+8P-g_dSvjt{b@G`QUhC34y}Whxh?y*$l0GaGZE&)CdK%UnLs}QALA*s0_P-B+zLNg zFEDAE^F4(H;#`~sbL1&7UbEAcEY$ z_7wUlT}i>X*8b7cFt|3dAwD_baQxRud}xfXrAxA&HM$LDh|byq+_tr}PWoOVftqJX z12xNN8e(c?dSUzs8nq9V$rA9Jc;%epIAo|((N9Qae{flZDq@nf3w5J~xWQV9TcL#> z3pM8nXf__B-qgY-jiq4JA^V|+|jws=Lsjr7fD>3xH_SA zTw*B6cgxw?TH3gkx&wmeRwWr3^A0tn|3t^}9zDo7&*U(_Hx)O<8xPPt^_$qKHB?CH z2cMUd!0=xhYsL=YcJOFP2-#KiGpPsNc zVR?M*h&AZ*UUrPOOkwgMxMeB+Tv%Hl4rEVN&%2c zIzWAOTP~=+*7gtssC0vmInF#~nldHn(a1`Et5fA#|LvlcV3lWqH;e#XdZ_pq@nS}) z6z<^J(oy^@L>{?De1!=H`H&rbLIolT>ZDxwtDhUir-}pAv*ZHfI9pkFAOE9Jp}6kx z?c%e=JrC{ie{^55FEVeY^OLQ#(#kcsZa#}C=+Ku`YopdT#$aRo#%{)J#>&hGLk=oG zQCzF4OqW#jH$HP6;a=|wK5R);hl_(Q85Bo>bQljs`8d403E(C_!*1Gu?tOiJD;L3z zYKsoiP<9UYT&SxYA+9q%TOs#xfA#RjxZUyH^`B2E!*ffQ1cTMIqL z8T9zFL9^OS^r-8|Eyg>xz3xJw_&4H+L(3eTt_?zI`}aoHv9ME1;bhq&XM0bWy`-y$^mKa*c- zEZ;yx5LDjDo#itU0gvn+AuD`54Bw4A!3?(laLw3y_2_%ILar@$m z#GMNd3oiDRaL=}1G6$F)?T>xW85{zN)NZiE^HN_bB?1GcHAs&qRb!!kN^VkMt&UfAUk+#)kRjEDH-L4 z@(6gVtD;x4R4OQ)0mD@hX2CI=hc6HMwlCH)Ix(upKJcn65$%k7Y{T50{8dA#;USUx z;aVXwP}1vmow9W^mt|^DTcH~r0N!(E@x9OxDv;0ed3CqG0o?Q3^cluxyv2N?mm1!J zaFSK)szmV}#X+;&0yUX3aBSZaYQmq{9OU31@DA+4_x4Mi0=~gV(B}&Yi{N^C39sDG z=&&dsy~;in2=ytc7^Akq?mT`mcsATU!bEn5W&|#Kr@1`#73Ky^eYl<4s(rwL7KEk3 zdz^T+<*aIDy%pIK`-o%O7_%F@F}dk2)C)pKO=OsC##1UFoZ@HkYrx`bh)PXPI7(KE zTg9c~P-NN}pl`l{r#At;Q31WA`-miSu?Ik9-^%q8Yp7qyzs8@oN$xKG5^ zXsBmkmbZcHk8QEJA2S9Dvytj>`J4C$dL*kDgq~)YIzS&p)-}ALMP{~fit#UVna*P< z1{a+Q{ntdXi7<;#fJ-idXy^%FP1u9XKNr^00q4?mOkvmwm)L&Xa3!FAx(OHGGIj#0 zMiUV~HV|8=5h{bpY9H&a;CF_eghxm0kZYb$+-Xb(q_=}{JK*u2|s90VLr(46x{B6u%2f^9Z*SDLJ2wv zzkjeeLL81RX-g=eR|xOW<$8^8{}UqG`LXTXTTxPXP%}-H>@(eN|A}D3@Y8UU@Xz2G zzr~yCykJ{zKEY%#+=g#MhUb5QYlL50OXjq;8Z8 zNlvk;U>2-kC~XtI3nHq;9Z~P=2*22AZYz3wuedP&ZXLM)3!uy%45F`y2|@8(Ghvf_ zU!O-0wq$cw^DabYlo)9dSqH+B>~HTK;Oc5`V;Nw)h&p~5L`VV9a>t@8y+|q}w^43l zzD8d1FrLyT`Vl>r-fS3(&S)R~f!YWo(>|as+XAeVZTu84E1H9h9^y^d=`{YD7tX0F zd~MWf8}cQf;$OjexM5I1fcDGp6D?|1awg-lR&**J(O)byF)W8?h8u;N1WtKRyGGlq zSaKUX7zE-U6gnA{y7Db>gI*z$Z=_6DWo^e8%o{$XILePm{$pLP~x(E<14e#G6SxD#AZXfI7wdy;3F zGS(^14&HHrTcP;K$jHC&v{3)R1@Bqc40~fsW#eFjndGsekWxWDB#p(LX_9Ztzm0X$m; zxSrg3Xi-!6v3w;&xQ|dVy&!nyN?InWi}958zB9#}9Ox6;7A7O(!}&v5a2_pmrP%9R zni%&OlF25BBJ1P+pMkt92YOIFp;svmz0Vl(A$8V}leW;k3~eZt=&fHyAN7{B61S)V zMPq%uqpawfbKGnE`TIc2T!Wv3+*d9iKa+opu52b$B03?fyvz0D8F9S)O1nuVn0nYI zx~x7npoNO!8GQ?nz+2mZGrNkNH$P=0YB{uF8q266FRE$>r7>cEkS7$ZU<4GGYq^Km$;o&c6ZvkS4t&7g-h(LzA!tyi z^Mi3}$y^>jv+zsYp~Mpf=zivd_J!_WzInlp;jWP-k>BC*p~iuQ-YzZ`=g~0cFx7>a zqvZzoWwacWQ<2R#0{iEHvQNFCz0^+=UC5*4X|f}Efw%z$MO9?lgD_`{$CDYt<>v|^ zo;=2R_yIU8Z}LaM?aPE)V=H#eKdve6;@|u?{tUXu5>JSaq(SOcVg-HBe9At{{lGUQ zSUX%lG9+>>Tql$%(AnG2WwpODzhO!k(&6Ktgtyid&gJrm{%T7%q;>K><(C?-7a~mL zbn*Z>5)=s$1nYK~G}0e^Jwhmqc~CRCMerY*@%H9p-ozvRD!-BM4u4_}{OZnc1>yPy z{z-G}ua(GuZ%CWfa%2r=j%BB#k7vGL2rdbCj0}hz3D*mi2rTn1a@DsxEuR?D(3dEs zbw^+JCw%`#IOz_HOQaK^^f%LH>LcNk>kfL)FQPKh44$-z`Vk(PHR3Mtaz>pug1^(<&-71W9bQ(Sr#YC=ppT;vJU*qwy0gb!3k6wr(qAA&JTGR z>g9Vll@j5?+K#>Tiz|lRH3d7X6IMD*ATWurt^Sj`Z#-%p<$UB><3AOQ3!e=y3LgqF z!RtQN{m*g6y2`X0uDp}_b#z{NRH{aygV!GM*G6z%ekysjMtWu9CsBqhM{;11?9eZ1 zmrzsRDQ}RDh(FO~yn{%)5>9}U_|kgg&u@Zvc9)xnDrkFXo)@5kvJ(yxO!nZ%2?L}I zYA+(5)=gJ!ja{w1@BBybj0S|Oh6jYc1@8F@d#XG0*vw{%DM;-D@#3aDP+E*GhaZ%T zjEI@bfmOXgo1~W`4q;8-h{Hq!;xY(@Rka3cW6;wJNkzrx$ffh)^n1-|TtV#QL0FLm z+|pKDZn$n6aMSRl?s5;{B3#YC6P`*V)k8#OS}|?3Q5dB;*xx3&JybtjB|JG~4*v30 z@)UHY;Ta93r;&{Q4RaPkQcF0#(uGQJ7eA6TIZ17;HOE^!N?6E5QYF?C0ivniLu;;P z$5(J$yb5AkTkN737mriuESwAZghVi~N5hZxox8`~!a02lIm<$vn9cZ2!T~8oJx+9> z^O)b+>bUB8Px@B{?}nO%vxWMhh z>|g>-gV(baxG`DC1o9ctlGv@k)dVm!?Mha;95jBfpn>wC9xL+kn5{G(_3K{f&|by* z68SG+1+7D!DGQiIEW3si`6NkE1`13Nq_a;k1xP{41(bZZO@aXoK`@#8Px{>6$cq)_hfef6=x@E1UAS** zLnBf~e2LzEL&1Z$)fP31wwNC?4KCE8sMBNvfiWGOz6IpPY2f!lL!aRikE5qub()SxXXDNtRg)mF!4tQ5} zFi&S*tP1AKsIl(c24TKz(Z5hPjVrA|XLiqF-{C-xkRe<%{4Ml3STzvBTN~`CX_br* z4KIk=T0!XJ=3!rd7BVBRnJA5xS123ReOOTj^wT$B7j?r7+q3ZgpTvZO>R8bjytVD( zBRHnk;8u$VrI5lGb{sVv3wj_0pnwB73(Oc^j?NMrmNPh&N$C{ z-=0AJP^NI7usv)*jvexKc6W7@vZ6n1u#$72Liz+cNCA-$OMy1GQ#vHSS0vQ}55qV( zrz!aScH>?Y@wwj;Oyga7|z)U6hw-;jXad==+UK`?7m&;_{@TL{v9UX1De z6|EG@%iia*i8+dx9B>eA0HL7(C)Xhon_&!$dSe#%9VBOFIIR1me|G|;T8aA9Q9&%rYp0fmu^{SrG7TN&$zKE$w? z7hhT-;ga+dq@VJP-Ez{N=+5Zd5?CJ+!@DC}B7As5=tf|m?>e;M`>caO5KJcbYD1M% z`1}+gJ>{Kk7oelqcqTE7L$v4bp%P7Ye_ZwgRVBT<#NP6T| z#2Gmhx)SK%Tj!qPXkoROJ^%&RQY)j30nK_CBAk5aj`bJ&OUp6u<1D7j#Ov*#>+PJViWDgwkn%#*Gi}kJXs^JNdU;8Zwq#K}A ztU!!Y33a^OQbUkeT7#-K3Dm0VX)Z|E7+G+$VdV8kp+7u^K@@hWQp=Ri+I|KwnSaFNKoNcBjUuo8?5 zJn-gsS8znEZ;TrZ>xfU7pK%Ej(bnTT?vGpQ9{Mk$hqKieiUhWM-?{GD=UJ*7y@o_$v|3MIf*f@zqOXpa z(Xav&ch*V!&NK4@*x(SYaoN^hpG&?lVHfbPM)Eym%EqrU}!mnYv zTzL=yAG15a>uHZY-VASW50_u4D{)E!vA|Hs^w3(*Io4ClKRZ}DTpG`)c%*FjaPY9d zx_7o~n!UXx2N*VCvsK9q_}3lYC`75-teUOhxcPmqI*0ZG0xc9R0%(IPlBez7mOPQr4jrMjNT zNk26Wv3+t9-hKYp!T#Y+_|mFiMSFuA`~|!{TpjHNEq|Fy)J1giE=pg}HCqB-M=nHu z?fGuPCh@%l#)Nu7EvdDJ#>b`|h70?rGFi!@oC8(ycq!WBY-GkrsOsvq9bWm%Azyk~3-pNts@1 z4%&OW+Iu-jhWdreMjAxUhi8Ot2bTCKPX?#U_Re&S-bcRIdMLG}RYEd95l_1?ve60r zdf_`}!wpdSsY#$w%+We)vbsP`RIh}@nSUwQ%+l{!N zw(+Jf^cT{r&sN$>YvF930g`(!PU61u225=mBu_@vCaHDt)=F!lx*cr00DS64lsTZV zXMoGHBO;Ps$jo~pvg(3dsSW=Ozn{nK*Gl-(w#DXv_cxvW#=RB#$-}h=WJ~6qxvG7L ztG@Rd>U|l*&%=MvSC0=h4dnE#ao=$~wq7)ypzn}z`T=F5^bcR~O=J-55mQXyCkQ*m z=MszicAmQ?biZ(Xv%WVy zrhk#S_2VGTK8Cw>Id)`DXy2=Vx6wwN4kE`^WuR)-+TzWP)SAIfcm@8IMD>O;NC_!N zdr+U5L6*1nEN;R# zC{Q;vIc$r359bI!4SE8DO$O7sEPE1E2n)0sucJkw z?ybz3gk;G8e{v7QJ7Xj32FFQv3L@H0p-EvDIlnJ_D@XMp!8oLmjN+iIBt@e z=w37eP4_W;DLpZBw?`~v>>Iq*zoTtqrPw80f8nOI7`dZ|o@-)J5iRF==`#k`g?5LX zk&od(_+pUtpYY~(H-W>bim5c+30>Mj$|Q6;p20tHk-Ld%*(rV+eD(97Ya9cL$N^A9 zc+f0oBI19kOvHL#$P+;JcmOYEHfUP=KwXxH(Sx}s0iFP07J*<9GyhuFM4-l^POn@C=zGn;$c<~sX&Vm^0pOK49xK9U|T z6#f)+1+IBR?o1BBa^1M!aF-~9`R?VV7nrt@oxcJ4LmJnWcOoae0wVPUIX|XSXi6OR z^EOUp&$5yKmzrsh_POXr1c z$hf9+{qUSTs6$N@x{KAN5dQQA&_2CaSkNt=fEY1bX@jXDZ!wj%JTzphrK-|zbY*@B zl|VMUiK!tiF^RK!jKw~RhwqVu+U1)#Ss9{lqW&_~EfejFUCq7c{fB~?!b-SOBo+?g z=D*|b>pkZ>XS*geT%DtcZ9!oa7m~WPNTZv-XTLU z;y>k~P&s~&X&?Kkr+PcJusld~3J&PAzzZ5)oYOP#ScFAwh=dUR}MRvYk_=+CV5V(2^ zvwLHGarZ}KzuC@wbMdI$2i%XT^j=J1xa*kf77^3V$C;fgoD^OYnisSO+`c1jgEP+N zHCvgiR6lfH1*w6kV#dQHWXrAa_2=bp@iT?0qAae0$7-qkL(U7m)F9mWBQdd~2G~eH zUim-UnB3C)t#^zX`hsfhRweV6{|bCgGg!-D#A zOLVmli_P%;H^H9{MlW{IEM+CWwV}B8gSgpNKyyIKE1)*mDkg|y(Sxi2uYr*p%yOu# zT|;)=0@R_8Vtpl``>0|}3v(}9P3K>CF<+*@y5O=lJ9&FrL*t@p`%Fxt-5Dy0Tk=4IYP)L?NFxcOQ{RYPjk%n#M#la&euC| zJ-9hUg`Z*)q%GLZ-^%;NmEBPsm6{@STk^U#1k;%RfKl)bXCoLfsEOk?!5phR(1`bu zSgEsoRkniK*c~eWkvJ`r!D3M5%TV~$kTGo$zP_R2GpHYCqO#opM8Hy*?|KHF-ZL!6 zZ5QrJ4b{vVsLpN-S@*Y!gb63(el$M!;!vDYoZL6KBDUyLvN}JcF`;J#lGT%^oVOQU15== z;k|7GEiw$=dL7W4s(>o?|LkZn;_QFYMBF07#kWFPbO6(#Up>m^Wgo#amyb1az4!{^ zWI3}Y5wgKx5-s=aJzVEKr+lpf(}R^l6G9b2Q-k#bn|(7pL0235V9R*p62nWPsg_Hr zD?LShL&9o0pr?5JKhjSL;i=GDq)-DfL&I|u@2w;zXf#&p;)D#t3w;Sp_-gWRX!(uO zNYrb~;$G9x{dVr`)883k`hvUm^`+d=$moM-1i8DYQ^Sinx>Fy~K9P)=xT z$c^+qR7J)*eC{UR(*C`H={{s4x#Vlm+22J@y8$ce z&A)`+ZU^evzhV>!LaVquLT+if(nzmF)nyu*OW4vK?cFoH)%|+{D}s?wme67B>zV!+ z-c#--$oZd|MNDWIjaia^u%ctacI<)~$Vf+_%b6Yh)tspR)r3E$Ikb+s5TEUl?}MlK z8$X{hZ}*CP5b;P2)FXz$c~loCb{ZJIYd8tj2O0T(0{e_L@rQ-0Qc3lveuuh@XlGo0%sc0d4XA&ybH`c6*1%&JfoG!MVj)b@U8B_ zKB|f*)PNh!XBG3yE7jUW4nv~RZTW1QG zT8o)Y$@&U)hI~uxhFfyqe-k1a;})@CZif$Ar2OJ(u^{GfNK##>`wz(vai8#Tts20{ zc@Ldodpx7A(q$;1Mu2lt9e!z;GkJWz zZT<&=S;0raJ;6{g9N6#s;yL4L=-6QWXmT)3sndEFC?U6Ex?w|p8>R*oLTz&{`r@52 z3$>4+2$MvkGy)Moab$~ov7&FXAS>8WKk@Y~m#fR~abn+s18OFoQFC-d%W-!w6>b6g zSFgDw@OGTaT&*0Lk(P~bEVJx>S1nHo-v$4tz{=pW;MSlT`0DTBo8+nIdScIO?PuCc z>*OfCpjup>BbFCxqKZ(1OG0&IFuHYB`D=V@;jb`FbYj-`PpO!kf~T|&Z|anMU4AM* z2QBpfd)tG1jRte~G!#Dl(GM-h9S5m&4)m%<%q*%VjZj2w4>_K0V@k3bVJXu^H9aA&DG;i z3yY*XN@ZOouh1J!J*-y8FxPTVSKl4~-9YQ$4i^7tiQ9DvT=LhvoahQrL`UVaAJ@5y~c_f1$zcP zIIpw#=X-a!dpT8GQ_Bt`kBJnlc3Js_PI*4eoj8saRmQhA5xw40m>pdNJ;i!Z7B@yT z9g>^NGqIB{%dhZ^e!%5)8}mC_;-(#e{zxro5e4)|_JVqJ0Pfi*>DmS5vs6#? z2-*1Q=(TuJKk9)lPzC-1-vskr`@z-J0rTZ8n0M6+b-s-_hwjLa;P1Z#A4_e-kgdV> z%!=sf8DAXL8W+C)P1x6GxHNvLxI+G`jwebQDB~UTP#f*6>2Ban^%V^e!LGpu!6$() z{*At>bPO5 zv92ZE*4w$>J>47hmkKC>YQg-$t%1}2{=O@oJFZy{ziqVnK9k?DfvBeCMh)^O{(TnI z5t`%f9?f0mGU7aTyoUaN1Dw&T<)e5u_wePP#hz-9D*GBK5gnc%aD%Tv zU8Ny+6CKedYzCZY!-R@bFXbsF+~=cHjhifG>>HhT+`GI5{0#%9V7*}W;G)1ne{tV1 zPl_uux}k3K0Om850_}bt#3CLs9hKMW#!p8ZQqmUc?NrL1x# zxhI~}B6%%hqotS~Uqb#24_`X;vzO7&o(Vm2C%D0`fU-J`o5$I%6B-r)?^ z!}fSrC7h3Q{0jp`f^Bd*_6{`mzxT#@806Pgt%pq6Fe|b!l$okr2Mm?}Pu-l2FRdgx zQ_DF4C*)_W=!ei=e2yGx4qOE))>Kh$hRkuOJPQBY5E1Py#B0Bx>bM8G#|ob0dvhfB z2=8nQw~sF%y5wq@QQe)&$^0{Iw3cwpcCGO=_3idA4upeA!AAjafb)&_9&zt*wzEeq zeN6A^j+CO`Q6I`Vq$|Q<=)MNxR?CGaKOY*D=KNi_9PS9!#8a5$Fyg-z;MOVdu5LyKnu3|CW_+u~ z_`ZT$GAY%xQ*c-F%zATS+g8V0*K^M>-x>d+025?_>jMw*r3Jku-EQYmTM5f%W0+on zNltmxX7YJV1DeR+!?%`!^P}e97y7-zpgQFj4x>J@O$?xleoBg=zSRWXhoy*c_sJ{q z=8EA~>kDN>EAgz58A^!XI9;=XIlT~5h<6FCq*lrvEeCnqFv^(4a?BQS)^g|dp7;Iq z9|%~3(ZI;SJbzu^0na7ZT1REuZF4Q-Wl&+AItK;F0x?-=&F@F{7(gB3|M%JhOlLaG z7Y2)}C=|q*z*l*UnnYdf=DDb4Z9@Du0Xr%nUq+7oL2L#OeRf1MzwtZsa@DvCaM~Ee zlhO?(gT9`u20!dFOPqa_bDq10_mwZ*zdGvtdcjL@ zSvwDv%m~zuIwM=_fc!BZ?*5tFGn|n75VP%sZ+{&;%{`#vk4Z_m>pLLdAB~lz$W{JR z&w~?z6`g{nW)$9gZ^TFKxW9-)T8WwED(W&l8+D29ZKACs?U$W5+_Svjv7)JgbAc&= z-~L~|McyB7)_Ko99N+#5rhwrwu>+jZG&sEK3Ox{IcSbCd50Ub8?98g*B$fd;xIE}n z8L_j^Bb~^Nx7Gu$usJvZ#>lPZ5WcmhxM!fF+d3XBYN~Q{@9{fLKYD zW*(W^SkKziF{5Oz_qFe#zfE9zphjT3f19s?ceDGXbFRIb^|7ft!%`cGA=)VAy3|-K z4E^UR{MQx3Q)&sV@H?&q6tkIyWkNih- zqd(}1UPcDm6qS{B=&D#y3%7$$|BIT$_|40$oIT0q_8j)|zAyfcfr){lff@eMzOc8O zyT7xFJxcu(tz*Ki)qL#-`? zTp!W)EIgkTm)8GPO5eE<75r`k+Fs4l1+CQ-Pb)u zeaU`9pktsRqS=UF@Q(4Ecb#?2w^g(}HTI=VV6JRcugY1Zhd6Jo{P_R1bQWM$W#9YX zn{K#)sH3A|%P1-~b{Dpy*kX5JVIejKA|g7*7=zv2GIqx}w&K_BK)rB-d-m^h@BjbY z=K>;d)>-Ra?~1ec+T=#1&`}@O4x4hi=APy3>n%L_Uu5}ViL$O^|78(0GaA00hp)9{ zuj+m}$Cp{vv5hDGajX)X28tGQVoyc$a!agjjQy0V=yJ%dzULY5e107Q>IIo~T?{cs z-{7d=$l%k)&xWJ=NZp{oSpQJisFz2!>nhnzb+AvfnJn8`jsJs~Uys)G-YRtxupnCLV8pKOHVi@a0d*@Cv|Fm4N zy|h;no0&3v(6f|Jg5UapWkKQkS%%5Rz~JV=1%emD*S+;uf_??O@Z07S?Um!chHR~* zD#32ldvt!5^E9(3y(1U8hz(eO`aY){z0p?8Yi)%07qkweW^+W5`uKk@vdKEOAfAd$ zVLIhT*1oM|1;W_eFXRl%=!9=$GJY3p<-CsUfh**?*}a5Uy3haoWZ3KUf zPnqcI=dsAk;#1qdd7xgGtUqFyY0NRY1ZNtJ#v+ERx|%^T0qy+?`dnq+Z9dmqVz+jb z75nooU3k`a79RSYoVEmeJXi2!q%}{pO0vhnnwD z_0bsf7jsY6Y=zO2xy`sTDVf0fu`wM=A(S*-ak>rK9}8qg|SuSWN`4Hr_KF(YMj11#a^XQ2B@^mbmXGh1-J*zCK*B+7| z?PfpWVCz99JXf>zbNc=a@LiC(VtvU(98dZ?vb&}Qo!)ObwV9XaZkcXvo7Y?0CVX6H zyJvg0@tNhfFrahLQ(dqj$1u-$(3oZnGS)HZ^y`D(2ITp@^4ady-y_)V7P}hO+HcsZ zT3@jeHUO(t_MEsep^L+X)w@M9c%9lLNY$9+7|%=5>m zJjdF|J)P(G0$9bKZJy4A*1(uKk>mtVFYLLImh0A&c~7+}@-I#q zobC0*hn7cRW>8gqjG>%yjq$WG&iKagRlkoDY`O<@^NaMccph|*b*;jx%wqOv+ha=# zyR5Fm|Jj^6a)k*lg^A!t==)7%XIe+{+8Ufd8-h$vtmTNbE2wM#Cek*weYMW!Io2f0 z3v&~)^t$Z8EXpeOIp+J8iMHYPRmvp$xIOV`?7hnOod4;-@w(S~Vazm^3N9Ud+h{O) z88+zbfrbD#ziZx!o?-3}UDCuzZ9?82W?p?F=Z|DUz#P*Q)_I;Y`6AIxGPdJHk^S_v z*VFkM${aHno)z^0)l=DbS;uDO8BJ{%y9)KS2Nl03HNRi(I-WS?Sq|Aw;c3yF_F2nw zh4&NR&;C~ed+K)RHyRolml)%XA;xZomik{oodOg6XZm*XF2=Km)l_P|v{`wNtRu)D z+q1?yj2hxRs|UZ~Yl+1E&O8~ZPwcPEzCB-_p)u8k2)lu5Ka1|#0T{_>Tgg+Sv8>Ym z%{-a7_$nuuYT<&plGRLR)P|FCAypX6NawFrN;3_%~0JCqThq3 zmGKYp{poeoW3pRQm!hJm);ur6T9-_uLhekenwzYyylMJrss}?Vo*h31*{8^AQP znAS4cIy`4NJFF(#@xYj(NY$Ed_)ZCM4xRb*p7oKe=-5GFcX*D=@;Io0~yHt z_9^USi(_T?t2~?5Lhf)4;Y5xrKHvR52V?{l*Vi@VHzpc)<7saUCjEIlZLZVPqP)C4 zZn>_MGdVZ(p{=vk$D)`yzKRSx&-B4$GnJ-Wo0(IL^V5i)=I8Ly7V@KZ*0tnEztK_e zXB&X;mnMcJSqrf`+m}7+FIcnw2a`N^v2NiE&-d!t`>5U0lL-M=y?lL3;AuO9{Pn*Z zUK&~(`x*-w6Ug{G>+S{?3<%?#CbQ=i_f*%xoCGx5e%Kan&BFJ;lKH(c?M6d~O`lD5 z>D7wd5u6s;hI*~2C4p6~mGG-;%sz7DBcrGyBeB^ACY&DNDb{=@`b?lx(+1>iArDEh zO|`FAUuA2zlO7@7-F?UVM+ds<`sv3S!i_1$bYrA(zG0TWyl#EqHUB5Rr@T`=`?!~P z^_H&qew@ur?RO?u!HNdfS3EVn$M<`~EBSM0(qrq)3j5NQ1u$f7D*xLsQVrrqd*t%u z2~B6Z#yvn$WvbaoD(omGdc8BZvwG!uX*ET>iFnDN46Z+UsFGwNucX+L5A%IvNN z%*7nTn#?KM1Xe=!=9IeOOkG*0-R9)e2sKn?aB_m1C@Z4aL9#?_7dP3x;Vp}^>n2Ke zl|$rEIhwOCI><`Wz=^BpS>2r=`iLf?k_Zzed54N(!i}?@�BE9}{LhINjukwoyyh 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/gong.wav b/FreeFileSync/Build/Resources/gong.wav new file mode 100644 index 0000000000000000000000000000000000000000..e01130c7c8fe37b9358aea02bae49d614314325e GIT binary patch literal 230274 zcmY&=1DG9I7wtLa%kJ2=CpITGCzzmP+nU(+#GKf+JuxP>*_UO!b^reI-uKd9cP{GI ztva>OK5MVFb~UY2v*xHtENNNwj~dU}?KlpDM@g^6wzKfu{@?T19`H7{jQyYVIJRAE8(O?t=v-@amdVgL8(Uwe;j z8TA>c9R7cMu>QB-U*8b>-^RO1M;AMq*uG=m8T(Z1)ueBaz0Uq`EAqcb zv1hguJL=fJV}E0x`nB(*<8|7w( zsb8a%v{ll1CXHe2yxWbX>o#c2YdYMlLBrDb6eDT$08xHYQ2O82en(Howj}w!frT zVq3+&Gj?XNzYNASHj|QOPwbVX<4Kxhv19%CFwYljwJSe>@!JY9lI(?Bb;>BzqWLo*zv@+NqQyeQPTJ&9e3wevNu;AF;FfHBzx7kIk^8<4uaa*e8;XH0f$3{f&KNEHYx-$By!? zbHcgfoOLeSm#i!nHP`A>^p%EUj<}Sj3ia)Z%iR3fgT|B$!b!F{z)m_L1vH@qy`YBl3zIpxv1zOh=|1UFiH}x3+iLS)FRmZTqO5&)MtzMO4z3zNTO396FgM zV|FqTrUUaE9Y#8l?W7G|K?~A}ZC*6fTW75G z_ISrfTH&1E+wJU$c0;EC=|$XRsk7OsPEyj&G!;Ea8j`2ZLZ_`W%{lH=c1qYItiP=E zcA}lpnP4Zk?^v&`{B|dMlPx;m?MC(+%VnRl(~+O#2HAoU*hh-d2GmX8k|U%v+2j<* z5fyg|IIC^HeZu-_4aYexu;<$k?66(m+2B+lm1&fwU|1#%Mqo5sfGffoY?yt+a@+%U z5qqCa&57J@HV6BhNyj#0e=;5>Bb`eIk&@({^UCp(LWCzLopVlAa+*9Ldq_DFbh?qx zqyn8mmt({RP!By#EHZ%pK~Ir|&Jz2)o!-gk+_BTyqpZx<8Z(bs#H?Z=bS<*-x!D)^l^C znP~R5Hd~#o4Q9Z|X;!v6+b-v^JDcw&rr>^tG?&*{v_egSDk4(i?xKC0ryP0mxYkCsNat-@Tx?^u zD3^k7%P-+NvF{n3X~rIAhqL>chx8q3M&^=QlrlLGk!i_8rxo@%mu8^9kvUFhT=U+f zD*aBgF(+vnTAX^B6l?>w6BEQSY;wX*OVWZIbYwfVJ=z*)y|>!f)osoiVa(Q->eY?w z#u|e)w&`nipW!lH7O|(=DeVqcGfTumI@8JRl(COn*Q_aaZ)d-=#L45-w2ND-%<<+5 z^KbL5@t?j#C&pXjh54IR+{$kivdUSvtn>Cprx4;#aPDLFwI>^BPDWt9(c;WW<_~rO zJCg0n9%5$GJs7dH^dCBiZU!!?NGKJVdCW>?IHS?|bUeLI=P^~;F06{r@$75X&ClWs z^M|?moW#9kx3fFgBWy!<8Iy{s&cw4On}u7+# zbAj33Y-Fx6J6IR2(bhY&p;^eBZ5~5}rLsC&YpqSzdh3PN$KGNWwL4m)O=^}k>zftL zY-VONy_wO>VNNh>S=7#If3&(-{j4;IU*36!?>k|ab|&EaJChHDrw>RO;vzHgr~rLT ze#f}XCuQkX+6(7&8o6cwGlVgjQS1yhIh%{wL?z}LQw^~)fnCbpV_&gx+#Js2UT_7u zjO=vg9kZIv%oXBxuzlGxY;JBMx1Jl$-C=o_Vam`J$VPRUQp`Ba^j7p6ZO+tUp3ow+ z0~zJKu~RzZog2toe&>o^ehll$bgbJI?5Z?%S6bF4v#62mNKelv!fORPoq8T+;M z)jVXrGE-Outn+4Wb0uQytKP{dZr(8eGYgw@5h;7jUe*NbocYxlXS6jI8QILkW+tn$ zb;}%P@|N3{ZOeXdoyF*NHSd`%v29nYomI~oV%4ylJ0-~3 zPI*L*o8j2&><2a{+XB%LWe&0L*i39SCKEHB8Ok>0PI6bcY1~(~10rT8n}f^F6=gFr zpJ*RuD%*{lz!l|8_Bm^_UAQRMjqkt@=Mvad>@>DLca*Ei&0&9I>#}>;telVAfRX<} zuaMlN0NG4llA5HR^AvHOj=U#tNL{ka3EAE40(LcfoxRP@V7IhNSm&($b}y%?v(*;t zc~&>8lBJsm&DQ2=Bi{I;cQ(42*^zm&nUjqH#s`d7(CBSQ#tD6f{;xj3=xa7cjM?T< z^D$PgAJ#=HxAnK#!JJ`!H8C44j~%ksS$(Y|)<8QOR*#m*@B6KIWa!n%>OQBc)5AID zG)ERG$BbgU%naI)E~aIe226T}!S&z5dyT!)UTW8|cUnJ?w{IdM zRCBS(o02))ypFR!ZT2%==4>OZZ$pNc48ho{XVG)$H}xGxX|sx%)wGN#R`?ah9YZqn zn;(oR##UphxyEW>*RWq$^Q}YHJ?pde4LNPS)!IsBwX||${&`3onT?#i->FZIlHH^h z3E=Stpxu|`4jqVD7ov}mjV7`+xeDBTHYLuy278yCg>z2EzvFswwK00<_*Q~TxXayU z=OX$Va({6p@wgXqQWaKbA8?)dcs?UWrVpEe+r#bULU?C7GnOIj6}AR9kbA>EV4l() z$d*I#eRD`z=ZsCA!#Ji%qz*aiFwR_it*zNPobh&M%Q3DPJbFeX7@2}T3I$-^CnU#%idPd`zQPZqyJ~Of!h4sza1#P%q+-PO&)I~jw9@g`i zKQK#wH;)*TjfX}L#N%P>l$Ft1Y<@DsRw?HjR+`k#Vf%&M$5EZ!Se?f>{~^xz+UuR` zq$ABmcVaaxL5m?D9cKzKN9ja*fp%qDvVUM?HgL7Lh8)W~{7@mCP?evCQJjOBR*4_N zx8|R6{kcxu18yw;56|-kMk^z`fz8C_#GJg&ZpPzlY-#QZmw{i#ooDm15<7}Li&Z2M z_$dcdkg3L0Mid8WP1=K8b_Qad+6`pT*d7cdSPz+Mg4N1uWTmi9nxoB0h@#}yDKm}P z&!}Q7G~&(LW<9eO@_ARYIdWD$Gp+g3XlNYLJLn5^P0wI_&{yEmb^VooS-*s++mDe= zucy-I>g|n0qdw;0G*h$|Ss$$|c0+rNJ=<<(=f%h{$e-7o1x_cd!#T)svWSes-=0{{ zbCP!C9cc_yGnm$(3urYaB|D2R%UB}nJOwGk*;FfX4`Jp_^|HkcNZ?P4)*<5Gt9ov!p#GF7DT*Ox4MsxYNo~+1L zWpA)4xFKw3W+-OiAs{^ld4C6a<~+Ci*)y^3xt((MEAzTB$#`mvGEbT7%*y6{W1z9k zC}Q3*vsx?7X~qHlhCbZLY8ExO7}<>5`UAZdvU(BB(Q-x^eZ6*H8;N;&Oy8$HQV*zq zYK`{a;wm3CoN=F@?(Q<0L+0iciM zBq!}jKa!@T0(nF((|*iyWURF`j3aMLr!u>cc^0#!fG+>x!u&pAgOEv>!8hcO@wJ7| zLM4%kzX^-@9eh8bx!6OTE_CB(aMieZ++*$~^3@aMwpGkq<`R2}Gq^U~LX6MfY%A^r zH;OyNhM1m+ty4@1b{nqBBH%ld6r?lh2s)dTaH!oAtI{f9#Kl%ttB-lbSZ8E3515zC z;@oqKKcHv5>Tj4OHuJ-NON^X-UUTHmcL)1GNL^-e%h zqxElE4eh=vt7Ejg+BI#C)=N9CP0;HYmw-Wg0C_wxJ=Qd1%>a!x8S?+{rH;vb#4&G>LHhrPs3kAKC8j~WLE=eyuu1v zkb8=md55je?cxq&O*qT`!TvzzD#eJ*9on8QA`hIuodeEi=ZxcX{PswzjODXFVb!XL zEY#Szq@UOG0$+uUEk>O2OizoWbb6`P*l7)YIw>F!L;ZkUmj6 zrnXa?suR`Qsz=L@C|iIy8=!U9lVerrs^`+D>f?<*W}MZ+nrO|mZd()WL8v6|l8ML- zjhq=yO{~)YlA>g{GtAlP)BqY>MsLw>^f~54ZCZmVfVF-PP}Xba1Dl!G`7Xi$p|X%$ z7$RgBUx|&Rm*NwF5o~_1FiKo5W)QmzFZoUU8@_?CN*E&?;OB7vvM<<$TphkRKa#u3 zE@vg~9@m!d%?o@9%)iu#v?stBnYsLIK_*JaGey`+>_TQBZ9v94Ih^E97R*S-{@1)^ zEHyHiSIr|PF*6ur^-1~<{STw7QOH=Or_gU`S#-`wX;w1V8`lv-r}WmwOrwC&S6`(u z+B22Yx@f1gDcUtvQlBWT)RbBWjn%rVhLTx*sLs{$>#Oy%`Y|Ak21aM|fYr(_W8Vda zC~E7dmCic*oN>tUtDLAamlUNvC z%&p{hawE9p+;+rjUT!+KjvL99;Ywh3wgRT$xc}J6Sa&O;(pgGJ(X@0RS?(xUfd<&R z)gQI}PP3-@4-i#IPif=>@|>bK1cG{}moaJ?MT~TY%ebX4(l;ZM=Q3=(lT}Zq*TzgrNv8JQadH{sHflNTfRSlWmAd~1;)bx8$-4~-O9RYOxlb)x^nS;y}wmBC@Of=>j zAYbhf?ujF%#?lV4hB!kkCLNQmNi(I~(rhui=oXuZtHm~APoXCA*Fr8H^D-0Po4d!Z zM3lAXg4oNS`~@x}_nfWEW#>2WMS0|W_D^7n!dza=N);97Ptt}|#&IPhl5^17ZMHKX znq{o8S<38STtpl^(~BC@k?H@{KWZbiW7-~lm@&vWtLN6^^~riRpwT{trI*$FX&JQo z+GDLaqVK6Hsv%{LT2X6)S(sg&rF2x*D@E0}Y74EgW~)Ec>Dqg}7_#C{^Dki6E>;~o z9`W=LIbnh`2iQs{r|2E}JDm>(<`?0t~(-aANPr)*y=Uc6}TcJ8)1GjJD49djk zX9P!JnhB`k(t$ndWo*If@7K%df9k#U*2q*N^f~$%eVo2W|Drpnq)Y3W^)C86eY##v zm$kRr18oULD8J^_-l`qdT}mfqmU3QsrJO;8olw%MlhmL(Sc__l^{={Ulr_E^&& z@0sn6Sd&*erN|QG^)PYB1=Ox<$Pdg$oeU*;fumoO!SpC{`~`X+wR1=22eX6i3T!!> zTg>I*%Lp~Z)8Zv@k9b`yAgz+#ORP(B9g{Lio5XqISFxz{LFC2a!bAQm-%#i;X2)_(_!WG4ehm;uh-HB~vT`17HoKZRNB^RGsZQ6>d8EG6 z7S&A-@G64ErDOeUq5IBBcMi4}x{Kwtqr z9sd6ebmd1i^qP~o8;Gl(Tmm->d9WCtz-Qr?a$nfYAYZ_P7%o83*(Y-+66n*jwZ)u-u$^*VY!%)E&9Q!4_Txlf<37sVPlR*xX#pGDkd z(|>3OwH{hct$_AT{Y!nR3{|Epca`tT6=jjKP&uM}RXiBIIqGXQzgAuAj5&K=-(Zvn z_Ihp3w0^fQ+nJp~&S|hKCP+Qbswa)Khb-)08*z6}gSlQmqaIn_aJ@cgHHR z)3|1y181`zT*po8l>OP+fjKxH6@Mon(vI{Q&5n9aMP~Jrg`_5sd@=ej7}KV-1JeUF zb8j#X6S!?a=!Jv~7^@y)KCzQnLh9-&=5FH-xVpPI*DYx$e&$M|#E9|2I{rHVyF zVJ0H$1(%!e!S@B$|A>4Qk9qeG*M{HEkK+}r1P8c+d?#Mw8*u&DV@zJ=1X#(*G&LeMa>aUWMv%SB8h+!xz7-MnUYnqG*G>X2y#{vgs)w|%S_6HdJ_;EvtnJdq zY6G?H8i!|IeSt=_v+7act6J(>#guo+Tjf)-EVsv~Imlea)!Awljp#!VnX`dukAev* zVm-Fz+3B1is29UdFEF^7sfN5&nKUP}$#P`&`D7P4NzQ{UsYZ{W)@{wyWtuV%!NcU@ zmg1c2@*nt~f-LM2`%0En&Xv-2TY4xZcQtesaqW@{O5eq6;(l?9xIvsKRucCMIfcjk zBVOY#@@4tIP-pf7!cERM0*W2Q*W>fz)qy}8!}+^hlpVp20b02QO#hW>&fK6)Xb=@g z0n*p`(=KK8HY=C|@pB!ytDjy`@Af~0bWk6Fky)+n(9-H>^z_CZ{Wtx-W@!uXIH&Ox z$m@CuzRzt0TJckam%?hqOIsl$a@R8{Gh%A1G*B9b)v=*#o2!zmg)~|$30(0*d?}U@n+SvX zto(0$cZ|tSjL$K!sUx{kyv?T;rty!s9^4kLJl4z2;J_Z@y^rj_z_01KS!{jAMYH3W zK7$we436%b+2VifW^Po>+l>dv+>f=&`a?a1aZN9;|I~8nd-beFQ(!Nbu~x5&oYhai zpl{S|Esr)E`2CTpsHfDd>R2VaLKQ}7qHIo)zD53ytN(bO*Ln-lNB1xbKv`9WGk-aII@n&q#Uh`Rs03XM?caXOkQv> zYp`w&=iY*Y$tv6xDvOiFRiY+Nk7#U3njtlpvP!GPD6o7Bp@UFE zc!CIe!hJ+cmFFw-wfK&xj!NzAIe~ zL_f|pt+&>9a8J+7lIBy?ihmhBjk-p0Ak^-9N4(Mxyk;B-86>%*pMjpKSY|Owz zT1Wh>Ks~rjyNY}@R@tqjR_mxyWs*`rF=V$=Upb&8S5vD0Da)02%4Jp8J^(|6b-~zV z%r@IWsi|T&wX1@QJZR5wMuA~o3qARWlN=RLWtx{>C$-5Za67HQpq>DS`T=bG4Z4p( zuHl6)}JSWzd#z-xs(o!c$mCm~|xJSFXNi%@uH;ShCSZpcI6Yla|`9b^{ zKA%up*n<&k2i9i+-vlGL8@$&;E*(FUmrxhv5?1kVxS?1jC5&7*PGFBv7Z6Pb`UBXn zhcm@~Ypu5awys+%t@q|e#7-amyFS_&Y&cl?a)L)V0Q^467;7*_3;mk*TpJG@@dy=B z4egU!P}_!xS)@5?WA(byL8+$9SMDn_lvi?1xuKj)i6{v`5x=YT)g5XRV33E{^HF1$ zvD+wMR=3*QpYgNF-T^k^nNtTUTRrd*cY#(%J1?D{WHGpiFW|q{0^4U`+CycS0}i1! zdx34srGh$fmVW~FA)lC2Iw4)hh*iXx^_6IyqiF%tM%=uA`2p)r`>QR~h%!|vrEtna`JS9g`CDPs0LHBm9(7bpYfH6xSQjMVxz0u!6AYvEk2Mxb zNXWW{$l4C>^aC`u;?6;+Y{^JU5_K*(51cBHefJ}OHAHotlD?ucGZ#G9TJ|gZC&%;u z@*9NK;y^K(cv0|(_rybzERBU`OTVOKVN}cQH^=XP+Nf4kLbS}2aP?3iEI_nczw8j6{9vm&!kP2#YmozC-^;Z zGPpBzJG?GBRZVZM0g4)KH3HUMVi2RAzD0YZK9Flfa)dueUMeHBA?k4Dfm+DiWLI+f zIFz<#w*v#cVBfQ8xz)@7XDF`MZF8d~TD?rab=lUTvaBUVnQWYkU&NK?+Vhje)>2Nf zHh6^osO5ezJLyQ8g>f@AXo#rvKej4g7kP&j$GZeie@`LL7*A^NX3slU1!0%*o^AEF6r+)a!P{VILu6D_wco)B`#I+V?LjCx!m)lbV6@#5bI@5IsaN!kY&?a zJB*2XHKT2ZVERCv#9@IF;qFm0+D9Iy z{$~(-qcztoWVLn9K@lEpr-4cqvAxz8tj1lTS5D9iW5$m*H`^h5vHg!ziJ8c?;CJ(k zn8|g`HAvbb31P>zptO%11UZHu;%3sJUu01k81LzO-;eTr3rnPK%ADVy?&1YoR|^ioL{4 zrl;*`MhC5@dQDN}#qw0;yZVP#M{Tb(Q}5}DIS+bh1uJURwg*~O%_T;4qp_Y}732fa zyK+ONxI8hECel4xR!OGrRg3C%%$?RA@c8enzrYsHvXh%GQTl%nZn#m;GN&Nn_!L)u?ksXWiwMkFI#V670|mFr_873 z2(yzVlHp*Q7XUxx=XVO1_!jJSTA%5_>P#gXQy&XZ8`@DxyBQhF+=P1LV={50g!e5hA|ktb^LvlG zH@Z5x&Pzmm#hqfi1JQpZKkU-BVXtvqPG-BCtvYwI8^<@`!HOG9bHjUuDu=PHBBYN;{O$_M;O zv{G8{y6PU{?kANI+F;FHz}_SetUF*r)9cICE=qIdpfXyyBm0ye>ICBnbeV#pYawe?DGd6c|F-W`1%z8~ro_C;IC6_otydhLqd$e3m5(%Cuf zWOjP1i}6%*X?@l4%2~Ona!mcH{ekr$r!mngPHxdv;HaK)-}u7934RH5nS0y`W(o3_I>k^F~k@R#`ZrWy_M0qK~~Wqy}|s!4F_(_&V6PULw^|0j0H!x6)IL!>l=I- zi%rIUZhNpsRG`J!om@L0s8K>iR7MPV=QYAaVIIGRJq(rp963mSI$52deaQ)tlXMvK zjD5#X6^CO6c5~wdON>hbW$dF6f)Xd+|?ec@^?FJ4hL}oFixuX0? z{xqLos3Sb*(}KZR3PhWdx#tl3wzJ@7Q~ed2(??ZC%C?ckNr)X0nIDcMnWXpNy||Ag+C7u?`g z;~rGWAKF=PK8KaIieC+>2|y()^|j_ldmUk!wd{EQmQYG;EqbKlt~BnOt}~JXp;U}(37fxYVDmqn=v;WlF;MyNnGsE5_8 z`cU^;=yw89RwN zO;gb(B)7BEnq_u|a@o$xZXE(sKGm9IuXaum5uD*O_A0vxIq5a`73|AtMgsfdWx7Mb zNr7)V=WGE^X-^Z_FWd<5Umt|o5^?Wz-*;#BwDDy0jB+Qsw!3<{x=1U8XHaI(1G5}p zdqSZLumw>iy@%SnM{tX;!8hg-JBq9r!x*r z;@Tb>#xObP)pBk5k=y~CYjIqQ%GwTntufvVSl^w!^cHi5P2hWq zPr-{mk@`yur5mURS;-cAN&BSg(hBjA_(;m?zT@_IwtHmnFn`auvvG1<_xQ!}dfX#_ z8vhI57T-MY9=9WTr8(kHVD=$kB2GZRRrp#$YVkFGu$1u^fK{LH-{6lZ%Dg2toCIr| zu>{wh)z&F1BgWs>8HL_7ix9%i~4vSEQ9J-i-65*tPGT`=&R_3=;-M5 zXgE4t{#Q<|$jT9IzHtyN^(OG_v*AlBVmATLU(){5st=Xzq}dIwiji>Hz$IY$oV27B zy~;dBr7E+-xi`EG9(Wc%6dDWR%CKi?Q!?IpXBW2rvaVQuC!Rcl!ZOMp?Rb#25>fXo zW+=N4TuwdO8px`XQ_NzZ>;4AM#dc#dSl-wApT<2S2qedl^klTt&$&W6Gkcj8;60m= z17s@M@8rj;E1Z;0Lwls1hU8)L0I8Q|w{e$*%F;UNrL@ZRr@J2J`9aq-cS+A8_hM-? zMsOeyN;}MgnWzII{7oQP26KM1luTMD7M4o6_JAR(E+!ZHK>MD}4r4yjx-=yzXzw@6 zn={Sjz*Xgpm#7#MjQUVMp6N67*;+BRkn&tUFMH*dkwu{aK{c==&^b^bkPz4w_#1z3 z1agJ$hL1*0VK!%s-i+pv1JTXV6Vc<*qS5Y=?2#Lhv(a+$$EY4nshm+&ZHwLtNbabe zm6)U|gp2IypG&XS*g|opYRfO-(IbcmR9{Nr%}bq$-H8o z1Rt~3iigtr*v&oBp=cxEc?MGMhXq>ddj9dn&E%qnJvP)iLmD#J_n!rX3E zw>68mwji1}g0o)g47ZEfx9z;nZbW`9doF60eb6#nI#cOC;Q8H5nBn+y!XH>W3rfjc zwOs4KjGdM?xLUeaif>REofamEGP3?j=$Ap^yO2gKgLg%DE>}w_AdPgF^OSe@mdc5i za7b9scjQh&F>*2gI$wZ7Ps8oE!EOsrR#WQ@YVliUUnpz+jZ0cB^`4SOoue#?)(K}0 zNulDQb3tG5ULY>$4sHmn59|z<2{Vx?;b7>`@UqDA=<28x-4z)fQN!88=R%pnBvLH0 zCEPSzEs|W`sPtD;X$g86=x9N^9?8PoVn;%w;oyEq5aPreLKz@}dg4v-t(ad*>&ojM z>2B^`=T6}b`wAeczWAHR)rj98KQ=yde2Mr*@k8Uz`UZQOdk=U!dXszZy8dzf?w;%Z z&3)UI9kI67J;$|3+AeXf%@QY80TxSy(~_gfogeT44K(-b)iqshr4`gNsBz%!#;C=# zhu~T|g0IV}6;dVTom@*1lv&Xa;Q?WPWN0K1t`i;?9v-O{Z5W*uJtJ>YDcGDnnoF-? z6hdZdZIpvUCzH7w7gNFlC0@DNY0!kn*^fyMjW@J@(Q@CX$Z!}%BePnSs zZTM)Ybm&&_VvvR+!QR25`2XMVj%ZjuqwG^zeVP$(Z361uOy@8TGl(t2l><|J5G>0F zsDkVHmV#dli94ihuA#10Kv_e5hx~QoR>f6|_b1zs>~gX-$(klR6MsH#l|O~Qpg)s; zps%Pm-m~6)+Wo{GcIWWS^}O(G^Az-?MD4xSI@8U zMcbs6(3(M|`CWC?;@WMsi#ini(-QRu>bjzeq|}i=Mh=8)hg*k>gy)0^X4axmFq9`e zD%>ryHo8I13a#*vnhT6`6)>&4{-+dP&}TuvFArrlANayr##r;YrP~jjx1=evm>tK} z=8J(7-UjBE5jR6MZ6uuI_j0=tJqN*ktMoE`PgBA@Rg3w+ykhUd^*9HN`BHEvPvB=L z%MaqZu{oJfaPa>L{l&5hTjR}~@YIcfBP^p;&?;*!1wsqLT`>m!j`sEi_*x5E9_t~r z(iz4YJrg_|Ug$K<&HLs;>l&)c*R(O#!!3Mzp|-G6XdSc3Qw-`_Cd@y^Na`Um@A* zWD}BojV}_vEpAL)-MGvCZ$8$S*Spfw)-%91G)s#V@P()a1}0>cRbS6IkmS)UK#JHrh6F zG|Yulgfa!E1$qYt0}1>c=op+I+7NCR;iDPlV@h&pi3hcn+9fTO&glPY*EK`?rVWON ze}F@tA?_2mi~U5OSQ=ISIrb5ghp9;=aset;b?ZA^0u#;t=2D>f zE|wo2r)5qe^vR6QY`c`LS{tlV)*y4DF$HSNa%gl}ppxFvdl(7kZi{!ilKu1)(}k7U z_1sEes>SfwD%10B=^)u z-ikoW93*bzKf`xmGCp<+{ATg=J-Ex#bRPJx`Ai>X3~h~0g)`uU6YNpuUcC<(glfv? z=>lDHKj0>KdFJ+!uNl&JtM??iWrUX&lWhx0fr)$>nj;`H^kmQsKto0^uy7@qyZj zMHB7Br>G-u1@DClMaD$~@^1B+{>jV%F7>4IJLTco&d$w+c3chXb1!84?`$FNJ6vhS zQGYxX*Sn6pmwE02X+`6bCEJtCojjDRL9+hwKjYrU-H$62_tH1fd&BdO=aWbDX7`ry zw)Qsl4)pqbDf}n>E5J_t?G1x7yzE})a!JjF?py-Xm!x-mHe*dNo@-&a(Q0U2u_l_T zsix85wBp)2O@rQgM9rYQ$6N_TxX7{4)ZoX!a%nTQkp9Y?V?T8gXnFP_pND-xH-D;DMOR z)Mruysa|6?F*%vW%t7`AKLBffQ)#5DlPAHq+CM6;eSFDet&;`gC&fRBZ-wisbAIdf^To2??;w70rh?ooV7vy1>BM6)P+i%azl=nhesBK3I~S-%)||eGZXhD z{!HWo83Km`sX}MKuMdvC29GKtiVA6YfzVX77vgFV91xS$sj8;zQF?>X>HzQjSX6v3 zNIj-GJb6`w6JlLS0ct)DKgTukFF}VYJeoC_hD=&?R~&~Qqz8AC%OY@68`l-rVHfM3 z@80Hq<@yRWLWB1sFJF*N#w??s$r@*v?E~NO9G>iI=+br+S{Q}{RzY5%Dg)J@t>u$MF7uq|o7b>5IMv;or%vpO5MjBN^s+aPucQ<>I4 zN8n3m0K8>}y$Kb;O?0L8Wtwx49|JwZi?-J;v>GA9Q(L7sL`mGH_sTS;ESw zHP+Hh$iqFD1#mP~XKS#9*nV*T&SxhuI$7-OvM*cn&CJFTZMa%TNiG+RzK?jKR%Cgk zXk=KVYqW!WOE%?VaFlFO#w&#tw{l6o6U`PG5?U4<19o*$;6mb(#593VfqOwA{4(-V zZU?7tPrZ~mz%D@kL(fwtIFe$z{ttQ<>O(Q~xMhIDYZ?<}=Yu&(pSo72yV5Nzm)&A|F1YU zZk+FmXRNye)yp>OGbZVq!9rFQ46lC4$z+m!)JUKTpnW>S{2PuN2@6n zHkvh@GBh;!3OTD*B9oXSu~Fix#I}L$K`V3~8GCQ!O|&%XoU&@X`c1hB?zNJV8QgO| z<&d0TZUffkj+$D}WK=PaSk;|vBso+qlXDBBghj$_A+K0o+#_t_55e^lfcx_gu-<#v zPx#3Q4SAGsM_dj!!!g$$_dw6zsFN#sEO$BgaY+&{KqXzozNRHe7WAl{v!|mkVvF6+ z$xB9~E3YJaz`8SpDFkQjpXkK7gPy+0CTk7>N7o8^=U|Q1%ju2u)aY-#Y>l&R`;H^Q z?X-ZI0*&B0x`$ZiUoc;P!;${P&IA;m)}Cf>MhC_!Cm;R9WQE$Xhj)w3C0)u2KV3H< zudUK}ag$I)IL=??#<5+Q&-5-`N4r4TzJ_Rg#N+~68^S$;t9mgzhdBn9^c|qC)n+PV zpH@%hmD;jfmgL*f718U_X>x+{NhzS#P+O_J)j!nAY7X_bBFkH%4I*8`q0pSrw_xqy zqrla`k3h5FZ=s3dHxUNxSDbo4+ikSAdfKU-BhC;KA#v1;Zoqe>5!BBX&Ryp}G8$eb zm0o9>a$aGFSkA?G=6Z+uKKh1&*_s~zAii3BtGMI-zx-AF%Y28unZ5TsD?P(K`#r0? zAALvtcm4nQE5^mgFNkjzpAgs5U%~g*Q_ej^vW3692M|m$_gd4_|VH4P?r~~`LjUDDHg6*v<<#JJEtF)fpo`|QVx01K1$8cSh zeu$>9i_gZrLIgCYdL!jQS?<_ z!x`+y>baRM08dXv@q;)Gm_AWzAZ-z=i4TMbf0^6Nvg~oNC1n^FP-A(f5mxw%&?qaz zE!LX*0l#?-IQ2I}?{4gTw_2H3^^K^Y;?-12D!E&Wf&qaf3$ccJ}idH2Ui422AT)z2akrng|DKj3duLs%{nn<^N01wp6}dqMADBW zk}E_(_effFOm&5$elICP^DJjw=?|(@%U|J=NUvU8AHwg$ZnWy4*R7 zUZ>9HAR{-he0HNSbcY$}>7rI0^P-`lb_yFyp?^(NTFNP-m%@ib=|Ure0|VU?(H0}i zw=>XbOha@fJ>+ikgHWl=5sJg7@t!NkSuBCOxF9=(=>ir#ft$fE6vl~zrP8h-RH|#9 zHQo!z%xADBCb)J=oq?8hb}myF>{bWN#uH8i%Ks#|#zksTu)=gVeTU37k6Q2;{&XJM zWvzWiayS!a8kvkH`YWxX{!Q-)Wp}-a{z1DFIMlXeEuDhZYZkqUZn1~Za*8|k;W%0j zKDGrIi`mw3yA7PgJxE5T7`Klf4UQrt?uSdPExhIh;MdF}PT@0g)6k)IkGY3zk(wT$ zg|QyZM!#Af?hoj^-NELU0uEE5Q57JHJpr0Xe!ZNwNxiQe1NRw>&X&I;`>1eowNnjL zR>`zt@LL8{3(lLKSO;cAJ4Nz_yM>B|xX_{C+Mp9$5&9kYOo-l=;hWQKbf2fT>o~;_ zMc>d_&=uV_ZOJz$;N*cn;vD?-b>JfT%+vv@>>%EkK7&1Z?&;z?hdME9+(Z8%|3QBp z|KGl-ceS^f_Y~Cq0-kT4o!)ES$#`X)Zv}Ez;keXsy8nRxp>MT!rss}(musqcj_b&z zp}oi;Cj>q*h#se$#%^>6%r#o0BXyZk25y%z<{#)N*s5IuQ`$`V8C?~*gc@;C@Mq$i zpQRET2hs+uz{|jrV0?H$q;m9r^sqcnxubMZN!g z+FhM7IlCOxy28vLCV~A496U$JCtT*&z%}&`+>3-Oj=o(VJCb?NghBu+BSd(9eF2XJJ7`n}dIJM9-IK!T2|K*$^E71X66dtb= zh^$LAE0E=4XM`PZQD`m$jQ6M!zo@#ZX>Q}H(a)?4Ur%A@qBD-HrZeEa7r++eMTbf{ zFzL7KFgnD3w=}aH)5 zSK$E2imu{&IEqWOGt-1U%}!$5us_*7+#+ru`wia0p=6b#Sb5Bg`UCBdT2#@Zr=o4- z?eYLQle|HGraV+t^$MJUwc&GHudY|6E~*GiDZqOLyf zpPn(kasDIz$^PMBBJTUU`1krm-%4*zZ(mQ)&3h&TzZ;(H-Zx(2kNS(n<&KMs3G9vn&^EFUNz_!{^lm@0T7kS16vG%#E@B1axauOf4oQGQphDT|=Nv(Opu z%GH58`(T#w$_Ay2nop~yXEIyce)5&fq9>WLT)eOXen(aaKrd;?uS3siaqbLzj)_ng zlLh|w4DkMc<_Wl$1eX9!uZw53C$Fcon|B+og{}hdy0qbHu{*IErlY0E9;c>r4_=a= z_C03{e1sEl)q;qy+hF!bkQL5R@Do3vM^;3qvjOMSFzuGMOs{HeL>JtD;I5C@XW>;j zL8~%VnJ|3`AAWjf8{JNpfHg}EEuk0mg)^4VPC%DP0y5Td_9oYZj~7(oIrQwi(hqpW zsPq8qdST$dKe?&we4soXt4;JfM14rczL`RHI$)Y)T|HmPw@ zOHfNGyP&pBkbUx|=$Ggl`4;+pMyUJHy^=?}p_Yfby-|r*5~H&s`@_A$#lurV$Ak5P zQ-WDSXF~(RTO*BRK`oD(tThSqukrWx-}a^V9RmwL$6LoMc|~s~{Qc^2dAE6o`#k=J z{$~CG{t3uzt9*yN%dr}ckY);JxC-oDT#J=<5v!=V9KQZsMk%9B)6 zG|)WOi@M-jx+(eO=aKQ@nxW269#RL>2kpS?K>nZ}v_pwl-B(Ae$a`c(ZVSKPU+Ngu zfm8Amyhk~ey6^<!9sV(Y7>~xm&-eu$ z5GHEl^3d1cu%Fo;TnB!=a2xvRf3E#8Ea0x@F6r**F65@J!s0-_6jv7=mI=6%$494u zvk{8raJ!tNpttcT9KQKc9p$AmnTES}ba%GcAE2^FpD09-f7Qu?vmW-R@B)8+&X@Z zuu)thRYvaug%fNQ>XC;66<%`7fupiQLHtBhF)f*q=mj4K-}N$dBMUg{0qEN6PH&Nr zbJd=1xy|nSWA(MN5?)iE(p0|kKW#QqE{lrrqf!R0iTrTfREPJpy^>eX9o-2nF;k>r zctnT|eGbaOf}tN^w5CND%8k*>5!Rj>wXKw>W_mfllUv|uWA`bUiVVJ&bcV0FIXz9^ z(p*d;Q-NE;pBHR#r0awGgJ+?)h%es%-Cs2Bx<8wLldr2Ug>Q`ahvybn3&nHK^W4+M z%lJC@die(U&iYCq`hN3o^F_SLyyx9zU7N+$LI!>&dz$*mS^JE24PL+YMltx&zUs%J z!e@qmWi$Heau^z(r_}Rk<<$X7H#vRuSy&0p3*`t z>X$Fb_mt)8F14AOPi>C=i&pA8IB~x#h1GiMJavdB7~{-8E#2~Bt(wkUMF))!%#jx! zl%9Mmejhq`Gw~LboUw?gMBK+>8}OwM&d-9#3hAYISA7@nO6}U^s)CI58gAr+Q1YVC z=#C-+ic;Cxi96af12d4{If6UDys-~ECBS_*!CftClFhgWP9-~w?Y660-_fBvOg{mC ztlvlrrQnNE9et!vt-8)~+*P79e3?(_A~;wlGmmgLh)1M4+&Y7uznqZ{kGm}Fv9~!o zVc;07%(~FM9pGOGAH};;Ue{S^vYumhCMr|a zrRpnXseB>2KYA$YiGB@-LrX$ELpehMpxt%h6Op>nttf)8OP6A`huP-rp3_)zx?2 zyVSeW+sS+0Gr)7)Q^(uco7S#cV3rYdXFyl-`KMBBV25$ z?Xc+pXAL(R83DMHf9P%Cjw)vSgm2jmXX!mSXexo%+A7bFE{HhcN#WkHc>4q%MX<*YHc+^-2%_hXZ3G%teir}N^UKmwm@61Z#MogL*`Fw zs#AcrXVS6{*n?b8ej6NG_xU>LRJ{xJFoX^V6`eYzSet1NPP`+xk*f)mya~NJ$)%D~ zSUTa_@46wqM*qZR;U+(ds|i=&cX}V{^#S{mUD+uD*Scj@#NB;*I9;$lu7vABL(V~4MxN$j6T(G)*z=L+*iFwc3Kl$djv=@KO*8iElGzX zHk%UCYQ4%{1bCr%Q+i{GWcT*ckCd_{bpy`1lym-lw{EcE2__VQBiBF{t5A#ZJ88s7qM2XAk0F0bf0 z<@zZ7DOD5O@OjuRxXaNg)YBWF9}hwI>OB2B9La~!G3~&|xCHleXk?s5A4+Xa)Hb6& z%rEDQ?ud9IcIZ=ZY0w|q7CID~8hRW$6#gfYJGvm6AkR^HsIw4PW7SOB26PmZ0dG1T z_23)yqcqSf8gb@4(>7&mwDX<3qm!6A;2r8BCtZT)r?8Mr=*q9golrVLsk@8&I6Y)o z@DE$igMS(I%V6P{5EYh-n@I+qP}nY1_7K zBW?S&Zre=Tw#_8vqxW@>jK1&x?@cFFb@pDEYtFeii~H&A=Zo2dNNY3MYKv(@JKx@T zVYD-+n9+E>48SEJ1ySrIz83q=vTv>sywKR@BW<@QIQM+#cctfw!C(n3C5OS z`amJhO$&b)e`dee=l0j~_w^t27o>jm1UCnM!@=whGwYo1sn#_T-r&2_%h-ne7$Zk~dNh420w2HXmkv%iqd)x^;sXfQt)!pr=HX z6UQo2f!RDGn8-)Dt~%E_E~z(__VOU9w-{u06uWJ&t+!gxe$f8g?zexj-?uNYht)8Z@;tc?SZFa}JPu~n z%_(R_cH!-j8CAkfqk!3i^R?W(i33|>D>M9cySdS5Yy8n$(4S1ynxnsIr`^}K;-8WQ zoHZ|~{RnXu-efKClsGRRz;!AD4~?yo3DdAAs`qUuv4VVc!^Nr;&Rv;!E*0!A;4F0o z&3%HR?*v>s!O;%hikqsV9C+y}P^);9HYw4|XGK~03>T{HAmc;DuSCLS)PE;I%1Y^} zwQ-?Ofye$4{*L~l{+qu3zQ3H3Sw7Lf-d{d2m5S9Kh)26pByiau=lkhR?HwMUH2z)O zskrTNGvj)5(^tjIzS;guf#X5ci`p)|A{D`FVJ*5%9RzrW^!;BCF&3<@E7)3n`8Mt} zyOf-^54J+~hmN|gX6~QvHlClJ9TANp&qThC+!UE8vUJ1%PZCc;y1!oT6Rwu7(XN22 zJ$%7eZigq5ivEQABnWCg_e*Cc$0Bv4ZGhsDtBI3w$2g|1)y|=d8yTJy&L93Sv^rEf zoEv4zNYp4fsOx`+TZd1fi0%+97~&;X_41@!vs!wKbP&uWG|^k**Yn8U=k-3 zOA8&XhGqq0A@OG(obcXwaHPcblwP!I_OC% zp-HZ7?4-9iVd=s%u_e>w3usaSE%`p6CGrJIQb#k2HQ>2SOoNd0>aZABg1 za`^R6rG8sK^c9+KW!(UH`ys?BZFU}KCr##` zIGs&Bd9>Q;}UG7e!q64EJpG4DlRv zZ*nD}bDHZq;5y{`<7(|*?w;sw?4Ih5bw70nU6Y*k9nGf` zeRf!mznCj0hp)UCCD=IXkCu92a%FQZ1K*J&G%e86Z}ac)?esQ???8=p0rpwTc-foT zR}?g~4C-idAKI@5M7s$%I@tA*nv1Vo+84C2qr+u2cxie@spX&o^T^Apnxp2Dji^FhaO92Br4-D+ws%n2`PY8qdzM4txXX~uk zq&~xxKY`ZJk~Qjzbz86$e_&tzJ}MnJl|C#++%~&^w|_eaj@P zCfdQ2`XDO!b;fJ6Dmv}A_~1;Go567oDp~M>sEuxDhJ1u4kqGqk3B1%(oQ+K|Tpvo6 zn62MtdN|c_)A`9&!>xiBK6PDmC3U@W?6sd!FWPD;2c$)y9&N#DYop+5E_ld>o&WOC zn66Ai&3G7{*JJ$OE^D5!6lxb76UgkBeDl5Yz1_S8y>-0V=({ia=TTcd_3sNL4ekp3 z@Xz-u1qjfzhGt#H{va0%lz6iCBlF?I@%+ zqH{|kS)xRpwOM?L3)dfJnQ!EhHlI4hF~C{ERn2|U69*2uHF8_zipV;V(O{)BV9u;` zk9G}mW_6BnYR;mra;~1Pm#*^ee(r5VuErk2{n53S$<}+c!!MQn(pjOr^~P9+b4WV$ zQ3{6HAU<% zCBjd%2+Zn4M68NxZ+gU`ib8ZKC%qP5^kqzL`A?g_FD++_N$YnM2_PoUEd7{z{VLZoy0)4#K(9TFeZ1qH&ap zua*8k@X~c?jdp1{^!@r#qoTE1=*L9pw^V_;_B!?7ZhF^|HXQWut~m!^Y>IdZOgjY? zQeG)qS|t0F7HTtlRmTYDBW5EX+^5|8`P0tb*rhpcpzT{|OQIZQ7QBw$bux2p2RiTw zF~3+82dn_UX*#YS`GiB(Av3!%Mr$8l0~VSYg!we5Duee${ImFx-f_N^)Uswy6 zSShHV*2x9rR(L;r6TS2f$;3#wm9MDmXG+K9k+!h<&A!#~*}2wTD8d<8fHO5bvPIA< z1DED7D!)jybRR-1LLR2ZDe;Zm48AkeNPIp2iTtF2nlXAC}>pw&uD2(iby@ zZz2|u^22z{h9gWew9HFY4F&u<`3+2{JnhsIfNF)w*iE#?>e4o9Yy%FBn_%hxT%+0p@ z3j5#rcLp*B^Wgh)Ixvi=mD}F{-uS`z|Kc{rzWB59&!s<}*xIr8V(-V*@qY5n2viOg z)$HW=1=bSbpm-2`FsZzq-}nXBQ9lZ#CgAYBapg)NrjTaK?-kRQ+rAnl*&g?LPnn3V z5h}RD;)nzh+dK_GLC3keI`29T;rnymvCVPIQP26xIn3qY9yRpL^c3=}aXa19UAvt1 z9R1a4$~h^sxZcWR`gH|wzqxpYG{+n3y4FQMuNS8J9fE^XIV&f6*OX>XBbA<3OB417 z*9MCDv-o;@lX#sl97lsUjPkW$uH%3wF$y=0ZJ;PqjkCsS@M@3IU*8SdeFC?)pW#37 zI0xfkw#K+;YF2J=hdy#8R5e>{6?uL)Y}b|VXxJ2Wb1#!T8kE2@z zPotU9JS&jdadWMU4x23^?r`$Q3H<2Do-hT^>GmgJ$u}9TmziB9XHhR zwth-)yhO|6Yq%BNLk%#kQ(|^%=`*0g_oORS(i6oJLKwg4Df*9a#n7|B8UG8P>gxwj zd7Sr{_pLXh@1pNN|Ehoj)1fdJVV>a1zy8vnWZ=TU6V z`1andzB+-rp(I*6eWLNz>?cH{PdSZ?+d8~OvWwG%l&JSVGqul-gVIh^PRZfQ=CD0d z$2qPzce$3j*LYge5ypEqfn-(nGffJ9kp?=*LY`<%h4NJ&h;UZtRmBJ&;W~+;rVKce4WEUS>pHM=KMduY|B*0ho zrRlJqSPjId(oLe=9Ob8y6m7UoX-CyCQzX+Fv#6P(w2t9?^chWH!7kGo>l&HgzPf z5TkH4z`2Yw-v;;R=~4+g_Hj~2xuH^uZZ)yvp!1Har#k}F#}`p3a#qAV&wlq8SHQU( zEl*ap0BXHe(k@)gUcmReg$s(Esbnc!k=x5rayh(_w~8}hFU~j985OlLq0%T2kNJQ4 z>iTrA>Gk-^_zwCO`^N?XOgG{Kxq^LzZJ1hTpikfEo8ZkKpAl}8Gj{f$#D6mXdGIGT z))60yFY0^d-y5tEKA`2rpV=;K;Ojq_YK53mR%3cv-^#|#?ST(#L!5{Up?bL|^;gQO z8}0KQgW;O}=Pn3?)O4r!r1DH~w{UfH%FYjt!H(VbRQ7!KEq14)0`cpJ^9faaDYxRT z=3WRE5#ie9m|vVljQ$#b$=>*aMWf!zX_TgyI%uUq`JPOCg;%p+ zjWV;L13any37uieuq1F2h1fUx!JGcDzYqLAH;#2r@G?qZ)HU`11_l3 z$gP9*J5)b2wMF=7jlxOtrIkbMAQi+Pc^y$`oh^;69qzR+!LRa2^I(9^Fcou-Q3;0V zL)>7i;QnyIDhIzQOa`5W%St0ODcw;eUt_BC)@(Td6gA9;K=I)AV0^G{C>EXV z7){f*!M!>SmerB_(ZiUAqxc%?zZNhaw&6D?;J>g^tA(d+F=LatS|}*lao+8t45qIr zpyt8FV6^(%b_!i_6pkfRinMV z?%vR>E`zu|we?pT$UdnsX#8yEn_1DdU1Gv;pE*M@Ch*nef>I~Ei`QD0jCJpK{F*NbI`(k@9r3u6DZy32Uw^sfeEuc;6Mdb%IpUkeeTy9syYo+mKMnqr zjxB>mVP|}t_o#nF@I36G%KAAnL?gJhA!{IxNI|P4?i9!9mTQ_0Fq+@yIxFwr3{3^l z(AHSZZLjW_=IrM>=Q{8D=t}6m?n>sW?0oIm<*4BpZa=FoQ(voj>`m<}>`_c|ZD@HK zxemGtxYN0#T#cOL9s8(%Un&LZh6C`<`k4)k*SZ&%%p2&qqHsFT1JCKc)gGVITvV8a zm|$+QKI7Q%0t6u|&u>^TI#43eA<%e7rqLw*jgL-uiiBH(nlHfU|0D-(=^$qG3cb*h|ad zj_-ACa4vH6w&zeY+qNk);RmgUi5(BuUSL+eNI608c}}UKY@q(ZyB(x=rkMkFKtVdh zr&Ln|0|nqTeDtU06Mh?r3uuAr!Dq~xQ-u};zkzIB%^n;2(=s*) z?jXcJiBHS)P@|v1eOG&cYOaX+l=0}WK@MIa5Qu=SQJ;62dHt~xVyaB;cM ziBF~^!3FP`JY06tAs&I>(*ftl zD(c~xvx%{_sfdyb%6MWI$S@Fl>#>Xv>+Z(qo?hbs1 zA>PTpS>O|)V2N;X&8>%Z0oR{ORx9 zEt2=N!d@KB!EVQL#}sm4F~<`7UA46O&34o_884rKw)6B{ebk$3AA1JJUdLsoT4Nkh zpu@H7wbk0T7U;x-;&I_TlaEfEEF0K$S^RXq7-?ZR6~rIYN5?-;Fa;O-;h!wJrwNgE$&6nLsjVeX2HpfF-Ds{lnpiDUO&TcbQxTr71WBgt#|zUgqC5R zMQ_*G(yVE8Bp*bDXEzlG@*m1jTMYg~5jcv*!Cjgl^@3fTK-h$ewJVj;ZhT*ET5+7# zvqCRCCN4A6_dJ3p&^A;&d=Qj25N=Bq zm;yD!60I0+qnYp=%Ej!dJi6UL(1BxLba=itlARkf$PY8{I4cc{=M;6|O*ux6#as3U zvw&ORjs2*(XGu;X*nHdr4%>FBiKtBmyAruKxbu0od5*(5sf!+=vOB9Qh=1xmduRK4 zb-OLBT)~?$Mmes0RqCS}8GyR2q-~yUu`Pr@`w_MP@Hxr#;&4@Rm^8PzGL~ z2pe!kuri9yp5ZF^FD4GV!)sxdHG|uUEdFl%hWKJ2rq$ye-Yc-@>-aYW zY;^kj!vnN6`V(WJITufaaXf(|#H>5?R~KP2R-oQ^M~%P3N{4^hPEnO>DCwAqEXNV) zn0>c>u6+-#oulmA@J<{Iie1n488^WR$|WWuIc#HarAdzJdXl}XeHIR%tMDQ4+A`Ug z*vU5O0zAu-oUAD0fBG6ucO|0@yFZfh3R=LLjJ4Vkxi;WyHI^s%8C`v0BfCC;T@Ay+ zZ$fiIM{(k8ffLyiI!aUj#;zCJUk_($!Xjc`5bORZqFVCwAM=wgSq-^0{jKD1&fW-p z#XeFr?_&|(wuhMs&Qf(;C(_w|-_)&bN_t*8>3Sy5~V641b0#T%+A ze%qih@VZCheFv`_*PIsG4L)~!C_C=R0WYN|-R72Og+%?keq4KqR1kZDKS$Aqzdh%fj$5eZJ5a3^m1p4Mh(~y=^ zH3x6F{njLBwzK6fmaU4-a zFt`pl@!v-UyNt+{5MIPYUmjl%?^#eF(L2QZz`N3ilOXO7=U|_G)%qK?QJ1tc7qPu0 zB^!>~m?g~A=6ZOHXW27SfO+a$_T%VQl(=1*g9m>VTLtx;dP9A#mcco(J(bf!TVY(s z@{`r=%2jk_38@Y`;L;YfwN{Js4*GJ!y5dt-K^;Yh|4Y7s4*xRV8QHB*#H*pyu>S)S zZLY7<8<2sPz(yHQe90;Nq?+Hs_Jjsj6Dona`Z2AXwhm9+&u9vNhxAYr9APU^_a8DU zn+f1BzU9VGhQEIU@42#~Pk0HAK92l05*|vdH2}6y4KVdsION6f_S%9!W{kR(=-Lp* z@=?5d-=Vo&js{v3TfnONP4w)9i{dM54?lMs{Fd@eiSIF)D2k`h8u6|$(<%VVtd~AY zYaA{DPrMrJuw6{q9>WxK;cmPnR5WZd85m8pNmhidEDEiu`gjtXQvSY|HNR9-2&{D|Ln0(5g4eiB8+a!`M2 zYWNfd&oNhX=4mJ0XWS#)FR6XI;PsHvk;|S%?a!@o;j3{%S*M)AC$Boyn+>0cUTO%G z{|p_;akOw}#33lU)0!@$h`t1W>&=|3FR%pW!#ZhAZ8!w}+)4I6Y{RuU6fTByR?*<3 zfIBeFf5hkW_VYIKj`!a7w)N$PY56&DB6u-m*IMf}+2E7F>}vjGvr|heH5GgZcGe6v zDl=JqPln1yHGI~*Yq{W~C6p!QyRyfY1GjI5Xj{&Hh&#U+PqO?>*Kg9xM#yR8qH-s^ zY5GxVMiO_=*v7%vHq_zj9gv{CxO!jXv$`dm#%p;D>f!Z&yF&d2H@m)`oXsi4*TQ*+CrW z_A{9&!mO>ha2$5}|JVf8hq|pC8*mOt)8&EGD(N{@AvLSLls%jMi`rOSVY{F>+4R+c zxb;)`yGLj@yEr8LOFdvg=h>gKmC8T0oC*YT4^bo!Jo0Ls`O=0*_cHff%!wHhOczWO zYyg|-J+9bFcuzPpZU7ZXKOoJII*f|>>;0E;vy+9tkoaQl!oKIE^DV5Q|+qvU;)Hk+Oc=P_I{;DSzmmK0R z>lS|S0enW5YYOhfb8*8#1( zGJeDF=&rIzFQ^-qY4Dep}^*6vAlgKbtwgxCpLV zoedz_$o3^*{jUwx34aXtqEh^RoN??QoD2~_VB+^SbrDV$3Uc&OR% zr!LBlh2JO@oP5__=_}Y@c_EQ?)mX>ojH|e37sl29Wbinu?+4WIKSPtkuRwsyurJ^i z{L!Z1?t8;U!UIB^f?0z>?)Em+AcF#C;6SiXC>J&FRQ-ang{@90P*ZI~pK%&|aUOcq z#NsH<`CD+?vrHkMQ0Y&lzYXEOMHi_q#FeF}GlA=bE9lzCRIQln8=kx=@Sp0WzQ@ac z2bFqSdcjUgA2vc%1urY8W(40)pypIR;{?%2>C3FR6F88~Drc@V9_T+%Be@>+oE? zIvd`eQQa(u<^LLOQ!#6TnX(Z(eZiJoJupP>f8#hrbdz{(|xfH<#DyaxIGDrG)Tuo(4yYqhQ#Pt4lGMy)=$i1if`qHarrUSt#V{7ESGPM{ePg-b-hI6P)@h-;?8Z5y zo*Iu@TEQnXoqe}jRb60P3Ev?x=+7>-- zTr55Z>IIVG?v#hqSRC!NQ!B>yuM5UTcn`byy}!xs>2OuDp6I=0JjC9CdYuzj;?O-WNfLCe* zJr0(}3-rg+g4+Wb15^DkeM322k9^tuUHx&O4)sEvz+Ap+uXP`IUQVkcu7Q_OhGe9A zU5&SP2{3YeV{q6V46Zed+nbu1;#O(0yb=a;7BxRHC@1sWr0P1ltL1F5*vo|L4Lfk7P zDHV84t2q-ptTK3GUNzgB(O_Pg^>Nzv@WoJ>P-&dz_M+5ZAIcNXq@{;1FwAHGQhJM= zSc%GC1GgAKk8p|l>aV-)NT$NKtj6??W8f3)qq|B$hfx~O@{9II_G$Jk_9x7x_u8&1 z9`=`9Fzy+u5(KqfXjVNbG)$>ue~?0 z%LI1bj3b^Vg=4Xu%2$Dl6$^{>0oKp456~M#cyS1o5fQ6p39Pz%Q78$ZXW)}_sYXF3ggeaM-m>0c3)>(bQ={b2OT&20Wi&QwvZ3yO;CoZhr!CW4f^Yk{N6&)wLFLl~TKT`D z$wh+SQ9PJRMzlvRJKuOPOr0P$XkpI#}!Yb~L^y1?mti39u= zRHo&~hHkKf!%|QAF`YZ|`tJ&T$h^;WCm1b-{bTgG64o7{GvHV|5JrZuoE|+O2ao2{Yg0dH7auuuH zhIQ7Lxcm`DPe%H~0i3K7K^@d+M({eDN(xdpjsi7JrvK27aYuH60~KM4ecL)j7vW&C zaLc^Hv}qUk;yW_~b>lE$fw&oeoo;Yr%j4hZWL}#V*R7T?wB9;a;^w&-woimQoLhgA z-CO0Ag6ys-$_bmpUd9GsL-p}27THWPP6@~rjY*VY6Wi4o+w_3U$+uXs?r+{XQ9H?Ojg z?Hl`e>d+Gk$_Hi$$7LTC=5F?pWw5odt-(R%72J>oWc9hqHDYg7c(~`O;ZK|Q$+w&I z)@1K0dLw-lim*f==Oy`#@6gv}0CPx>y1lpg*eGEPX66Q`CR`UzK{-&ZV6Y+T-LlM` z8*5Lsj`|-agCopDu#OtQSUVg(Wogr02i?D?`a6&j+XQZrf*D@*tI9H!}mec65rm=bG7v43q z#b{xTwZlA*V&p6Qg-E)PFdImme1_vxvNC8OEOkY#_o@~ zVB2@77*E-L z3ebm!+}M29Pd>}yR5Ppek0?QZ!A70{%6OA%CN>z!<_kFtf~rl@O0c)}4(=`;=q_zm z2lS<#$ng`bXinEC&Q@`@3B;rQj=@*xFy6%HsXEr$bmDHvrcf~oFfscnC*=NQ^jny>SLDJ4 z;ka-in4{ygm*^-mQM>G+b3a61J`;Kyo(P{UUpU853L2Ekn~#2P>UJg zjoy6f(|9LmxMgk3ggl9FJnez(KTF7|TWn=TA-V>Z%3Z3hE4CPvAc>gQc6S_c#NrKF z4A-)6;60x}8E-0+(MKl%7rn@n84Uve9|%NhI{M{gp0prA-PkFm!lC*l_JbR-7ama$ zvm%xDRk*Rczz%MJaGqkn^jG}B{7lOdfdJ*y%7&vtJA-M1qw&#O=wAs-@Uj1aKO0&f zEBGL{M4jwAo zl5y%U+hXYzM{~vovy0)B(hTo_)$Ai(qzvWtS4PJWgRakx>wZ)90}(6$i_WfmL`8BD z7yaSZLh8BPAn_kTwPwR3?`wFCoYe3u%>vdOu*&M(+xL9Nx6$S|)6WtSBEvaDYp8VL8^~BvNoU>s}4!hfY zwxsGia?WJp!cHb!UF5fH^;AS3`<(2YuD&R=57AS+Wj^xBu$yJfg2b|R@ZY|&t7Sf@ z^#Tx-dTfdk*%Z}_S#>0})?5GEz%AZQdU$b(beU;RA@KJ!u%bQ^Ehd|T$b>V|^`3@H z=ht;TD;u19u!-e1eaj6oEZvdIu`9U%v8V)3#mm__3%2_c1bzUMlQFQk(@^OO;3->d zSHO;<)yMFTCXst`gB(qvKU{B*AGW%tZ~Y%S7)>(GEb42-~o#}(YlosYqfY8bwfJD6{;!BhUG`GMNx2n@cX zC;)5Xq&UtzM25{m=RO~;QdpeLhT4?MGtSB;+e@3szJ%%2S%;J;I5kCa0!k#khgJI+ z)Uqmyi@Ne7xff4RR%e5+UQr*Ym(>aCM_VFWJ|&-=O8NyN>!gHfFQo zBLUBnt@OrqoVn7#w692wvz#s`2{}qK|CmWZ@G7FQ+NB({O;W4bU5*26nA+|r=r{}7 z=;b!gLL*jJxg)odQ-cGKLD^v79CC(p6{&V0TMlNgS#8y&E}|amgOY5DGz|7*MqxiY z?e{T1%?VS!COFh)+*Thk?;5~4w}@On*twWc%ZIDW47jF|fuVjoZX)k}HDC%Z2$aJy z{$}V#_%8b!7E{}-GLM6}Z-9qVpJ!biFWGu1mUTX#^xU^k=$ERPRnWZ67bk)~_;5k2 zV>^e_nMf=;f|sUX`;UDnZI$dinpsWEC z+JGO*26oKX=PpE3A)dg~Ga1p+GPj{+8ILC58MEpCgkr)Z>w@{q_@&>Y&U(${ZG7-b zpaxC@b=cPDWXoDdricl(bL3?V#4qEHXZN>{es}g!1*zP0Y-v8arywWg@ruc+bH(xWsN@eW8 z^RSpU3V(+Zp)I(xmc;dI1=vF%I06k@R%*u9`a``6&u1)6 zR_}!F=O&Ys73`hHo< zMXLlIRcW&qp5-U-hkuV}Spk?`+x&_A4}5n2NIbXtaob-8pQB88pdHmm(CL>qn^IqO zH_M_i$z)8#8+|X>aUM46meZzzZ*0(4v4!%vbyZjlDl}N`NEJVZGj*BTw+WfyHrrW8 zC=O){$krZIp+(8*UJ$KJcvZ!z-RF;D-=fQ>Q6kUH8dbA-RREuyiX=PP4?S=&}xNErUKR@~5xMhvwWC_&4!$K)gmbKQZ z!l0juukdB?-$?Tz)v%j9dk>~oZ@7hT;n{sK8ZxK;WvOfw-5^z#y~9v&+fdHp+- z3Y@DCcp+qEd)`2?Dw>1~)LLs{N0eoQv`1YB+vAYE7>;$x*`b-&KAzj&AC*i4shL=j zodpHW9k2#-!kIBS8!P#3spvm$(pRX~d2<+Z?pH>6qrE;E#$)dAbhZdBLDM}eI5oID z=nHNMO$cYehaj20LC?TzA7qT7uIgZX!V~!hX!taA86hS{tuz_db60e3tI%GB*z%A{ zX$5L3+4I>Q@Kp9i zy$yK%ZP2+c_1}gypCRxl@Fn;IUz)kvKt6>%@MbQMPhId=(;Dsc=kVq7YX4(nYJIvK zKYM0<(BhZ-q5IgdW(=Y+< z!VLH~INMSb3hz*oO$trM?M!0mSE(XA4>`6_MlB#_bj8vUFZ^93_ zk4NSv9N(5p6X`?-NCB9ugYd14hHV$8*04vhTWO(vEPZ4KwE{V(ARMQK(m^pdijJW| zT9_XVU=i(Qsx%UgKmsuxJ|>A!h5t4)nu!g!UP$Z7RH18db)Z(DNuWOLxytM?ogHi+ zN`_%1p-t9@(0dv1YI5>vf5bay zx9p`_QRxRiIbf5| zz)!Ob^SyS0hrOS#*gW+`=mW37NByvbO|oz4%lb>-PzG0m6%&Pb-ZRX#%Nv9OW(YU<00zJ$_e3!qWiGGjELqD+p_spm!q3CK3J0dy%wHEA~3q~Ge zgZ>Q+WhOpt0{#1Owj)(!lWGR7GxgbDg+@-!R4R5%edKOr)sL{(H#0Ns#ypYg=z|J_ zk9W~)8Bu0us}nmdyHg4Nlv{(hC1Ijdm3iP$aH{EY5_u2j>940_8L2SpR43YgFqstfC_o!PUxw3@F^5#TXPyQ0)Idgjn_>6T@Ud)Gu1e& zueFzqprM^Cg8xufg-h6*rU?Wv{J59@V7BrC#lNe*-VJ5lATWRnx|m0tEsTVP(+Mf3exN`RWF zD%&92vjccMIB~RUCwCOI7h;N?*gi)+Ym;o9@vEvPPnTYbtg3C`^4$v9=fA{(pcVFvX?k9?Hqx)%1%5W!@Q zZdw_*@g;Z)$As6yFX1a2hX3vut|sh4eR!UItHX_h@B)vaWQzo)cppykPhm8i9XDl} z(%(SC7}b zE@y_f)f}fKgZ=g~{Ov=ixJ>*c9*P^q?wquR!VhrIRpxUv-wwSIPoo~Y0$afBN*`*% z^SDpddzI(02~4Yx{#`F+6a#tPLr0kxXRB)PYUgvdzO&aXCX^3VyBjpg#nz>B#!T+x zEo%_0;?C5$&v?a^>CT^vCwK-4;A^S!@gSF&`elQ4*?0c)`)~e))vFTrPvgpdgMaS;Xc-OK|(e#(TY} zK3B8DCm)Dfu{Qg;KiRw9)fXS%J_6|3>5i?gDLY}sJ3))CF)}Xt6$2N=)oT?mDCC}g(%t8GchUa0S0e{MwI!rb! z0Gd%&->0`iA(YCx!wrcO*5fy_O}ZgHlQeGo7ikfh*ux<7y3DryCG(% zHPBVfV>+LPjduHR@V^{ZVI;I?FUUCbzpt31gmDJkD&!XD(L=RlKl&JaIsdYwR?w~I zfCagY6LJWx$tt#x{89R#s!G5P?hheiWa1TOo5#SUC2NxD#nZ1PH~Kwux>oGTd8IqR?G%u! zG3;R-Zge(!8eKt>E^k3K_QYfq(A9S`aQa(4RrqU`N1#k=T8EfC<&RKWLe z2Q}t))J}8p4=W+rsGm2XL#rxxW3%Tk(9Cc2$-Cs8azi)>%f#pGbUkT3F=LG~bmIMi zMAi|j9`G)n;TM=#->YXgnxGi1V~k=4PX{A|VQ>@fu|pz`o?Rr?W#?c^=EF5g7poRNo>M#kzbadyK#V4`~qDK8~9TmpGx@#l7m$q?NF2G66#+JlJ zU|@&Au(R<#{t&bNM@FrJOUEMe-~i&_GEP`x96J7kD>#j3a*3zkk6L9C+a<0sX}$!; zQVMA>yrnkG=`NxR(7~@I(@i%yknJiT>1_9bXx$;_U&hDgBk#F2nPv!Q ztG$_#x$Y_^3>n}LDa5Msk|aH0HZ_FTG8!bQV@1`P!z~1|HJLVB0K#S*RrLtvS{K||CF5g4(G7- zD0KUf>HIv6cJPJfv&YY^S0a04F(!gt-{pN&1_8=Wg*n(rZ=Czr!PL_nDC7sQnYaqO zN4|g&AJH07?;m4Ueb;Qm%(p(T{UG&9c9_C8xRximtL$~-9aQ2~yg_|AmC z*8fx9DZe>oo4_PW(z`Uqab-Jmx`vXS&*CeuJ&L<>f%wo1mDDlvX<{Xhq7%b@Qh|Em zT`duAz(4M5o+8Gs(z|hAvU9=$bbiO_r@9&o(fK7}GfrtH2Su2E9-&^|2fEZ1l|vGG z-pAbbigb(*VLe_nGB8P6M_1C0*fj#gKLfl1jd!*Nw74ZmTU~HtuQ?G$-auHY{kXrM znFJ?hD!m6T-5EA#kA+wBl$^bc%&`WR-c#;#I^hEnbT;x?f&6@3G|CiAmVb(Jxn4Xl$v8McE}WC5$#%ET~+sM>?G_gC=|%`;iWjSp~> zE@-b{P&9;j5YlIY3_IaRl%?NjEIy~Viu$L6R;2w@uy*!6k79q>0k#6 zE1^9#)L1-~4l=bG3BP7Fn9CO4OD^Ri%uO$GuK@MU8nzSE#IK|Pv2GPH>jAEAF`Thz zo`WQAW{NW1ddjPtZ&><5J(+%$vvq_%DH1Gn0~x(H%H*6Vdav^tKII)X2d~&bZ?c8j ze;w?P^WhQUc5p=Iz%xw8iA+Pr>1l`#r#M?H&6J=t!>vJ_t)W&cm_&DQZ)|BMHJ`DO zNj3Yxs!hs&OUHfLEVjU-W*WM`raXs*ypwTE;wQs-tUhEBBs`U=Y)wo9w`qxT5?AkHbXCzP#-GxQ#gcU<;9T<#p0|gS zH61Sa5||%9@iy5fPQ*7b4Y*ef-r~_PkH_)KN>W9ArUyAm2C1N*K(*e_cxep4@4gau zwj^J-L($M34yzGfP3L|_Z*HV#S~8ppxEz^K2Z8z}oM*1M zgATj0;9~2M1R9b7SH+t`Gwy3nX|nz2Niofo(_xP zH*KOa&jx0^8n*EuOQeTs#cXIjy1#XNZ#C*=fqHo))mUccqQ{L!<~OrA42fp2YUjbN zO#{1P6ZpLfe%6Mkd4a6t1jA*oxz$Dp2|Ms?&m}LF?~too!}@5gJmAd#_4PO?hUg~a z@CWI@lT3;_;}V>na@6u^xa+l1IxmrSQol@+GO>ZPA9ce(foUe(#&+EMyKEU-Pkv5I z6y1&|#1M3D6L<&H**GxJ3Yh)Pu(1Jz>jCk$rQVoWT~}W~_J3_mFoSf6h3P@((XSVf zI>B_V$-bVIbXdvZ$KGPD|B}15j~lno^qU>6%T{%mtM|oiC|yUeH7YZ=WHz^6V;?~u zD#l(CE;8UPDa5TrxK(^_CThgOG7jGxFvPss4a#fkjv)@WdA((4|_8?2`(@c_k~Ehpse&dOrA)C8)O^kqdjF_9?Di((-cOI~w`S z&t??SwIhh4Q}hz6&I(tFS2a-0Y~jw)On8)NxS%sxfTin^21>8kg&HI>4B~4Xv&f=wIbTy#&LS?nwvtYSOrl1tg{mwGcxfAyXm|LjmHA9JTOXLgD4V>e+f>|HW)n#+gmsw=nZ{ggq&D_aVb&sIHDRvX7rww` zcp=NcwK9S@@hYS-)DtbK;4iZy0ePIIDe@%4%LG#oA0WuDOiII7Eu8kW_Hr{SWauS-9SuZldB z({&w3&_UGhFUSub&Pp!)4c~zMET!`o#okm4C8Z5g0X)4t?9J*6Uu_1yt{bQw#~bBVQ#Zm0J9{=(z)KoN|8|{sdy}g2gEdDe3P-&! zKDBk|G0w^>@PyE*6wBlL@{9iBDfp<%x`@NX4YFBQKFQ}e%_I@W;o_KvigUBHoBc}F z=!WNj;?)-(QU8>|$7&R_tvcpFwrd_Fi`VC7Ub9}1O;b_Jy#Ci&e1VNpk?OKKHNatg z2C9MWAi^_I95+P6HV4k^A2emh#lPN-&B3wa=|`iO0TyGT+11Kx#qpXJGwYY=NG@A7 zsf_=sgxbNEZA1tEfG0AUJ}I$$lFI!Zoooj7)^!)E&>8pSY|Tdbk%*Xej;UCHdiXfm zP!YG_&2^VgY$`p~RPJ_2zXPW85{^R|URe-!))V;3J*>U-KnaNE|55XQdTE>Y;W$18m}GsZza>?N7V6ys9j%ju2!J%i=r~w!!vg9 zd85w)YirAC@iVI%B=?qU$T_JKH?q-fADwzOnCHvkp7-N!I8fkhXg*&oiZ4xy~7TC@&$=a)_gf3B8&7!ATLI+ZYJLxm4b3=Y}(@O{is4zaj?oDs) zG^?54h(1}3=WLDJ&69b=QyGF+TNzH)6HY)6G#yi@wW3f0e5Ud$Z+e)+tb+gDm!JKT zUU)7nquo{=D#`AAwo|2_QaU-UY^MX+MP+^o=kw=m*?DSpCKIYuHI|tSt>Y0Z7fqoS)M>4ldCH zCIefUFaO;Ud(f&X9Ob;v1YzBY178ifEp>Y>rc~EaR946La5{D13G(19VKOdB1BChD zCmQ~7L+L9IQ`vWAs+tpYWuow&O5!y4;u<|{K_eOG{+<3yFAKjQFYoCt44UESE570I z@Pi$AJ$bKP@S@&92ftg-0P|xl`1MBZJcv;{(4c%U4eRq1Co`**sjH$vZepm|ds0={ z>D`u7zpf@~E#RLIacX$Oo{TwU=W^guXL0JWI5i>8RSCS%j&h&#a6iUV^{=2(dkI1| zpH8>2v;)VzXjsCjQ3l;*lGL9qSRHY>N+J#tj?lv`gy)eSUerFcM$gD2Q(-dv<(Qp; zM=rCODjS{lU!S#vX!z&TD_-GA6eVig;B%)o+OrG&51q>Z-hDC7!*KZWlXwPAd4}hh z@>k;?Zsp7_Ct_(vcC-OubH0^@%upKEb3^9TC)kwqonB)P&*A}*R%9QRkBXu>b#jCi zPhH&*X0cPKBkTulTFp(DaI4Hm1^*dzpcsh20ihzeX=5uD4%#tP?Muyz{FdZ!##{6B z1#)Yc$n^rP_HxjVyF|3=MBjnzOf1RdyE9HCOHrikf;+bbt?n+NICIV|V9p`%!7nI! zP4wdZU?ERM@At`kNjGo_-e4u`Jm1}!EPf98tuhJ;FWzjSH)mKy`Zi&nC67xT~8v< zXXI{w;(0hZ89t&{dHkzJ(^ri&uIa^eul51F=$v*Mw5}Q5eP$-u9jONTQ4M^f8r;MU zh{k!kGC0j2bXa4!V`Hq|{LZ0#E}h7iv1E}=U z+{o8fPH0MclB-kWec!`tb0e{+9`(s-I1;g7o;UxY2Sd5> zLAEEKBx_0B_U`m)uXr8Rh$7d?Bs!waJ0zo=D? z?%*iTuo)iQCvmuV!uk6Hb7D9s*A{l2Poxi?0V}YO)LvSFXXqjPgEgj&ZK&Ueu)Syl z@vn$wQiUeue5~T`r3K$g!+Sc(-Ac)A%8M2jJrH;P0w?PR%<9H4#P7rNI0mmwMnhAR zxa$KSCp2+#o}yDX>c3bk8P;@Nwlop^;Fu>&akW~Pf>L3Sh2?QIvU zg8{9@o9=Jqox?V%A3|aB{0p+@BPqAsKyJzQ!PV@zijsp=ry8@X7&NNqU?YElnG$J+ zsA9{|0gk5fj^y92XWo&Rj=qkNnJ0aVyfPGwY7F1`!Z<)Z-Wd#a6n*4o&Q>-~qmO%4 z%xc8BT1ywx5gw97?C5ExHNWC$^MVfN3>v1E=0hBGH-kNFBC0N6mX?PeY$3fyex@EL zsb~|3Gnp2iN83^9Up7q1nI6PF6NL%ftSVqCTbNK<#EBpDSS!T_Jeje~Ctq?JyIGle zjRyTkI%=XQPSy*aMrr}QEbk;WS#}Z6#0ej-KDp!>w`m#Eq3yiBQn>7VG@J4LU4`2y z-v)yT#Grp03g&N7rBnqq`|E}lM>d>G?A}MzN=A&nO9lKyD8dPO$k~cw0y0J#%2UZj zrXDQjqYAviEUFGu_EOx?_QGIxP<&>;c5$&Gw_+wyw;H}!t2uQZVGHc}(`Gk%@EBg# zGCr?w^wasx5xlatoQEIOi`DS^D^KLQPgE~pt)-`$PvniK|Ef>Fo=LAs?f6WuN`|OP zZ>-?&`Vz%hUo^7b z)blsDy#aaT>c3fAKe!v?h+Kd1{N+4@W_Z6mm(t>8J(wv^G5EA2xbMTrBh#5&4Tjk@ zlrxnJ1R@_O??67afLV?E_y=}hac)~`?uiKI(x1v_pV=07nw#9JWt^(%RA>9ZzBsn9=A10A5VYR5D8q4CHA>L5y|z_JB#m%#2XdY&P4 z8p$|0iTS%tVB8!*ZVW3O z@bA(Qv$nFsp)S!sr;v-hJ`S~J6gMvgh~IIX|BJJaxFX8;_q_Y0(q?=c4sq9q!V_8w z`cuSAVkY8rHN(y3nwg)I<+IZBvuco^2XpHj!vCm!P>h-7Kq}raZYlddY-Ne69nD_+>x_Kk;u%^2+|l8JD>sXF=Ky8#~~tJE#?Zn9YgQ z^?CLQ&`EYi6+G*w}Rm4$wF23=VVo_-XkQy`wM0Ml5&eIE%n6;A}N%R5QI zUBAUED@5hnn|PNCWb!2#j>vPZM9noHjI|IwRud+8-S}rhaT6K+C!cwGYd3Xo0lKR1 z##QDZ$vMMknGST}?^k8MG@g94h4|8!yS@%K(GRl1eP(e9jYl9yBlPi9S;@(yD-FX~ zh)QuSNJVCD)?eSi&BUFR{O)S(vs=ScTE^!!=pUN#6^2?DD$L}Z^}V2mpV&lOnz;Ln zilsUy|0dOYF`}K9_i%}G$jp|z(E)x-W-8@a_)~c}U70yq(?EqT^3G23dG)56`5Rkm zvbQt|vz+Ec`}by3YTw_?(XtamCz4+qbFxmuC`tl%upNxGci@e^sDD?|876~i-h#XK z6IZyNyy7E7+iB$RL_EO{C;|$Z@9~U`+u|3VHx;!HmNl+J2_`D=3lk-g|m~8IKPkX zp(EA9L?Y+k+xUZsGK?7BiBE1If3nhzOb4C&%!$us>g4CAMAIE)>uPZPJ^bA(c%5zF z_v8m{-byZLOWfJV-AV~3JRR9;JSbf*;x-%}>INN6=LM^YaG%<&J2miI`U?#%!4Ywm zSQqbtWn{o=oai_(ip!`G@ZqLjpG(FKaT*iR&*$LDU7!j|1Ew{b?(;m#oa+4i@4PY{ zAD4w7HOZ)Rzk_j1;8fk^cD)K0#P=@ZY~`cQ+)pNXNcR$nf}t@=7$-Gh zJLVqqsI&TTveJMoJ|F_-H8mr^&yJ&RNWi@3FjJlhC_gq(k#}Ii{8Y^SkCT=LSL(H7 zp@&oj9^ocuB#ZUde9Jt$KG}5}z3Kq!%p&yiPyWS*GEyq)WQ#tf8Tn)g=g|(DJ)7!u z60c%3r}q-|TnBzyXHLdEs+m6jdZXjqQ^DFu-;O^I-lvz1kM#EQc!DYTFMpU3?BzL> zrb=B8f?gUd^F6giPBs~4W4<>Vj8f!GrT`;oL&f#moIuakl6>(C&*|0T3BC?H1_t{!Hy35v6s+y#nt?8VR;~*TmU;`~%%HJ=*j>nZ&DLS8BR3xRSPt$brQRZGOt@?#2OvwHB$3Q z{9xzadT`RmRNf!>`;*9O>A=75@iQlp88)Hxy~e95!g+r#B;)g6CSC_^yiR_fOGQu$ zzRxl~#WGBTUU2W8^ZILW$N$4md*>53UOyHGZ(2&+_{b-uQeyDJ+CG|u0;pvhmuyLSuV{#hDPlum8q%$umu=fTw=YMSm;+H79p27S=!klr7Q&NsTh;r&k8`ikz* zSN2@qw%gqlXC0E0bs%Lfhtyb&(ORc@q-%YSr`0#3TvT+aKHxv9>QuV|@1?ZFgrnr| zN*?i;D!ql1L`C@d?wn&T+OQ(6AoOH1f;V9?&Eu5MTVr**@SYiT_`ZRZkLBM%oOQ1m ze1J(S>a&c3u6d~oRmSafMfSVKv>T6@Rfha+Kj`w5Ta0&+{f9@Lg#0VDfv)v5cexp> zcB#t;48z3>aq}AMwH@y7P2zu+tp--K_k!L?QB`CwJ`TS15qxu?bU0?XRR z`al&vAvuY4gKDCN8m_GU#5ZVR{_2XkGH|rZxnG6g(#sTH;P-S zk$OFqOHJJ-uDQ6z6-G1wo4x??i?GB#=xW2gbf+vWeb*qVT&G*N6#F92QrdwsyuG^c z19e3&>XFf1fhNMm7ntstUHwI3a@0&~ryt<8GWv~#V`*X&*OW|^@j2x4lC(Q1^=;Vv zJtMo$Y;F|u%&CWIX(Q^MjaU1FuiVB_7xR-oc<7uyZevx^Ag3&stEZalsAsdYGha^L zLDloPI^=|I)cvw=uRZohbd~ejZ*UiPyJ5Y#vt8J_2sJG1cPV9Z52RsScqY4US^4ey zDvqUT*Q-p|JB5(}f3%bLcNoX-M*55$#5GjlY3}M5{&$z_wXShxy zbPN5j-}M!DaLe6YRa@TIJ@~z=Zf0q@_Jy?_O$(Z+yyx0&KS&j4i_4wPdQ6tV%P`A> z>hKgRcTv^Zjfct(bARbPJZIJyQ*YzX3S>C%E`AHV?z*Jw(vzy3k&6fMd%CZS={NaZ zrv35NYq()PWZfm_PT5Nwinn&@<;{22=S6PtGj|z@-KpLD%p$sR(cWui)m=^ftToQ` z6mVD0`&2L=$gAphtq$w1Hlp<(n{x-ORRQY1orcR^L=k>X#c~0EX zpbb8Mniu%O?r9bMr854#8Feh6gBf@GX=+z&llpl!tUoLd+pB^yQk8D7bu|o*6#YYX zSWZ#w#;yt2$xr4?pJa8<@>V0@>N&pg#Kr5p^6RvwP?JyW-okVhcsK%r@U2*`{{2^_8*I;pS81^;!I9-$`0ust#=}%mTWqz z%UY9PEW&FZaC+OvNg{sv{~o_pyRQ%O?-7l__1Q=o#mr zHsi-Gx}Z7WsTK{b2a`2Y|5eLB}Gw&Ss)V;ec?@hx0_qp6)5HWCdlu+rflkJS+mV* z=C>2`7~V+y60wjw%TXjBUf5kp@zCB$2f(SdPb{N-8s~Jo9*t*Rwu4D zms0Tal2cHByNauzu3`mxRu<>P3BWNlq*>YnI?BNgr_+k5c8<8pXrel&l3v3aovruz z_aROI9XIA$7;-fQ%$I)Bvok>HJ(MI)NY2LW58KVkr2kc1SF5C+#Xiot2}iICCT_M` zJ9y;s@rHGKfyy<2$5|@fgLZ(5xVy(E_-d+7Ss9F2G&g9*nz zqAHqVjmpu@Rh;tm`m6Pw{Vj;oqtbBc?Ieh?kR@)sozSn6SadChXsB4_RDrz+4Cp)GS|0>O@BG#E{M<2EBdwSl(bR9A~JJy9?oJK#c^6EYIDSpK$ zp$Iu$vv(||Ek|}9XSL%UJK5>EsP{0`*{4lvmfIm~6}{=Al9<8Yd?VNIpy!jgtk3MH z9@Q0}WJYCFB$Gu^wKs+uu3nnv$tjA(!6BXsiDuJm2im0J6Tr(f4z?J9UY#q8^o9 zN0LrIYWYp0Q}x&vaxn z@mlv6c*FkbvyipGtTO3|O`>&YbWA%rP4l9)xDRt(MG+VHZ659EVNEO5j`PZdA4XBP zrp^xK!_))KWvME+l_;JU*9N-fqv&yEXa5dkg>YJJOoN%g_rUr&%BQ&=5=oWm`u6{ zBH!f3@4->0Q}RQ3TZrxC_k;cH9h$@90QX7XLILCSa{*ud_=dA-52&!Jaff^Ls2ba| znu{O0a$1E`e}seB9T-+2MV{F8!V%LP}9EuQ7S$OPENV2Um&-* zT+d4rpU~$Cx7FIe$Fm>8SM&8ydW+o&-mJDWrem?&Eon#S;Si^mwqMMl-};t2y-dN` zPjUT4otPftM1N}-dB`ek6VaMRc7kh&>^sN97Vyps>07hZ=b>XS9x6){a%T9KlbXV9 zoRX7EINfS|{H@+~mJZoJDGmMGK3ApO;M8X84Aq<+>aPmh2`{JhigKiXq&h4?4a1FB z;D?4#h{o!qKjl;n70gTC@iFgt6(8HsuEL9umEGz1PWrzsb>m-m;_gcR<82(A*-rc* z89C4n^_^3WYwZBMqi%bJhcC}Dd~P4&jtoUzZ*^SPc)T9UA*Kc^Ou=XLA{?U7fV#BdH5@U~roKh@7obsQ>NvAfjaZ`#Z6?YzTrY}!=L55(%t zxZ|UucC#z|-{dg-&L2IwQLemt-R{WYiv=;=1b17B6UNVpQ3twNpVP`F%g1}qs%rJT zkky`x*kYIQ5t)8F_Soh$ZFLO&6u(>{HE-&6v%Xf>={1~n3+3Ixi=NUg>|_NB$%So@ z*hlX^hbsZEbJF98ne|Y?Z0B@t41JQ* zfD7~)YEkSfMZOeWAGDSQ#m@nxNX=lRFAbh?C3 z+K)?yP?AD8>uK(+omW-DqSGneS^Mb^$glm7wb3iKr2Ux|cOHF(x*t{{mEd-Z$k~(B zc_97xS?2Xpe_pGvb9Gu--ee#Jx!}y)Fs}ciDzq6*s>}DE7NPFOvt6gYMp}lnuQ;q_ za{Rcn${lq|OUcd@&t0h_drQ(mxu46r(z%`K;(5Ch7Ke1SKIGyLs#P2FqDQ#P=w5Gg z-ReyktEjHd9(wY)D}Vmv8lUy7S`_6`YdTjZT_W$ED)yLUQ&k~PBtdm_!%_b+k! zYgPCE%DpjS-Vh77go7VwbbI>yi%!SGT-G2_YbI_xRO_|VGo~-+no=NZ7Hm!NyE=B* zJuFY>W;)AR#+ifua%s2~?`2J|(zl;0%Nxk@TJYPO&tHQhTB+S`6tCs>D+*DbMy_Qj zM+-l|JcXR_Stg3*^hA#FK$);tR~(v|gPiBY>?3Bo$u7iJweg$S`+i*XiE$Reb?d3- zIO81aY};Pfa5bPvi!k|5*e;8+Mp>}iObm3r6XEl@xB5P%h=^7Y?fE>l>&R)}AI@St zh|(Va6J$dG>U*o>Fi>vNi@ zs29U;8Orb@Fa8DfEXk)o%dvj1KDr6AisP*O-1IWsRuN0D)|)AZt#UzEZr#EsRNY5t z+oM?f6y)Eb0;%e}<0bA(+)?)J)cq*uw8|bjxK%8UT`X+9KXHm?kRE(}47W|qcP+-g zS(SHyI?m)s%j;+zQW>s>=xsWn4P^O7Yg@~;uPfDinK4#jSlO?u^PHb%mR+N*aa*pn z6X&tYPDLJdN?&_fzvu#8a8lreE0gA0g9~bm3MrrHv0Q_-HgJlaVEqLd@(0dptycM4 zr>&kG?y2G|Z5)fNNJBe~^C6_5EIHt=je9t(qB>P^pTNU1xIwauK2TjUB)thl^71dU&Z*=!?0t$8^CmQl5v@Z-<=lTZj*aVyw-o$(!J08s0wc6SCR2 zzajOU&l@P`3qtSPkkyzT7ELe2%Rj?IAC|rMS(l$w>g8l)Gc0twmHO0LHuQ>7>E(?p zQ~FV;+MSH-2|oEW|GwDS%*;6PUbR(C%y+$l#RUwt=G(2}Pt)g7nYQ-)XVPROzXLt2=`Cm%M zZ;am8&GrTU)w@1?@p7-3pz^uVIvh`LphK}fvc!lJu;O{Hvu4dsgKhJ*^bpVQ8ZBW%OQhn{5m6-V7a&QJ9Z)Sg&xkWOPaWwovgL zEld8w2}60#YxTmiV#mt*X7}3Rn3=enBlInWVt!>TFBn5b{k7LI&ZDa6JDm2t-8%P& zlTB&atw&V5+qvI-IPRj)eJy##A8PU;IH93z&(3o^=z5X&Rq3}H+d+P&p={h_)qaQl zUorPDu+^Uy)xaxp4b7!k@DbhMSFl+j%J+h-U4eamcYbb_{`GM6Q31by_B}cJG6cr1 z!6I7Tm^t3*+*D%xi z1h!`CBILG$ah@+Ny#`OXkiKW59%<^>wd$Th`qRg(VVsJo?j3J|um{wJgY^V%k~Oof z(_iYHdps&Rt8yLZ6eo)Bf&Q#Gcmj@i(dd_P5GVZ(zy@}b`tVL0tlSb^na)ty0)o!) zVe`$tJwNa$jLmcc^Go|E57LjVVsMe?FUFf)#y9++r_k0l0SEY!x6H4xoT&$uW8nA~ z{;!q({$kvl*Lcd%-1BOHk8}oFtI#h{=wX<=CIr`otg4XJ*M83`eDS?*>0WHS%;}C7 z=y3rI^sWl22SinZtvVcSKN_)@?%v0F4yA~DVe1%#tw@kn9%6UM-Dh}>LYS+jz1hKD zae$-g1=Sbn;TY?>#jdnFUHSS%4`qn;YC=+W9o<0>hqn$Tmva{ zb>auguR{91KT^Qa{7Xx(9Ry=D^>NOn6oIAIYOfho^F=l2J>0_yUALxI>q|KvyL>Uj_iTu8jDxcMZoMSc1apgb4;8?lUMy=P3z75q`*s7YoR5chY z&6Pt(G=})N(#-%pL#hLv<5d1fR;PCnginWfja6v@%+}#MOLou zZjN=7m75@!o};&q)i)|G}aq^x-Q*@69~?SJtwP z9LtL3r*cGXaY%3KxPqFba&7ftb{&6~8Lo0e<2l#MEcRLVQo0@ZHqJvf)|)M^-*+c0 zckz>H9Krh@t9{K@_uOx-e&_Vx;7Q^)IX%EZJfJd~Vjt?LQ$Cfg>nl+BAxv&C$EjZP zs9O9od*xGMtAltv&B=~czaCX5w8Vseq?fcZ!_;yIaduW+(qmBnC8d~vxq8XEW*kEs zJ3fp3biaHmf^|F7z!!1XSY3pM{9|q>EO&d4NwQ^x_3tlgb71r^b}z(>w(*Ll-0~gN ztEtxNHPu#E99E4(&Zt*-l3%!(mIDvnt9v+)T3t;?Ucs}U zP^%xY^;gbqjS~6WQ~rgFS5-@oCg`l?U59zc4YIX>QTK(Db*hs;<=}QR`2ZSv$-#_H zA8o=tufoansfMx6>Qr)U8*Td1sZ<0jT-gtS+b( z4;kwc^BE6WO^m$rvA zzo_yotLst21+mB6g4aGct%=JdKjT_JHXWeuMXUUNRIC!&j zM#~^=A};cqhV{qej;@nE-cE!w=&!1 z*bbTY884e*dM~OJRDV$E!Lzb954dxw7N^3b57KzF`&YR>qF*?{eic9QQVi zj8r3hhDFco8Qo_DvoYb1?k8~v-pX%Z`V^!rQ58k+=}*s`B_Ahx_jf$L!0UCb_p7pc zHy3!LO5ti=E5)g~Nzi-`=NLWQp>WmA?5pt%4WO$l=I&>#^Hd$fu;7Cf_72x9?Z-K9 z(t!@Vd|M86yiU#A^dyTeP9w-1Vyy4N<6H9KRV!0G{U2+;g^s?QIN8BEVC&V?Ww_a; zVwazWy@#{@f-9{my!E>?6PsydTszSNkB`Phqu^|jIcKJ4cVnPVN&lp^KcA+MHz#`d zC1j1~M4#~(#HY^|gHwM0#HD6?J#ShPD;`bW_c6VMJ+iwaRyhJ2b3Eqi0?(jazoq5D z+wIjXTlCA1+3EX>!tXMxC(J*eGfq40WzR6{mtD{DvP?Uw^0^HT!hg(%f;p;}MRIH$ z7xOeGdt8sJqJM8@9VgPO1Nu4lV~B}z;jmf%4O<5&(FZc3nvVZ>a=E`AX*K=v2mHUC z_g&39T?JchaNR2N`bOvTdmd_v2v+7BF3N#LJWGEOXbI8b&*pg5H*_I8&sdnR$nXDG zI-_*RIfPG>b9qx}?Ha!Fcb$z=`Zc5NJAGlza>>=}A?r#j_ph9t;d5)!w_oVjLNg5h zSjsD9_1Vp7U!0T3WoNFW{l!sLuHpqkSfFeLXko z`#IF-ue4h{cd9uS!nGTDtQvJ>|v*4*wF^s;(|> z2UC6Z?t6MganAg_*=^;ECs5a6YOfdZ+;X|S8`GuhK@{M5Q|xkVz-v$I)SrR>4))LX z>uSw{kfE|FYu4J zPlm{LR=hb6+*<_V1Y};(?Joi&;?H<%np&Vf+{ABH9BRz>a(K7k$tuv*o~piX z{6E_t%IYM@H9SBLweAs}oR$7%1ZEy=UX!fER(l<{P>3;VyOSxooy@;Ot#?P_VRz`J z^}s+mAZw!)nG26^LFCUmQ^mNM!CrGPB|G0=NBru@^DES?U&!)CKJfroHyShDhqc1Z z*E6F&YN9~NEW9<(T>Htr(rU=>`PMPw&|AOeVbxwY`MXLdAyswRShR-8*7-hr3cpr| zPGoW2bZBUI9QQPAjl^+3rCkGc|LxBXm92;Ptimu=k-`_&;W=j2-nMR|)i+P!^0>BW z8qMD;qpp@4_ff>>DdKqjtmcqb0Jioe>M#sfYkE-Z*~K>BvPhvth&w*zarOjO*CqH;IMoS?)RM-v1%*zrqR~=b3JTt6Ol^ z3H8-8T=FN-ej_$}1Fo`(!DBMLv#v-DxJ=Pu96)_@aCL9PZ);52 z00-B?S^cfaEXWEUzZ6o&Q2Z8@GIRPK%Jw=Xcv1(j2JY(5bF7rPSEwvoTEkboVw0?x zg}=IRvPC%mHDWPXHPu3Ib+9`Bb9r7?zWCj}GIgo7DxmXsFE3OJzH;hNtu^9TA*7)U zuS7kb!n(6@P`d6zbv@rF0@a413fc7%zFqMWU|k* z1j7w5k4ZRdHW&X#S`krwUUa{KfIm6c!&dz$@AD2+yeLD5s%M6X<_4Z7hpyW&+zYc1t?9pmUKGOy#A+`M{MjQbTlUKITw)nV(XV@+DP zmG_8yoaB+msXE$c?dFz+=CNY&u~}rJ|5v(S#Xq=dpNxq*W1c9iRU!@7*4v>#B>(<-oz812)P^Sbws*rDs0T&?;D zE!_`~Pl??Hn*4&#XrU`x%1(G~+}{KHylP~hdcSv#<2mD~;xz36u4$p{eoSrHLR4PR zFI}kOft5Ybr7oAIjryh{UGIsv^a%+AFfz| zb)xgKT~2=>(;kJai+uiTvZ(`}>!jZ8V*Vf5p)Ck^1I+x(#F^#b#k2Fu+r@RQ*Up8F zH*mvN{{3o<^)fBoD;~F~U0ZXCQHiC|xrtDJo9n5*Pz^oBnY9&#L87op7rJc1S#RQ$ z@3F)#TDqHK7{KdXqR0BKoUUVh`O+_!FXwfF?&4s2Bs{ub-{Ml^xePikn9uvh67J_g z(XK&5;}hQY^Q*De2)XxLN?c)cEgd-J`^_@EQF8iZuS|If4o1P{Y@Bt@ozh3*g~KVa z``gK$d4H!%+spL4&Rz zl;)`R`r9`gYX9aHo_2TQPMXTxg);MFQF&7?^n|vY=|6D&6JYRR*m?xAMmhg_KCQ0t z?T4&RIy=WxuHu%eLr6Wf+|@YdXLZvQxzYl*n(072$;;2hK!0##6`cecq@zAk_j5e< z>c|sZXE)+ot1?D~Gr*&}nscDpEmxgyQ>CBQtBG?;Z>t9Gm%VFndMz3LnRzsE@~VoR z@&-6-zP;c}twJxX`?GUSr>WOr`MC(k-KVE~9RI$MD0^G1-5zr-)MdSt`aG;ATu3o~ z@T#n$n4%Z33)gJY9h?VS3#`f>2q`R1&vJpE(X^YAy~8?qtB4-eeo-30#b>ZrH^$nv z^=OBAMo^6(^`PtEwD;xQ2{m*o4nB<82dU|<^hs~fhuDL;s0KO8^%lWk(R-SX8Gh9{ z%tQ+>u^)TP_`bqg(f#fr#=X4zD>&$5n9oa#pMv_8Fna)|j`ALdFzI-1j+sxeYK&ZnNvU4Q+!O-@EzTIh6Ywr9T(R3tI26}l(n;= zYK@$G*E>a*x2}J^;IzrR)Gf}#Kf`ampbG!S>DpUFsy*hO?!6W!Yx#mzsZICK)0~Am z4+BJG03ChPef4tk0b{Lv5xn$~sF&oI;yiUL=qkkn{-l;2gQ*_jBdTHi$3-J}_pg+j z<#-=gaZk3_GaGC5a$evLuZZ5^bohHlP2bQfn$nK3+}3+u^|d_T%7-n*3LW{*Kg_5< z%{$K_zRW4MbdIW#lN%$A^E35DVKE(L?jO;PL)7gnEId|rmvLoH%w+;^)`mB0>;G?J zv2@zrp6adT-Q%7k1+YUJT|BG{^rd%MB|q0f*&j}j)P(t0j3w%+(#{lA#nvS_n!l31 zR(H`o$tlP0!Rs$3y(sA{agUDHBXVU1C$vNTd`{%I(y2bWlDS;Fw1BoX7QHOGZW;9+ zujX*?#(NWF$bOohN0g&7JWY?kgrw)N#3NqO0N1t_wFO3(ndS}VC_Xik-EegbzSdHd z{ysUbOg-p18?D3~dG?hYDWGT6gOco3R~6&l+o-6X!VRT(lOyWPR+#%ID?OYR)>K>E z&Zn2eH7y|P6*vi}wN}=|l^Cs5@Zyvpsi(wVZDB zOIkfFh``35CRu`SpskCr}u2xGdc3J8kt1t(aI$*i_zS{e)FLr;EbNCcPY{7yn zptVs_OKreGWs@_HGoZZ{C2Xq$`V#itm3D&_A0juGTE}n1VV#l2?C)_d1`};{3bpgbU^%zE%2Ey=k-HEQ5>4{CD&kuRf&3}=pn3C+xFMT9Y7af(EHq}r(RKQ+|wGahlnlyTuyBU>pI-1 z^Ltb^7(0m1V7Q6;6d&@KH>;+5h}L>Mm2ThTALzPP#IraX`zHte2Xy_cqkp|muOpX7 z%7VS{@dtF}<=?NMZAbX5S&&u2Zp|57_clcwn$+iuz3*>aP&Km}f(yRaefh!qEWy$J zaC$aA>wU~{zwY6+*eW;2k_~@7V9c*U)>fE0N2AZ^i2iFVUvf7yp(%dj& zlK#{qGO~|#Uu`uv@DlT(eWXmNX1Db!v)Q9x`n9U&Lej~&)3aYw+wV7z*v+mCS*0+; z1^Ut3Yp&bv{Yq!xfFB0MDt>hhnXn_Ct=U({Gj^z4hQB_3!`g+uZJO+D*=i#%tJj&~g{ulDJNqR9j`Hr?d%lkczgZkp&`Q~yWEt?oUh%sK5 zaqr;&#o{nPwpA3lv$AAC(pQ~}JAK4%J=gJv^HiC*_)KEGoxfQ| z(S-8Ol@Bl4%H3?4!g zzUE?c>HHKjnuGK3Bs{)kVKCr!+{{V*&=8;JY@Mrkt^# z#5)VE>I}R1lQ7Zyyv9lW!iv^+xICNaYg|%)G}kq{iHlFs)jFt;b%MLRg5I`~HGABB zteUl2sW!X<6V*sKtBes|g;9Tpl=|A1|d(9z(f3FOIO?6Nnr|Yr5Q(ULw zPkrp=p7*@yG*Q`?@+b+p)vd}S>dPmr*#jb;kBePnPH|=5DYeK{UNNe?j%tjFwC#Im z8E?V9e$O+eo+G}q%xAcMR3ol9chZIbhy$1_ua@Yn?oZ3)of=uaX;yJ>;-|yx_RR&m56K|LWysNNSePL}?^Oi?jDfXjXKc#(T`Cb^Gzn zZCH3TOfB)+#r)6MBznc{?R?{z&&uL@l&}b9z16D6eszt_y>FI7*CMD|%XfW^wX$M?+F0u;tNenDn7{=;W4?FWjm@kVe})R4aAx;PKk4Wb zzoHp+<=|!xzOZu%4?EG=P)F-tJ*~p27cu^)a^@}EJjFV`Yo=e*kKAz8P)DvKwi>R^ z?dnyPoWDM*yE7fXb%3AB*dmZsL3GQSe^m&p1ZSniIj--259*(i;nDY9g?~oV!d^IQ zq|;h&CHtBs&7%h=5~oZS%jqde_ow>*khKwOg|aU+_nlnUbuzA}=d2d7%vA6;ot&!t zR85~+3=`xRv)qt%lMyw>($AUG2JtwlBa%T?m^t+nPqP|o2IH(EuyTavti_k}jCCzV z{#HcudCz8EJ(%a5?4xTh00liwMD%p80^k*XqoFME$qdGCxoKt6Rv zI!^ruv&Ol~*sGa~jpzG*w>_S8XRfO06^yjcyb!83d91c~GhKE+EmsShK{l1iX>Kje zb8i#9ZgPK}vHzyWmQU1*+f9oqeXSg5X5J^^H*~s;z0*9#_P37urEpSJ?moen52A&g zIn+mGdU4&tAFTc=ob{>>!mIi_i>>gFIO|q=8Yg)EyNAX=xN4;m&~iQA1k?DeIFyLiA`A*QD4wTYfv^`u|^yKdkX89E=gE~WshVDKDAb{iz!uaX?4 z=P*K@*a5amQ_y|<=4|;@53UM(6tb4r`!jSg&br=%zl!Px#FgO_IKtbg^iEy(Y3hoO zNu4{^T$byp=TdppQ!@?ZQx=Hs8zS_AzgHKr%sLM{J!`hk%Obt3FI=aX#mY6eN(-^U z<#ekYRjDKD^+c+)j(-6KwjGLF& zI_PmG;jGHA{hCqlq&k^Xui;a2z||oPu!PU5N_+R=#XcNgRE7m1;1c>(i0;*dl%`_P zT|RWNxB3*7yg5DYD_;IOJDbO3^~bVj9{;ouvex_izdB!q zc>H^4VRwAhk;`i8`PZk$^(i07uP1bP%2@APc<4*;@fB9{M!vYbh?bW{7nAZ1Mt&QQTtEdQ*hSL;;~Y< z?FqbAKn?Icm72i=O{H{iiu>=n@Wt&cw!#$a7 zJo+@fd5x#q>DA}?sGOMV8ojntdMV$~!}q**^bppmA9vHZ8+~^3q${#YPh+p%!?)^+ zb=H4qg5o%5m>uh9R{Q4U8E>cT{rTV6cT3^0uEAL07{3#(KC&TJuM)#J7cs46b% zPUVIB3J_68b=RINdJJOv@}Xr4omgZe;dvp zhK@chn``5(V%%>w%vA&*MCCQZ+_GblK?$<5#yHby^5854XjZwjVX?~X&=flu9PGsl8*2}(cD$i~=i};NlA7klt zc6OpBIhS@dWqm^R9Cw9?-qEdeqB+iLY<|_Cx*&Xq&&r9lt}~8DMQSDv-XYgxhu~j- z{-Ns-r#~Nuyu$Dw*O+X8)phu2hdx>KUvHr5E%lIw;Jgvg+1V$Q@!m%uBl^;DDy%4E zWjD&=a2BUVCt46WL<750 z&u6{r8JyJuYnAuDS9#ZK<=i zkhO_>kj827hoLBpc<*aJ(7yVHrCD zm8@}7Yd@5qT}};8Q0b^sZ-9urP=1lu+68fI)u4-12w(d9UnA?e?bu}|Z+L!PGd~R1ui&N1RO<$h z+!QgliYI=zbS-tyqm-dZ!aSuQBOBKGPPgF$4t6q^GM(#KVU2%Ci!Mqv$a;*v59cX| zLRKd@tAm@1z+_>6-elel63*Jl3*M?~dszo_U&^J>R8^;{y2o9-RPNM^6yiHGTxw=3 z%y6qH{LJy@=g8_hN%Mq$>C+JQfal+m^uRvg2!_Gx{W7_f%#XFq@4K7vb|9>vuN?gG zHV$JfHmxj&{xR;gSa*t-2s|*H*HI;WM%7&628CAzT(;B6buTJ5pf-;PtutBv|eDl&!sePl#G@*lxk zA3#=rOxwWNE1FLMD_IU3H!_A1YOD`EYrnmqi}pef%J28MlAfNMhwgsHD|T{f<94-S zZrI7d$rq=FHTkB;tZ{pIsSjmUF?10J zIj$b~t~F%cZob96_qEVf5gR;<2R1-fX=97u8TJ#WRm?s|S*WS5HoTFijo(SSPv3p5 zk*+h-^?b>0x{(_{mG-QrbhC-wggP93QOMe>{+$b1kK?Bj@N^Sw<+t*;)1>lNq>?|& zCLVM*k1RejfFT1PfEyOfuIj!q_@*OIN8rDCwxD;eXU6zygD``A6DP@1v(;L`$v#5M) zmdmKhba8snE911wZn#>EPu`dH>&5mszy8(dMv?N8+0Q6PP-iLDp=(<^1G{fwx^(v znQaa4R8dq4)_M@0>R9hO z9LSSq_@a5P!+u90>mtnO;}Y_5W@+||j^WoI`MD!9{G_wTS9#}$xwxTx)?!_eEinI; zU8E1iXCh?vHM*vls)BWlvrV;V!&7iL0c)+Z`UmVI?DL9`y?QLn-6e~E_33e^&4*z9 zW~jxTk(S_$Z=o{KwUE--yKrx>@h}T1P{Ew8~jt_KdHcTxc;sXwacSdnlUw)bt|+i zweqj45EtRBa3vRWA#OKjn4&NGv>c701^T_r4;ZH+beY5s35o%Vfzw|5nwiALZZ{SnJNlRZ$+_ zj=9Qsgc{cKvqxZ~C4AL@n^L@ZKAowPdg|Z1nq?zZ*y;PY*K{`h-|&u`*+^bwt| z9LQtdql$NqtIi(qPTdlY-XjXdoCz9aw2#}3Oku8XBLmF$$ryWKlw+^`3G&x;9Uz)*1ND)XDSlc5rnthATrB2z;_c5-<7{_ zA#d+7p9ZRjX1rJICoJH^meZ6CRDHcDj89xyIlB9cwSM2)wc`M*VyZIMHaczfzX@~RNdYu1Th0)%?SqtE69!1}TcMqv0 zkE=s6s&db(7SC{GyOZ1+En3a3PjT_dn>Y%;^2fTR;m6Y|q&P(q=fLMvw;uoF=&QNP z<6PpeYTJL(QtfjVwhB#TQ*TTa9iLeqZzRn1T*APCuINSbt&G)LdrPagK%xR*xYe;YMivFjk*oy!Dp@XMnTd-MSIh~;AROPdVyLGz*6lz=Sl4Pl<%IF z8Iz#!EuLky=YQ_mi!t1&q-$S5HvfigXQ<>_!$e){RvK#s$J`BDvCq>?4%EW2HRwbs zYLJE3I!Zsbs*bi(#x3ymqkCfHq=&`5{$WV!3Q_TUbe_RnamVstv97Qc-MbHP`ycqX z92^Xw3Tr9f1-+_l3AV1XQ-hrqY9uwrkOp3P|>+W;P95xeWtjQ4e`6RZ$>5gqx&N915_Uk$uR=;n)%wa%PQiqUOQ^dL;dUGfLoKkF@4 zJ-NST&ZsoO(Qy{zadGKMx%+v}7<%#oWcBg9ww@DL<6OzZACm7M^X753Yo$N8@=L$S zjI%mVS;Z_B{!YTnm#{Zk4)ieMn($N<;%~sJw~1K9p%=ED=N-12{qNQzg(rwprco=V z;jGBKIIDPN;t#9ok#(RWuUPT9@UkLNiew8+tT=xu!;XZxW3He0&yt#&ekKR}MJt zPI%nkvbbBujb3*jBnC$ffvh3?)?kqw=KBHu9B-a$t=b9I$gMOZD%W>#(LVjQ^L7cc z^Y0l`&rrvGdUYQg@e+(RBbmYcL=(5r#Ql0er;;<_XYf~azE*K7WASiv?^#L?UgDhF zF&=d%FZ_iXDDLCAn!}pIJH3cG+nZ^q;pLF^7hL_K;yXwW)8t3tq@NplI2x{IK+Wd#=Pd)27ZDFsD>hKzT^efbCGw$W|Y^kr6+*F)l`H?sJRV^1>b40epHC}HR zTNjK~U0xR!n|!jr7!If=dL2Y{vgodd!Gm5IDwqYXvf-^z#>=qSrE1rxFiOhG*xBr3 z#A9XZJJf9*m0JZbZ<)!n$1!fh zxKaPKNEES*5ff|{PL!P z*R+(~U1icBU6siygh`P58U|S4lQybn{*XP_n8{N{@{W%0UhI>df4`oC%4;v}yfZU< z_`=w^nk{4Eiq^SYSM-NJ!DewM`IC0D|DujRs!2ZgFRzJJbg_#iS$|TNex2m|@?>v+ z8C94^8(vafbTqzNvL`y$nRHA~L)OnuV}xH!OAE(%2XFWQ7Vl$6VHAXog~ykD-xu$; zwGxf^gvMO+Gmte|?04yH=fhYJLeTT#@|saENgR6IDRc%8_;P}t{vs2<6{i=h4fo8U zV)HUa4&Lf4B8{-t4I=%w75WYyR&grPjo%1YyJ+80%oRL*9IJhk`266k!F2i|@hWXb zIrNP#^Jh-nbvtZDMfIY6syK^r2(r#QQF8%T{mJQ`!h0uum*!{rbdt+c>PMlbAIwh| zm-k`nU5Hr*H*aF2sYW^i@_HI;FIwJ9E{{xDEAIEWhTmFDS>nESPsx!Rc-#F(|CV*@ zDjy%Dd)2Lb)ULr-jYOfHM|)#@oHE{*bRTXu_KRZieZp9~{r@0U$!PzlFsJqajCK@( z9_`U%_#&aQ$C!4a$9T`1 zC`M7^#x>su{)ekj%>#)iTO$Aa;;h=<_eyzqPELPoJvZUV54n}OywXhAj*faSS{?OD z!DJ8qFFxyAmB{ya>nH}hJV91%v*;+>hU48q{v1jVdz;aJE76ooh`TxTfwh;hT3kPo zN#0lILi*vU8R8N7zDz{siqd!)-W}??h+CX^?M?{?_!?}~q4dN2t($*uCNs)%TUTS< zW8$(KzP4kgk1_8~eW?Ak{Umgpg{=b^Y=_xRl`Cy&N{uAvv+=>1J+i2_%3+465=Ua* z_i5`F-tRcZyTEe>y3W#$pRnL5y7;f!Gj`2uKzID^o{92ek@0^(=T<{m+=uo*{_Sb> z1I%Xt1O=|%@UL%s-fDARZ06%-O#C*#LKyK+LqzR#aW=wUeEYw>VIr6ndF=sl;aiKqEV4b0+J?3=@`Ld@WRKaa|e^Y+bi zC)G&Zq$@NKriMb#i_kTIt9m+#Vq9n1)(m^gnQ54Dk9g*9GuC43%pkwrFFJ}7EOuk6E2XQ@7 zXxcICbHr!vg1EP=adg|_3YlAUf3JY5bdk8kz5R+{-g|MvNM3QN=We$uKYRU881YxW z>wvo8M@;w&bj7}PcAb&itY9-JA8I_)Y2q?!xD0y*dS-F#L(O4eGK07tJ$A^a%lCyC zYc*H4)Vybye^;KdygK6_V~lHOhgqp`sEs_Lc5UGMxZgu}=!*Vl2g-OqKD!+=W!Eu0 z22p$MqiyxI7q-sYMaxfR?uN=1V$jb!4%ZKQ(dx(5{Bh-W^pvB{xm+CXm8HYv^ajr9 zFCP9%YM9UC%7nFk!$si=ml}DTd5F%>3MdR*{SH;`^6|ms-D(C%=?XMBx>FL+~YrK+3{J$B)1+HwVrx& z&&sdf&=gnHH~ODXc+l!}4<8oiIf4=1l3`OgoW8QHK9^FITewVQj^n8V`05Y@oWL&s(%lO<|BN;Kfs0&$ ziDPf4MN+fIS(Wp~p8;p(HoI{0j~f3->;I10X#4_VXAu{(6$r7F0n zo3_>ngYO@whz}=zy_N5KVyoxr!APSYCU^V6%cJ^SW%VXR9=p?t>X!@wwl>A>hhGR+@4hK2W86_aQwckj>`a{Pwk18v=a}CWlIw&1wetYZD6p!C4 zX`(eR z@sHtC7Wms_nbwI*sKv?OOw}{0aN|0lBMGw{$BgG-D>_q|xuxI4E$;sFD!p!QT&1mk zb~|vFn^mslG*o%~(9XZUjJa3B(l%@VtH*D$dyg^i5RDC1=@ZQO9qu|$5u>y9pp_hE z&1V|ltc0zm7;pH(UNH3}>O@puKJx-NDgZQvV%pjjVkA2>Ko;81l z7i@*89*~!ht6BbZ^b7j znX*uH-{IVsT7#AHW^>q`ChUnyWlbzxR~mBFt0&-NF#QofbO8%|}0 zF;DgVyU?~pgPnWp*X$E^8Kh`7_mnaW9W4IC+xsjHJ=s za8LZEoc|(`Zog_jTPwwQq7esCvR(RL5K9n!Zy&_aI zemhODRwYf)@#kJCzRVF#9 z<6SbSGj5n>{nx1jzJ$fy*5*r$zRB0eD&|#KWpnZ`KlzNyxxA9N_i=d|*m_kq&%>y% zW2|w0(n}2+*RZtm^%zIi+s`I@-hB95Y&O#={BYUP1ncGyhdtgsuF;8Wkvix;w1A)v zn7#{a4e|4r^>#+#tii_FipP(u6R%89=S-sW@VoyXwRf0Vy<3oPjJvON_S$Fh)fDeL z2V1>k4j(2?^c%`?R#zs!{$UH1##rP2m>!;vX)4LXsijwCd`o z7Z8UuS+NVIzV!G4!hYekGMili`4b(zhdj3hCT|8)_laMeIE#IX(!RUT&%4RI<=Egn zr*)SVA3z}{%ZC5@*Yo7@YqC0U^(@qdM+uKL&U60btmsFs;DF!tn1jKe$82roeEg2R zxZXNDG^Og#WrCY7hwUBqIIamQ($MM*FAE1v6Yq9vv69kh_8(HVIbPDXL+Lwpx}6~8Ant}AGR z)tlwzZ`d(W!9Y5isz$8_BLg6tT8g_;>>+}|pv8vR7#7!T_HMn!Vo)v39} zCazVe#~D0oJs$Vf0*^l+t7`Mb6@0~6kY?WLS!(zmE;#0HM5SbeO6ZZxPzcmrE=27vbQ1!mY63?F?hCRirk?N)d_Q*?7E=%@2FZKUySnMje zimMspRQlgkEh0BZWS%j;dyFei@W%CFaaHm?vZ9@szLeC4o1yAQNZdpBzUQrWz}b5F zzJ^!(NL+UCZGR*u;*0t8rqI#Dn8#APDgGQ|22s5>_h<_D@n|jsx$chSNtXakG zbw_$$RV9^ECAAMzz6nFm;r-Z`iLQBcr268i=tYl{>tpo~2KiYV$f|;A^V0Mb*gB!2 zItf{sbW#dH+}+%NGYE~!aEdw2#TrY^V5QN2k|=%DA=ijP1E`Pdm{1}pLvw%S4kw}|(L7;B~2L^n3>*Z7^9@sv8`CR)WH)Addl%QD^6EV#8`2juZ>)-NDXh~5>r$n(E~ccy=T;?D=J#iuZX*jjP`!7 zdgU8p`?k@qG`B4Y8^`Wyc9H+?%y8?QZCxN`A0YFd;T|iDeqeSs0`n+ z_RIVv^zauB{v-^hdG2qJ^|^nWgcDlhjB;xBG*$GE{KQ6|xy&aoGluyv9;X*ZS?i~h z3b?#pb(~1MsAKXAO*{-$Cz5XIpX@9_{8s7CF}nSVJRrn(GjBOBu$ zJljR(3afb!o#>h5?yD4Hk`<3G`yesuZmykiR(Ipnm}uAj|Kj>Qda z#jFlRit~zBVXd6WZeI?2?AK$gygKH&{XDbE>nMNsxx5_@-<_fVz6AAU1*u5S*N&^!YZp;$u3kU81H4Y&l-O}WAEtE z0pglM)T#2*BtN#Y$n&@72fsNRe8IfpU zs7QFJ0b(U&o=QNIc~o#z56Xk({YATWIb}v=p1p>ZZ+w$;G5y+hr6#?xysq)tx9-aXu?{4sR8i(*9pjPCO_Q&xqkGGJAzA+vM3h#5(Rc7hSS1 zIrljIx5dw*!~c6yg_NLV_4vguP!c`2*fS5tijj9n#?}~P#c!5)+)uhD{odH;86%Uz zCH6MUX0RD|evaSry&1A5z)|3;y*^EhX^?!3j?@_ZGeV|!_w$F1|5l83t!&SLu}*sY zrJDNNuE^DTb0y_+Lz&!7E%3Zo#@*fC^H@a*qqhBuvyQtuu*KF3;X=tP|Si@g3QIr)FUzeb-l&d2`CzyD^3@iQ4e#q6I}Rh1QiEIL7P zb~3J@-z2L)#41a$=Opz=53^_j7ZuFn>f{VFuV;Ghi5%T zL0$AA=gX@1Rff@vjMG{(lMZ$dv3byLLTOH`AXjw_EM1lGROn#rNdL=gAE91xGIR+S zGt#W$&V)7Sbaa4A@mrOQuqlVw+X$ztpx(n$8(`|I1Y>J3)+!z)5VzSr*q&tXC$7YZ z>osfOtVdxd?in?Z%NUHa2GPAfNp^HdxVLB0JsE8-FN@)8T;p`#jg}dmv061_zZ|lD z@R{$*=gAN>z$l}#>jNFV{TX{Ran)h(1amEA>|NBnXyV}gQY1PUo;MT!eq-W49yFV9 zX#KrcIIZchH5YTdXC84aN~n1_hQb)Lu^f2TYQ9DF;wp**9Mo@+vro@zhuZKHb<=Wd zzDQPwYW#QFF-|*#D?97YKOizrl*W|+L+Qu^`Y1(2B7V2)E~~h~XNHS^ha$Y5bZujI zyt53cZx$u^tjiPTJ;iNBC6bnwm8;LkG2WW!Nt}0T1YHk^)4jgxiC#SJ@zaN8Q1>Km zaVq$Tx~hOV-76+tAm{~aJ`R( zpIl3CtdPeQ+)C!8D)h^woqEpb&y4sg?nn5eOkIsJ$D2_%$Zsg?D^c(`t8yo0jBZ?K zk&CO47Q)b4JQY4`o7a7*+ZTKlOtzVpd@DENH{0g2dKKhn6Ori&O;H!VXQbC&-$?KhN{-3#`I=*|ASY zE>1BPlNqs(5LcDI4?{cQ>KH}*g)YV&t-r@bUvTjoXx)df6Q{C+{Yhiu&*!dLE~R;*Je*>im9AD?EJMyEgCtTzoq0 z{&#}rc;c{rgo^O*lSM6@V*@U*l58m^E8-fS>PFlOwnDKdW9)ah^>;DVio{{XZ=m_e zcbgI|`CbR(tjh3epIC*OMCI2t!PY={c^>;dCvyk*GrDro>mA_lBaAjwaH3g77LD=u zXSuP~Mqh=Jr@HF!TZ*yJ=f7a>hvKL{9)svvTwxHMtTDK(D|gaVOrpmaCo6OEDCb4u z9A}c^diETmQJPZ3$BCF5BRXr)}oa;#npweGjby7 z_k7_q*KtB|kDN(d<4|5P_7S4{QzY?Ofvn&7_oI4qr=aUR=E{}y=8DUqIIDK2n8iN* ze`ky;%Aeq}*gNdYDa;hH?OaM`T5>yw(aJb_iN;7PKHi$Y;_GFx87US~pHG6I=w&UG zQE`@FC1zd;TXX$%f;jdNnTJ$|)e`9O}c&`dT@Xz3A)AsEioYmOp{4o>=-h?>3He ze%-uQo6A-h4$jK1y1fGmo-)?BkI@nc`VuRJKi^B?zR_LS3Q3<~t6;2;VQQnEL)^9c zC%hJ_dBpzV0e|1_S+R#Q7DKnQA{9B4Y}P-zJ)09YejBoAC$3k^ zZZG5lL>-5z)Bb;s&&q*;3sL+##HW@=eUHYvEx}!JUhP4zk2%KgLXDb!HQoL1_Z5|d ztQPd_8L@cLdQXO}*I+63oL_;oi3xkf*{#>{?7aW=Qx;Rk*c*vG-x0|y9>rRBxx(xw zKI;nJDwEFWCBD)`|1VwnxF6vTs8}Iu$BRy!?s){>@8lmVnOQ}iCAtnz7-N64o`|#F zlHIEy>OU3y2wTN(Ws45PPEm=y?!TSh%7e8^`s{iXr49b=4p-493AYvMH^|?6C%6jl z6^s^o8J*I}W;)j2`clhQeB$liE7dhT-}=%|>Uqi->n(_HBjq7FG?1{rW=Dcz^0 zT;d=jnwQ{b8)StN9)zyFSnF%N^(jQfDeHB<`xImC!BR()-JoMs@d%9V<;Xr1l?mqA zF46dcGCYGTTlVSF#BU5(Yy@#eY`A%JP8@t(jS$zA+#n~iI>#9M>nHUd{)Dg#X&KDs zYX5qxypP``P*;rNs(`@NeH_94JXRA*6Ucf=E-n|NA9d-nJ1Yp*7!d>J1Z90CMm-HN#agec>`!3dsJMg^ZwQ=vTmXy7^EGf>#M^zYpD+7mhJ~{t# z91adz%T2Hq=gy)Z)Z7^F!d9i_LcGv!eXl1ab$VPE9oJt6XT_=2=*-0V zudisy?-Vh`C*24Y)e>w)SF4-s?_+KI$(CpQ8DIVUeV{BD^8fwhBqM*xPX>B!JJ}W5 z5mygK*ZN!ITq?Wc_Y?hB561l&##pI$W3%6T+A>U(bBpHwkn%f zXn0&97WW5=YpF)SRs3F*weYk_et+(}UXMTr6)^r*j z_s*N4pAhG;Cc#(u`Z&iJ`?$0H`<%qd#~JeYZS`?3I8JUpLx-BGGb?aCaX*{5zB|s& z#7WRo{QIwPwTHKe-oXN_In;W$hNHSL6*X@OekINYH^gLd4r;Kd&cofY{~U<=9J)U9 z2yA_5l|IIQ5uJl(k)p?upAyF@z(*5BjFW!h-@~^L#{_Y@dZ51rclGBe$9P0_5+`Jb z`B_J5SYKZ(?%Mi~(Z=cC_&o*VA?tbS9M#=mXdi~JUgCX6Kw6w`>Y8}PyNo=pQMih7 zTrM_Qp)2k-a(hxAHs_P$I{Y~M7pG_5OL%w3|0C)=;C(LN|F5A$Mn)M~85zk+viBa5 znN5f?3sFW%B(k@xGBQ&}$V?%!Nr(u^H=~S_!vFcaZ+`#B`|&yFIL`T;dt7_GE~Ll_ zEQCMdx-{HEL1boAIN)7QbAHI(>G{YNwY>Y%H#Fle&dq#F>7n9Ug7iwz4-PH@4upyqIb&=iKrJmO^c>f%`N{f`K#cjO7d5;eg zX&$r{D^aE(wUkxf!bs>&TT36&IQ$Cswh*Z`6B-mg)T zLTJp6t}AfPaXg1DP|FPN>Frn@4wI-?=F@buz4T3$VnYii0@ zkht~XiXz;D{o!X>mRQUTn&Z`e=wcgE1wj-gC(NMj!o<>1wS}U=#xhQ9!mAgrS z{z#5*m?zlqHLx6e@Y`eICS!D8vF`8SFy}iQgWIma7msjW@b}^3Jj^FPL z_YB~CoOHC7)BBZ*@(lPT&ggPSX05ErTpeOoV)dC9G_LS&u!i4*%ZDSuX7E3A*}spH zEmo_vBZpEB4=X1&yj`o#pfHN{CpYEmS5DGe|IO@CY{G$1R%__K0&@0QWR~7kNx0nZ zn6B)5EVBD^DCj$=>L?cIDbD8@obJSp{k+dUzB&{l@i);ZnV_sv@O?|os~qO`W0qm)NH$Ca(sM#i|FZX5TD$RT`-30k}LFR<9k_qHigK-{GfcBQe&p`#n(A5q4wk zqI8uIu6P`dv^Mrtr0C>eXYD{%8Pz(=>m(M`erRVCbhMO{pUu5kTWXb@RdwG$gT}X{ z!aYbfC$o)bUoCmhT*#~|ocm_Z@)K5JMn`m%$o^sB+#>U{PnHeX`spd35u4vl|=3|hfD zzYaV20xGlLdkQj3sr5cIrSCWysWl6VG6G?3v$Qn{nf4|(DpGW5Za6cP^#HVW4T`$R za~Vtf!X4V$gKk^FtxbT!jh5Dcn(|>G%I!JO=Y_Dh%uBz4ypB~`F_&{3+ByO??c&zA zq798je1kMor~DZr*-s*Ut0A*Gq6dtLI@8F=)&SPl582d>71|}%8T#tPj(b2=dW(IK zUrJmvRN{dp#*;dZH2fUxH5{6-$JvN!KWOWHXvrEaXVVQ2|7#sebI!XgoRJw_k`x~? z8D64jM!E2NMGmo+aRl;W0Wxa~Yd!#N9mPJd(&4}0-DZQo%fOkPk)jjf+!avEKCGwX z(3C#kaVTpKy6+oKd=YxW`b+tG0g_-XGV6PO`VjV(l(hxQT7*q(XINS8HxYH@`|y3{ z6Yux{Zg>+J+&%c_9fQ1T4tMJxYQv`wKJIN|4i~5>H5PA-E-^A#+|TErK&O~mx7|6M z%j6vYTNU`_$61BjYVNzbWrjlW2TmT8W68CWgz!+;ARIPxdmM_gll7 ze?(gS%x=!3FaD*=1UvqdC zAhXtTTU&zW+>I{$7VW6avbMiTK-0LzeztLv)8JL}=$(*Lb_%|T)zJ+7P!EZ#1z#8q zo(>wi_x5G7ha&Z!LT;TSdw3rllL@U-63@`ALI+NwSD?_I$aG_MJ&+Sx##4gK(l<0` zRhAX>MjFk8QjF1wda#wR%s|aY9!-F5Mg{5`&gXGK8;TvGHL2XP9?eb$JMtT0>pTTz zCFOqXi2endH~}>rLAF?DWrk)I(qIPs{uUZqBs{A!v%oDG*jH9`N+~3OmexC1gL)d@ zqCZ4)IKf9dd=EiSTY0tz`E`Kr{0U_xM;{hLX0}Ju*`qTK`kw^wSg0}9@Ngb_{n&ES{6jGfy73Uiii|8R=p(~EJ?6^;G^ zyC{m>O$Wy(fvSwd8`a9p&PpPcJ3?Dykbojy?1mGpEBga$;TCpoBJ!*0IWw)@CalXY zq|Z3}AJK;A=}!HJoZvMwJD=kKKmz;pioNUd!_xbag1`!XJ=bVwf+2KHo$e zR=_t%2x7%v&T%I6ZN^GIX$)=Y-8Ds`imy=xD@qKe%xH6GsQwSyvabIm@%Zz2iB@Sn z&fSz`r_b{{cI&*(Y4l+KRxiKCIYlpJI?=lAaL;XUu8I)j>;kV$K}Kz4WxIn;-^ni4 zK|c$T&E|WgF8jgks+!GD+JE&GkJY80^1V^WhAwzm)u1hF9v(s){sC{EMqd5I?H`6B z_CgERSuG(_G#b6q6MlXkN;006p65|K5Tm{2;UhK3Fr=3Ko_0_D2xTet_VceTSPh%8 zg*Wk7&wLhp`c0*!0O-paFKQgNi-+h&P zt;#!O4e_62Q0@|F!TuJdRa+iy)z?_FF;g|+C~oRK?y4JWt&iljVjv?oqs+Pwt(XEC zmKUqH9>4!4RBDBZh!@(1PSL##KisAYC<#_p7NkoB__8Cb8N+EBt2&6BI!heX2_4ok z9OpY$Evrkk-<4VRlPrUhL_yib-R=SGYRkaNE zlGVqJxPz)t)^prLX8b^X!^_aq>0oJ{3tiudkj8eh+tX|O$}S7_U}ttOPKD^x{dxZ} zM2c3Td!3K+D5qHuE`Fc==#gxP{>`piowynLSr8=Bcx0B|;YjFj1{|>%d1W=n8nosz zs81`tFVdn3wu-o`V!Pc$mYj!rr3kI=ojl*7#q@nAVR!Ulb?U+r#ITJ8J;o~&UPu9~ z-uiIXTWFkV@cBBZXE%KA^cOJ+4)U+Pyp!EL_E{Va*#xzr{#6rhM`V*R!Cv`@^^RfH zgVFY854F}s7_hIkFLV~;^RHodHOD(F%i8Tgvf5`8y!R1j)fa8p79MQ|r#ng0%=BAW zEB5Qz!DFXIGkCrf9G#6FB*KDEfZQ_jAWoKC(jLmv0$B+^SSNd)lerb-Y*P3k9k*W) zE2}1a*^QM?LK3av1`b12e;}{^qMqhTkXZ+yo^RlOt7+`sTf&Xm9kv-Avx9rn%2#69 zfj@!$wS`xl25}zBvS(@}EAPa5okrOJ4Q~HPeWB@i6Nw`X@iH_pcsG2UmFw9~Kl!t3`VElU%J>Iv1%fkxIt`=SLz zFJl{PULB&obI_GaE~f@8gs#41N1J&>aNGce+1odQd+Nk{iEp2t`$<6T_jhhx+^Yjn z#BQFg{9Td7#U|{{i96xkI7(XR>JdJQ;Vph+b#^VMk3njgIW*!Rb?#x++n~@5P^cZh zM&j+^`5QYp9ad6Vypi_EpaD?RICwye4QpiY)?XWmu!qMeeILG-vb5pF7HJBHmF6Z= zgzA7@$iz9^#ZXpZ<)xC#GqfUJjK~jp4S}|-+Gxf7IISlS>o-I405a|vkJ z?R^lmN~2KA7;$vXv7d*oP6tcthhQPauFCmP_j|0yzBM~Q@^dRWd2(PU7Qu#ZjGV9r za7u{7+h=$H8u|gs+=ryviY;v2tx?pkky}Th;QxsFK7k!v6RGtw)MHKANFFO1%sda^ z&U%H%I@u1$tuDw|D+6DKx0`dTHM!f|SkwRE(;CxVf&LiB8q5-k-`54oGG;miJ@{de zx>m%$hgRpsT7#+_LC7E0(iCS;=qThLSE~yBm2pOEmuK zt$WasW_jFEC4Q$q+#-Fx5+v8_oQ%~VWAIbvaVI<2-@msraT|KOL*Nv9jrO1if5Lw~ z3T^EV5=m`051N{ejWq+h(jzf*W%v3@US@ixz$5ndwTDVe!GTW@S2jX=iNB#f{DJ2H zH1G|7_e;1(Y0wWUH&SW`NFmmompjSB?#%L-F?a(@*`CW4$fj>O8|$=;nVRKMVy)$# z%pV@${V$s%d*qLoT9UMx^v3b?-qeCvSR5PE7ju7%)W}l z-);HbA;ekCukS#9oZwWha`yKG&oCX7B{E_45YOtvx~*E?$W0t3uI_|ods#2xEB=gs z_$~Ijm`);|EaSBnt+J8tYQ=nqEZBq&S;{?)V83GL<-u;e4mF$GvEtG?jrPz)wBGOb z3|3*Ch0z_>EQ<GH-B_0S=pUhi9Z-q+9b+rzLz{8(<=~h6SX;S*{az9t zs)HW4MsXC@f&SrYPR9J^1_4|H%236KPNWA;!aIV&Elb$^e2SrcpZ zLwD%vWu%q;VQs==48kc~t$7*~FD^w@>=Pj`b&k_9wrExFhrv>}PVNJ~9!*?oB9XRf z$WwdRwZxRY4UtYpv7dyVG9t6G!6yZ|g{th(c@~4&@nTjilFBjSBp0Er|2TgwEhmYT zB%mQmk~=YEuk6pg5MtM*@C`e_&&J@5Qmto&-?GN_LDPK(9f{gB z4t=KwKZ$jJj82>fJuQL0tXcj9x-$;ngIjBgR4;{hn3>pv{i}aNTh0?c#d#h=mnpZL zM==x5e>d#$1y))HNmT^;Dv4CChOg3^|Iq_;{^Tm?$IPL1rMr<>+qj=ioRriiLf~E~ z>jE-Me8If@?Mg_XR!F3-!5e&sudG?NroT1%AwJFErUuZU^R&wFDj#y`Pa-{1VIN+E zt~PKJ=60+fwFkdGYw87cO=actkv?<^y-%tU%vS;nJ;CsyHRKG_4SSND~$~qMy4ttmNf-4@yp1L3GkP9K=@H6)NX(ZU~TgFPGcGvQ6hRkXce8e`$tgdj*+gOs5&_&h( zpWx1u!7p-4N1`9&p&+Y~jl1o@+b~|Lhbw-_4F1>3t#Qb#nNXG8#bP&_ZTJGtnG3~z z0Cm1f45bFQ`xNh+8h*TmUoBGKX(CZp%o^cb$(_w&7540j5TG6@Nrlj}$YbkYs|L#I zguh{5vDg}B39b3t89w^eM!xzQD*GN9IKsO-0mX<`ZscV(?x+n`l(v>p1+zOM$tj;& z@INh}e(9?fYqcBEISW?ti*uL>U-@6?^e}&G4OBfDU&B0fzwqc0+TmpkemtMOskb@V zR#+bH!HNgxy*PKX1Qxv(Ok>W!E0i^nQ@8rvn8OvMRYGV>6xM8b($C;IRAUWRE{c9< zAIloJ+iA(>cz)rtw!3<8EfUMA12MNYhnuiV&mK^bm(?$#@MxKc)M`y>B78$@u$=-j zjM#vgs+XYmmYjVnv|=MH#9H|1*7Rpb-ljxq-4pu5Zcs_%Jmy=s#G%T7<@gj5pdvqC zFW6a85B5N6JI8Hg;2xvFN7&)RME^_R6B%6`jkPont4Z5D_DhR-G6M_C%Fzi(r4RY6 zfA}%aA|5TQ#e7yK4T zEoNhRc*tt_j>y>e(1kPM^kv+Lbtm8Ro8Jf8vOjDcGRq9lcWA>?=oYJWGDBGTJnzemyF zPa!ocp*fl&S9@b2j)aFrBi)Ea{sXqwacr!Ecn#~Mt)kl2yeJq*Rp)05AJp*MuhP`6H{Y7N)i4ZB> zhkV%qB{=28h}I}W-VN*G}#)hd;vGxZmOJCi( zxW!nu3&YFaLvLdN(#sy|S=`SE=ns^m7bKi!|gyl@O049lE|?;1}nKzJgvg24n19D@zMhT!e2qJ3H6q z7<=`4qM0W`B}U%N7p#NAjF%{LKY}l;95=UPWP2hz*UDN6U5P-ooSn_!C#*7e_++5#AWRUvMxP!Bf%(lOP&aaHjviheUJkb%UJrwCZ3o3MO zg`GPGu&Bg0vU7STU+>{N2aql&kTKQ_q(I^nM6XomOhxpyw%(UKvz}q z#VbOCVtR|2u8ma;+dUh%{Xnp?oW{Ks$`YT@NV}beRw~=gsx};ftQw8%8qWWDvf5%z zf<_UOS+wuxpsb2~r#a_81p1uKK0Nc2NUUp6meqcbV%0r~l~n-QVy$&UH1%un=ZBo; zr@{X_h@BO!tP{wsAFxsMm_$UG3cpX~=ce=WHs%KXI19T=oI^X^I&c%E*ppZl$JvcF zLDo5R;>6ACSP|L)TT5g$dsA!Pu}N|U-`4pZ*U^JlLY3Anq7KQ4X5}FATQvArA|7>t zq6TpW!#In{LA!iT3~(nm{XZxx6*g~0PTU;(c-Htia!7n)BkPOd0qgv%inqelUUhrc z$M8sBc8Xc8HI;u^Y2!RFz4y|{EUT66thgMag4T@uh}^RBWfzip4Uru!EpsQLBN%Bk zdu1hAGh}oJcwrE7VOEF$+0A;8-xo75_DkyzT4O4n$U*+X8Dv%>?kYdrUz^>uyF*n* zLanF~UC#a-V^)>W)x}w%(sB0+ew|cH0Qb7J_9QdOG_DX4FR~bHv4ci4NA&-0kW!V*C4QXmLMRrc!simc$ zZIRKf;qML~4Cg%M@Ksp5yP+m)V~+FKceoEr%h^Kmyjf{Ic3<yR5f_IEV9Tx`aHxPM5(xqq`HK6c!9W<@hp+3 zvJh{yYc*EAi&Z=rz8lUxPJpgvBQ4h8X&q*dH_`Oj*oMQsU(%NIQsxJlJcIo>`TA8Xg$8Jr!f@jwX!xs8@6X5?tKZG@X&EgK z@vMccWpc15ojLFlbQR-v_1UeoCC;@Lp`M}r#CzfQW8paheI0?C4nt`o0-g(YO9G^T z^C1huCv~ALaY>z>EbfUFSUuq3cG$@E_v>jb-(=a~otxpwfP8w84AoWY zvM!+suVE}d6fz`P@eYdznPnWSFO)hQ{-27~;G}8MHnwt4`o2k_0K38(Vu=iZTBbo; zM&OiL_7s};oWN?ucNq>v*`aB!aXam&SRzgFG#54 zyr4q)Yf}PetA3D|ODWp^$_1Ag1Un6%P^|2Q7C^77- zI)Kdj3%P9dcqR0QmL(7Gi1 zTh)+ainI?|-l|YmX=K%NA$}6`AWiuhqoga@g;nnl;lmomvWB#DpsE+~E!wfR9%049 zp$<{Gzl64SgdFZ^BJk%z&4UzXSF1BEBIcsgrS07tjdeAKI~vK|yvtr+VwYv0EGI^v zg#$N-{Cce1XaWaRhL%bqe~k1L2@#({Sm%YItK8_r4AjP^AorPoE}rX1ty@^|Qr45~ z%-l}%5XbHb_rDKqnSoo1CfvqN|4dx`KPsA>PF4c#(L7j=BcXa{Gk%38v@2v2>t6w7 zeZuNLfU2yf6Zh;L=*fJqQPZhNEBnIU=l*)44VwoGz7R4%M2A1nRNCBDyR3t%mLoAm zC3DuevtuVf_x-q+cHE1329fz2VRdNicuJG+39;+4yu{OJRyuEt-Z$)hO;(@aAarRGe3Xkx+i9*Ie^k(sL z4nPNDNX+5fhCvzqp>^%_nBDJ&q_XR|FOT1k2=T_WtlY4!zZ&w_z7}T$_lLJ9!%rJI ziQibY7GX{(OCRC|_@HHwS7HYkaS)?dJ8M09ZyyrtBoTO{4r;>f#37tUr+26gJQr;- z9qlom-x*4qT+CY?LrYzQ(;tCb?FnlQWxWHn zXlWUr5@XqFWj$H(Zl|z*qbJ&0qLzxV?!*hL=Bx-F1fTRrW_4mmb-B5MAxDysn8WY< zr5|~W6PPnx&IubO8Ux+DhclAc2>-s$J|4i&GP6@0X;h097zJ%dL`N)#KH;Ms7G`nkLs{jptDa_s+4!CG_=72l zkfh`+L^d&pehMlUBX1s(btrW87IwRp+d-U)8OZBh$lJfDTS|+spzZGDnFd5d+CmAU71}#B7EU#;Zg%4)@In zx$7&$FGc4wuTTwH+?LZH2-Q!AmX@OpzlArP$6-v@NkaCjYq3~SZnd0 zqK#}da5HYTD)F)Wymv}$;j36j2av=Y`P)mOl24ITVk%BY8d;$=JaGMMNP>voFOt8g zK>8iS@%U#!hxQwat$Ym4_d8aKcFH}RSe$H;jL2pZo@7u*26RlJU=NGICid4`tk-H} zJ9WBZXSIQoUkH(gGTdz;B;1pnetI-%V%|?r_H6Jz%@rtPMIx35oNUsI(^MzE%DLK6 zU}cgq9apTKXuXrudPHe4npg*&@e)5X0F7$r6~*{z6W) z;XPD3h&C5xR%a{@b%XU-h4C`15-$KnnK!TxIt#k+aqO&AXhU%Xtmse1^C;hqwHxKw zXCqFaC;G-t9j6Y)dd8D*ySMW&cOj-(Y3REpT+V*Y z-e@e*@sh85+iXkLM3_8 z?amR3@hqdIU07Q;_{NHvmyu*mv9qj}DUSb|7m58iPipwUnbr3rvHs?C&Y(SZvEF6y z!DQ%a0Gh8mXW5>YGSylwaVEP5`sxU^wMAOB;%S16vJ>yc@Re1lukpKs*zIgI##U^@ zi^!}roO==0@H`T$9j9h*t=OqUp{og4;${S`=W{a97Hlf5c99*dli3MnZAE6ShUd*1 zPC;gkf~Mqps~Wo?nVWM0mAP4`L*5^<(Hl9(>FmT9kG@77=-)|;MUYmxkyD~BX5vW? z{Ww3{D!DX#PQz!XUT5Ja><2B2t=}M6D}A{kGbG9@eJm?5e}*srf^$-1l@!K*sDq~O z0*wq|Uur{fZDVD|M%HgsYYyKZgM3mK+L0jkb;RL16IJ5u;TT|7$-i69yyX#>Ssv(&3Nn&HY3u6Gj0J@HHCtj@pU7fSoIWpJGhgp7v)H8I1joq?jX-t|6_hl zQ-5No--jf;A1S46<#f6S(2I$nEBBKF4Ofa4)J7Xv_iIec31=cq+P~V7eKzK)hOJeC zpUn~C4B`{s&*MZ%eFClbukkYG@b}F}4`)4ZK=V?QlMStUb2gS8=jPp7KvQ?CD_y^x zcX}Y^K3)&jh?8=^;06xj8{WV-e4G<1!R^;Yf_7k);+Z%BVFc$p9lBYHJz^Hb8st6U z*~Lez+?89~c;xwrs-Y$vfxJ_Xyum(S;T_F16o<0XLB;a+_rZQQ5y>~_`)rYcb@KLNNH9{-CXLpsj2KJ%b zVdc~d^(&qJR;H7VwXCC{vLXtDsu* zAa>)_|_pem)*?m$sL6L&ZPg&jgCZs*y+&De=A*77VU^26Yp$9`PvuB~Irh3s%<<`J}p zox>y9iHK!ZS!Tm>vZKXrTVE;oXa~i^&{m|cBv9Hzc!lc2lu)2mjLt-=&raLH|9#<@ z>2RO@Q_8Dfi2`c9Cxy>*aIVG`8^AjqId@SVK4!J+k-^^uTX8QOV#n=P_G*vYyx@Zj zfky`L*iH2or(ze3l}}BAF1%ailnT%AB4@dqGhL5PTg3TJ;-<`q^yRMXJB}5=+QRL) zi#Fjlta|MmB-$9HtyR@ph&y;6Gda!{y&0@5DJ%uHmC=R_$gJ#~WPa$nEV{5BdRg3e zakTUe^){5Qc1i2MT8mW?DlEcpXvJql_K7n7H}C%|dUro}@f9o9<`s*nE9+|)=%yk5 zy0aq9a@I#$HDlEx8`#^U6ca7I8E4;$@3x0$oFQ#3&~&0Ln>qjAp{$hr#e#Tl&vRZK z;c4Z+5oeJVjinpQS`lh5cS2Xr)j4ygGweLwVaDS{lC&z*S_knw#aH(pL_KMYMAn|P zJ}wz@_yWFrqJwjxwW^>uq^mdK8fEcfB$Sl3jXhbd zY`wnGhn2yOclP&WWma#I{tbv|}o%bz>YFVxS9CF*W+I9O6@9j)7S8UFF zHFCx%sW=FFhW36Msj7)Ksli7(qnz7dt$^8GE&7PbqJC_J*3pCP4_!@wu9n~rh$?jg zzD~%x^PsJsXB|$|FaxC~oJRgbJjJzW!EZQyd-D!KNmjsLf~I1Rg&1vnk!D-5S5|Q= z+VaL5dcikhR=kL$5CK7~sWed56=c?StX%t}&Fs{KItruV#QU=EHVw38HC7U88xn>T zV~zI&efW5wt4!=AClQAt?5HLyXoip4A!x$bsrn8#r|wpJJ4L8XuyCs&-HKsn<%PD= zLaWY0zlIg}KPdPBa`sE8VLE@`=(}~}(J!nUv|*Xxu~)$AFz?nd(!6NA)8T9*({>kp13&NJ7WQ-c_CBAW z`pFX>u*HLK8c)X}b&i3?=c zro$>v4D}cXwVyiySwd$_N>9GDypp32#Xd>SPdg!{02X}JU{TsJ(wFxWIWJCxIE*%o zIMI*uxAlDMat|GYZ(;}K45()f{9uQL^=1e7O37t+bj4D6~7RE z--Ist0e$G?;2WWSG&MZ=IDhF0s46FSm7g6IWlvR+M{!n#orF$x5#2#KJDBzAZG1bMSI+jdO4+K6&jV#S<-xi?ZNVn!Ip>Cnt6vS8sukj){>!MS6K}=q zYz#eFZ`p-?T483CT2#z0(MkJ}iGQHW?Jq1EZ1`4q7iM&{vSNRM(RVT5wMY&lpH4$n zze81a9I6e^p&P|o-UsDv<*vTuM(l?k#ZC0#Ce49}7MqiOiL7DwfmnFXqv;P_#ZIZG ziHv4I+a-r`5}_ zIwJXA!!Jw$pIgOYE~F_k*=f0Bky#5^=|)y8w!^mIJ zSK=s(N!lLDs?FUNMq;Js2J8c|=hiwM5&7*$hzPjOGSq_a2))GmMDsRc?pK4#>_mS7 zYSQX@kdyoOVHLIbW8uv$Jl#l$HhwM&>n^N*sWHi75(M0pS^{L(DHVAevNM`UYUK7 z%~^RV;s;MdDOrQ$N{LQP0#!K|*RDcQe8r?l$oKq8OWxeh6Hu4ENM;zEB`@--XhB-> z-p&~)>t0SX2fCv?7KwCaz1d*wC-c$Ep&)Co#n<^cRA8G`5b1sclGkcR>i~^sMGa&9 zleVK1NSs|$mN;o1C`<3~3ij|(&eOTl^RdMzA&1Q3T9IPCtd@xAOC8W0KJEG1sh<%~ z$QqR){DyTJ+9{i{f6RDVkM%EFF%cRuJ>0K_l@}T*#9fsMo}rWO#aE8=$i;@XUQv8M z?f5Qe|Hi?O)!TR`*t)5);IE@?t)LKj!b;^u@VRl_Vffb~D?4?-dGB^e8f6d#-T7H| zvS`!$+3IBbTAFk6QdU)yky=ZkSSjlkb=B6;Jj+_^L*w>=Nc~P?bEb}QYO5S} zg#EPJc*g!kE0kN|t z1b?J0yda8wb|g|7>cONuPWDzSM#QGX{9GC&Tn=cf1Qm@=)^k>1Po&mF z?sYZN!%7rwNaKnHu_BGti3R^QQBrF+^h?~Ac;j|->*0#MY9G%AxJfJr&(Rf%^khpChhe-b z#^Lo5YeP{rLq&>JDONhw;9vTTt=N}HWdk|;k-W!DPCzW>gGk1|@XXV}3vz)N6g`kP zPB(LsgL9&-7WW3WL0fyFA?FE*ck~-ioN93rOY1(uS`Ce-~wE zMjig-2Dah{%|c^1qsp%P+N`z&H4C{zoK%^mjMCb=Cs-WYf_40HCE$X zn<23}p$(0QybD!nWkm!qEqLkbRrcHzOSdvoyAXGrfp>}cCRXg*$-NbyVXBt_>V7-1VWK^B-9W|o0ns)JtxEQA&Sr613>Ke};Rs|3D9F+AS)SMHG zumWpvs^M#_a~S7o#?by?eJCvwBky9u|Ad@6jnoqP{S;Q#FIZrQgZ*ny=oe_3sYr%F z=>KSERfA^=B0H?jy2cG0f~LQOPwbxTi9D@?%qoD?vidk9j~178GvfC?f}B!XJrL|K zHDn?rm{C*n)#4OefhzadY1|**c@lTKj#IPt>;e9!xoBtAv?OL=MNH%ydq7r0McaZj zHsWjl{mx*&uSHYZ$zu*tJgNaumHm8H)x;RQnNX|h>%H=L@wHy zSP8wLA}a@Ctf=*!*pT@VtLol?u8h@d0Xuiu=z}TTq&kmPISvt=ryzJ z#B1ez2O}ZT z#YT7S-RXh07C+L6`wKz;iHs0yGezEzrp)}bL$hl)z7h5!ZP{ZHbJC(mJ{jyRd)Ime zJ8LY~h~0N~PdO7)l+t~i{&6VlGba z>%iLUMCkZ)NXo7J?YZ!%Q!Bc|yNy_}^Z$&Pri5xzKszaUk`u)e2h&WnnVyV%P8Gfv zh$XP$8z8f~L06->o2BHf%r9I->OY7!v};7JX~a2qhf0RBvuUhSsb$VEB0?Kk z5gT(Y+T56yy*yT!nxB3LdK5`k1W-HLwO(p*V`aHxW7`?<>iTAF=dIXCl zbFi|ULuZvkP2|9f=tC>vttlS}4>+UM8nT#?x{DmCzoDI*p7${Z@60KyzJ6iFdqafR zo?G*Wlh7nq{+THdqf;c|*!R*Pc!hd~4SCrYW*v=sQ3`8`JhpD$T5;z%twZkWL&Ygn z_8-({ov*+FRw#=QxdeN|dACjpv7_EO(P~6dxsO6$&dEF$sOR&!_Fq5=T~vV$FWiDo{F`XCD9*s@Fv=D3+lshtkU|d zb-}jMH@xc?ibO7wvZz7hc%nt@eOU+WJd&5$m71tdh;HX$S1GysYf$pf$N;AcEQDvq zus1s?jWvj!>Rs7UDKb@i=u3$uj+|Iu_UZM#W2ekv{cCxbeca4xEJd+Blk*p>+q1es zTPq)YYKK>S&h{l}y*n!tNzxjA`#xhfO1iT0Ro$(8HAdoefh16tXx+ab zS%gNMjz%1f9sf2f(uOyiBhG$3EZG;r*DvxfD`?b}Eug4qi&>x5p1p`(Gai|-99z*I zi+jl*&v{1KS+C``^WjZ6ObFCxNL?u4kJ^HX<7T8kiU^W zzciEcMxHDdeq=f*@GAAw8|*ItCSzWh%cex?$(NPc)soA4pldIQ_- z5^;@FL}!jb19pNeW7X5(OZDL( zh3+dOl%!DY=FeP-H7U-55DUH=G!>Db{bP8#Trbh93F6rAP^q$T*y z;%%HHV{sk(-MqATd707cW_F4qu_~aC&BwKd(yekZ8*M$cILl_GonNj7HwM=z_#4(J zra>Rx17)cTeZlehax0Xb z8>C(J7%#m+t$CyL)&f4kM{5H`pvnbZ*(F_xJ-23OVg=2CW4?tO&xS~88qTu-_HJdQ zj5R{m@!5?q9=fsfTP!)dcl3SDB5q)pR$kAAs>btk(v_JVaX3VwG6rwt!8l!oV83ML z&W*!cqbUl!F(Pls+@X`*db2L;X6&?84=S^yvq){?Ku3&1d&9(z{S^7JAw+~kk5#89 zz>Z7JU&+b|=7dl3ahlH&^QeF%t`}~sZLs5walehW7>Ebv9F#7s)XZV@H41aXP6<*U zCV&sFpuc~Cy7uyX7krPE#I}^Tv*3&ofxgU7sl~0B5tGIEs4*Bm{Qj0m3GJJjA-_`s-^BWId#s!# z^dtM)2xTll_E@1A<(AV$dW4ggnzXvCdXTnWM}rK2{@zD+k3<(ugCgx`(~94RG&u)t z-M}gmFE1UBu~O-(D1WycyE1#&m=)~Y6geVBOwCG(ux}(zT58S9Se%;BDhGSp#93Dt_GKSK*st+k zQA#&rOP^r%B6Zt!Tl!8!(OMt#=3}u(?BleaM(lXrt}(f@m=LqZeCV&Jp(0{3i$^sX?wyi_U$d;jK0HyKa0oizS6^~`8e6~oUzp$ z+9XDPj07)0BAYc3QOEgI*8Vvi+es)9KV)o(6U92^u1Kv8JX*!IgKuaZa5gm6Bgm{9 z+|qG4b#t&;X0Z~{T!vr`n?KZQaWay9FxG0hXKi?UVqQbC8HN9lTbarFmm&4GVp|{O z9WOxvRxLYu#d=a};PV8TRhAVvpVbch_Ccarn`oznzJ?V6+J$CrMGsML*lm~zE-)*7 z6I;QGieIqf#bk>0`%AeKt$6Dm>}pk?ypB#3SN&yZN$F*7{Z(Efo{8@*s^7rmru@S&WjCEC8TOYM4AbBLK?^~naLl=bi8+&V#1tWx`T z?5vu??+jsg)|1BSGdU(dcC_8Z3DNTBxq&@U^-?sKlh>_~)zUJ)D|U{(7SEvNp5{@8 z=`$AKv$K%$@Hr2k3-TSYDx67P2VQcv_S^8xB&7Kly!Sr-#zpQV30#^Ty4Hp_65Jd< z?#2!1qmO~MoV{UXqj`6w)(XB7BSf4ED@%=K8}k)My)%-bJ$Ka{3T?=}iko1Cx%Hm* zg$B`$ z?T>7*2i)EYYiRP|Gux4LJ7g9_#IqME_U86uz3uQklv-7wl~SRnwJ0BppzTCxdWM%1 zSe@~!tjf!G3nAaiVPT2fWL2pd>CwF7XV|~n;l#6{c1}*uh(;{T>3Rz-kU3qsfr#7` z@ssDH`_zA;LyO8}Rp}>abP*|>Xd#A>{+E^4MzdORMh$}vUkaMd!)-r;e)$W#;zy#- z>xp`ZndST~?G+JTL_8Y^ef8xAw7isIVhNdR>x-;aACACwo*XD^8F&00H}ex*Z;!}T z;zIWjK}m+xH-DIyrx;Q|@6cF^^UCduQ6E~JsJs%LyCW-Y24z)+28%#f+4=2MypI+2 zPB**E&D+a*5}9>?ziHi~lb7{s#Si%)tTxV+HlLs-?}`-AW|YqC7Bhz+{!hQ~^Jb*& z6^WT!=TF^-g_s%sD*+$gttQjou;^l(nW>yy}rJxR-g{$VB)& z(p499yna+ww4pwf(dyLry#L{uh;1#>Vw?sl&RjQWOPm(*Z!17uWuU4u&``->)!XAO zT9X~rO0&Yeo`JeV6|lO)N+I_*7CB)~cRPG|h8uC-LrP9m{ZX2|)rTUSa%67Q+1+-K z=>6KAw19mrggdO&FsEbRq&}`$ox8mY9r@i>VPAE*({jA0S!wr`n0@^LB^-pdHn21M z4JTm5JIT%J(m~Lb(e1bR+?$Vf9$Q}%@x`@#oy#?Y-&)LXi`nZ02qPzIL+3vvWhZHQ zo?t)u`MbrTm&(ZDI?(w`Xa{R%dg71UduxnDL`mzaYv4)Rgx^~&O#+{=4uH5<5fe&V^7|w0`3}B=Onojv2<@wr@}x6)9X zXb<*JJ3ZR$Xg#d|w(!xLXq`#upq1>)dDx;kIHM*tT%H}N`5Y%)3l46M-0B2vIhj>V zVq zIhzUjT8y)=&noq-dqDfn0%=mBSa<%>gv=TF%k<$(Wv^w0*6jn1DiRJy=MLT;N znzAaOKa@6r&+qaQ^UQf%GqCyR^PA3QwT|WoWQjfa*RWO0@Yo-nhBLGyH7B~Q5Zqc4 zSzy(&5tUY~uq`^V6Le)?*emdWI9lckL^jFG4L{BeB;mbo@}5_N%({rab!MJua^K^x z$Eq5UC`6I9UgJG(#~u+8^!z9`uC)#3q(laEKW070a+5RR?4@`ho6s6Zc;COFtW=!8 z5k(Q%USOSegoqHJmtha5b3er$9~v}cw7TpvR+EUs=5*7kNC0yS*80D~(+ufnjlcMX z1>l(U@ZEz%QvQIl4nbL8p}9WcOh<6iy^%WY(1%t5NikK?arSpxcUGIPt=p)^W1sK*%rB zm=eo<7!@)`s`oA?l{eLdUDf9%Ye1#qQsswg?LC%bobe^vU&N4{1qY9Z^PN8_8ukd} zl~b9;`?N;hSgI4^ttb@@bQXHV)$6f4V`o2Sbc*K^_hUuBofb*?OR3?NjL5Ag`PvLy@K2#X#h_7d-;XxD&U=b-@;egCS&C-BtvXy!%;pQC zLG~Xxw_N{LT(+Umk26x_5jjGNbFPJaBUb!a?r}PlwK!xAjVGQ!;#$X)ob!JYsb!4B zzPy%jr@XCQX#b%#{8sS~LfeTBJ1X$@3~oa2T=Zn?dWU0|#ES9u?6E%kt;o&`vn%ln z)8LgTg{|PQo7md^d8}y!{A=dc`b4|At%t44Q;W~i&I{0+m5gSutZHb$BSN6vNiDG` zJHtN%*qwO=J1mR^i8OVKI)OC&)hE$d&!Q75a?*7JeROA6cDcmr0I?jL^()GW)0~V+ z*lVaJ>;_#KX^4?5vpRMn)#kmTj@RRT6n?QcOw1ssqO6BLMEbLuW+Id`0t#{--ryj? zyoI6M*>I@N3GtJI4!3t_A$Ph4e%sD_9Oj8wY{ubl63c#&(@PbiDDp~ZA z*qMH>v@Zs{h#{@uYonz0f_MWt_^s4ot;(z$A)X~KoDANfk$9*3tmRggBKhr^v=etU zYaIp!4M8?JZDt^^K|E@4X-nKTF&4G3#FttUIAdqv3cHWQk`&va5Oh$L(~CCYt3k5b z9~tMV_Yah1UD=0l`e<%p0*^H1e62`VgSij8?!Aj9NMdU^#6!sjXQ$w7{)4i9#e%SU zV;OWXjk6zu#bIBIo!z2-HHNCJ=8t_zezbluRs~t*=)W2f0W+&$^tU%s+1a#HxJ^;B zcA{(b)+7FEChRPui=wAjXJ^(t82@q3k^OH&*^RRwtip=ux7K9pg~UEEJ42mt+Jc*F z!d*4Q!m5RwD32y8f{w|7{gVPIa5YfYKJLfNK<+VBKrcja@O|5y+ z;G1HLI7wX`_@dmb_DEWOI~l&=eOS9a*Y(0V+70YHVynx# zLS5EsSQ$HzlebI6{O3q0YE-zHVSLBh%ZP=d-mregDhK_-ILZ6opbN#;*>%T*aw@BM zc*b|kR9VYt9-%Rm6LablR40*1Vi3&^|=y#7yafE)|n)Dz_sxf{2Xv8;WL_ z03LrVNMq5n$|8@=F=$5`^|YT}bT7MI#8V!{4y<{%zuUelJ*-!;UfLiBns6dUTI(Vm zB3%{dzT}up{0-}{tvox-{fHRy1vfJryGePZ4JGf7-M+)?Ja)EfYdwtId5qi21fAG-SP0q@SHQY>ZAj%-TPUmHmZhAZK z#M5vlt~im_##tY0RgiUzMibld+tO7nBylPBF8YDkO;#&ggY9H^J;UX!<|Ec84!>Qt zW}?g>Gz-*a{=f`^IZ;s;eZ9o11GMEwGbGlV84t3nY6`dc8SlCkx)s;gDWF!%8qKnL zT<)&Ksv2_o&cGEN%#N4Afv$$J53>$-y%=xQe^**Lsir0T?>*UPs25)~#OO+}Gqced z_$&A08D4<0oba^?jV2=73~pu|lojnPxm$aFJTI~PrK<6)c}k$HkNNop+^9B|(^ti2 z`vt19Ldzcfd!U5}ku~<=r$VC{d&ml16@YJJcDfd;YZxSz@=EFIYHM->WsnzUK+M`2 zQA~{VGh=WKn!OlgmMEw{1Pk#S&d>Q#%1;rRwBsjnwiCD|`C?31xfl>mmA492X*HFf zoQ;HAh^$-1{#PoR?ts&6&8` zDt3}tZDMttXccA)pXUy&3X-y(;0BWNSHy!dhH{u25FJwsUbJZaZ?m*k#5IGiV$RcQ zCUXbkYdB-h8mD%AZAHI*xl&f|5c?g)35ZR)3Z8e?`Y(4lCMl=+IC?-#AiJolg!9)H z5qIu2Y(g=2h6LO31LW3V=u2v|x6o;ABFb4wE@F@o6z@v(PrWUx>9mTir+a`Znm?h2 zAFz=&!nZNvDBitje{!|7G%I{8y zBfn2`uUhZ=B%(}dzn_FZcEBBoWElRv^8H_9}s&!BVuN6enw1k zb*GsTV+{kLO!MpZgKa{?AK`|@p%H`Fm_t@pP=J+`hI?!AXpcx)R$}%*&WcK`2DG|Q zbS7hX_7#eN*^ZwUHL^j_H`Ulv72d@vWwAfiFd49XjM4oKzx;wO+zxHa^E0`X3EYD| zlays|jP&GmB=Ll1gqxYcXDRDbcnyvlMD4h!3hmd2o{Vh0$jylWCd9>=v=>dmJmC#J{Gq<{8kFcGxF;HII)Ak+|Y_uH*c-^Ipzz5);q)zS6&V zlGa`tb!Zm2RUg(UiLo3r8&q211eA#7nh&}X-#%jd8dYq{PCBwyvmjO?tI0*4iP)FwbmvWKWoi+d zQ?Cw1D)A~H9i-4AyyH_mIoMlDI3{|$$FWtMhq5NftU27mN6?b?l)jT+O%9LW_g+K= zoXxX<+gXa7(9YU~%-YZU{DLGo54~Q+E4&Hsi*Tea)Pj#Ot!(^tYvP|_J)%}rhqlD( zb|#7u(%P)K61#p5%F2b-FtcMtfM@}(_b;@qxA7}}LbSA;81)UFm7U&tSH?`IK|A)@ z${Q2egYs(@-&Om~pzv@>}s;A22j1#``&?ehSr9; zSUFvr%U)`c$IYsnT{r4wUacb>V;yglSk^!0fMy@#M6AD70=pCE|LL2HO<8mD7B3^eBAwYGr;gEjR1@ytR>ZuPUy^X=+E}^a7}0&p zK^x{mV)gh-?9RE8ceT46(B_`)R970QW6M5fwZzbMAboC-|Ofl#+KT;zr7EjWk zUoHimvIm*ttt{Z=wNlg`>OG?mPI5CRr~Z)BwM*vS;r1`EvCP|T=3eZm`hlp)3GUsV zJ^Q4!7{#tl1Gl7uvcwKC@?e*kd}D>F5mR$cQdiwzi->Skl3O+6AWo@L&kUrQ9Z{6d z^Zwq)NqGHG(1ykxL~2_O*DrwXjmfDE#bMIdaP`x|cg3Dt6tuYLJ8Ph+4g9Wi8MP6e zcI~1^JLATEUhkWdi^D1*iL(K!n(7tyQ5h;^4Npa0B)(k zo(gmNMtkJyhdBKkaEbAgh%>c0=nSiY#6YlHL_8@`Jn!~;in!=^x&_L5lb;c}CFT+cN(ZVSMbe?bM{t&>MbdcBVFn9nr+tKRVVi4b05BA z{fV(r_pZfa^`@B|BSXq7CqjslV7|~v5TaF_hZ24aH8g93EY<><7_8orcjT58ehm3&SGF@)cCZ4wE#!^4$kmyF znnc()R%j)L^>3c{9C&#Fl%;*;gtK-0-go@|f#B0#!s-;)AsK%)8-G7)Lut#7;uksD zNLN}~MoIL)^}?O=slKqz(ipYz#GZk&VlA&oEv5dCS5m_%;w?r^c#1u!Gn|{SkTse^ zP#c={v|dRh!!Eq+{lk$i8A zax;7{Vxd0B$)K&o>ApoI+qv;ZvyvkvAA_<)J2I}WUM|N|9hp#z-C0Rp9~!HL1hY5& z8P;o+Lpu0OyzYBg?e$<~UBVVR%VW>)KGtre)O*>)I=*C8QjIp0(pT9fooQLkX5|Z5 z`x4gtIaDZ;^%{QbYu4_Zpkrtavr;#y6;A<0=OXGZVTUM7;W4BIDu-Z~j#!6CaHhx2+A4S&3SZ}m*r=iO}gjxom z=|se{hEX}C#omkW^@Fko@>usjBz$}yN^}~G-n)ALW4K2PWedAH6yhjX*p-;+YK%O0 zv_)m;N!@5a(=}k%~Iq?wpSNj8h==aGkhiR^1vW?vM^|Bu;@xTyiqK-Ur8tsh`G2cnDD~<9A)T_?MqSfydoQm*U1AtIp*Ti;teJV9 zJ;^Brp{yr311CH@%-Q^h#;`I@G@$LQ;!AuXx!OpH`KNy9Lu)RbqiKJh)TO=N8|r!! z%Id>US*an7nT2zLl~P(6VBLw+D$G^=%BlSuG`u;G+^pj{_GKkc9iE2ls|EYeB9t$z z=aZ&lEvB>L#r5mVu0_4p$F0o``P=2WCFj<7Q%~`};%_+hJ~2FVog1<*)M;_+q47nW zy0@XtJ2vP1DZ?;$!ZM^f0YpC9J-NzOGS-d92wPA=>TBg4eM;NUim7cI@r`nR~tjUEPQ7 zb`ono)@aPZd6{BCOIv!}=Fsgt(pOY^#R!BvZ#GRd*jOE|N2nCGYNi~wX$`4%W9lHY zwB9enh4RP2pv~8@swGgC)#BD%T3czaL070L+U;-f9pj)zLi+`}8XA1Tp-`4oWzB{e z1$QVmsZpSloYggKtOwxmba1M=umn$K^s|)JDD0y>tCxO^I%p}{zi!Q(sMp=ulRfM1 zT0cwNt;*bzea1yOqo<**Jjg8LLn577OZq1|#dwfD`)cT9A*U~=YqL*;qO6B=2BnoL z$}hQJJ>%W!5w1p)OIKUjgY|w^l8V}WgK7J^VdJz^Y4WSXy8i#h4#NF$|^ z)|6}glvRJiSMsq|?J`~~kPlzMOHTH+x>{*_l=pQ8<86F%E3?d-8}U(JSLX(_0=2Z% zg-$t?)0=VHTJvp!PSj77wnQqk5-3Kothct}`dRj4J-@j^{ShY~orgP)28p$a^?c5X zolj+Eb|{jlFFs$Km0&%reZ|HXoC)t_xxrADxi%{_&6DVDP34y6@TlqITzG3R%_-c= z8K#C`?D-Ta)+wN|hsvCx63ZT*7J+(uAva_kOI^5)yLFD? zPdvYI=a-T4vBp8$JvnsjbfolwuJm}VbZ`cjm`C25R;2Md=VR7@lPdE+=A!d+yT%;i zi~)wO1&JjNh1g6dxs${E)xAjE9jw6&!k65RxVYv%)!Co$+1G5oHbycZnV_Uq4_fmh zCbV(518CJ#to#Ch;Rg1I6F#jcH3F|xmA8$xDW%Lo=|{EY(Sp}IY|G8G=VgT2YBPVv ze1f)FO&+yI5hzRFDGeu*5cz9d?hO0*F=+F3(D@>`)hwnRht@0Iod#<6ogJUC#@gyj z;|pfkr7b&LMnhFbfoE|;>YX?(-dT-5z%S?ENzrxmce5j5o-jA^9EX2zIyT!m)oeYl>&`}4A#Rcj}&$iM84n?#(Pq?7@@Ow&v?+@V80t@To&vQ zGtJ5=ch3@B$k|6<{5SAIr z%E`{2LSos|S_FCO)ZdEGQ}w_HHTYPadvqST|8J~1E5GSo$P3P5{EK(Lz+W-?ew@E) zkHs#i!|pI+rAkpVBu?Yd-ZIAEOOMeU+5%Rs4%h2&g8Ei|H_9ySkxQ)H*&BN2Sy^pC zBu(jX3w9bgpE%N1D`?7?gV>PTSt4&}tN1T1Fr`{UZcPneE$~mAp_iTAq(ovl;l2KmZSFl2T*jT@0CAes@;)*M|lvmpCZy>SkntG4OgwsKdIx4NSj^!TnROUd{ zBZq?)xr7yQ>voD@k^Fz_y*m4;7bLFMxzj|9EcAl1jG0*BYn`KV%vi8eSQ#v4q^M>U zxFIbT^Pf)IN`u7GQ!}<t%|{R-ou)%d8ssTRJ{ z=cvGUwY2QD&B|}4<~<$?zTrQ-w>{FoLRCL;Zq6ol66{uFkNRW{YjPfy-i1D_7P~vr zGt|n8(NQ^QB|jZ)_gzR05v@=2&c>jwoH zdC-ct6Un|Q@p`r2tsmDPR2S;QYfZYl&)Au>o37B z3X5cBpR|-ER*M$9@vHc9o~cOVYWLRgia7S-zS}RUUa=BI>_TAK>(tCs-1$Y$?5|*FnL|#= zlOCFmIHl5+`9dp$M4hv)v^=&|!St`Rc=Fe~&d4fKS>z1c3e1W1WT- zmR6P+qqaxH%(`8xM&HwOyUw?|H%xf2FZxH$?i*-azd|e^Pkt;EW9Px3V8h!xX>?SY ziZ$0}bKZsS{3&ZVj10}(JSxTE4P?nX`>hgBb zloJ8%0ymQrV-8wdQkb-*9TqERw6ki3J1ZF^R(2%u_ z&4X&}uP_EL_TO-%RYb_LA5Zi+BlOnI$Q|a|z6tu=iT73jIokubeOdYcXQ#g71ao-DZ2kk7okF?)KT8!Ny<_X_{^6W&=%9_SLt%?IkVz8lxroM5jy8$r$KLtZ(V|OHa6WfNL*3yw6M&mYqj@+wwwzX^`SKP8Zu1} zMNimxwspxdqhR+-c5XNw@1Fuacn?*iSArL(E;M7fg`0_}F0t-!3VbsLNfkArb-ikI zF&EAA#G0&S$ZtKG?fia>k~-hextUk6v+UZjDoajD2d{_*pF79^vzH>4D7DP)D5KOF zz9MZIb+Fo`2)AK{q7|iSuudLCi;JLT)t|FowSIR&8(Ud}(ONw{Z7r+C^jtsVuAD9_ z;)7B7_;2G9>cUu^u{+4DEoOw6K(3 zX8&8DSsDjjVeZd<{5tfy=G3H<< zT#HqyWgJ0SYAoK0{w0C7l(5RL&!MfAeDr7ZJhrlPF({l#Y>dpjj?pY@ype8!DZnomCIYs?I&x zC8CDU2*ql>n}z;2Xe_ZDj&crl)H^g{SAz5RQC?}kTT5D&?^>yB zWk$qwFuEJFxHozKEBu{PaD}xe=60-;)^1YA>5Dj5R!naZYLr<{eP6)KXoT@yqi~y9 z_fB+%J#29?cZ+HGVE)m}~~71u`o z+C`ymFU=nGMe@NPF-C1QztODo$Se_8x3O+>2d>n3p)$x`1A9T@boDopQ`P{QITZcJ zD7>9{=5&l?nY;QFj#Gc^_3y)ITPTGwQyfSn2A=)}CCGY;?LZz;3%w5<2D zdc#xizauXR(e*SB;3hA(CvA2?k}v~{DGFD zXh++jEp1`-y3q&oJW2vjJlb7GNX1BMyL44E&{cVUvo!m74jm*0SPr<}^5 zdyv)Z*BZl#vl^}Z@$Bt6lCs|974Z)A5sgAvlcfc(6&&jtqK)Mw@SW_-y58fQ&sn%! zd_41PDY%79a7rFdyad!=jlGEI*%oPZR~uRdA5npvYpms^^=@|E`Wb7pobla=->u1w zm4oNhD4DrkD>Kb1NLN;5+6Ag-C|y~pto|?xZyqOF@Y0po4tDO@tDrVCbEw2JKceTp zg*z1?%_z(m#k{hHxy6K`JTo~jq9qp%?y|&kh-jRFmI^M-k9@__LZ1SPAPsKiD2B`Xhj)z zp;ph&`^0?0qsVc!p_nIPEgoden>j~)Lo;il7fyhSMfM`8xFBQjT8N_{9*iMQq} zTJXk8m1jmv%`P}!$4V!C6LUK;Mknr!{+j-k)xN*MG0xo5-m|C5OuJEQE1UEUof|0U zXlW_Cl)U4hEInLhmU?|1kJ8-T9N_(83|{~6O3;U*I+#z^+t$~zf6}VT;@p?nfAh53 zR8o}HLe`>{<+HYz(oD*Fn%}ZBFBR)g7;eRRVRw=L4s$pAp)I2iG5!#xmO9bsmOhs@ zmo~j}Y!wpCx*2tbH(-7886GFcibG*V*@Nhg6v#-|94pD3b!a@-`%ve3C+4Qr4n`S7 z=rn?8#>5P^dOg*JJIX z5O;uP?9Gk7pq-J?OY8-$X~WwqW(Arut5x7Ev%4|38>@oEow19?SeDU7Da&Z%9{5)a z#VD|-4t5Zl+fhatbC9OYO&Q^u29H>yWu1dJWqm}ng?A%|_0@hsniz$*Bh2|I(H=He z;=Xbtw_>c+o-j4O@du?=NvO$J8TKuws|||<>1KCgW_~|)pe!}XRVYh2b26Nty*v7* zQ77&~dVL%AV}#CnEvd_1$mOh6%WxyBHr}w4-!j*EocB4$JzM$nH*|F$)~7iz^ z0<;UQ*RK>TsJfi3RS$P#?M4pGco-RV+NZG-vj}DQ9sOJTCG+u!O(80&JuPaD-}sy2 zEPDFR5K_XLjg@b;;YV}scOwtdQpA|DKQ-b83<`H*e%A?xW^qLYv9HaZ)x+${+DK7b zqJ|fl=`x+RLo@K@1hYqpWV2ymcR?}koK^>ZOW_| zNikz(PsLxHq4H7?VgIVzqC2q_fiY9rR zdr{vSfwBY5s#5JG(F48@ToQ9VMnk0~eR2I*KkNUhFRiVXvW{>9W;&zZ&^C`6D86{`Wn z@jc4QV+39scOfUM|72&U8ITWxMif;?j0a_w69h5 zWK~mkq?U8&q%13}tY5N`_`Rcxmm@b z1!)O9zkAd zz1vf%%(5!mX#x%T&Hu;Ly+3_km-ij#-j#!ZY~;8CvMhK)qK$FP7+Z}IXN)zip|K{h zrcIll+RQYa>EDr=&UE@qYeOfA+NfX&D2j+dPNIvuC?^j{I4we8cY8fPPu{uV zJKyErd+)>N^LbzI>v~vdgk=LzVlpmTy#mgd(K0X(Tm0`?3&>Gfk-_BV|Qeb0*Z zd@|o9?qf;KV$x$#G{i{CDb=LEK3>b2JG6LI>W|FRdwMJjlY-Hr&7vpekJ%L+jSjV} zN=9gU#Z~k6k?hb(PUg_a*7dr)a3YD$u%B-}i$d$(xdZhP8DiCGGjTmzv5qgjZeG}2 zmW3OoCHw093-6{PMGHVZqodpT4%AtD=8Bybbk6)%6GPMuUO#pPXQ>5J2$+Nb$)Zes#01H_Ww~CnO_u{vw#}+6pLNk|w0ExGOn3@%2=(r<42nZN zJZhls2K%VW{+hkt8;yF4fO)b z481Jxn`iA*4xA|@Z>}dBQ!6xGDg~)*il&kBS|@JG8Oox&#;&kg=6UM47dO<{us*Dw zW&PeDay=8e4529#xG`9&IZk2DTT$4kUSU_v29f=$hRVHE*)N}$>1g_BoC9HI9-XWm zuR}pi8fK!l{EN9(yo27Ps?xlcdo+WC!iJt)9!U#z-FN{jc+=(s_$>D7u8t1U(pgSP z6COS=SHWw^Bh+<}@QjCj{v#kb~qt{ab~hHk#m z|Jp1owKyFEE0UX=c4Fp9dUx|X|J&?wbIbHnsKeKphx@InK;8T3JSl3X`0C5&S+aL} z(s$0zaQeCvYX0LE3+p(7-YEe2D_Nt?3;s$~3$oO`N9n{T=v(F2W$ki9nF;m=&bnvT z;f~F&?3-)1XedAVv#}z2Wp}_^{mB;jb+^i0oD=_%iS6nr*tu4U9_Y|KFV)?jZkF}v zWQVf&Gv=8>ElzfN>8up(g?Ax4#c$5K$--Gaugt1M{%_6|-!hg}51V-hdb(xjRsq_TN$FrS+eFB^{)e{(D^y&_+Wsr#k5H`6Pwn3rg-*WP&*r%VQdC#Bc$`{f*8 zm`IIvP&d?RKpDPV?}}xf+^2In!&$nnXre-OD+3b`=GUeh_-)G}af6 zUG1Bnw0C^g&*x{;!{W5?=Xe9BT6nnv-b>xbnNKi1uIt9RM(pT~^EVX(ddqggy>qR4 zuAdxKC|0S2;QlTdYp@gYuq^)9CkkLpQ|+q_@>j4G1`cB)!rL)d&ecQ97`%hCt1q5? zB`<7_gGqQl+pZYS`uap@NJKZ=UrxmM^RX@pInz$)`e1o83~8C5D=Hg33cB6I7fi5R zif8`TtR!6EskkdsTXYamxljeXe6G$sHY~cCAFIa@zYyPmePC&3&QSEnj;f1d?f7cE z3jN$`=gC+bwNO>O2~e{;AKBKP>gA5H@tbBZe|h!|%lg<@4gHy33iqe-t|vskVuFjz z(AiBN9rPZ?`o?BgGA$g29w&SN^~Mu(U(OofjVYE*{Db1DQ=Dj{hExn$50s9ypUUJVI5&zTjSc`AypR)A?GR>L1?h zl3l@;>Y&a&`r)?5{JV_ASvyc8FDlDbgHoyb!|_^_%J?jNJGOys={q!$Ka7RDgDZ7i z+DA@`!2e?DoYh4)Y(j;OZ_%*a@UG3*l&AASA0L~MZBY1KGxw)1rPt`cZeIHR^O1Rt zGz8d3sQ4?JH?V%++59klrpXQuZ+M6T8>`LZs7zZcy=jmZpCw1s*OEo4`M@Y{eMPZ~nRF{~H}x2wWmGFQHT z_5^=x#sxiqDNXm!)qZEQc%30?oFAU2UYDeJ0gdFhOce-{q zG^a^z#B`84*j2@xSz=BQ#fxWq+dOU6S^Wl>hq_U#V5UTO^$;anusK9+` z!!IJvE%TH33R-yFBbJWdseV>=t$ujvtQS4fI>#ba;IRV(jA8kJi<6sp; zLrB}H>O3r*rP~Etifen*cr0^$yizN|!)DA;+dz+DD~h;-8@AMYaMs-YDPyy$p5_(e znpXXTrp$CEXX$-ld%oAqbC*L=)X*;DO=(Z`BNPY!bhAOqUj1LyLBx7%027EBI;h$! zIzC@K_oKUoqLfCw87Nd5W>5Tw@l#G`#b5pL{GINd@|{xFgb*`AetV1lJeG;qCUct; zblIRsvsTrtXy+cDb-90DctRW_t`rZi|6V+VwRkO^7SI)jR3xN>;L&KEJ~>~_W0{&| z@|RhE-<>s8D^WRobhE4!4<>i>0<=6YoArA6=C{;ooie9F%}ea8UAA?Eq=S_&rn@%L*Il?LT<~S{ovQFJo@!?t@{n+@c&&>M4y3qUg zwjGn+49-1$o||clm&|otF;^(hRX4rFO`&F!B2J=;8S+wH~kkIe$~v`Oy5k z&K;b#+72~2F6*}WS&+-!^OMsoA06*_Y~sr)^IUZu;dJWz<{|0XKYyML{kW>0>!<$Ht%sB4v$Cvv=Xdj8)HM`6xJcF>o`^}6X|rJhsYxnZtO_lw>aUJK8zA4Ii*ch}kv@<)CObij(Y6)eHmSBk>VmLMiJ$><3wjyu$>C3uZ@|qyuANd7<~8 z&F{Wv^PRFE{4EZiIB3pgUwiDniXH4n|(|OCb{%GDNf&KgWTa(_MJ@v20ro=)Fi>XFf zY%?RBg#@YC^Drb0jrl`Zf&0g{VG;aKdWY11pWJ+wK5$-34($IXBmBkO55-^89I!Ad zC^~n{QSeM(j1DwUoq_36iK=>@$AUj7tZ@yl9g|^|pPHwy zXNvW=qp7{y&*sp4WS+6v&`t9UY2g(`MX`frslDm2(o^}Vd0jWQrJ7}~24)WyQLB=v z$=KYH{>g*e9%-I{d2CmYWxZ!OP<`Ge%wZY$E0fbj!hbV%XS$!7J0%A`SVfeA!(8H5 z&QCPA3YR68P&zE1WeN?Q0KG3Q$KQ+>Djw?B`^embbLCA4!8Pc+|KIc3kB#4k^yRtN z&t6f3(}4mL!#elRdO=zG6)5S{OX`$%R=J*-i|4tk^?K1iu`DN-;jEnoR76qL5eqMx z)sYuE?U&NQY~`c#?DXrIYG!sfA5eXoo>Bia9>O^U-9EUrE_^Tk~ytEYFs<5Krf>$9!&Waf%P?)W#mV?{!CrJ{x{F-s?eD67Sm!0%2C z!N8knP4%kxRu@8g2TG=E=B>^qD#{m40}(^aKlt#rzabLyrPLHsC{s|FF!u9#PVv5H zZdXCo@9EkhPI!37Ag8OxBRO49M=qUv6Df3!&|<%LKKt(ZcTp4HfNzlnhbOO?Cu3%w zvvSXvSYrN(d&4=LJ)p%oe6$c?sv#CS#Z>rcb%IdXrWMb5w+1=KkPLX$ovGmfxsWhQZ zXJAo!@hNpFGO7|*&oPC9x3yQ?A0MG>Nv{s&jJ_jC#Hx$h_)HA$S2qkot4yaWJ9Gxe z2ga7nZ83R~$MS-BX>3Ff`~I(Hhs(}Y3)R;6WLSVI3r9*3WIEI@%u5Zl(8Y z{&ucUm&LX7g0s}!c`RM4%U?lWI*dO$9@v>+bY?P=_y&p)*xAH1)86y|!^A2J)DFKe zh~q`u9a=NqfJb6muNXVRx#=(}I;z&e`M&O=?doA4S-Z-Jydm0$Ixs-&qnSH6JenERka=a9Osr|UoVcGYj)JH&2v`W z#YdPZAd=(U(>3@Brb$z$^0sC)tvw+Q-#J%>w^h~r+(dwM)gKz$!bGby!&qiKIH?B4 z`XBSz<}S+4(_YJ=zC0dFRUy8?6Dco0KF|N;xg&iD?7TB+QUf(xhc?zsWtC|fKz;c9 zm40tC9GY~irb8oS&MKc}{*36N-`gw!3Y1V5{KKp8n=~)YMyP+sxjUcQ*eV@*e>Qf7 zjpwsqEHi?#D;`S??uPkp^@Lky#YCVOhx)?w!RY7kT=&iIKYw1%8^M;cELe+&hAc7N zX}5SRf0MOWZ?iAVLNM3!*gP$>xzr8S5!83!Mf}PAa~g?an=W-nhtfyJM0J1^WfZ(h;RNK zMb_D~x+>{w-ni!u|9xkko~qh)gDq74^zN8#(%digH8If4#j-;z1a;gsv+wB}#13_K zRa+_(F$3l_?d|Egt3C7VP1)T)JLSMUPdV;uW`~I6s!|kY>de`djuARQ(*#Ta^m+YA z&Y!9etbQGcQch1JYa$w+5|ZZmp*#8$_?#2n}Xa`bm)Lg z4zSNCQDv2MIPxtRtIi#Hp}CLE(4wPz;aJrLn`Kd#S~u0+19N?0c~KkR4rB3SuiUJT zW#LO`aA;GjN7In=iee!SKJ;#eWmzG7Wll}{2ew6FspbRI!}z|hTA@7o_*|#_6?$h^ zuo3+wWP)2X)6Ex%U}bhcUX@+x2Rmbal8A!m z#X9grderyN6{)PN?7)a`8C!YBW;d8p z6Puj-B1@!}krnb~?;X4Qo$*(@r=RfDubroXS;5xDk-~Bm-8fQwlcE+yrF4hwr$WoQ3Ccq`Sk%f=_r znVWqi@_Au`>;|0}J}*0?zJb!t9FMDN&(Evugy8)?UQ3LFHS1u9 zH}yn~)V|`$ohwO4s#=Tn)OTJ}x zl}woWntnX>j9PAa74&N|u~-O);DISD<-X45_|)c=|Ib`~Tq)bqgJ@o!GX>x<8iz8H z_y#M7p>ryV*(WA*!7k=Wp1SQDSe9NE&cfM=HF68_rWnGm+{;SFk&H)bYoG=vx zy+>h1`MV5JACLMv+?bk%$HHA4QbK&!CA1aS!EYZWW^)O3@O#j|B)HOdsqUUDKykF zA`E;z7PRnA;aF^`jF7*g^p&T>DwrR=B47Lml*J?4Wu{Zm#p#<;iQYF?!Lq~z+=w0n zGksNeoDlzen~&BLs~LJD39G>A1D~~ za$-qYeHkI1%Nb^_k;aF{t5c^=8U#m0Q4LCFh;?K4*5fr}Gj)SFVG83;{R($AP0_!r zbgCE14534v?(iB8LtV#7R1^;4uqpQHOqiZBLo-f3InVgxV>Kq{P@3>sRS#7O|NB^) z8VD4o&)!@=vngcfx`=VW;=CyT=sbJu`ziB#V|_izDhbX8d-K={oTWoV*JM^jkNk&Y zPycqT%T!-1s#u79*HfW$#EeEt6h6S(G%@egxwqqUcaP6+*SX%5&nj0uR=IBoUUkQ` zSXj%ftfnwR0y;s>(a|kRtBK3fTXpBGBcGA>`sn-~=!FKJazK8BBT^Nm{)>a;uXrlF zLlz~!qRrrIoghN}Zgv)RmmHJk69dR6%0qZ8Xk2X1W4Vg&&E2u4GQt>AtUKO4k41-J z1xz7;eoh&Sb*0#9-u1lgD#d6#sV+7MCB-Y`&bBay;dh=uRqD$5YE^@SbBAym{4OuV zP($z7o}Z3w*#mlwzP0UWbq`sQi4%GdX#cR&&fmzQ)N{Q4eC*A1Oz2&A;TPv6JJ(<6 zE=3JGTPJkUROt1tGg_v^`@)3mgIbY-UzdpduT0QPKW_|aRUMP)%mOn3MU}Mugbz-~ z;p8Bk1Apw4R9=P8!W@eBdLPAW_+9-6-9aPEO(h8nBcGkg}!Mjd<0@ zWz`R+!pk35wNjCFj&xa}U2WAaAB%#ntOy>SePw5+P+UIiQ|69=PmQ@}TcKy>**!M4 z#qZ;8)M(^;G6*VsHpL%@+W9TamU@FpfwE%P2n}Xis^)mo=g&(f1$Uc{$nH)2aw;w* zgU&FlmR>K|9ePhq^4hs}vopkS*N(BH_2a4Kw6yOs50(Xos0Pp_V47+3>BMPFpbZ?T zjGXe)WEwFM*FYn#zUBlf=R24?gZa@*Vrn7AMO97z0Um|VI&GeuYy{(=MumBM<5-9} z0xT<}rG^7tnUra&j+iJC(vYr`AM{b0cncTQV+hrlAM3mdQ&*dMokBpDjr-Gwz-N7a z?B?3dFX2P=eZf}#-?TjW3nf1UU;iqbgcqok`v+_BUJw`cs3~9Kq3IJ?xGTmct99x- zU|0I3;2y|YG<1#BJtlwAAn96D|6^C|1_n|2c;7@n6&>ADKE`uFDRGL`3$2|TO3atR z@EW`a-L-1%IrF@o++AJg4dbt9H1MQO7%ajp)ip0O0?Ltr2 zGz2JxdS=b(7YA4XUM;;P_CW6I3=6e$>I^>13)(WH7-np~=e1*7qM+(z)7+zmLJ9yvGw7Z&~2WE^KUzcQS`9OseLY-YrAZ-7fPf$ zy7lU%091X0y>L6UGfp$AW*A#?a8?w4cju?hOHS*WMZ-0z<<`w-nVm(;8=r;SS*LHX zCwc&pk!Gt?I8?1u1ej0pmGN0-&)7pUg9m4IRn<-!Kk1p-&s554{B{90%lW=<8>@20 zfLSNFE8dE)!a+30S9Yjot*62{vTxnIwLUQS=>!N~*;yQvS?X5SLqveHR0LKm!~}nK zBDne$HrPG#%x3n(0L{9lBV8Sa&YQn+EK9cxm4hlfFY>dwGZn)VgR>}K=#+8MG;rqZ z!^UC;c0oNvt6KsRZdA!JA|}SXUPtsEVUe7 zOC+SFVRycUf74rp&Beh#K9))=E=TvA==9{%SP_{-owp$^y&&p5-uQ-aJ#94=5a!Ew z_|5}!#cI*^ge*o+FD8-d#}%s9uAJPH*Ps6BZT~{I^6}0KqNNcDRYE#9k3LW>NOx|W z1C_t(l}zTxb6>bb|KSla)aq0wg;4C`Ofc{!c2g_!O`RgdrkdI0q#)<(;7B{WsX3ob zG<)5)`>oB3X4g>m^T_lk>LPe2^AHYgc?GtPQuzymp(y>$2s0aq4om&eWHZ%LovWSw zK>dz&ke74{tD2#_3+G_UTr*v&8DdGxw|Fc(qI#hsNE4T7@*h}VNI;mJ_U+kM_ zgmFDFd(qFPEXEkf@K~38kKQdsR#hC;cD5yJ;>YNFD7avFl`T5}UXu|xA?T#}`Axs1 z`cr4q_oA*L!B^|BQSSI|`Ypb^7v^*ze`Fd(NQ}j>Z9(MenEAjV{aXvHSRB^Bg(@!g|=} zp0o^wGHcZg%`*$Z%MHy7c>j1S${8I?I8^7F;ZZ(2Peh-gju=|t@6XSxwxg#8AIl1` z=2TTMxN3B#qpUb6{_#|FL{qk7dzXQfT0NcE+|u@b-Zx#miI0*_~gQc!(qX=saEM+|&&G0v0}noLI=N z&oUvw)GXa?Cavl>PpdJ_3yyCUVjTCFK;b%jvS zICHLvl#%qoR5DSkE8KpR&TvNvJ~2+SCk97f4HL;;J)pn_k#Gs=Jl5 zrwi(puGP@Q_YG#XBQ~J2oq$uG-Tke(5>W<_vJeKd!m7!~)D!VxXsjr>IaddVK zD>!wtEhjoTA4Sgs=3UhZcg2!emWU`WRv}?qO`D=2zhIsZpM?{U@4#cjuiHes$bpo!sVKe5;ki&!OP{m%oXLwQyQyK>z z%;bBFm$~taC6%#2@cKvSo2-qKW@~9#yaRs743DtiGeaiOqNK9 zYf&>aLDFtnI7>C#?vvA-PfYQrVj`E9$(h>!{LN>{B-j>@1!qAeVn_=3#bRT%s=mSb z;-Ty?tVIQHt{gTSUR0A#@!%{7Q~Ol~WrxK-iSQpCtHL9GV0@M?gcJd^$$vSkpb}z&FXo43IX@4A#|qOJ?Dh|1 z1IlzgMc$Meh-JNLe3mX;)g8=3r@3Gn=#$M}!_8x}@bBhUG*2BjtVSx!G6BUz<#V6)Yg%qd?O=s4zIE?lI$0LA z17r@ruy71Rd{+7fXqg`q4a*Pp{KSu#^QV825&=8lylOoIFd_enxsmg^N->YFK`#aC zQs?k@>MS@PgUH{qEDCW!z>`oy4?UxbyAyC zwfe~CDE^vD{JD7&*KMB86aoEvaojQR%|Fo#lQu=S8~%}2na6@(*cIMg1YuF4iL)2& z2WW+-Fg*tg55>oo+WRtGXt@0^zcb6%?3>hC@$D=L6HkAEX;T5l!RvOlv(qZ;Luenk zR-Com5bBk0nCfYg981x`=Gyc`vZwMv{)&ak9ciCL#5fbYL){hUY|q}wZ2AwL-29}7 zp|+{(5lZ^X>}<9b7LkvCczl+(7>M7BTU7mlf}PS~rjh-f&%$RNnU}0Y%wSpWM@`|p zxtBU};SOx?RfCu)wD9nz%DrnamDp#hud_ZyLUkQ8=gc%x*`dys8@p3GU*%VBcEwp=baV<FUob^fbjG7XFBjI1w0HI&8`_=P*7()+qbLEa1bzNoH+>YJC0C($H4xyxZ>v$ulR6+3&Res8O zXgZ={_jip=@mQVfOWmR70A;Bf>Q4|2>jCuyDc*34_Oh88c1`okbVxh>;jhP%sQ=(A z9VIai^|oPNDB$rhsk7+*#2Hl+F$M=rQ76Jw=jD@Szb3G%#=(;mSS-l-S(qUG(R>#E zA|X_jT;Y8?FG*}3vm(GQ1}nzX-H^}$T&0>SEH zOBaU6>sFQ4ABYDOMbx0gZP`UQtCe|7_#l(r?IALgww>H~`R$0cz&$8+J=34NHJU-8Zd7ybPv4xr@b%`j*x@3f+ zhc1k3XC-9kdicabCrdUZ1W!l70xhB}PZ-Y`P+Uoy2cbDpVZEY6#<(>&ttt$xE${3qsa+zaIH zEi>-)WwSnYu(B(DkI$-a0sB~dvHquyZJjlqKr9sRLt6X*@610J59^HPyOv@AqXp}$ z->v?2Ixk&&ssGfe!dciURU5k{zr>QNABu^gcZf@8^pSb~UYHiKB~>#FW95bOwQ;;`RHr39uZ%_wM0ZO(iEbzLd@3z7FHO;c zH~6hi3@U$;FPh&#pR5ne+1MSfLf7aOO-V|Es$gfoNSAe!r)!SGMdolt4951hIyII_L;8fII()#^Ni zwfG5mkoJ{rRXvo+>U4Q#>(0|>WHMWOU2UYkLy8(cHQU0TW>c;kV#Jn|8H$E>ggCx$ zix;cw$fP=G#9n~3^nTc#kOdw`Okh3j=kjyPhR`k+8~6Uni5b_AX^G6>i~oHov9b17ZE{_^RKYH_O6Mn4}5&t2daHcf~vt zRp8ZYxU=zDb?#6yxi52noQEWXHBV9(Nz4k~L=1%H-H}{3k7Yh_orY;>ROVvf)$p7V zr>>voN#>wt;#_7}vg}ZGfc?mXVdD7G(7b926qZ_<(g~V2k=ApnBh);dpKp;}HwS)} zvK?E>7uz%LE^p=k?91P<61aLk&UgK2^B{Wp)kDmIuIpA5(I@RUi^t-dNY`#i3utB` zUj@~xB=Bi88tf2)S2wKsp`T5}Xu1?lFP@~1JnXst7V%IHrF#lSXI)T;T8{k5XXt&| zp4{A|bSG&!Av1LrEKqMQluH|jr3o{_@Gse}5)((iXXe-2#vjr2QPh}P`p?I{Xlu-h z{;jd8f3fMfoDirlTxLkQodzwQk^0QN?MyV(m1IW0=Zkf)XUxKL_M{V1sIOeTo?S?u zD*n^+e^a$oAl!TBdYksy>~VEDD`o-*1aHP!yGFLvX<<(MHR+7k!X>&-+6HR^r4&(A z5Y>9%FpL%iEe8n=pE>p=*Tg-^a#c(4@TSQ^E;<+RbYgQA9%!rX=xj@Fs46WYs!Qjy z{1iPr$DhT$X-ioaHO=C7;dq&AY&&~e?_y!y(XLzOq1RAOYtlHiGL)OHRPL;{bk|%5 zU7KgP&e6j`V;>flXJ2X`P_L|tWwAZ+MdvXbBfm_Yg{d}8Lr#J}v#ZVeJ9nO=GX~%+ z+^)GN>e5$@^}KCt$XrRdN;d@sMDwC$j%I{W{K+P1vt=u?6gq#C1?bOUNb4b2H_=Qz z1VfE?Fg^W-u@@7PHMFy9^zi*`en->uLc|on zbeeD$&VjwafGJ6fgjNe*WJgpbVOuhI*{u!%6Ox=dWqIJMsdxp(IF-6@;VH8?7&vo7~JAoFyQ&K2^ zyrC^;O@4On&$&DpJvOL6dYur)Le)*ML@F-$0MpZ&CaXKNonw|%8Ui|H_bD@!>5Q zC-A(_s#eDgStU3DqXqxS4zZ=w>o~3S4$d}-EoED*XXS(bUq6@_iIJf8(R+_0X(yB& z%BOUzt8}hTi+01M^Bm==FA80dnOul{I zL_^aS@YYy0o<==cc1W+RJC&YHG~|!zXFs)Bmfhp*<#-hLMZM8kJ5@su&03|xRw?GQ z>W>y#7OPD`01vC#Lc8JjT`a72$a7UC!Slj5?jf$UO34}9oj?+Gw8{_hC~SpYu^b3q zgl0_;R!IK)+vnwvR1w%8=-*oCjHLsCPM$Y*(BvGGyU(59FN@$kW8wHMXx%doVNs~$ zvC@^Lf~TTUH#9{OqqQ^pLCko3ewVz|wZib~AQUGoMIL3EmRFXP_wpyLyqHoC0#AS? zb=p~Hv^7nc`h)fIkoE|jiCF@sve8*KRfn2iN3O1kGDNDHe>(We|Jl}W%=@ilak1|@ zt7Vn;z6zF_D&-fSq2i{S#;RkVcV}mreI;IheeOw($NV|5zOyG)q4{DN2~8~pyjWQ}L?lyXODlIccHR8KSHgI))?55m{IhkM5q&sBhjqYpY^Fhxf>Qr*0Rk zPE(e`dc{M1w4I=5Pw+|g+lB>SHGdb?y`c9?=kNSOy8&|{FBc7)i6?^lU7gg%#wo}` zB)IrEQ*nWBvXA9ce3hCm7K`OMQ4)TMO%w~o{k!LWR3z}!GIO2xb_lN29h8wnhvJ%f zO(yH$6xi0=CkC2yPXU4F!Zw)0DrWjx9t(44SK+!%VWf0`;Oj4@^VP{(ZnCDb=>nG> zcIG5C4c#)<6l%xIvnnhK?X_Mc9G&?_>I%)7!##HT9A9F>jxJpYUhhAjtaDJds*_OP zmwnHUph9Sv&&m(r9ChbIo>>+wOiQDdpoa@)bQ)c_k^Z{sVV#G((xI_2+VHprHr9Me zb!+v4P>HGaX-i>vyN|9Z<^d0gFO})39$?sbQnf_US`753XKkn8Nz~J19W*p7iyjsW zhXc?%wfroE7f*s`ZqAhX*77JQ9^N&hEcS|807I=?M;*>rnjhr63Vb3?*ow+JL-6y6 z<}Oa2-=5yg9<5SqY7WbS;?31DZ`CwEoaohKUFLGLt4{yq*&u$}4mB(>-)w<&b5?|= zqMAsWQrCg$fxk`lD(_7JsM;BdWK$xe{nJ!Zy_4A9u@3BN^@UN`qi;3SPQQOPnpOq8>Aq0D#zQ#^47^zuo*18 z7zYy_WG5IU955ZnQ?m}$+8}sJUb<4!5D}|P>$ZTnvN5RAz4=#F1zkJr%6TaD zP^z=vJ8RTgtmdHD{nki73^uBB%I#=7|I)d~`gVBOxJEpY%**^2o}uhrETneUTZZxF zv*O|D3&lgsgOfBNa`iyAm8v1DN?ju-r0`e>UKLw3gtOFK(v2LM6_a|{@F*#)&91+AuK4`1O>=0ddF^W)oQSJaT6XSv;nyH7Guq5uRM&t> z@IR{5yqA2@v-cTTnDX6vVed^+zHRQvo&9j`MrPp zD05e_Th32Ve|F-k^QoMc+nh}JOkLh?H*pS%wD+hQX#_eMP#1(~fb}^vS4Tvgp2joLrg(+V{K7rz1p z^IEBu@uv79NJcIOli^KFM>2VXU3pse|FQWUbc{OC#53_w4FG=Rue|LYZ-^ca;j>^c zdU0r9j;?CZtX>MEwLkyV7u5-SK*1Wt0qIUEWC(((P!iu=KQG`;u|j9_L2#n zEQ=aeKbvk=9C^KLR=^rSTI>X?#csqq+Dew?nSXw)3I?IJDOxnaQ(iBY!jN^bLY#WS z^xmJeMJmw(D@O?c6IT1DcraVX>9=Y(ki7mJi2d!GzcTxQWtqWB;Xr{V`q!l^`iQge z`>C@Qdp4g%)ky7vg_rfJisDnUEcL6r7OVR7>=plq9C>IxxqmY|#`%mob5oN-G}U(E zhIA}IkhnV0Q1uGt5Ld9lkd{}Bmb;C6U{x^=7?bL{wD4FZKY{l%tAO@bEW|tLhY`(i zo)9cgv}V(=EnZ922h*@PQv8ViDNmuZSVa)*E`4>nCR~XL_LN%Zjg5=rBDp;)>#j=e zBdFdkp!uPnxpcn%;%z7ChD)JgU+-+4lDsUvcuFQ&hMq{w19h$pk!`8mc-nTd^-$Aw z7N|T+%%s=6W4?n9O8#rNm?mbIKyqvDa5-+1hpCe6S^0O9=3-1cRTAF9KzKQ&!vs1W zOXn^XnmP$B>FOR)uj8?3v#RaTBb6KKr_}8!8k#U*-VR13Uu?%qaK8LBw!v(k zR6+VD#TRRp&Ov6v>*E|_fz@Qh1>Dg7%@2thO)^p~5&uH+JQ$D54_HGL1PGd@l*fAh zSd|(#l;u>aCMB>eN>;HDa$;Lhqzbtic!%a4y6Rkkbbn@Ex?=IeSsT_P{ht_4oj}D( zVdcCcOq}b1qpXk@RAmR?P@R|M1R9*GngOmyv{3!1*10nlYpOM-)D?@}@L;_bO}D1O zOn2frSEV+`#j3Ej@}?%kers0l=D9y}QJ`G;fL*fdvsBmD#J~O9a~Hp{hvK15TuAGU zvmi(w%dTm9Rvi}Y`zAWb+^L4j z2VpD-F(nGGi%G(P(@DTsswDc%cr4n#XXf{H8i#Bb|Eto=s_vg>t*@05FztIYOglk^ zE&|rq(XDa_^;&V$#pIo$m%bMnZF5doI9z_|ys|BxioX)I`7ymlaG|V8t^#G5GcJG8 z*H8JUew3HRzrlkT68cwYR}VcDPlpNbVi?2oYKrs_cnSIvx)}=PTgEdq>CYX@Mj$w| zx~V~+yJi^jq2lz?^f%q!sq47)H_xlN_WH%3cAt?E${;&s()36ei(kHEo=JTPa$fb2 z6Z7=w1D1*d0x|#gyW_D;pTm~wI~4DDC()0E(U`w+ey8!qGK*lIWv7NU z|Bt1tI&wyW)tgy5e;}Qf5Kd% zb}^FO`6@BrHA9Bf8EMMO46A{NhR)wm=M>9eC1(ObyLc+knKg-j<|3%1Iio^c>728) zOR){@A1nr*OqI%1Hdxf}hO+P`GAGX#;t<9Ni4-69@65bd30IDq%aY zdA@2^w6bhj^m1?JGp2O7aJx1g2z2h%+TcQ{9oJx@vYFRK7MwkF7FNWOo;DHIDIDg2 zsg&Te?G&g*on3v&)M^orf1Jg}q6)R^~Q}eJa)hCgGM#D^ZRc2PCBOh8f&(JLL<~Y56 zEDt+M>r3A+SFhJioQEG_6U<^U-u|WwNYe;s;o90QvR?6y*ZS&MR91y$D9WqnK#xv3 zOL@I87EkOaTcIW@i@#>FQT@3)TIKfr!C3{d#C@`@>V!V3T3Ag-E(YJI3R@#FoqzYt z#C5eXv5-Auq(o!bY0Y?Of{5(Por;RAEsP~AG?Q7cc>6rdIyUi;c3%`>6S4-CgR|#a znusgI;!XJ~_LTohp$Oqq-G~*uES%Q!U)gMZ#4=enZ3m>vaJmLvDBgkQO@^q}gSWER zp^vgb&z|aCPN@5vWkQs(C$dgEt_g-Xx$XgLD~{KTM-5NMuLqX~&^%wPX_=yFhMlIT zPnWKd_Jqeub+(k_o)E<_UQzr#y6taQVlRu)X3vGQ_@nBgeAd?nIqC~{DhwTj^Bv8_ zcSa!o1;T>U#2xp=bLcL`1(jN zupWHDXXT@cg?I>mkD=$+_&45}kETI~-d%N_Y;{;z12Y3)BRf+TWabK;m5PndRJ;~0 zntkzN;vkO&xA1R}8J1O)7ujVZFs}XP%mkcVQ#|pR<{X*4q*5R^w zE|CRmjBoJWehOv`LuU15)iAebX$A-E4rizu^2SxBFqM0b;x6#m>9&#q=@S9b^aID8iGLtm-m z+d0c@NJKQF#*4lNZdc3Eql9&jceg4~fxglDkxZ*d@rPxGS5?_P@go~fT3HF6j@N3R zgtyF?)Ys$Q_-R@oy9;uUMS+H`l8lq4MOBAoowQkkoL3!Les$hF`}61C(ksg{C37>olfB@gGTFl}NX-zqa#F%>uQ z6uPV*9;*_;X|rG~Dw>e*QfR@yYdVuiXpJtN7thMF+|i3>FYpYQ71@dzg`G`oXY*Km zxy~K^_v!^d*{%tr$g=EKj24#A1Q)wFPh}#DnKkeVC6>&|ckoyuJ`N8%Mq>smn#g9p z(evi_vn=_?s;S8hW8u{fb$B}^0kh!!rg^Imsy#P{9CNM4pr$2;*k!OylZ{ek(ay-v zRV&Yl>Q!mf74Tj^w{7l2h% zNMajV79=P)tSX&N@nYdC*$146#ir_XANu#JL)Tk@c~>KeA(g>nhbSGOVt#bjqM#5F zBgO}tSRCZN?FRZw%&xU(1M%;!TwZ8Tgb%R}?CQY$AL0ma!B$-_EaNU+F@3`e=b9ly zIY$!%{2qET-9|jDNdad6K_uUs=X=LkkMqiJnWwLgYfjCwDp?a&f*(*#Gs)VS9r#gE z-4p>l;u8}I%@M%&U;vkPkqSsfy1Jp$yI-|=re^D~D4vQB!_GU|IOVv!-AdU%D-v2) zmRq(9y{nGJQ-?lMS*l3W_{(Nl5Ice$v#BPL^3QgZP9IgS`V?r%JtMpyHc0NNHVN06 z=Fr3-+`C=Bf7Ya|y~>-YC;H{5{JHQI9Bh6Lm8}ycaE&~cpRN0ak6Csl4(d0g&f=}a zK~}_Xv8hlyJF~l~sp;i)?5CX(Q;XnBKcjaC2gU2KlcFKEgw4b3R0Hhm;JjHI3HlQIBswGxZrf=S=*)vVf zQ+H+0so(kH)u(`)TD3P;#7D6wJY#>iV(y^bVTDYT6H{O;D*m(rYC5=vrn1SlsYldf z*^*bWk57QL9@s36)nO$lm_!d;0~QObD@#vSHxC#C(%hA>0&GN-6iZ+d4q<#YR;$lhO67I{VYg zQ3RRcV5*e!8J%|qiLAQ_htQYER>f;m<~kW(->q(3`UMlfIvd{HCQ~2qS8Pvh1p2j7 zqNzP*w_#CKbUIrAe`)rr88kdibz-n6}G$i z#H%BkiV?zsf3ccoIj}4J5v;2FgQWOZ*DRCerD+KG1Gqh=(OqCoRy|jCRsSlFr3Nh$ z%CYbV?uwm@db||NT5JP?N$#i`k_QEVHZmhG6x$g=S5 z;|-0`!aR0UnJ`&?zLhtBzFM^Fv_CP;;s9?XH#C98N>~e85`U_8 z*!yzABB5%d{li1$>mYr>GrEG`dsLGTlYDdVJ)MQy!XTh5vppyq$ zH*XDlMc1o(+X+G{qE^l`u+OSN$2O?jVwqYA=oJHni4x!8cmB%NuL!R;Eh@t7{IQ)B zZZ|;>Z_lpaE9ym6D)j`DTRW^+F(23CRT-Aj1Qs>D5|U+i$}w`L+;LgqHmV_ z;{fatHST!#c7CVs@K$g;#+|nk|5#K!X*^bSL_STGPWkF+33ORSK+e}q475AQ}<*{%iO$(uYm7ANIYJMC=X(&tf z*Gj-9ycc`p&%Ql-T29UqRpt2SYE!T~f2fjbf6%Y-y0}`bjcmxZ@>wbODMKliaawH3 zyN}H;G8w3h5W=#TL>vfuBc;iXFN+D%d@98Q;F6Qre??wR}H0LC<|m=b`RX{ zu2dIIbW!K>T+*JT5ipTfq%tL@2~?i0NXV`{`6?r#UU6Fl6cxonyFzvaBis9_1Y+H> zak3m)flgETCj}|%ND0KQQork52~)8v9O+^k7Ka79<0oYO;;CHC&Vvo<0z?tEoX1i% z5aU?8SRR|z?k_WZ$y{->jPUN^TNump=V>uxelk@Sz2DL8H_PSh1iBtMo>dSJpk3?0 zo;-Dt0()iOvpn-DNcg37SN455A?{aOm-fdNGL08z9M~tHAqP+TkzDdw6YA|UyjFTsgq4=uEWx ztxGDVg&jpgnXY^U+u*E+RfVp<(;Za@)ko>BzA@KN0W0hM_C&)x^PTgrrpCJ`{>nP# zh1FG@?qc6l%s^Ob#;LKa49y(Y)~=CB$^F<=DA?M>HA3z3L3=|s#@oT}c7B@o<69(r zX7l^}B-Q(3-EnGobvCrPb{@;eRiF7Z2p`@x(?VW}ZJ_+XlR#PVSS*W5QHF1!|R5MjQWR+^!e77~&GakzcVPYYbc(X67mct}Yl~FTFvT>Pkg%sC zYhN5ntS_av7=RRUEKlD$TskjSWo1Oh>`EkOTkMMW=66N(ri0Lr;*=>!vArr7 z@;YAA?w66sv*3=f7OxtM?|?CcP$tfnna)dwPq$UI_5Rg`!k8M|wPvC0kY z3{`1WI2D~#e)6UZx4X8l@VfRkj+)w%uT9~QK9QakBeL+jm=LG1SS!2Qs@U;jhJHdU z7R6HcX_rs{l$Sp_zvtLVOhFs^vPzfzW5I`;IEu^!XPQai{GaaTGg>Y9Sw@$_-l zc`R8BYy&6A&itmj)6>o9FHBqF>N<-Co5il$FEZZS##h}jkx-87uBeekcacy|2rKh` zt3#(#SF5aJo13Q&dz-Bzo_O-{)HsF3FTn9UKr5!=CZ;sIAr=c)saDUfcopj-Ygcun ziB(bJiEy9Tb-aQok#dHT)C$CIu|4^Q`Xa8vyl>N5R1B|(cCbNT=mKshSP**3-6`tJ$KenX{5x%GN zrIU59kZ)_^sbDCZO(92DA(ml#uF%NBjrv7Qg{AVzvfNF0(VwyrcEzXh!88r3RN@M6 zfz5}3<?W*XwD;;2G&9h#0k@z0>SCwsft+FkVl3lI5U3MjF$A^h1ENjm;P1$e5 zVo{IQx5~;fRFI$ehjZYu*cX2#)2Nb2VZ+bDIkH0P#K*>_o|riJ^rj;9yXZoAOUyqn z~bNJTYMHw6VL^;*%G--j*iitwy@Kg+6jI27-MR=%p) zC)AhtF8oAn9sI~bn=Zv}&C@Ws4AZ2tZihQzRu-kprs{y)P*xI~Qr6qOiS0h`E8LZw zP<)5p>42!lovrRX-_EO1dl9>IL)S}H#+w$?t|@ltnu1Z>t?VpXy5{+|9Xwu@!4D-#c31B|t&v&)gHN}73DRvY?d z>tZy!TGeRis@|h?$r$cB?{Hasg)4`1tJl()Kw0YN@;DJ0Tfncf6wlV)6%R$ewaf8` zbaUyIFb(*}r5t~zdCv>U`jx=J1CW**cv+_VFeed`}K z6Cs_fyO#4poOl@QYo`avNa+0Fcl87)ON7vQ$Nu>&mKAyo$5$2gC%f`5I4lzby!ZuC z<^FATW$kjSrIusSo@$j`_}4zh^Q?ZV80$2>e3w_Madna6`SMtzdg_K|QFL;&eZm97 z!L$O_NG+hK7h3lx2BJ79H?IqlI?&pt#u7u~NShEs8&E}x&tfI&;dZRd0#4^^v2wjI zbgn)Hnf%_K;PFHwKP^qQ&S8^b*%lV9GlKG2c59jd*%Y6}x|UK4w(?nP#Isp7{G5zM zHdY3O^YI(T?QVV&9)ToWKX2N;kSkFwr6`4_%nfPExcMxm9?**88fZ=qjy3WH&G1x% zhTnN&|C!X()Ds-3dLi`f+H`YvN3fO*v3nBbd1U7d!B-(Mb|n(3#1-E$jryA4Qan~2 ztB?iDDw@;*y7(Y0c8aU)Ddv$UXx+-fQ8V49Jj+gR>RhcH2pUUt0Tg-dDwyaS8 zB->xQ46{*+{F{0X+l#fskis*bMd+PISf`(EU&zGN zvL<=*IpRA%Y`4hxR15M??HP(1xZQpc3t1o?SCu$Ql`?m_CJ}{Y9ozh{c*3&uLB$5a z@S>d6ah;SLRR*064XI$Zi-qzqeiwGO=jzd``%~2o@8l^$jrF}WMTT<0E?%5g-U=^r z_t;y^o3GTHFN%h;_zOx0D`OQAhyRTU0@9n zf@SeqA~)-@W10;B&u3qFhCAj>uK{DF*oeu}c?7LM4iJRAdi8%<8#oJAf@6c625d?_1C3y*7Pf9tQO z)2F%f6FPNHty9-H9!OrRmwWkKoQbP)7LuK@f7=;dKf97~V1cWP+7o;&G{SG3G1pv2 zx~WO#m(#E5#Z+5TV+s)-nH^j0Q2%`|v4TCZEuXPUbf-FxRSsI{n!o%~Gdf52@v4v$(WToY|U*vA-`B@eQIHp#O5Bq}d9i_=(I0XrY_fIXmiQ)6a7*%A~l(s=&rIaUEC zvBTpVvMe}3EcC)@_&rbrpDP-s+z)3}ak39wGwhgieBke{c{`mvtjNB7{K z%6-ch<%3vvCqS6E<~w-eYM|E0`WEA1i9NGN*#Z1WqboAN@cb5U1nEL6)pl8ySkekv zIbOHgNIDHHX&vh-x+hOeF{2-6uJ$ITv6NjNUY00B%y&UuvPQ9+iW$mMY2>l&v{05< z*z5@y1z#SYg@ecR*1^iUSdbl1o>-J{%_6uRp$>%eu5ZnrQ0pwKrOu!rPUp}()pClI ze{4(6hl%#avx^pGPPAL!ndeWL4427G>b~^^?2?r4V*1Xu2GF9+n(v2_77HIs4aKWV z!&|s^bwpZZ)f~P|#H47#*oEKAO+>@hot`>Yyu#j;m0+S^+h(-zu_g{EzFhGVU*Ct97wX^&_x4wg;0zXI7}X zPP`lhFJ7^%ax9s489Ww2bfM@;gPd*g%{s$)GoHF`RhR(Esu#M7VO89&Uhac6QLV&# z=$&UZDflrvVvD<>C^3VynIJr^o#cD?TGRbZ-q&SR&5(y|1~kiZ)r&7tdVSmocNNKpU0{<#J#8(LUDGUbIwkjupg`2iW{rGQ&z5Glh!h%-kd^RQ)~W;h7Spnl&qvoFAhw$swoZX?1dld^YSw+KCSyemPR!D0$`#dw8d7aU$!~R&d1oU|p4uvfS40&^*c>@00&=tB^<G7`L!UNd*0n&fv^{p9=Qtn?bPJbs8ugWuA9z{>Qt`M;`YoEL2}%`c>7 zAGxzKDmc^{Lq7Gl(VM{+tQ_h{C#U*kuH~8xi(Oz*RU6nC%YwxY&l`THQej#AgZ+XR zdU*3%_$Hn|?Hq(;_p)@)9jj6WaYd7bf6K0LM&bAB2=+cSY=y9|@HG!??|X)_Y!S_l zl}qT{%d*746%|!}Tq%$CcVk^tMA!=6uDUkff!Bf?WC;+knac5{lr+s&73*MhmW21^ zfjx8gRlHHjk`LPtm|{vrsM9K7`^=A2rF6|z+FY}aka9#h--?NOF3R0I=imJwdT*Kz zBqQeV(bT-uM0^$wpbiA5`!4oGuIk#=4n>F*%aq|w@fG{(@WgY8g)Hsh*i@MzAHXAs zKD>A{9IP^}19Z|%Jkdiugg<2G>b9;}40keXQIWrj!>Z~4Th`yrvSK8m1V78lQf11r z;1$fEmEkkPkSxnnFEh6qUdvzge_qVL@LYTYEbJ_Dm=Wrs&#G%D?S`kQBTQ!E+2Yl` z#23i8y|c1H7)#`+(kXsc5wRkeAYKrPmxH9y5)mPaW|U@Iv2)#r2xy5^nq@~Lc0{>h4sJe*b-7ZN(bVDW3R|LTm^O>* zFRs*8Lt0@i*_2FzC+#}b3|I~8;k#55#R{0diVme;Qx0$|ux>kr&z9+icJo@zSj0Zb zL!gV8A5R?CIyjb-chXI4N;I3wYlZOj=bA8peT;X32;3WOP$$G!c`3$2+$v|DbZk%h zIM`5ZFAB0J7Pac6q9P>bUYni=#q-5@j4E(!S8k&IW^L#l;v2WyDyS+R}T z7!&16LgyKfsg`y^Q5nGv#hS&0w z>bw?CYhAZzgw+&_DJ~!r*T%c5 zaaMH`N6Hkfif#oxRw5<8CT>8=)j^va;3@6UsxwG6%p051w3adrUMtVbvWkSV!gz?7(vTRP z8Wdk&UkuSYQGinR$!Afp43UBkU+Q|qHntV>kV3O*YwQW5<&Ec(iNtD&iDC<%4z)PX z@U*eqH9<0dXyJGaNHsxz35%FTL4;JKtW2!rW&1j6391R~nAmz^{wCH!+)%uT7#|NW zLVaw91+lBF%SXIx{4Ok_BShs;{ou^`IkY^luavsh#lBc*WyV(IM?54-ZYq4IILETX-uh)<5l$8`wDz1)uvd_?^!8( zKNh(2OilR<1Lw8W7}PVXYeK)QDK0*?G-S!r;H{XWn8*At6fe4C1Yk!`j~zqAa8kHM zz9lB|q|`!eNgOOwQh2 z)m5iD#l>z=LHzsqy-f-{zq@NYjL)K({#(4bpT8# z7Gh1D`pbr}S*p=-@zommGZ9hz@80C|)cp@mlznJ^{-d)doeXHl;Dpl9iD$GWlo&WG zD68r&yGs9CAELYP7A@nBL{S(NVzUS73UPEIyx2m)vuZun+(nmAap)yZ%R1QOF%!6l zB9ZTr1*m_8Q%p0{;r;aJuJI?l4m{4j<$fY!>{aNIH;{R`QhvF4SSE*6FCd9LYl;k96*iQg!i*LV^IJaem0iWFh(6V;R+X*%(5geGtlRTjHJ%~` z1fOlm54~ZlIMOT&SE|3b?h*PfQ|;@pz&*e^t14m*%6Hj77wf<_rtj>{=v_SQPKIW-hOOWh7EYK>a%uomc?SkJT_(4)aCPbkF=T?V!2EF z7WT_7gIlnDBCGCYYgDDZX*6PdsKqK#Gq4`lC0e`35WVOhn$Pn1w$vJYhghP&Ngl#$ z(d(Jx!kVp486qqk){^VGK0BFJd8$9sv9`l73e z+C6kQn0)oGg;)Fze2R5}F6tj~XCV_TV;H^YmqIgTd3A@Bre%p?VfW|9dLU)i{2FkGCh;gDXy33c>3kY*vdZk2vUEm4|KldmXHG?(hf{5B83{ z7hf3VY=lyF4PXiMOX`-hqpvTao_#n_WTTS1zX2!r9P9H zoS3_^-|bs5L}eE?5eIoC%D;AwKbw{(B8I!3G&>I+pE z_$q9VKjBHUkJJ%kfmoN!BcG-Bh*|)Pt*YBhIhqCM}AFP4ZkS8oa~W>AE!Zy}+A%mOJ9HR;}Id zb`5-JJH-Bo%TfhR8N^RHZG&pIn7Fu7bsqbuyQ3(G-IklE9<2_ZI+LJR`&MU?I&=6v zb~N>DoLyQ+teiKSyI?-57sjf|%2*aIJ~V8N7S3W1PP?S4!D+Bec9K{~M-&rJtGYAm z;3tO;W8$d*@r$A%E8$(ss`6R#aJg04p_RhY%OG*^@)8k4%M02d1Csbvf&+-KM zRhhRZ?@2ca50`atA|VUIlh$KM`3K+Y3x#~ucGa+;Od28eHXP8&&t~bAH1=njCj1GH zg*nOVig$byKgC~lMo{^o>=5%nPowK6wvJC|b^K&g**!OwEaw#oScJ^PQ?I^o-rNPt z$+iy8-*7AasaBd2koMX>5pmoNoKu}g)*0^zXJP5=f3r~Hhtimf6mmjX#5?AcmElff zBw|crdf{I`#mZP4IgdU9C^&s)`s=bI{wW?S_8}YN7xHMV3g%zCq3$D|)~WYmZZ%6b zElcIG#Ol0O2&L*X%ZklfiU9kX#lc(p`|Kc*)&8QE>O^=tEu13T=0Dj!4FJn(;#U*# zIzfc$kNzFX5*7WMnJ(p5aIZ-rrtz;nN?51JadhmMMT^Fw7bL>Ac-F()zJTE4-A#Eo zG+yiQyz9!xJ;cbD9r~WsXI2biVOiEi?nApH3ft{?opN<{B`cKmQv26moYxZhL-BBZ zC|=y;6If;Y-wGEGRYO(CiiRdL(5B?GOwy}cEGvRXVj;7zGDHy(|Ba`xx|9#q8eH9t02uzXh8 zI;9dEn_55wgs_~5%?J25KEWt|*0@*#Y*XC70EdBDP7 z_N-N<2UMxfTVdfLVs$t8JN;V}=z@t1fR`ujqlqv`e0>SfHSQ2qg_Gcohy5Nbi2JnYUGi!0Bv3095s=u4h@~3~n zI+(CmcIbQYL-s#TySbP!wrZ%X1a4(nX|?K4;<4x)*p=(-qkLCU(rQ*gO!vTJwco7_ zq|$Wdx+meqlz=#^?1wc$weTYpBL;@FRM$fN@IYFcb^|ScmW9{E)}}9`(r~R9g?hWI zAM#I>D7!OIL`yhCWET&sugT2h1^l7^%Agj`rDDS3;11Y|&WVc#Rb4bb%4ED%i^iD3 z^DGYf6*t);#n4jzxvw}nDjJ->j9LvAX2ccBMcBXSjq?p>#lVY&JXh}!Jbg{7M&D82 zF$8bl>(PRv$`<$gqaTrm8OyQn7@SN7CAn`efLQwf8$tFFl|WOvwfPyXOo6fCt~D?6kJ)SvGZ za~*v9=Wo2PD+R8@Dd=Iou?HTI^M^fu^Tb(!g;eMvk~ezmJ8!f>WHBU5??~_SQ)4> zyTW6ZXVLD^E2wUW$*Wo-Gla0bpmzUk-?u`EaqFDri4n-DMP+V~@@g44oBtJ21h z*7XWm<*!mYuqz0YW}L@`J>^ew1{G=E6HXP&JbSUw+(I4drnEKBvl?`22P~KA4+qDt zsGF^s=pjefl_cA>X`1pi*;87s<56$h#Qi-YwdV&Kzh@mjn9goDwp1_wjr ziQO0MXoa#W)ddk6#&VCmR@TLDdBvgfR~X({UX>BiLh3{2$2fT5O7$YG8gy*D7)e=^(xG#hn^na#)JJJ$`HnmmyP_ST@DF8WS@ui_ zp5O8cXEiZIev5~Y6{i5CqzMrhU1WxOH`1!hs<8upFT^4y$V_P6?dJAQc_3S|Zfq{k5ylmpV?{L46nyMX+x0-Enmv@J;oF&C7WKeXK zs{K7@`&it7)$>#8(|9f`;$LC+>bLFhrl5EtDNc0Mh=rYs0xyaL?F~EP&|G`Gn7UhiA@bQO`i;aq!soHRFMe zRg1@RnG!ln&59TvjW?UB7c2T@&h}n6;>=hh@Tv5brFTNLW!i z(v-|quBz*lXE_6#7gVte`^E%7L8;(fhuF*;i)=b`>>P})d+P-y_O7zCyHDgJtWw=; zMHZ?l`8!qsU!Fn}ABFLRvP_os=9hHj$zQXn5SWUgb;*OXs`4sYFMd}=U4F1T0Z^8m zt>}Fg>#!hJjIXNRE>iGj`7D{Fe#55r%A%m%)J!RsRXTO1K=7`*IKs28F7GmTSzzpy ze5u+eHjovH>g^#J z3@^+BSJ%<^AQO?BW1hrk&t1G0zpF|W57}1i17xMzv#V!}Wl=3`VvyB=-q)V-d)!yJ zSFMfmMLfhC&|!*)usLsRQizkj=^H4P-3Pqr-ti4-HLV>CitocXu&Z*cPGZL3RAq;; z+#T$MPhdZM7R7_e0e!*AYK53o-p$vGqI|AM$hKlhi-nXxJdRw*@3&Lz72K~ZF%1EW zYWhr81&^_;qIVdJHVfLN{(|T12f3VjrLHa&niTw@bNLaQ^6t-Ow9qzq!H41?6o4hh zDN!kl8K%pH-d#t#Abn3=cTl`N41KKG_H3!DZ5PU33h28LAtP;OqcaS4Wfqo zQIB?2p0RzPW*%#zl3uq+9*VluAsE=XxUYb*mD0ZD&F!Y(Vs~ zzxmOH;O!jUpw4J(W)^D_3B4fqGF)tfS5+PN#BYd&G&KHejYLBpN7k(#D%OkkY#vv| z#^7ChT!h4qJ~r>8^RXU>x?x3=G7#C7pUo0r36`~1Nqply@meV#VopUxRRWgA!de0K zcpfWVmiUdyQ2Xi(Z;S&!D}u9g&r{7wohP0oh9ny+mUjm#qx`Lmr6{iUfy?lpoh|l4 z4sugA0sq<6q4>Hxp;>;5CG}pa0J|LPl_!XI=a*Au)gMjGg5MC=(`<+rRd!%SC$3Xx zsUuXC)`_GpnCeW{h)aAmw+ zBA_=Epb`yl%Hp9kcLcRTEcGf_`MO5LFFQv5&I8o}4cm%%-qy_8g=In~Kbf!3vh0g8 z_B>V*<@l@+-v~{xu420y53f}{P&|a`i;HT$w6nCQzJ|Zzwd!QUl2Ych8=jfper&Fn zsB^It)C(H7?>;@={8Wz@t|B8t6JkPi} zbC@l=+MhA=#f~P;vA|ehKcW31GsjoLB37I-g#RroR~;{&=d*Y%d2Jev>Ymus!<${D zdY7M+nb=PdjA)`ip$a>^!`F+y;s?x&CE!CtBe+siB2{?AJ9fk)i-GNuqIybF&w;`z zCJV|@0gENY2k^tJ3X`3tyjpwOW7sy;a;!*}#`01!H3JjI@m`w6P#eFZZp^Bhp67n- z6kZcw8oTa1BV9W2qIhop!M|fiUohXp--X`UC~v~D;34yf*M5K|QqnAr7o)(>hSWoe zWr9^W#cwucKdp+Ch=Bp%8~H2@+P=-Q-PLTd(3J2WvfvPlxJB786kdU z=`Jzv)j#SL(Rq(O;rXd!MMFL(MZdg*Ha5$m>#q|Us^X`3w6YV?6N;3%^H|OM<1<8g z(<5j}sT{;CtcNFEHZC*AS+6}~-}F|;5eMr*RQIe_!XE3df^cB0X6slbdV%JC;<9Dj zkgVtc)w)0LkXO~45W78JrO?-5VB+3+T^OrVWa%89-YO(eUGcC?m&J%MRe7@{kui-A z{~%_tENVe|A3iTdfL#uW+6^)AVz@O)3(u#6 z&smu)Brdg@I$jQXaaRyql`7FNG|UsTXkH7}dalo6J?=7l%9hHCv%L0%?_pc;j@&k# zGF%#q9bySJ7T?!B6%%o%ys{Z|A|=#brUB_fXHN3q!<#&0pU3WYz9H0$w+r|kI2<1Ltec;)D!Ep|D)>D93esm+ ze3tK&w}=^PcGmdh$;vSfqK0R$9u0HLjUZXkTm>qh6wAv`RiAob>}%;_eGOcRe+;{{ zLY@rlp4zOeN}Lzv`59M~h69GjKE&8HVIw3D&9f|blgDC(>FZ?iuo}!_t$4$tyfu=& z=d;R9nzhCQxJ&#Ne!3O0*Wr6_s}nw4H67~lHK|EV@!}h0p>K!nG9fVj^Kl zF<;CsBUIh2!U@5*7a-mcJavZsAg2@6U~%uY>+Rfnr{hqd#B5J4C==IbRByM{P49&% zN&kh-qADwD$j@WnR1G?JfOQoYV;J=%SOb$EWaX=0gk7nX+Cj~1vfJc^?qt~&UXK5_ zgW*9jO+L#Tu^?EU-(qL{7`$Hm%U_9}qF|WVikTE5ccwNlhf`G{U)(zMl(Q_%1Dxfm z`KBz%p7D|W0=M(ivY0sSGI^`WPQxso8V^vMcW*GuQ*&SS99D-D5#6ajvnG4WgixIz z)-g|^y3(FwSAJTk6hGkk;mz?{m;-1$3m2VZ<$2^&=DDY4E_a08^H^al6Xl$n#}BeC ze6qbL{^HMQ|TwQ~n=j7x#-4swzjvuDrTpD^SdkIk=8;4k$3bG=&Dw z1)aB>ixn{p)1|B{Sz+#$h2pRXbYV52E8#LBx%00^c7tVr#s=#=9$HyYXGBv+? z(V!>KnDvM?c_~QQRmOPv*X6Nd9C)o1a@7pg4QWa-jO^MKSM}s+^SCUmceo0>gLm_l zd1&8nHErPknw`3Ct`s6|!5YC}TyDa&FFv88gnScKxDKOw3t z%b!IPpSAD%`cM{}rLt3xG%VaMSd_>SJ*EE&4Z>!+!iUF97R`fq$IAvt&j#BYZWMfbRWeC!R+=t+>#%V zd&Q!hInO`Xxj#ZwWB zJSV&|l_z}1vOWJYDf3F3;4S}UGrSUe63ds`7qaJv(+%)$v2(B^-+-Tymsll{-o5o( zDQTQ`*d6i!p<=seF;?sg2b4k)H)P0BG%r96M_XcNK=b{vwwhm%Pu|hmlJxmeLZ&kUm zQ|bqS+f!)TP2mCiCFaOa`Tt$re`}pZ6vS~wi_lgnLQ#DGFU~&{q*ja-s{6TLhfQp_ zNtz$`?(Ug0Gw19+PiTw|k;+hvcBMk+&eCMLpSvAupvhR_=$A9f_M%`9x-bBrL(lP^ zv)0a`*ncsb-CNR()Og~RB4UFvSKNigTxZ`oA7ky#sv?0))al(BnumAwefL^5V)|xp z+IV{7H7Ma<|K+gx7&Bk!LI2eoDPhhKdf^w6*GrSzxj(MLine1=uQ2xV{)h7&5Am=^ zWhWHJ>7>*j=5DNj=dUZH)=@iHJ8Nl;WrMvZcUA1ZU~0Z~K5g-}M|D#t0J{=Yjfij6iSbll!B?`Wo!+XF?jws0L`$!9 z*D0um%&rxd`!pX?#L4I|RrA$F?IaXU!42){U@XXr0bE^-|8SO5xYQ83I`g1sYCp7{ zldcsrD)ZA9&fBY?@ETv1^WTWwtCH`b4jI&_r-#$KPn+|1Nl zyU@^Ov>3}+WggWq3ZvtZ@+8pGSqwh59)vywPelV)H{mN7Y=6}6s*0PDXXikzlBn17 zEvH*+=*Vx`|EM3guCIPXy9@3L#AiX4&f|6$xHZovGON~IS(YsrNT0=g%niEq3hV=5uEu0+MFkH)5?x#mOSJk4 zKjnAwEMYWG!CCahDpr+1b+gLB4jZ`?pRq~V|Qbz`-Zphzc_Suq&UEr(`E8pPSZ%I zft;7=%#8aFV`UlD9tgMN+-kdde6CuedyrYzPMmD7*4dzZ6imTUr%!af;E8UgpH(aJ z-iqMmG(^S2{ov#FcdW8e$(TGO7l~(eNp@OJ&ir`h{L`8HkLT<7YB{4a?1ZwprG@H$ z$l@U7s&y^@s}R?J;780emZpdD-YGc|)5WR-23HWdey=Ltv%)R+g10CDba`zRU%bTm z)rAm0Uu*q!R=e*dmcv_|nw|QccCb=)wXhC<=v;h$1+G$Qqm=_V%x8T^7F=J*ze8zy zh+);T6vJ3o&04*7rKnq)Xq9ub+(&HX|J8%=2Z#8I7s-v3Ei%o&dUJLTbGUW2AVVKf z&je!heDy$KD7U_|;?6cXJBruTgw@8>BnA|bYAWkxZgmF50EcoPb?Z{@u6=Ctf;gZ_ewx$2(N1nA#!KXK=oRyW)12;i^NO5=zCNKrG)7wx~(f z1Kz44igk@EzYbd*ZErsB#JjV8W~?5thia_48)bn!ND;+A&)S}TPM*^7y_Dho`Hq#x zWbqi}wco2(F~yMAuv$H0?WwxX9KC?{c|q$9TiapF&pQ)e9DeXro%#8Yc65p; zzh9TcK3m`7|1C%hN&`uM#!7h8&KJv!92X&#<5f*WTl8{ zhYHVbS^NA>`@BDoPAb(GdVQkOd7$sl-1M<%2F@#6tO0fRFUQE48ozafe=CfA+M{Xz zzf+C6TwQ&TLefY+Yda5}mea3`8SL!TpD7!8J3W=^C?mqAbwzmc*JmbObpc~lfz;he za+%|65N~zrnez7Aaaf z8gZ7sL)bDWk5)PTqc~KtK38rer=Kf}=Wf4LOmwiZR(HdUdAz##)X@$@@Kgr3*6Nd7 zSve#RxHacYbQ7Hjz*$Djk5=>4VXLRhYv~Yjv2vF`!B()QAJJE7CXQ18!no`cB!Q%E{BJUD@N&S+n}27;7CO`N*|cLsdiFO?jhds*?^W@&}4U z2A1;gy^po}UYu2=szo|a4q5a)pM@#ufSkcgoRz);B*mr1S0v$%8^c%ScX>35>sT#OhaG!)n2)*Ko^A-sqwyj+PP|n|tG`QCCqB0;zvC_HF}D@2n{5cbe7ynmph>I9hAoZVv|)s+@$Ne(krn$EYrBZ*-mQ ze1JTowACkEbxzfq8i(1`b#Nb6GYVB@$lN+ib-t{+#XQ>+GCOq*#)2$dY<6|W#a(== z;;nIADafkA@SY>QxZ<U_)iI^-PRHXb+9zB3sM_`S zhdM{v?CK%c-xgCj!@=9r=domL=V@F~*%?Q*QJ7Or?bR&GY0v|gTP ztrvr}oHm;iNvEx|YL+fkC8mcv#ZkYVK6Xy98nWH2_8xd(H5H$=4iIM|3TEXE%FV_Y zjn(dzNBVvdg|X~?_MNo8+^J6*u3|ws)5^&joq_r)6nXv5{A3zJ^I|UCrSkCTS}O4p zL-E^vr)Vm)KAoBHE_hh~rmCnNo4Q)nCsl;?8@Q3WSs@H_^JOBeYZ0GUAr4vgDZBpV zaxP@$=bK6B@?OToX;eQn^6;Lef1Njxd!0FfE2B!$WVy3|IjwY|lYJN~Ev$>!?uVZ@ zHyj*Wb6GM(C7PnQ^41>HR*zfjPWbNZ27I;a+*$rk+jRO@U)39zB{H#JoTWC?J1Z;s z_f(!v=!TdN4yymwHB}rV#+v79r!cMWx_~+=w0*hTy>37I@o*rjJjF+j70}W z%P8K4hTxA@47xas#zEhWF=yC3Xx4JpT?s#u5(8X!-`u0^iorSl# z9@TQy;W$gZfj5m${Ynwrp)L|UiFL+dd0Xo(R&m){@zi9a7C#a`Y!8_}r_xT<1rA6K7@exyv8-s@IH(Pk%RVM_&z zg=L8c@0fo~{d%w4^8Wn1>(?o#?AiR{3;8cTAuFFn={pe*8+AVEM!Dl76xC0;zjyR={t7H!!4@Eo}{m5t4fACw@B(_yOi;Jqg ze!o}6mvB{I3#zWyN*~OI&*I*5`^Jj#ywx>^EqtD<qh)QIUL zRk8Bvggsv_3on}??vr5{u}UQ8(0(forx59()$a69Zj0hMGwS|7yjItYK$|OJ+_hz= z2h5JP%CpWKSuOj9-IZ->`0C681M51#RP573@$ z{bUZbr>18bz#ZN|Qy**Yl(!ushi*9Oo zp6C&Cv2V{Z_OyCk=beiWS3IwHlzL6)9qy)C^{l_B?4Zz3elpA1>Ju+Fk*i#t!zK1cgIav-F{S-Ny}>ZXMJR;Y^C z)=}wXza12jTV+_@Lzli&Cxy5b?nBiiHeAVAs~$_-g>~2K5~26DS66f^G(#XDyPTb=H22O6rN%~y5oLl!3BD?L|ruZ+=A5o^~#*(*ym8ky@_jJX`) z&+S*~kJ9+Omb$lo1RXR-bH{fyCVi1H)~!ydnt>kT%rYMHn4eq^SLIKgJ6TjO%U6A0 ztonI#!M`v?VPpzs!2#*J*Tz6^&(~lo@7L*)RfTyisEWHFN-w;crhRE+w^kvcSHrmS z9>yRXnv4i{iBx*ndtdkP^XBb0RcW+4Uwx4U)8U$)87qZzf?0;Q%Hb(p515JS@mfCb8e{hd?5nV)Yp-S(jTqa!M8aQ9 z&|OxW@Do0$Vut1`D{sj9Ev<13;dui`Fx zR99szX+_Q}v0O%u3`I?Hf+bF;*N}^_mBXjy7wm^wz$v| z++YRbaI7d3+cnhvhiyz__4#-D&1K0GEKL3AAB@FY+hy=mevUKa`<;No#CosetP_wk zuj-*#rg4-xtJC8viZ6%kb#j$;wBqbC#9ZJ>r&axr10c2?X)77$P-9w00k^ZWS^5f2 z^W8qe7Id}G)2?(^RMmyA>rk(eXX^fiITmb%w;-r<`omW>7~I8JW}g?Kf^yqE7gf)l z8poOtj&-W`iUj7e%0pCrY-9B)+gZf$U zg1aCHQ-!9wTiadZ?8jO|);H%l#N7J$hqvl+;>pf0(*gM!Yppso(smK$!>u0USJ^*(1({|7%>HEyCEGppO~PA3}cP_;KTMZ9{LbNBTa z+T8SC^2v57F0wdyjG{w;FY~}0cql1*IzvMPJ#v+hW#-=0UEcHj)+YwGT9h_s5hbdU zcdRO@CJgUp$iv(&1VpV$Oc`^Xa5aAjwa_3RtzPGV>YRkDMm4pz;>Icqc=_q35qpmK zubQ*{wRS3?+BG-S8Ow6y*r;osSACDW@>VKCdxq@>`Mu>5ZB^xpvYn(lrLe^z<_R00 zoR!C&J?=UreJl8~Pl%`VxO^vGgGHFEXSBN4+|rJnX@qX)^!mHv^*iUZ(mK7Y>csI^ zSd%@Qu{zlu=Dt18yk$XYrhI zhgn$tSgV|z^j)pzbMeXlH$$Gc9sAhbx>>`*P>!;-yua?G$E!Kjqs%vhjH?hfs=TPEgh;n}_{i{5? zF4xYMRTW+(%tPa=dZe%=N4Z8WsdEeIVA>E<<-0mr3VGB6rgHoIiEezmQaG#nEsw~- zb4lEtYR~SHndDkH<2opflJCmf)1I&>ww^(5$Fy`X4L5G15Qokt+9!jmI@b_H8SHgn zF&@iLc;6Z2JiOoL1$dn5Gw6jZE{Kx1yW^fMx+xs2}kknx(x1yn($4Wh? zg)*vp_B1c9heOpv!7nn|US^dp?QX|LeF!_f$Ha9VsG;+YAut!IZ``_6y?b^Y8`!E- znx0_fdMX@>Xry?23BF1Z>z2qSIg+x(aTJJCOIdi+RU~@aT$Xdnb!vyLPAuajTn0<^ zw6N9N^V4aN--b$+We#zCYX;#MHXx)k}JNL%W=x zA7jRtjf4W!TZbqoMsr!=sS|WK3-apql-Dw4St7gZlX7+QiCcSkZwG31Qv1xf%e^nd zR)4=YU(es~?1|rLhI|RV?Z^6fx;QC@s`o(sRwe4K2!4NZxuUK<>Bp_t5Wh>?Gnli+2rd~CijjHnhE}$9>OkF$qREBRe9AW zGWhmK<0(0mDi#a;Rjxx;or@Ee^GtRs8atP`8B~G9S`;rASXZPx$lpVj-JM$-mf6k& zSQX^=74A ziAU*H9oB7EC)OLqT>1^7Au709F4a-7GSI6#LxJ0|vhtiqS4?4GimvlnM=Pe9(}E^g zqxbdRvGc3*IUc~pIw@vGnZ%}^0|g!*w|7-dWo+)%*oh49vGjIy_(YH9AX<73umx9q z3stdW`00`P=CrN@{z+ekq8Js*>dePsMofk9H+yp7PM}k?Z4P}J!&GW*LhnQ3)QVE${l=5O6Oeb&Zp|qROo}nGQ zorCYJ#qdw{BK#CaR+I6domCuqjb?WF)4qkrW%8(`)Sv{2IAWynJ z5`sckd{s4A?|q#tOebDpwEYoLR+rQf5Eo}&>Nc)Efh&q_ymFyyd1zv4$OXVvmAp9V zWL3e#Rowt_f`oh)=Nd+;_MpAaf{cc)a=QxAcX69Fkmt_rQ$sE#hX`FxTGO+xJc4S7 zQe#mU-9j^|-#&j8C;N#M!+488`QUl$l*W-zl`Ap6^&KEd{byGYbEStqo5#v!VXR!E zx#XVBq8hZ7%4MnLWG3Xdw_&Z@v4O7LW2bJr3K6#a3@4mlNe#_QPKDSiL)Vtqv#C3m z^We0+(QN$_>fSxoyI-s`vwB!M!Ky>tRxK%a$~G04{4+OyH<6m~LHNJ>WxxgQF{W^p zmrD26!%7Q>tC&EBz!ohk&h?hVz*)rex-yl<>!o8)SgKAmGWA9HhbM1+)I-$)=g)K; z(x~;b@H{lgOZcdNlfq#s&Px9B@MEuXV3lFot$wQiMm1zy`Q6Ted@xeJ1WL;DbPz-J znU7W--h9r*`+0gHf6PRVbm9Yp_cLWY zXPm2q&#K$sRDJ{C1Ps3xesnu8Y^ zn5HMX4OX1{rGn6PJ9fstNBa+E?jme#wwD15 z@M$50-Re+8#giiXyl2qGr-k!F)G#LzK4iZ9=pjz4UPDx0kqNmPd!_bF+hd6VJL!;y z>t@v*k>O&o6HOsZ?s6VI@>+Ez)Zv+2Qr8*?PD3;VH4(#5p?!o}W z)!DQ-Oc{?jt*hd*8#$ebRfbfAH`|T~eCY#WcW8j$_R7dlQRl^{HX2tzz<6=e^o1POg*X`RbLm141o4FlTE|#q%26 z6U6XZon6YYhq9c#?_e!C4_|5iJQn}?bn-AJHY3@MJ9XggW>UAC8LGpy53>9(jLDcP zM|yAW)*;toRbKWQ+xf0C%%{XVF%~7vwXY^j{UOa9WK+G!c1bYTt+FVTD1`yo!ousM zK#RNyJs}MGF;pp<~#TI|Yf4tZ==nb`og?Uz-ne20-LuYhN*=Hch6sOlBvmLauX88@0rg{`V0nZg5_LmnP>>h`9An0W50 z?_l9PlpOY`~bBuLsb4o5N zcs$tJ_{w66QAev?H7J59cVG1Id7OsZWV_kQ@pcv#3G6Ns{qC3!NhrR#vV za`WM;&#MW|R>tV;L07RE}idcgLZLqyPa6(=BMPHOwPMh%flA8*h7r# zk6}Za8ya$1W34$T`CPYD#M(P)w2j#3Tu$A4jOEO;xN{C+!3@%1dxq@+#3GX|B)qU5T4bW@IoA}vk;F#L8!K=zf#)yLKwx`f;m9>Tb z|8o{crOU9#H>V51-J9nsL%-FXUaSk8TBSNn`7F$0zsxAYMp3}W>nr%!EXUDuqrH+k z6lSr0gXc*LWr`TJ|Au$C*ObLd=Uehus&ik->B=ccqnT7urppH164i&Xd-nWxO4iO_ zb0|u54m+t4tvOufw(|FWsy%vueV=dUiPIR&JalYn%VUCi(*BDFI$aBv-WMM5H zRc_xNO57te@{9EzFjlOEvqBW5rK$rbX5vV$G9ZvgK zX4t*-TyYVmRE1rwSM=&SVvly##hp&2TUHV>>K>%a)fAo05-a^}nkaU>z9b{PADj#T}@zU=U+*pi_9$MG*E+;h| zY|XJ4KjNo0qd0oqEYB_m&pC@e*Dt9qm)q$B+{~v#l9S^8hJd?db}j(F^^iZy8~#9_ z^3#Lev6VG+V#JIngmD^UXG$T6hjPlgo%K9BrlOF(4_mREwOGaL`CwKx&ArzZs4|JM zc$OYU76q~oc0i_u!RBivRn%WSE>;Y|3b6ZU*0ye{0BcNr}wyhU1P6j z-|zC&l(1bruTm{nm(%^PKAx3K=fnw**1v7VeU7<&RexlzAM17f8Wx0EoYHHxr&KR$ zJ0eAdPxo1Isl(9MtiA8$M4(F^mDzYZ#)2<*y|8ZF2&B{RJ>nrNoM!&jqI{*w1iIRX&b>e7M zr-gIBqETMgRY?ER#r6%TWLbr^t3vcpcpa-#70m&qgl0v-^o803u+G(nvO0E(sf;r& zD<9xWMH|!NbFovG~|Jr!lR#jzbO?vaG)~kXevQpK?GHw>Wt9 zIv>@p;fXu>`k{(CS~58=4^^0;sI4yX(au9>iHb6(%iVQlM<}B~^tFF+eGZUQUmEuasqAl}I z_}JrCPGz_M!<_n1KGY}ew4BxPoUzJ~#i@o5BM>8R99H+HiL2(^LztOTZr7DSSDjth zQZ~5zxPXP;YPP^r>0#&*Ul)Y{P_R3>97no+kA#bME z8aq%G&#U4Q?M?}aOJn2I?>rkI7-051wqS3=8Ch0DWrAmb zQ)3QU93qA>V(4zHvbR7AO5UyMmHFLwuX-0c1{D`~k(sW4n+AyBag@0qd z^^>l&Zm6ynB+b$LTtxD`W^0xq%Ni7gPMhdAP!6jI5$pTr7v)BkM?0UoCp;Avql5j0 z$DHLdDt#$uxE<_qeTum7tySJe6K7pcq8+kDO{?+s+~b9gvwV~ud- zYC}35LU52(URDoPw6SncfrBkG$f-TR`c{Q^#8?S_a%S~wTz`QijIrdOf4>UZ5 zE0rKzwXcBNVA0%GVaYjF0iO3Nva>8L->tv6SpWH~u!Oxr6z63oxOa6{S2D>D_fpZN zv`)Dnt9L#<9LY7x{nXHkn_HZf9@cY^NxtiQ&+9^ZD8~BlI$HSME5%yYPlSx3w=!fX z;-@+fZf5iX&g4PskFl|bXQUdtZ?~0d%%Q>>R%dvh;+6BeCckPBgT+q~%8b zDMidtrG(XsUELP1nHeSQdVAhR=7})>5Z_m`*UNz{3Ss3N<6L@uEqJ;PYx5AhVuT|+ z=%M)Jg~dM|?0kh9V!ml=nA^fn>$*dhuZBGF6yJKR?%oOm{yvV56LeunTm@NT1YL7m z<%x`Gq~)|YhOh2D(TRO(*vTw42j0Rk5M8&#cw*S<_3lf<|K?Uw$Du0Slpp4Tqh*)p zvm!Eld=j>DB*xWP)#m9OZj#-dW~jfFuZO&%k`k6bp)@v}!so4X+G#;*jbE|tx^WQJ zo&pu*>!*yqBcCvx-?0YfRDDy$jz!8)Na&idl(1aqMKG&Thc8(%q|q1OOq1Pceki5nt`6rXul!9P88_Eu7osQDtc^i_?f% zeN550tN00H)x)YY3V9Ia=WdDlw$a(B4u8>utxxy@QO7zQhQ(Bbm$ce{XRI=Tol~s`5oV zFnsV?Q~-0qPh%UxeB4*Nrls}g(sI#Z)$<-GoL7N~KIsh> zL6FI1(6@-@86g0AI>w^SPazbsIX;p`qcU7Urr>JD@Qw-JQ>Sj*^WU=LWD zYvDlhi+;vW$GhT*wVQ$LfGm-h-Q|P#IbH9k6DdZ1Jy(@J=H2~NY|u3fIZeD#7q)vN z<`^M>A{!IV;g_sAChDrgdhB7#S78oo`5Y3lmW(k&^MWmSH6K2>zg;6H@;e7fL14aE zte+d(LKbzTgY)iwx}H#R^!utz-M{v_^NnR`9zIp`KCQ=Ax%IS9Hkh~h8gtyuo4FH# znBWm^l$uyGjL#~dC&=WKxh%idbig~eBE~>jwbduladcoC=YL)83W>yte-Hfrs=Dg%1B3Qp! z6%mRjtJPBK^iFih5;K6P)DgyXiq-UK4VI{O_I$aO7$3u|j{@6tC9F3;nIj_J#|C;{ zT*~I(Z^?>q<=J{}yleH!{M68j%KYnxbCBr-6_hy`Ev7B6sG&;0Gjb)O0R?b?XF46A zXENTB=k0m=NKu@k%iF@8n!Izoe2w)FqvjwxM8e3YSAMsU) z>npz3nH2eR9b^iNgYyh=y*0G1DFH;K+59_Kg_XuqI=;T9OIN3^?~4(}>x6?j;d6Y| z867$`Ha0gIEZ3Wr`pJVw-2C7z<%hAlSm7{*S+^0hSHb5SRY4G)PZArQp|Mts3PaxL zps2*vel{GIYt(5t#e>H0HN2WNtScpB%abw~L#2Sa@%6gku3X4rr5t5=k*JdLJ`J;e zMs7r{EsPnhhq23MpXIKaM|~`F$hXUp7*~bf=<^P8WBFj`b9r4(_a2(k!W?gBv5Eve zTr~2#_&W}kD`kmSuSU&p$mc0sJt}o>S5yvTsUSW}HJAOmjI@NpSMOr1X?z{6*hXhZ zriep2XUu7MbxL^hdGn}8A^Ul(YBLN_CVCwmO%EDhzR-D`6}~W-NECR*_%sG8N(HPg*&sG$11Z^4YTk%P+SbAha%v-V)uHbc2!H&hsDF^Ik0?5(ZE=# zypJjZj)W%h40ygew;ZW++vv>zu3#+YqKz1WEg?*9T=#pQ(hKPDa* z%XD*^&&|ct`1%j>gVO(VDqmh844N4aEXUgYy%m0gWzOB8NtruK3 z=2RS7WC0w}z4DS)nVB!gRQSDug~7KNB%k}mr?}< zRmBRbHfK>V6X;1Hii*!*%3JjZCgg(CLYRVz^fx|Y%eR%(P zB7DW2c!TkB6|UfBNJ;~ZU5@ZqzMoGvbF*k2$_p9SE7gxPnnq}pM(%HcK#>0QG-HU0 z%hD9@r-bqbZ_8}YUY7TYQ;pWsY>XeS8gnNUIj)>MJV9K$qm;1f*NppJ*=B7#%6Uv& zC#b8b#G*BB?el@q)!Fel7q-eEF5cH1kF!ANuLc*|$*-quc(pG^(rd9koI)@ap~ zQKX2CiB^ZJ*sBrCiQ=?~iA7h*6ambzg gagpV6MtxH5aE`cKSFyU#%30@f1lAjOh-ts(U(O~_LI3~& literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/harp.wav b/FreeFileSync/Build/Resources/harp.wav new file mode 100644 index 0000000000000000000000000000000000000000..87742dca6422e53d3c16f62fe4f7f775a1aa36a8 GIT binary patch literal 182060 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/notify.wav b/FreeFileSync/Build/Resources/notify.wav new file mode 100644 index 0000000000000000000000000000000000000000..5263e32ebf3b2ffc762e1999b80259f749d16e12 GIT binary patch literal 54890 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 ?;>Sn0fE|MS9rEEys+3-5 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 B6?p&v literal 0 HcmV?d00001 diff --git a/FreeFileSync/Build/Resources/remind.wav b/FreeFileSync/Build/Resources/remind.wav new file mode 100644 index 0000000000000000000000000000000000000000..d5b47041bde4f52f0673c82a5daf62dff69bbd4e GIT binary patch literal 59894 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 literal 0 HcmV?d00001 diff --git a/FreeFileSync/Source/Makefile b/FreeFileSync/Source/Makefile new file mode 100644 index 0000000..9a9e8aa --- /dev/null +++ b/FreeFileSync/Source/Makefile @@ -0,0 +1,135 @@ +CXX ?= g++ +exeName = FreeFileSync_$(shell arch) + +CXXFLAGS += -std=c++23 -pipe -DWXINTL_NO_GETTEXT_MACRO -I../.. -I../../zenXml -include "zen/i18n.h" -include "zen/warn_static.h" \ + -Wall -Wfatal-errors -Wmissing-include-dirs -Wswitch-enum -Wcast-align -Wnon-virtual-dtor -Wno-unused-function -Wshadow -Wno-maybe-uninitialized \ + -O3 -DNDEBUG `wx-config --cxxflags --debug=no` -pthread + +LDFLAGS += -s `wx-config --libs std, aui, richtext --debug=no` -pthread + + +CXXFLAGS += `pkg-config --cflags openssl` +LDFLAGS += `pkg-config --libs openssl` + +CXXFLAGS += `pkg-config --cflags libcurl` +LDFLAGS += `pkg-config --libs libcurl` + +CXXFLAGS += `pkg-config --cflags libidn2` +LDFLAGS += `pkg-config --libs libidn2` + +CXXFLAGS += `pkg-config --cflags libssh2` +LDFLAGS += `pkg-config --libs libssh2` + +CXXFLAGS += `pkg-config --cflags gtk+-3.0` +#treat as system headers so that warnings are hidden: +CXXFLAGS += -isystem/usr/include/gtk-3.0 + +#support for SELinux (optional) +SELINUX_EXISTING=$(shell pkg-config --exists libselinux && echo YES) +ifeq ($(SELINUX_EXISTING),YES) +CXXFLAGS += `pkg-config --cflags libselinux` -DHAVE_SELINUX +LDFLAGS += `pkg-config --libs libselinux` +endif + +cppFiles= +cppFiles+=application.cpp +cppFiles+=base_tools.cpp +cppFiles+=config.cpp +cppFiles+=ffs_paths.cpp +cppFiles+=icon_buffer.cpp +cppFiles+=localization.cpp +cppFiles+=log_file.cpp +cppFiles+=status_handler.cpp +cppFiles+=base/algorithm.cpp +cppFiles+=base/binary.cpp +cppFiles+=base/comparison.cpp +cppFiles+=base/db_file.cpp +cppFiles+=base/dir_lock.cpp +cppFiles+=base/file_hierarchy.cpp +cppFiles+=base/icon_loader.cpp +cppFiles+=base/multi_rename.cpp +cppFiles+=base/parallel_scan.cpp +cppFiles+=base/path_filter.cpp +cppFiles+=base/speed_test.cpp +cppFiles+=base/structures.cpp +cppFiles+=base/synchronization.cpp +cppFiles+=base/versioning.cpp +cppFiles+=afs/abstract.cpp +cppFiles+=afs/concrete.cpp +cppFiles+=afs/ftp.cpp +cppFiles+=afs/gdrive.cpp +cppFiles+=afs/init_curl_libssh2.cpp +cppFiles+=afs/native.cpp +cppFiles+=afs/sftp.cpp +cppFiles+=ui/batch_config.cpp +cppFiles+=ui/abstract_folder_picker.cpp +cppFiles+=ui/batch_status_handler.cpp +cppFiles+=ui/cfg_grid.cpp +cppFiles+=ui/command_box.cpp +cppFiles+=ui/folder_history_box.cpp +cppFiles+=ui/folder_selector.cpp +cppFiles+=ui/file_grid.cpp +cppFiles+=ui/file_view.cpp +cppFiles+=ui/log_panel.cpp +cppFiles+=ui/tree_grid.cpp +cppFiles+=ui/gui_generated.cpp +cppFiles+=ui/gui_status_handler.cpp +cppFiles+=ui/main_dlg.cpp +cppFiles+=ui/progress_indicator.cpp +cppFiles+=ui/rename_dlg.cpp +cppFiles+=ui/search_grid.cpp +cppFiles+=ui/small_dlgs.cpp +cppFiles+=ui/sync_cfg.cpp +cppFiles+=ui/tray_icon.cpp +cppFiles+=ui/triple_splitter.cpp +cppFiles+=ui/version_check.cpp +cppFiles+=../../libcurl/curl_wrap.cpp +cppFiles+=../../zen/argon2.cpp +cppFiles+=../../zen/file_access.cpp +cppFiles+=../../zen/file_io.cpp +cppFiles+=../../zen/file_path.cpp +cppFiles+=../../zen/file_traverser.cpp +cppFiles+=../../zen/http.cpp +cppFiles+=../../zen/zstring.cpp +cppFiles+=../../zen/format_unit.cpp +cppFiles+=../../zen/legacy_compiler.cpp +cppFiles+=../../zen/open_ssl.cpp +cppFiles+=../../zen/process_priority.cpp +cppFiles+=../../zen/recycler.cpp +cppFiles+=../../zen/resolve_path.cpp +cppFiles+=../../zen/process_exec.cpp +cppFiles+=../../zen/shutdown.cpp +cppFiles+=../../zen/sys_error.cpp +cppFiles+=../../zen/sys_info.cpp +cppFiles+=../../zen/sys_version.cpp +cppFiles+=../../zen/thread.cpp +cppFiles+=../../zen/zlib_wrap.cpp +cppFiles+=../../wx+/darkmode.cpp +cppFiles+=../../wx+/file_drop.cpp +cppFiles+=../../wx+/grid.cpp +cppFiles+=../../wx+/image_tools.cpp +cppFiles+=../../wx+/graph.cpp +cppFiles+=../../wx+/taskbar.cpp +cppFiles+=../../wx+/tooltip.cpp +cppFiles+=../../wx+/image_resources.cpp +cppFiles+=../../wx+/popup_dlg.cpp +cppFiles+=../../wx+/popup_dlg_generated.cpp +cppFiles+=../../xBRZ/src/xbrz.cpp + +tmpPath = $(shell dirname "$(shell mktemp -u)")/$(exeName)_Make + +objFiles = $(cppFiles:%=$(tmpPath)/ffs/src/%.o) + +all: ../Build/Bin/$(exeName) + +../Build/Bin/$(exeName): $(objFiles) + mkdir -p $(dir $@) + $(CXX) -o $@ $^ $(LDFLAGS) + +$(tmpPath)/ffs/src/%.o : % + mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -rf $(tmpPath) + rm -f ../Build/Bin/$(exeName) diff --git a/FreeFileSync/Source/RealTimeSync/Makefile b/FreeFileSync/Source/RealTimeSync/Makefile new file mode 100644 index 0000000..b43e284 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/Makefile @@ -0,0 +1,68 @@ +CXX ?= g++ +exeName = RealTimeSync_$(shell arch) + +CXXFLAGS += -std=c++23 -pipe -DWXINTL_NO_GETTEXT_MACRO -I../../.. -I../../../zenXml -include "zen/i18n.h" -include "zen/warn_static.h" \ + -Wall -Wfatal-errors -Wmissing-include-dirs -Wswitch-enum -Wcast-align -Wnon-virtual-dtor -Wno-unused-function -Wshadow -Wno-maybe-uninitialized \ + -O3 -DNDEBUG `wx-config --cxxflags --debug=no` -pthread + +LDFLAGS += -s `wx-config --libs std, aui, richtext --debug=no` -pthread + + +CXXFLAGS += `pkg-config --cflags gtk+-3.0` +#treat as system headers so that warnings are hidden: +CXXFLAGS += -isystem/usr/include/gtk-3.0 + +cppFiles= +cppFiles+=application.cpp +cppFiles+=config.cpp +cppFiles+=gui_generated.cpp +cppFiles+=main_dlg.cpp +cppFiles+=tray_menu.cpp +cppFiles+=monitor.cpp +cppFiles+=folder_selector2.cpp +cppFiles+=../afs/abstract.cpp +cppFiles+=../base/icon_loader.cpp +cppFiles+=../ffs_paths.cpp +cppFiles+=../icon_buffer.cpp +cppFiles+=../localization.cpp +cppFiles+=../../../wx+/darkmode.cpp +cppFiles+=../../../wx+/file_drop.cpp +cppFiles+=../../../wx+/image_tools.cpp +cppFiles+=../../../wx+/image_resources.cpp +cppFiles+=../../../wx+/popup_dlg.cpp +cppFiles+=../../../wx+/popup_dlg_generated.cpp +cppFiles+=../../../wx+/taskbar.cpp +cppFiles+=../../../xBRZ/src/xbrz.cpp +cppFiles+=../../../zen/dir_watcher.cpp +cppFiles+=../../../zen/file_access.cpp +cppFiles+=../../../zen/file_io.cpp +cppFiles+=../../../zen/file_path.cpp +cppFiles+=../../../zen/file_traverser.cpp +cppFiles+=../../../zen/format_unit.cpp +cppFiles+=../../../zen/legacy_compiler.cpp +cppFiles+=../../../zen/resolve_path.cpp +cppFiles+=../../../zen/process_exec.cpp +cppFiles+=../../../zen/shutdown.cpp +cppFiles+=../../../zen/sys_error.cpp +cppFiles+=../../../zen/sys_info.cpp +cppFiles+=../../../zen/sys_version.cpp +cppFiles+=../../../zen/thread.cpp +cppFiles+=../../../zen/zstring.cpp + +tmpPath = $(shell dirname "$(shell mktemp -u)")/$(exeName)_Make + +objFiles = $(cppFiles:%=$(tmpPath)/ffs/src/rts/%.o) + +all: ../../Build/Bin/$(exeName) + +../../Build/Bin/$(exeName): $(objFiles) + mkdir -p $(dir $@) + $(CXX) -o $@ $^ $(LDFLAGS) + +$(tmpPath)/ffs/src/rts/%.o : % + mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -rf $(tmpPath) + rm -f ../../Build/Bin/$(exeName) diff --git a/FreeFileSync/Source/RealTimeSync/app_icon.h b/FreeFileSync/Source/RealTimeSync/app_icon.h new file mode 100644 index 0000000..2e47322 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/app_icon.h @@ -0,0 +1,27 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef APP_ICON_H_8914578394545342 +#define APP_ICON_H_8914578394545342 + +#include +#include + +namespace zen +{ +inline +wxIcon getRtsIcon() //see FFS/app_icon.h +{ + assert(loadImage("RealTimeSync").GetWidth () == loadImage("RealTimeSync").GetHeight() && + loadImage("RealTimeSync").GetWidth() == dipToScreen(128)); + wxIcon icon; + icon.CopyFromBitmap(loadImage("RealTimeSync", dipToScreen(64))); + return icon; + +} +} + +#endif //APP_ICON_H_8914578394545342 diff --git a/FreeFileSync/Source/RealTimeSync/application.cpp b/FreeFileSync/Source/RealTimeSync/application.cpp new file mode 100644 index 0000000..8f72c0c --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/application.cpp @@ -0,0 +1,223 @@ +// ***************************************************************************** +// * 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 "application.h" +#include "main_dlg.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "../localization.h" +#include "../ffs_paths.h" +#include "../return_codes.h" + + #include + +using namespace zen; +using namespace rts; + +using fff::FfsExitCode; + +#ifdef __WXGTK3__ //deprioritize Wayland: see FFS' application.cpp + GLOBAL_RUN_ONCE(::gdk_set_allowed_backends("x11,*")); //call *before* gtk_init() +#endif + +IMPLEMENT_APP(Application) + + +namespace +{ +void notifyAppError(const std::wstring& msg) +{ + //error handling strategy unknown and no sync log output available at this point! + std::cerr << utfTo(_("Error") + L": " + msg) + '\n'; + //alternative0: std::wcerr: cannot display non-ASCII at all, so why does it exist??? + //alternative1: wxSafeShowMessage => NO console output on Debian x86, WTF! + //alternative2: wxMessageBox() => works, but we probably shouldn't block during command line usage +} +} + + +bool Application::OnInit() +{ + //do not call wxApp::OnInit() to avoid using wxWidgets command line parser + + initExtraLog([](const ErrorLog& log) //don't call functions depending on global state (which might be destroyed already!) + { + std::wstring msg; + for (const LogEntry& e : log) + msg += utfTo(formatMessage(e)); + trim(msg); + notifyAppError(msg); + }); + + //tentatively set program language to OS default until GlobalSettings.xml is read later + try { fff::localizationInit(appendPath(fff::getResourceDirPath(), Zstr("Languages.zip"))); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + + GlobalConfig globalCfg; + try { globalCfg = getGlobalConfig(); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + + try { fff::setLanguage(globalCfg.programLanguage); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + + try { imageResourcesInit(appendPath(fff::getResourceDirPath(), Zstr("Icons.zip"))); } + catch (const FileError& e) { logExtraError(e.toString()); } //not critical in this context + + //GTK should already have been initialized by wxWidgets (see \src\gtk\app.cpp:wxApp::Initialize) +#if GTK_MAJOR_VERSION == 2 + ::gtk_rc_parse(appendPath(fff::getResourceDirPath(), "Gtk2Styles.rc").c_str()); + + //fix hang on Ubuntu 19.10 (see FFS's application.cpp) + [[maybe_unused]] GVfs* defaultFs = ::g_vfs_get_default(); //not owned by us! + +#elif GTK_MAJOR_VERSION == 3 + auto loadCSS = [&](const char* fileName) + { + GtkCssProvider* provider = ::gtk_css_provider_new(); + ZEN_ON_SCOPE_EXIT(::g_object_unref(provider)); + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + ::gtk_css_provider_load_from_path(provider, //GtkCssProvider* css_provider + appendPath(fff::getResourceDirPath(), fileName).c_str(), //const gchar* path + &error); //GError** error + if (error) + throw SysError(formatGlibError("gtk_css_provider_load_from_path", error)); + + ::gtk_style_context_add_provider_for_screen(::gdk_screen_get_default(), //GdkScreen* screen + GTK_STYLE_PROVIDER(provider), //GtkStyleProvider* provider + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); //guint priority + }; + try + { + loadCSS("Gtk3Styles.css"); //throw SysError + } + catch (const SysError& e) + { + std::cerr << "[RealTimeSync] " + utfTo(e.toString()) + "\n" "Loading GTK3\'s old CSS format instead..." "\n"; + try + { + loadCSS("Gtk3Styles.old.css"); //throw SysError + } + catch (const SysError& e3) { logExtraError(_("Failed to update the color theme.") + L"\n\n" + e3.toString()); } + } +#else +#error unknown GTK version! +#endif + + /* we're a GUI app: ignore SIGHUP when the parent terminal quits! (or process is killed!) + => the FFS launcher will still be killed => fine + => macOS: apparently not needed! interestingly the FFS launcher does receive SIGHUP and *is* killed */ + if (sighandler_t oldHandler = ::signal(SIGHUP, SIG_IGN); + oldHandler == SIG_ERR) + logExtraError(_("Error during process initialization.") + L"\n\n" + formatSystemError("signal(SIGHUP)", getLastError())); + else assert(!oldHandler); + + + try { colorThemeInit(*this, globalCfg.appColorTheme); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } //not critical in this context + + //Windows User Experience Interaction Guidelines: tool tips should have 5s timeout, info tips no timeout => compromise: + wxToolTip::Enable(true); //wxWidgets screw-up: wxToolTip::SetAutoPop is no-op if global tooltip window is not yet constructed: wxToolTip::Enable creates it + wxToolTip::SetAutoPop(15'000); //https://docs.microsoft.com/en-us/windows/win32/uxguide/ctrl-tooltips-and-infotips + + SetAppName(L"RealTimeSync"); //if not set, defaults to executable name + + + auto onSystemShutdown = [](int /*unused*/ = 0) + { + onSystemShutdownRunTasks(); + + //it's futile to try and clean up while the process is in full swing (CRASH!) => just terminate! + terminateProcess(static_cast(FfsExitCode::cancelled)); + }; + Bind(wxEVT_QUERY_END_SESSION, [onSystemShutdown](wxCloseEvent& event) { onSystemShutdown(); }); //can veto + Bind(wxEVT_END_SESSION, [onSystemShutdown](wxCloseEvent& event) { onSystemShutdown(); }); //can *not* veto + if (auto /*sighandler_t n.a. on macOS*/ oldHandler = ::signal(SIGTERM, onSystemShutdown);//"graceful" exit requested, unlike SIGKILL + oldHandler == SIG_ERR) + logExtraError(_("Error during process initialization.") + L"\n\n" + formatSystemError("signal(SIGTERM)", getLastError())); + else assert(!oldHandler); + + //Note: app start is deferred: -> see FreeFileSync + CallAfter([&] { onEnterEventLoop(); }); + + return true; //true: continue processing; false: exit immediately +} + + +void Application::onEnterEventLoop() +{ + //wxWidgets app exit handling is weird... we want to exit only if the logical main window is closed, not just *any* window! + wxTheApp->SetExitOnFrameDelete(false); //prevent popup-windows from becoming temporary top windows leading to program exit after closure + ZEN_ON_SCOPE_EXIT(if (!wxTheApp->GetExitOnFrameDelete()) wxTheApp->ExitMainLoop()); //quit application, if no main window was set (batch silent mode) + + //try to set config/batch- filepath set by %1 parameter + std::vector commandArgs; + + try + { + for (int i = 1; i < argc; ++i) + { + const Zstring& filePath = getResolvedFilePath(utfTo(argv[i])); +#if 0 + if (!fileAvailable(filePath)) //...be a little tolerant + for (const Zchar* ext : {Zstr(".ffs_real"), Zstr(".ffs_batch")}) + if (fileAvailable(filePath + ext)) + filePath += ext; +#endif + if (endsWithAsciiNoCase(filePath, Zstr(".ffs_real")) || + endsWithAsciiNoCase(filePath, Zstr(".ffs_batch"))) + commandArgs.push_back(filePath); + else + throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(filePath)), + _("Unexpected file extension:") + L' ' + fmtPath(getFileExtension(filePath)) + L'\n' + + _("Expected:") + L" ffs_real, ffs_batch"); + } + + Zstring cfgFilePath; + if (!commandArgs.empty()) + cfgFilePath = commandArgs[0]; + + MainDialog::create(cfgFilePath); + } + catch (const FileError& e) + { + notifyAppError(e.toString()); + } +} + + +int Application::OnExit() +{ + [[maybe_unused]] const bool rv = wxClipboard::Get()->Flush(); //see wx+/context_menu.h + //assert(rv); -> fails if clipboard wasn't used + fff::localizationCleanup(); + imageResourcesCleanup(); + return wxApp::OnExit(); +} + + +wxLayoutDirection Application::GetLayoutDirection() const { return languageLayoutIsRtl() ? wxLayout_RightToLeft : wxLayout_LeftToRight; } + + +int Application::OnRun() +{ +#if wxUSE_EXCEPTIONS +#error why is wxWidgets uncaught exception handling enabled!? +#endif + + //exception => Windows: let it crash and create mini dump!!! Linux/macOS: std::exception::what() logged to console + [[maybe_unused]] const int rc = wxApp::OnRun(); + return static_cast(FfsExitCode::success); //process exit code +} diff --git a/FreeFileSync/Source/RealTimeSync/application.h b/FreeFileSync/Source/RealTimeSync/application.h new file mode 100644 index 0000000..a39cf04 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/application.h @@ -0,0 +1,27 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef APPLICATION_H_18506781708176342677 +#define APPLICATION_H_18506781708176342677 + +#include + + +namespace rts +{ +class Application : public wxApp +{ +private: + bool OnInit() override; + int OnRun () override; + int OnExit() override; + wxLayoutDirection GetLayoutDirection() const override; + + void onEnterEventLoop(); +}; +} + +#endif //APPLICATION_H_18506781708176342677 diff --git a/FreeFileSync/Source/RealTimeSync/config.cpp b/FreeFileSync/Source/RealTimeSync/config.cpp new file mode 100644 index 0000000..56d34e3 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/config.cpp @@ -0,0 +1,221 @@ +// ***************************************************************************** +// * 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 "config.h" +#include +#include +#include +#include +#include "../ffs_paths.h" + +using namespace zen; +using namespace rts; + +//------------------------------------------------------------------------------------------------------------------------------- +const int XML_FORMAT_RTS_CFG = 2; //2020-04-14 +//------------------------------------------------------------------------------------------------------------------------------- + + +namespace zen +{ +template <> inline +bool readText(const std::string& input, wxLanguage& value) +{ + if (const wxLanguageInfo* lngInfo = wxUILocale::FindLanguageInfo(utfTo(input))) + { + value = static_cast(lngInfo->Language); + return true; + } + return false; +} + + +template <> inline +bool readText(const std::string& input, ColorTheme& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Default") + value = ColorTheme::System; + else if (tmp == "Light") + value = ColorTheme::Light; + else if (tmp == "Dark") + value = ColorTheme::Dark; + else + return false; + return true; +} +} + + +namespace +{ +std::string getConfigType(const XmlDoc& doc) +{ + if (doc.root().getName() == "FreeFileSync") + { + std::string type; + if (doc.root().getAttribute("XmlType", type)) + return type; + } + return {}; +} + + +void readConfig(const XmlIn& in, FfsRealConfig& cfg, int /*formatVer*/) +{ + in["Directories"](cfg.directories); + in["Delay" ](cfg.delay); + in["Commandline"](cfg.commandline); +} + + +void writeConfig(const FfsRealConfig& cfg, XmlOut& out) +{ + out["Directories"](cfg.directories); + out["Delay" ](cfg.delay); + out["Commandline"](cfg.commandline); +} +} + + +std::pair rts::readConfig(const Zstring& filePath) //throw FileError +{ + XmlDoc doc = loadXml(filePath); //throw FileError + + if (getConfigType(doc) != "REAL") + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath))); + + int formatVer = 0; + /*bool success =*/ doc.root().getAttribute("XmlFormat", formatVer); + + XmlIn in(doc); + FfsRealConfig cfg; + ::readConfig(in, cfg, formatVer); + + std::wstring warningMsg; + if (const std::wstring& errors = in.getErrors(); + !errors.empty()) + warningMsg = replaceCpy(_("Configuration file %x is incomplete. The missing elements have been set to their default values."), L"%x", fmtPath(filePath)) + L"\n\n" + + _("The following XML elements could not be read:") + L'\n' + errors; + else //(try to) migrate old configuration automatically + if (formatVer < XML_FORMAT_RTS_CFG) + try + { + rts::writeConfig(cfg, filePath); //throw FileError + } + catch (const FileError& e) { warningMsg = e.toString(); } + + return {cfg, warningMsg}; +} + + +void rts::writeConfig(const FfsRealConfig& cfg, const Zstring& filePath) //throw FileError +{ + XmlDoc doc("FreeFileSync"); + doc.root().setAttribute("XmlType", "REAL"); + doc.root().setAttribute("XmlFormat", XML_FORMAT_RTS_CFG); + + XmlOut out(doc); + ::writeConfig(cfg, out); + + saveXml(doc, filePath); //throw FileError +} + + +std::pair rts::readRealOrBatchConfig(const Zstring& filePath) //throw FileError +{ + XmlDoc doc = loadXml(filePath); //throw FileError + //quick exit if file is not an FFS XML + + //convert batch config to RealTimeSync config + if (getConfigType(doc) == "BATCH") + { + XmlIn in(doc); + + //read folder pairs + std::set uniqueFolders; + + in["FolderPairs"].visitChildren([&](const XmlIn& inPair) + { + assert(*inPair.getName() == "Pair"); + + Zstring folderPathPhraseLeft; + Zstring folderPathPhraseRight; + inPair["Left" ](folderPathPhraseLeft); + inPair["Right"](folderPathPhraseRight); + + uniqueFolders.insert(folderPathPhraseLeft); + uniqueFolders.insert(folderPathPhraseRight); + }); + + if (const std::wstring& errors = in.getErrors(); + !errors.empty()) + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath)), + _("The following XML elements could not be read:") + L'\n' + errors); + + //--------------------------------------------------------------------------------------- + + std::erase_if(uniqueFolders, [](const Zstring& str) { return trimCpy(str).empty(); }); + + std::wstring warningMsg; + const Zstring ffsLaunchPath = [&]() -> Zstring + { + try + { + return fff::getFreeFileSyncLauncherPath(); //throw FileError + } + catch (const FileError& e) + { + warningMsg = e.toString(); + return Zstr("FreeFileSync"); //fallback: at least give some hint... + } + }(); + + FfsRealConfig cfg + { + .directories = {uniqueFolders.begin(), uniqueFolders.end()}, + .commandline = escapeCommandArg(ffsLaunchPath) + Zstr(' ') + escapeCommandArg(filePath), + }; + return {cfg, warningMsg}; + } + else + return readConfig(filePath); //throw FileError +} + + +GlobalConfig rts::getGlobalConfig() //throw FileError +{ + GlobalConfig globalCfg; + + const Zstring& filePath = appendPath(fff::getConfigDirPath(), Zstr("GlobalSettings.xml")); + + XmlDoc doc; + try + { + doc = loadXml(filePath); //throw FileError + } + catch (FileError&) + { + if (!itemExists(filePath)) //throw FileError + return globalCfg; + throw; + } + + if (getConfigType(doc) != "GLOBAL") + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath))); + + XmlIn in(doc); + + in["Language"].attribute("Code", globalCfg.programLanguage); + in["ColorTheme"].attribute("Appearance", globalCfg.appColorTheme); + + if (const std::wstring& errors = in.getErrors(); + !errors.empty()) + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath)), + _("The following XML elements could not be read:") + L'\n' + errors); + + return globalCfg; +} diff --git a/FreeFileSync/Source/RealTimeSync/config.h b/FreeFileSync/Source/RealTimeSync/config.h new file mode 100644 index 0000000..c94ef30 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/config.h @@ -0,0 +1,40 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef XML_PROC_H_0813748158321813490 +#define XML_PROC_H_0813748158321813490 + +#include +#include +#include +#include +#include "../localization.h" + +namespace rts +{ +struct FfsRealConfig +{ + std::vector directories; + Zstring commandline; + unsigned int delay = 10; +}; + +std::pair readConfig(const Zstring& filePath); //throw FileError +void writeConfig(const FfsRealConfig& config, const Zstring& filePath); //throw FileError + + +//reuse (some of) FreeFileSync's xml files +std::pair readRealOrBatchConfig(const Zstring& filePath); //throw FileError + +struct GlobalConfig +{ + wxLanguage programLanguage = fff::getDefaultLanguage(); + zen::ColorTheme appColorTheme = zen::ColorTheme::System; +}; +GlobalConfig getGlobalConfig(); //throw FileError +} + +#endif //XML_PROC_H_0813748158321813490 diff --git a/FreeFileSync/Source/RealTimeSync/folder_selector2.cpp b/FreeFileSync/Source/RealTimeSync/folder_selector2.cpp new file mode 100644 index 0000000..0a0e46a --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/folder_selector2.cpp @@ -0,0 +1,191 @@ +// ***************************************************************************** +// * 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 "folder_selector2.h" +#include +#include +#include +#include +#include +#include + #include + +using namespace zen; +using namespace rts; + + +namespace +{ +constexpr std::chrono::milliseconds FOLDER_SELECTED_EXISTENCE_CHECK_TIME_MAX(200); + + +void setFolderPath(const Zstring& dirpath, wxTextCtrl* txtCtrl, wxWindow& tooltipWnd, wxStaticText* staticText) //pointers are optional +{ + if (txtCtrl) + txtCtrl->ChangeValue(utfTo(dirpath)); + + const Zstring folderPathFmt = getResolvedFilePath(dirpath); //may block when resolving [] + + if (folderPathFmt.empty()) + tooltipWnd.UnsetToolTip(); //wxGTK doesn't allow wxToolTip with empty text! + else + tooltipWnd.SetToolTip(utfTo(folderPathFmt)); + + if (staticText) //change static box label only if there is a real difference to what is shown in wxTextCtrl anyway + staticText->SetLabel(equalNativePath(appendSeparator(trimCpy(dirpath)), appendSeparator(folderPathFmt)) ? + wxString(_("Drag && drop")) : utfTo(folderPathFmt)); +} + + +} + +//############################################################################################################## + +FolderSelector2::FolderSelector2(wxWindow* parent, + wxWindow& dropWindow, + wxButton& selectButton, + wxTextCtrl& folderPathCtrl, + Zstring& folderLastSelected, + wxStaticText* staticText, + const std::function& shellItemPaths)>& droppedPathsFilter) : + droppedPathsFilter_ (droppedPathsFilter), + parent_(parent), + dropWindow_(dropWindow), + selectButton_(selectButton), + folderPathCtrl_(folderPathCtrl), + folderLastSelected_(folderLastSelected), + staticText_(staticText) +{ + //file drag and drop directly into the text control unhelpfully inserts in format "file://.."; see folder_history_box.cpp + if (GtkWidget* widget = folderPathCtrl.GetConnectWidget()) + ::gtk_drag_dest_unset(widget); + + setupFileDrop(dropWindow_); + dropWindow_.Bind(EVENT_DROP_FILE, &FolderSelector2::onFilesDropped, this); + + //keep folderSelector and dirpath synchronous + folderPathCtrl_.Bind(wxEVT_MOUSEWHEEL, &FolderSelector2::onMouseWheel, this); + folderPathCtrl_.Bind(wxEVT_COMMAND_TEXT_UPDATED, &FolderSelector2::onEditFolderPath, this); + selectButton_ .Bind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector2::onSelectDir, this); +} + + +FolderSelector2::~FolderSelector2() +{ + [[maybe_unused]] bool ubOk1 = dropWindow_.Unbind(EVENT_DROP_FILE, &FolderSelector2::onFilesDropped, this); + + [[maybe_unused]] bool ubOk2 = folderPathCtrl_.Unbind(wxEVT_MOUSEWHEEL, &FolderSelector2::onMouseWheel, this); + [[maybe_unused]] bool ubOk3 = folderPathCtrl_.Unbind(wxEVT_COMMAND_TEXT_UPDATED, &FolderSelector2::onEditFolderPath, this); + [[maybe_unused]] bool ubOk4 = selectButton_ .Unbind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector2::onSelectDir, this); + assert(ubOk1 && ubOk2 && ubOk3 && ubOk4); +} + + +void FolderSelector2::onMouseWheel(wxMouseEvent& event) +{ + //for combobox: although switching through available items is wxWidgets default, this is NOT Windows default, e.g. Explorer + //additionally this will delete manual entries, although all the users wanted is scroll the parent window! + + //redirect to parent scrolled window! + for (wxWindow* wnd = folderPathCtrl_.GetParent(); wnd; wnd = wnd->GetParent()) + if (dynamic_cast(wnd) != nullptr) + return wnd->GetEventHandler()->AddPendingEvent(event); + assert(false); + event.Skip(); +} + + +void FolderSelector2::onFilesDropped(FileDropEvent& event) +{ + if (event.itemPaths_.empty()) + return; + + if (!droppedPathsFilter_ || droppedPathsFilter_(event.itemPaths_)) + { + Zstring itemPath = event.itemPaths_[0]; + try + { + if (getItemType(itemPath) == ItemType::file) //throw FileError + if (const std::optional& parentPath = getParentFolderPath(itemPath)) + itemPath = *parentPath; + } + catch (FileError&) {} //e.g. good for inactive mapped network shares, not so nice for C:\pagefile.sys + + if (endsWith(itemPath, Zstr(' '))) //prevent getResolvedFilePath() from trimming legit trailing blank! + itemPath += FILE_NAME_SEPARATOR; + + setPath(itemPath); + } + //event.Skip(); +} + + +void FolderSelector2::onEditFolderPath(wxCommandEvent& event) +{ + setFolderPath(utfTo(event.GetString()), nullptr, folderPathCtrl_, staticText_); + event.Skip(); +} + + +void FolderSelector2::onSelectDir(wxCommandEvent& event) +{ + //IFileDialog requirements for default path: 1. accepts native paths only!!! 2. path must exist! + Zstring defaultFolderPath; + { + auto folderAccessible = [stopTime = std::chrono::steady_clock::now() + FOLDER_SELECTED_EXISTENCE_CHECK_TIME_MAX](const Zstring& folderPath) + { + auto ft = runAsync([folderPath] + { + try + { + return getItemType(folderPath) != ItemType::file; //throw FileError + } + catch (FileError&) { return false; } + }); + + return ft.wait_until(stopTime) == std::future_status::ready && ft.get(); //potentially slow network access: wait 200ms at most + }; + + auto trySetDefaultPath = [&](const Zstring& folderPathPhrase) + { + + if (const Zstring folderPath = getResolvedFilePath(folderPathPhrase); + !folderPath.empty()) + if (folderAccessible(folderPath)) + defaultFolderPath = folderPath; + }; + + const Zstring& currentFolderPath = getPath(); + trySetDefaultPath(currentFolderPath); + + if (defaultFolderPath.empty() && //=> fallback: use last user-selected path + trimCpy(folderLastSelected_) != trimCpy(currentFolderPath) /*case-sensitive comp for path phrase!*/) + trySetDefaultPath(folderLastSelected_); + } + + Zstring newFolderPath; + wxDirDialog folderSelector(parent_, _("Select a folder"), utfTo(defaultFolderPath), wxDD_DEFAULT_STYLE | wxDD_SHOW_HIDDEN); + if (folderSelector.ShowModal() != wxID_OK) + return; + newFolderPath = utfTo(folderSelector.GetPath()); + if (endsWith(newFolderPath, Zstr(' '))) //prevent getResolvedFilePath() from trimming legit trailing blank! + newFolderPath += FILE_NAME_SEPARATOR; + + setPath(newFolderPath); + folderLastSelected_ = newFolderPath; +} + + +Zstring FolderSelector2::getPath() const +{ + return utfTo(folderPathCtrl_.GetValue()); +} + + +void FolderSelector2::setPath(const Zstring& dirpath) +{ + setFolderPath(dirpath, &folderPathCtrl_, folderPathCtrl_, staticText_); +} diff --git a/FreeFileSync/Source/RealTimeSync/folder_selector2.h b/FreeFileSync/Source/RealTimeSync/folder_selector2.h new file mode 100644 index 0000000..10bd590 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/folder_selector2.h @@ -0,0 +1,52 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FOLDER_SELECTOR2_H_073246031245342566 +#define FOLDER_SELECTOR2_H_073246031245342566 + +#include +#include +#include +#include + +namespace rts +{ +//handle drag and drop, tooltip, label and manual input, coordinating a wxWindow, wxButton, and wxTextCtrl + +class FolderSelector2 : public wxEvtHandler +{ +public: + FolderSelector2(wxWindow* parent, + wxWindow& dropWindow, + wxButton& selectButton, + wxTextCtrl& folderPathCtrl, + Zstring& folderLastSelected, + wxStaticText* staticText, //optional + const std::function& shellItemPaths)>& droppedPathsFilter); //optional + + ~FolderSelector2(); + + Zstring getPath() const; + void setPath(const Zstring& dirpath); + +private: + void onMouseWheel (wxMouseEvent& event); + void onFilesDropped (zen::FileDropEvent& event); + void onEditFolderPath(wxCommandEvent& event); + void onSelectDir (wxCommandEvent& event); + + const std::function& shellItemPaths)> droppedPathsFilter_; + + wxWindow* parent_; + wxWindow& dropWindow_; + wxButton& selectButton_; + wxTextCtrl& folderPathCtrl_; + Zstring& folderLastSelected_; + wxStaticText* staticText_ = nullptr; //optional +}; +} + +#endif //FOLDER_SELECTOR2_H_073246031245342566 diff --git a/FreeFileSync/Source/RealTimeSync/gui_generated.cpp b/FreeFileSync/Source/RealTimeSync/gui_generated.cpp new file mode 100644 index 0000000..e14502b --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/gui_generated.cpp @@ -0,0 +1,299 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#include "wx+/bitmap_button.h" + +#include "gui_generated.h" + +/////////////////////////////////////////////////////////////////////////// + +MainDlgGenerated::MainDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxFrame( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + m_menubar1 = new wxMenuBar( 0 ); + m_menuFile = new wxMenu(); + wxMenuItem* m_menuItem6; + m_menuItem6 = new wxMenuItem( m_menuFile, wxID_NEW, wxString( _("&New") ) + wxT('\t') + wxT("Ctrl+N"), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItem6 ); + + wxMenuItem* m_menuItem13; + m_menuItem13 = new wxMenuItem( m_menuFile, wxID_OPEN, wxString( _("&Open...") ) + wxT('\t') + wxT("CTRL+O"), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItem13 ); + + wxMenuItem* m_menuItem14; + m_menuItem14 = new wxMenuItem( m_menuFile, wxID_SAVEAS, wxString( _("Save &as...") ), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItem14 ); + + m_menuFile->AppendSeparator(); + + m_menuItemQuit = new wxMenuItem( m_menuFile, wxID_EXIT, wxString( _("E&xit") ), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemQuit ); + + m_menubar1->Append( m_menuFile, _("&File") ); + + m_menuHelp = new wxMenu(); + wxMenuItem* m_menuItemContent; + m_menuItemContent = new wxMenuItem( m_menuHelp, wxID_HELP, wxString( _("&View help") ) + wxT('\t') + wxT("F1"), wxEmptyString, wxITEM_NORMAL ); + m_menuHelp->Append( m_menuItemContent ); + + m_menuHelp->AppendSeparator(); + + m_menuItemAbout = new wxMenuItem( m_menuHelp, wxID_ABOUT, wxString( _("&About") ) + wxT('\t') + wxT("SHIFT+F1"), wxEmptyString, wxITEM_NORMAL ); + m_menuHelp->Append( m_menuItemAbout ); + + m_menubar1->Append( m_menuHelp, _("&Help") ); + + this->SetMenuBar( m_menubar1 ); + + bSizerMain = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer161; + bSizer161 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer152; + bSizer152 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText811; + m_staticText811 = new wxStaticText( this, wxID_ANY, _("To get started, just import a \"ffs_batch\" file."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText811->Wrap( -1 ); + m_staticText811->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer152->Add( m_staticText811, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText10; + m_staticText10 = new wxStaticText( this, wxID_ANY, _("("), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText10->Wrap( -1 ); + m_staticText10->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer152->Add( m_staticText10, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 2 ); + + m_bitmapBatch = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer152->Add( m_bitmapBatch, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText11; + m_staticText11 = new wxStaticText( this, wxID_ANY, _(")"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText11->Wrap( -1 ); + m_staticText11->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer152->Add( m_staticText11, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 2 ); + + wxHyperlinkCtrl* m_hyperlink243; + m_hyperlink243 = new wxHyperlinkCtrl( this, wxID_ANY, _("Show examples"), wxT("https://freefilesync.org/manual.php?topic=realtimesync"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink243->SetToolTip( _("https://freefilesync.org/manual.php?topic=realtimesync") ); + + bSizer152->Add( m_hyperlink243, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer161->Add( bSizer152, 0, wxALL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerMain->Add( bSizer161, 0, wxALL|wxEXPAND, 5 ); + + wxStaticLine* m_staticline2; + m_staticline2 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMain->Add( m_staticline2, 0, wxEXPAND, 5 ); + + m_panelMain = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelMain->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1; + bSizer1 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer151; + bSizer151 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer142; + bSizer142 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapFolders = new wxStaticBitmap( m_panelMain, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer142->Add( m_bitmapFolders, 0, wxTOP|wxBOTTOM|wxLEFT, 5 ); + + wxStaticText* m_staticText7; + m_staticText7 = new wxStaticText( m_panelMain, wxID_ANY, _("Folders to watch for changes:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText7->Wrap( -1 ); + bSizer142->Add( m_staticText7, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer151->Add( bSizer142, 0, 0, 5 ); + + m_panelMainFolder = new wxPanel( m_panelMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelMainFolder->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer143; + bSizer143 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonAddFolder = new wxBitmapButton( m_panelMainFolder, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonAddFolder->SetToolTip( _("Add folder") ); + + bSizer143->Add( m_bpButtonAddFolder, 0, wxEXPAND, 5 ); + + m_bpButtonRemoveTopFolder = new wxBitmapButton( m_panelMainFolder, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonRemoveTopFolder->SetToolTip( _("Remove folder") ); + + bSizer143->Add( m_bpButtonRemoveTopFolder, 0, wxEXPAND, 5 ); + + m_txtCtrlDirectoryMain = new wxTextCtrl( m_panelMainFolder, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer143->Add( m_txtCtrlDirectoryMain, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolderMain = new wxButton( m_panelMainFolder, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolderMain->SetToolTip( _("Select a folder") ); + + bSizer143->Add( m_buttonSelectFolderMain, 0, wxEXPAND, 5 ); + + + m_panelMainFolder->SetSizer( bSizer143 ); + m_panelMainFolder->Layout(); + bSizer143->Fit( m_panelMainFolder ); + bSizer151->Add( m_panelMainFolder, 0, wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + m_scrolledWinFolders = new wxScrolledWindow( m_panelMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_scrolledWinFolders->SetScrollRate( 5, 5 ); + m_scrolledWinFolders->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizerFolders = new wxBoxSizer( wxVERTICAL ); + + + m_scrolledWinFolders->SetSizer( bSizerFolders ); + m_scrolledWinFolders->Layout(); + bSizerFolders->Fit( m_scrolledWinFolders ); + bSizer151->Add( m_scrolledWinFolders, 1, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1->Add( bSizer151, 1, wxALL|wxEXPAND, 10 ); + + wxStaticLine* m_staticline212; + m_staticline212 = new wxStaticLine( m_panelMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer1->Add( m_staticline212, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer131; + bSizer131 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer14; + bSizer14 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText8; + m_staticText8 = new wxStaticText( m_panelMain, wxID_ANY, _("Idle time (in seconds):"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText8->Wrap( -1 ); + bSizer14->Add( m_staticText8, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_spinCtrlDelay = new wxSpinCtrl( m_panelMain, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + m_spinCtrlDelay->SetToolTip( _("Idle time between last detected change and execution of command") ); + + bSizer14->Add( m_spinCtrlDelay, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer131->Add( bSizer14, 0, 0, 5 ); + + wxStaticText* m_staticText71; + m_staticText71 = new wxStaticText( m_panelMain, wxID_ANY, _("Ensures folders are not in heavy use when running the command."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText71->Wrap( -1 ); + m_staticText71->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer131->Add( m_staticText71, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1->Add( bSizer131, 0, wxALL, 10 ); + + wxStaticLine* m_staticline211; + m_staticline211 = new wxStaticLine( m_panelMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer1->Add( m_staticline211, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer141; + bSizer141 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer13; + bSizer13 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapConsole = new wxStaticBitmap( m_panelMain, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer13->Add( m_bitmapConsole, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText6; + m_staticText6 = new wxStaticText( m_panelMain, wxID_ANY, _("Command line to run when changes are detected:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText6->Wrap( -1 ); + bSizer13->Add( m_staticText6, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer141->Add( bSizer13, 0, 0, 5 ); + + m_textCtrlCommand = new wxTextCtrl( m_panelMain, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + m_textCtrlCommand->SetToolTip( _("The command is triggered if:\n- files or subfolders change\n- new folders arrive (e.g. USB stick insert)") ); + + bSizer141->Add( m_textCtrlCommand, 0, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1->Add( bSizer141, 0, wxALL|wxEXPAND, 10 ); + + + m_panelMain->SetSizer( bSizer1 ); + m_panelMain->Layout(); + bSizer1->Fit( m_panelMain ); + bSizerMain->Add( m_panelMain, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline5; + m_staticline5 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMain->Add( m_staticline5, 0, wxEXPAND, 5 ); + + m_buttonStart = new zen::BitmapTextButton( this, wxID_OK, _("Start"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonStart->SetDefault(); + m_buttonStart->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, wxEmptyString ) ); + + bSizerMain->Add( m_buttonStart, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + + this->SetSizer( bSizerMain ); + this->Layout(); + bSizerMain->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( MainDlgGenerated::onClose ) ); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onConfigNew ), this, m_menuItem6->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onConfigLoad ), this, m_menuItem13->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onConfigSave ), this, m_menuItem14->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onMenuQuit ), this, m_menuItemQuit->GetId()); + m_menuHelp->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onShowHelp ), this, m_menuItemContent->GetId()); + m_menuHelp->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDlgGenerated::onMenuAbout ), this, m_menuItemAbout->GetId()); + m_bpButtonAddFolder->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDlgGenerated::onAddFolder ), NULL, this ); + m_bpButtonRemoveTopFolder->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDlgGenerated::onRemoveTopFolder ), NULL, this ); + m_buttonStart->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDlgGenerated::onStart ), NULL, this ); +} + +MainDlgGenerated::~MainDlgGenerated() +{ +} + +FolderGenerated::FolderGenerated( wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style, const wxString& name ) : wxPanel( parent, id, pos, size, style, name ) +{ + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer114; + bSizer114 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonRemoveFolder = new wxBitmapButton( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonRemoveFolder->SetToolTip( _("Remove folder") ); + + bSizer114->Add( m_bpButtonRemoveFolder, 0, wxEXPAND, 5 ); + + m_txtCtrlDirectory = new wxTextCtrl( this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer114->Add( m_txtCtrlDirectory, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolder = new wxButton( this, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolder->SetToolTip( _("Select a folder") ); + + bSizer114->Add( m_buttonSelectFolder, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer114 ); + this->Layout(); + bSizer114->Fit( this ); +} + +FolderGenerated::~FolderGenerated() +{ +} diff --git a/FreeFileSync/Source/RealTimeSync/gui_generated.h b/FreeFileSync/Source/RealTimeSync/gui_generated.h new file mode 100644 index 0000000..ea94daf --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/gui_generated.h @@ -0,0 +1,110 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +namespace zen { class BitmapTextButton; } + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "zen/i18n.h" + +/////////////////////////////////////////////////////////////////////////// + +/////////////////////////////////////////////////////////////////////////////// +/// Class MainDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class MainDlgGenerated : public wxFrame +{ +private: + +protected: + wxMenuBar* m_menubar1; + wxMenu* m_menuFile; + wxMenuItem* m_menuItemQuit; + wxMenu* m_menuHelp; + wxMenuItem* m_menuItemAbout; + wxBoxSizer* bSizerMain; + wxStaticBitmap* m_bitmapBatch; + wxPanel* m_panelMain; + wxStaticBitmap* m_bitmapFolders; + wxPanel* m_panelMainFolder; + wxBitmapButton* m_bpButtonAddFolder; + wxBitmapButton* m_bpButtonRemoveTopFolder; + wxTextCtrl* m_txtCtrlDirectoryMain; + wxButton* m_buttonSelectFolderMain; + wxScrolledWindow* m_scrolledWinFolders; + wxBoxSizer* bSizerFolders; + wxSpinCtrl* m_spinCtrlDelay; + wxStaticBitmap* m_bitmapConsole; + wxTextCtrl* m_textCtrlCommand; + zen::BitmapTextButton* m_buttonStart; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onConfigNew( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigLoad( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigSave( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuQuit( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowHelp( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuAbout( wxCommandEvent& event ) { event.Skip(); } + virtual void onAddFolder( wxCommandEvent& event ) { event.Skip(); } + virtual void onRemoveTopFolder( wxCommandEvent& event ) { event.Skip(); } + virtual void onStart( wxCommandEvent& event ) { event.Skip(); } + + +public: + + MainDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("dummy"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_FRAME_STYLE|wxTAB_TRAVERSAL ); + + ~MainDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class FolderGenerated +/////////////////////////////////////////////////////////////////////////////// +class FolderGenerated : public wxPanel +{ +private: + +protected: + wxButton* m_buttonSelectFolder; + +public: + wxBitmapButton* m_bpButtonRemoveFolder; + wxTextCtrl* m_txtCtrlDirectory; + + FolderGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = 0, const wxString& name = wxEmptyString ); + + ~FolderGenerated(); + +}; + diff --git a/FreeFileSync/Source/RealTimeSync/main_dlg.cpp b/FreeFileSync/Source/RealTimeSync/main_dlg.cpp new file mode 100644 index 0000000..e5f3bf7 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/main_dlg.cpp @@ -0,0 +1,512 @@ +// ***************************************************************************** +// * 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 "main_dlg.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "tray_menu.h" +#include "app_icon.h" +#include "../icon_buffer.h" +#include "../ffs_paths.h" +#include "../version/version.h" + + #include + +using namespace zen; +using namespace rts; + + +namespace +{ + static const size_t MAX_ADD_FOLDERS = 6; + + +std::wstring extractJobName(const Zstring& cfgFilePath) +{ + const Zstring fileName = getItemName(cfgFilePath); + const Zstring jobName = beforeLast(fileName, Zstr('.'), IfNotFoundReturn::all); + return utfTo(jobName); +} + + +bool acceptDialogFileDrop(const std::vector& shellItemPaths) +{ + if (shellItemPaths.empty()) + return false; + + const Zstring ext = getFileExtension(shellItemPaths[0]); + return equalAsciiNoCase(ext, "ffs_real") || + equalAsciiNoCase(ext, "ffs_batch"); +} +} + + +std::function& shellItemPaths)> getDroppedPathsFilter(MainDialog& mainDlg) +{ + return [&mainDlg](const std::vector& shellItemPaths) + { + if (acceptDialogFileDrop(shellItemPaths)) + { + assert(!shellItemPaths.empty()); + mainDlg.loadConfig(shellItemPaths[0]); + return false; //don't set dropped paths + } + return true; //do set dropped paths + }; +} + + +class rts::DirectoryPanel : public FolderGenerated +{ +public: + DirectoryPanel(wxWindow* parent, MainDialog& mainDlg, Zstring& folderLastSelected) : + FolderGenerated(parent), + folderSelector_(parent, *this, *m_buttonSelectFolder, *m_txtCtrlDirectory, folderLastSelected, nullptr /*staticText*/, getDroppedPathsFilter(mainDlg)) + { + setImage(*m_bpButtonRemoveFolder, loadImage("item_remove")); + } + + void setPath(const Zstring& dirpath) { folderSelector_.setPath(dirpath); } + Zstring getPath() const { return folderSelector_.getPath(); } + +private: + FolderSelector2 folderSelector_; +}; + + +void MainDialog::create(const Zstring& cfgFilePath) +{ + /*MainDialog* frame = */ new MainDialog(cfgFilePath); +} + + +MainDialog::MainDialog(const Zstring& cfgFilePath) : + MainDlgGenerated(nullptr), + lastRunConfigPath_(appendPath(fff::getConfigDirPath(), Zstr("LastRun.ffs_real"))) +{ + SetIcon(getRtsIcon()); //set application icon + + setRelativeFontSize(*m_buttonStart, 1.5); + + const int scrollDelta = m_buttonSelectFolderMain->GetSize().y; //more approriate than GetCharHeight() here + m_scrolledWinFolders->SetScrollRate(scrollDelta, scrollDelta); + + m_txtCtrlDirectoryMain->SetMinSize({dipToWxsize(300), -1}); + setDefaultWidth(*m_spinCtrlDelay); + + m_bpButtonRemoveTopFolder->Hide(); + m_panelMainFolder->Layout(); + + setImage(*m_bitmapBatch, loadImage("cfg_batch", dipToScreen(20))); + setImage(*m_bitmapFolders, fff::IconBuffer::genericDirIcon(fff::IconBuffer::IconSize::small)); + setImage(*m_bitmapConsole, loadImage("command_line", dipToScreen(20))); + + setImage(*m_bpButtonAddFolder, loadImage("item_add")); + setImage(*m_bpButtonRemoveTopFolder, loadImage("item_remove")); + setBitmapTextLabel(*m_buttonStart, loadImage("start_rts"), m_buttonStart->GetLabelText(), dipToWxsize(5), dipToWxsize(8)); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); + + + //notify about (logical) application main window => program won't quit, but stay on this dialog + wxTheApp->SetTopWindow(this); + wxTheApp->SetExitOnFrameDelete(true); + + //prepare drag & drop + firstFolderPanel_ = std::make_unique(this, *m_panelMainFolder, *m_buttonSelectFolderMain, *m_txtCtrlDirectoryMain, folderLastSelected_, + nullptr /*staticText*/, getDroppedPathsFilter(*this)); + + //--------------------------- load config values ------------------------------------ + FfsRealConfig newConfig; + + Zstring currentConfigFile = cfgFilePath; + if (currentConfigFile.empty()) + try + { + if (itemExists(lastRunConfigPath_)) //throw FileError + currentConfigFile = lastRunConfigPath_; + } + catch (FileError&) { currentConfigFile = lastRunConfigPath_; } //access error? => user should be informed + + bool loadCfgSuccess = false; + if (!currentConfigFile.empty()) + try + { + std::wstring warningMsg; + std::tie(newConfig, warningMsg) = readRealOrBatchConfig(currentConfigFile); //throw FileError + + if (!warningMsg.empty()) + showNotificationDialog(this, DialogInfoType::warning, PopupDialogCfg().setDetailInstructions(warningMsg)); + + loadCfgSuccess = warningMsg.empty(); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } + + const bool startWatchingImmediately = loadCfgSuccess && !cfgFilePath.empty(); + + setConfiguration(newConfig); + setLastUsedConfig(currentConfigFile); + //----------------------------------------------------------------------------------------- + + onSystemShutdownRegister(onBeforeSystemShutdownCookie_); + + if (startWatchingImmediately) //start watch mode directly + { + wxCommandEvent dummy2(wxEVT_COMMAND_BUTTON_CLICKED); + this->onStart(dummy2); + //don't Show()! + } + else + { + //GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() => already called by setConfiguration() -> insertAddFolder() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + Show(); + m_buttonStart->SetFocus(); //don't "steal" focus if program is running from sys-tray" + } + + //drag and drop .ffs_real and .ffs_batch on main dialog + setupFileDrop(*this); + Bind(EVENT_DROP_FILE, [this](FileDropEvent& event) { onFilesDropped(event); }); +} + + +MainDialog::~MainDialog() +{ + const FfsRealConfig currentCfg = getConfiguration(); + try + { + writeConfig(currentCfg, lastRunConfigPath_); //throw FileError + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } +} + + +void MainDialog::onBeforeSystemShutdown() +{ + try { writeConfig(getConfiguration(), lastRunConfigPath_); } + catch (const FileError& e) { logExtraError(e.toString()); } +} + + +void MainDialog::onMenuAbout(wxCommandEvent& event) +{ + wxString build = utfTo(fff::ffsVersion); +#ifndef wxUSE_UNICODE +#error what is going on? +#endif + + const wchar_t* const SPACED_BULLET = L" \u2022 "; + build += SPACED_BULLET; + + build += LTR_MARK; //fix Arabic + build += utfTo(cpuArchName); + + build += SPACED_BULLET; + build += utfTo(formatTime(formatDateTag, getCompileTime())); + + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg(). + setTitle(_("About")). + setMainInstructions(L"RealTimeSync" L"\n\n" + replaceCpy(_("Version: %x"), L"%x", build))); +} + + +void MainDialog::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_ESCAPE: + Close(); + return; + } + event.Skip(); +} + + +void MainDialog::onStart(wxCommandEvent& event) +{ + Hide(); + + FfsRealConfig currentCfg = getConfiguration(); + const Zstring activeCfgFilePath = !equalNativePath(activeConfigFile_, lastRunConfigPath_) ? activeConfigFile_ : Zstring(); + + switch (runFolderMonitor(currentCfg, ::extractJobName(activeCfgFilePath))) + { + case CancelReason::requestExit: + Close(); + return; + + case CancelReason::requestGui: + break; + } + + //need to center in case of "startWatchingImmediately" +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + Show(); //don't show for CancelReason::requestExit + + Raise(); + m_buttonStart->SetFocus(); +} + + +void MainDialog::onConfigSave(wxCommandEvent& event) +{ + const Zstring activeCfgFilePath = !equalNativePath(activeConfigFile_, lastRunConfigPath_) ? activeConfigFile_ : Zstring(); + + std::optional defaultFolderPath = getParentFolderPath(activeCfgFilePath); + + Zstring defaultFileName = !activeCfgFilePath.empty() ? + getItemName(activeCfgFilePath) : + Zstr("RealTime.ffs_real"); + + //attention: activeConfigFile_ may be an imported *.ffs_batch file! We don't want to overwrite it with a RTS config! + defaultFileName = beforeLast(defaultFileName, Zstr('.'), IfNotFoundReturn::all) + Zstr(".ffs_real"); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), utfTo(defaultFileName), + wxString(L"RealTimeSync (*.ffs_real)|*.ffs_real") + L"|" +_("All files") + L" (*.*)|*", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (fileSelector.ShowModal() != wxID_OK) + return; + + Zstring targetFilePath = utfTo(fileSelector.GetPath()); + if (!endsWithAsciiNoCase(targetFilePath, Zstr(".ffs_real"))) //no weird shit! + targetFilePath += Zstr(".ffs_real"); //https://freefilesync.org/forum/viewtopic.php?t=9451#p34724 + + const FfsRealConfig currentCfg = getConfiguration(); + try + { + writeConfig(currentCfg, targetFilePath); //throw FileError + setLastUsedConfig(targetFilePath); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } +} + + +void MainDialog::loadConfig(const Zstring& filepath) +{ + FfsRealConfig newConfig; + + if (!filepath.empty()) + try + { + std::wstring warningMsg; + std::tie(newConfig, warningMsg) = readRealOrBatchConfig(filepath); //throw FileError + + if (!warningMsg.empty()) + showNotificationDialog(this, DialogInfoType::warning, PopupDialogCfg().setDetailInstructions(warningMsg)); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return; + } + + setConfiguration(newConfig); + setLastUsedConfig(filepath); +} + + +void MainDialog::setLastUsedConfig(const Zstring& filepath) +{ + activeConfigFile_ = filepath; + + const Zstring activeCfgFilePath = !equalNativePath(activeConfigFile_, lastRunConfigPath_) ? activeConfigFile_ : Zstring(); + + if (!activeCfgFilePath.empty()) + SetTitle(utfTo(activeCfgFilePath)); + else + SetTitle(L"RealTimeSync " + utfTo(fff::ffsVersion) + SPACED_DASH + _("Automated Synchronization")); + +} + + +void MainDialog::onConfigLoad(wxCommandEvent& event) +{ + const Zstring activeCfgFilePath = !equalNativePath(activeConfigFile_, lastRunConfigPath_) ? activeConfigFile_ : Zstring(); + //better: use last user-selected config path instead! + + std::optional defaultFolderPath = getParentFolderPath(activeCfgFilePath); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), wxString() /*default file name*/, + wxString(L"RealTimeSync (*.ffs_real; *.ffs_batch)|*.ffs_real;*.ffs_batch") + L"|" +_("All files") + L" (*.*)|*", + wxFD_OPEN); + if (fileSelector.ShowModal() != wxID_OK) + return; + + loadConfig(utfTo(fileSelector.GetPath())); +} + + +void MainDialog::onFilesDropped(FileDropEvent& event) +{ + if (!event.itemPaths_.empty()) + loadConfig(event.itemPaths_[0]); +} + + +void MainDialog::setConfiguration(const FfsRealConfig& cfg) +{ + const Zstring& firstFolderPath = cfg.directories.empty() ? Zstring() : cfg.directories[0]; + const std::vector addFolderPaths = cfg.directories.empty() ? std::vector() : + std::vector(cfg.directories.begin() + 1, cfg.directories.end()); + + firstFolderPanel_->setPath(firstFolderPath); + + bSizerFolders->Clear(true); + additionalFolderPanels_.clear(); + + insertAddFolder(addFolderPaths, 0); + + m_textCtrlCommand->SetValue(utfTo(cfg.commandline)); + m_spinCtrlDelay ->SetValue(static_cast(cfg.delay)); +} + + +FfsRealConfig MainDialog::getConfiguration() +{ + FfsRealConfig output; + + output.directories.push_back(firstFolderPanel_->getPath()); + + for (const DirectoryPanel* dp : additionalFolderPanels_) + output.directories.push_back(dp->getPath()); + + output.commandline = utfTo(m_textCtrlCommand->GetValue()); + output.delay = m_spinCtrlDelay->GetValue(); + + return output; +} + + +void MainDialog::onAddFolder(wxCommandEvent& event) +{ + const Zstring topFolder = firstFolderPanel_->getPath(); + + //clear existing top folder first + firstFolderPanel_->setPath(Zstring()); + + insertAddFolder({topFolder}, 0); +} + + +void MainDialog::onRemoveFolder(wxCommandEvent& event) +{ + //find folder pair originating the event + const wxObject* const eventObj = event.GetEventObject(); + for (auto it = additionalFolderPanels_.begin(); it != additionalFolderPanels_.end(); ++it) + if (eventObj == static_cast((*it)->m_bpButtonRemoveFolder)) + { + removeAddFolder(it - additionalFolderPanels_.begin()); + return; + } +} + + +void MainDialog::onRemoveTopFolder(wxCommandEvent& event) +{ + if (!additionalFolderPanels_.empty()) + { + firstFolderPanel_->setPath(additionalFolderPanels_[0]->getPath()); + removeAddFolder(0); //remove first of additional folders + } +} + + +void MainDialog::insertAddFolder(const std::vector& newFolders, size_t pos) +{ + assert(pos <= additionalFolderPanels_.size() && additionalFolderPanels_.size() == bSizerFolders->GetItemCount()); + pos = std::min(pos, additionalFolderPanels_.size()); + + for (size_t i = 0; i < newFolders.size(); ++i) + { + //add new folder pair + DirectoryPanel* newFolder = new DirectoryPanel(m_scrolledWinFolders, *this, folderLastSelected_); + + bSizerFolders->Insert(pos + i, newFolder, 0, wxEXPAND); + additionalFolderPanels_.insert(additionalFolderPanels_.begin() + pos + i, newFolder); + + //register events + newFolder->m_bpButtonRemoveFolder->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onRemoveFolder(event); }); + + //make sure panel has proper default height + newFolder->GetSizer()->SetSizeHints(newFolder); //~=Fit() + SetMinSize() + + newFolder->setPath(newFolders[i]); + } + + //set size of scrolled window + const int folderHeight = additionalFolderPanels_.empty() ? 0 : additionalFolderPanels_[0]->GetSize().GetHeight(); + const size_t visibleRows = std::min(additionalFolderPanels_.size(), MAX_ADD_FOLDERS); //up to MAX_ADD_FOLDERS additional folders shall be shown + + m_scrolledWinFolders->SetMinSize({-1, folderHeight * static_cast(visibleRows)}); + m_panelMain->Layout(); //[!] get scrollbars to update correctly + + //adapt delete top folder pair button + m_bpButtonRemoveTopFolder->Show(!additionalFolderPanels_.empty()); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() + + Refresh(); //remove a little flicker near the start button +} + + +void MainDialog::removeAddFolder(size_t pos) +{ + if (pos < additionalFolderPanels_.size()) + { + //remove folder pairs from window + DirectoryPanel* pairToDelete = additionalFolderPanels_[pos]; + + bSizerFolders->Detach(pairToDelete); //Remove() does not work on Window*, so do it manually + additionalFolderPanels_.erase(additionalFolderPanels_.begin() + pos); //remove last element in vector + //more (non-portable) wxWidgets bullshit: on OS X wxWindow::Destroy() screws up and calls "operator delete" directly rather than + //the deferred deletion it is expected to do (and which is implemented correctly on Windows and Linux) + //http://bb10.com/python-wxpython-devel/2012-09/msg00004.html + //=> since we're in a mouse button callback of a sub-component of "pairToDelete" we need to delay deletion ourselves: + guiQueue_.processAsync([] {}, [pairToDelete] { pairToDelete->Destroy(); }); + + //set size of scrolled window + const int folderHeight = additionalFolderPanels_.empty() ? 0 : additionalFolderPanels_[0]->GetSize().GetHeight(); + const size_t visibleRows = std::min(additionalFolderPanels_.size(), MAX_ADD_FOLDERS); //up to MAX_ADD_FOLDERS additional folders shall be shown + + m_scrolledWinFolders->SetMinSize({-1, folderHeight * static_cast(visibleRows)}); + m_panelMain->Layout(); //[!] get scrollbars to update correctly + + //adapt delete top folder pair button + m_bpButtonRemoveTopFolder->Show(!additionalFolderPanels_.empty()); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() + + Refresh(); //remove a little flicker near the start button + } +} diff --git a/FreeFileSync/Source/RealTimeSync/main_dlg.h b/FreeFileSync/Source/RealTimeSync/main_dlg.h new file mode 100644 index 0000000..888747c --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/main_dlg.h @@ -0,0 +1,75 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef MAIN_DLG_H_2384790842252445 +#define MAIN_DLG_H_2384790842252445 + +#include "gui_generated.h" +//#include +//#include +#include +#include +#include +//#include +#include "folder_selector2.h" + + +namespace rts +{ +struct FfsRealConfig; +class DirectoryPanel; + + +class MainDialog: public MainDlgGenerated +{ +public: + static void create(const Zstring& cfgFilePath); + + void loadConfig(const Zstring& filepath); + +private: + MainDialog(const Zstring& cfgFilePath); + ~MainDialog(); + + void onBeforeSystemShutdown(); //last chance to do something useful before killing the application! + + void onClose (wxCloseEvent& event ) override { Destroy(); } + void onShowHelp (wxCommandEvent& event) override { wxLaunchDefaultBrowser(L"https://freefilesync.org/manual.php?topic=realtimesync"); } + void onMenuAbout (wxCommandEvent& event) override; + void onAddFolder (wxCommandEvent& event) override; + void onRemoveFolder (wxCommandEvent& event); + void onRemoveTopFolder(wxCommandEvent& event) override; + void onLocalKeyEvent (wxKeyEvent& event); + void onStart (wxCommandEvent& event) override; + void onConfigNew (wxCommandEvent& event) override { loadConfig({}); } + void onConfigSave (wxCommandEvent& event) override; + void onConfigLoad (wxCommandEvent& event) override; + void onMenuQuit (wxCommandEvent& event) override { Close(); } + void onFilesDropped(zen::FileDropEvent& event); + + void setConfiguration(const FfsRealConfig& cfg); + FfsRealConfig getConfiguration(); + void setLastUsedConfig(const Zstring& filepath); + + void insertAddFolder(const std::vector& newFolders, size_t pos); + void removeAddFolder(size_t pos); + + std::unique_ptr firstFolderPanel_; + std::vector additionalFolderPanels_; //additional pairs to the standard pair + + + const Zstring lastRunConfigPath_; + Zstring activeConfigFile_; //optional + + Zstring folderLastSelected_; + + zen::AsyncGuiQueue guiQueue_; //schedule and run long-running tasks asynchronously, but process results on GUI queue + + const zen::SharedRef> onBeforeSystemShutdownCookie_ = zen::makeSharedRef>([this] { onBeforeSystemShutdown(); }); +}; +} + +#endif //MAIN_DLG_H_2384790842252445 diff --git a/FreeFileSync/Source/RealTimeSync/monitor.cpp b/FreeFileSync/Source/RealTimeSync/monitor.cpp new file mode 100644 index 0000000..da1061d --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/monitor.cpp @@ -0,0 +1,280 @@ +// ***************************************************************************** +// * 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 "monitor.h" +#include +#include +#include +#include +//#include "../library/db_file.h" //SYNC_DB_FILE_ENDING -> complete file too much of a dependency; file ending too little to decouple into single header +//#include "../library/lock_holder.h" //LOCK_FILE_ENDING +//TEMP_FILE_ENDING + +using namespace zen; + + +namespace +{ +constexpr std::chrono::seconds FOLDER_EXISTENCE_CHECK_INTERVAL(1); + + +//wait until all directories become available (again) + logs in network share +std::set waitForMissingDirs(const std::vector& folderPathPhrases, //throw FileError + const std::function& requestUiUpdate, std::chrono::milliseconds cbInterval) +{ + //early failure! check for unsupported folder paths: + for (const char* protoName : {"ftp", "sftp", "mtp", "gdrive"}) + for (const Zstring& phrase : folderPathPhrases) + //hopefully clear enough now: https://freefilesync.org/forum/viewtopic.php?t=4302 + if (startsWithAsciiNoCase(trimCpy(phrase), std::string(protoName) + ':')) + throw FileError(replaceCpy(_("The %x protocol does not support directory monitoring:"), L"%x", utfTo(protoName)) + L"\n\n" + fmtPath(phrase)); + + for (;;) + { + struct FolderInfo + { + Zstring folderPathPhrase; + std::future folderAvailable; + }; + std::map folderInfos; + + for (const Zstring& phrase : folderPathPhrases) + { + const Zstring& folderPath = getResolvedFilePath(phrase); + + //start all folder checks asynchronously (non-existent network path may block) + if (!folderInfos.contains(folderPath)) + folderInfos[folderPath] = { phrase, runAsync([folderPath] + { + try + { + getItemType(folderPath); //throw FileError + return true; + } + catch (FileError&) { return false; } + }) + }; + } + + std::set availablePaths; + std::set missingPathPhrases; + for (auto& [folderPath, folderInfo] : folderInfos) + { + std::future& folderAvailable = folderInfo.folderAvailable; + + while (folderAvailable.wait_for(cbInterval) == std::future_status::timeout) + requestUiUpdate(folderPath); //throw X + + if (folderAvailable.get()) + availablePaths.insert(folderPath); + else + missingPathPhrases.insert(folderInfo.folderPathPhrase); + } + if (missingPathPhrases.empty()) + return availablePaths; //only return when all folders were found on *first* try! + + + auto delayUntil = std::chrono::steady_clock::now() + FOLDER_EXISTENCE_CHECK_INTERVAL; + + for (const Zstring& folderPathPhrase : missingPathPhrases) + for (;;) + { + //support specifying volume by name => call getResolvedFilePath() repeatedly + const Zstring folderPath = getResolvedFilePath(folderPathPhrase); + + //wait some time... + for (auto now = std::chrono::steady_clock::now(); now < delayUntil; now = std::chrono::steady_clock::now()) + { + requestUiUpdate(folderPath); //throw X + std::this_thread::sleep_for(cbInterval); + } + + std::future folderAvailable = runAsync([folderPath] + { + try + { + getItemType(folderPath); //throw FileError + return true; + } + catch (FileError&) { return false; } + }); + + while (folderAvailable.wait_for(cbInterval) == std::future_status::timeout) + requestUiUpdate(folderPath); //throw X + + if (folderAvailable.get()) + break; + //else: wait until folder is available: do not needlessly poll existing folders again! + delayUntil = std::chrono::steady_clock::now() + FOLDER_EXISTENCE_CHECK_INTERVAL; + } + } +} + + +//wait until changes are detected or if a directory is not available (anymore) +DirWatcher::Change waitForChanges(const std::set& folderPaths, //throw FileError + const std::function& requestUiUpdate, std::chrono::milliseconds cbInterval) +{ + if (folderPaths.empty()) //pathological case, but we have to check or this function waits forever + throw FileError(_("A folder input field is empty.")); //should have been checked by caller! + + std::vector>> watches; + + for (const Zstring& folderPath : folderPaths) + try + { + watches.emplace_back(folderPath, std::make_unique(folderPath)); //throw FileError + } + catch (FileError&) + { + try { getItemType(folderPath); } //throw FileError + catch (FileError&) + { + assert(false); //why "unavailable"!? violating waitForChanges() precondition! + return {DirWatcher::ChangeType::baseFolderUnavailable, folderPath}; + } + + throw; + } + + auto lastCheckTime = std::chrono::steady_clock::now(); + for (;;) + { + const bool checkDirNow = [&] //checking once per sec should suffice + { + const auto now = std::chrono::steady_clock::now(); + if (now > lastCheckTime + FOLDER_EXISTENCE_CHECK_INTERVAL) + { + lastCheckTime = now; + return true; + } + return false; + }(); + + for (const auto& [folderPath, watcher] : watches) + { + //IMPORTANT CHECK: DirWatcher has problems detecting removal of top watched directories! + if (checkDirNow) + try //catch errors related to directory removal, e.g. ERROR_NETNAME_DELETED + { + getItemType(folderPath); //throw FileError + } + catch (FileError&) { return {DirWatcher::ChangeType::baseFolderUnavailable, folderPath}; } + + try + { + std::vector changes = watcher->fetchChanges([&] { requestUiUpdate(false /*readyForSync*/); /*throw X*/ }, + cbInterval); //throw FileError + + //give precedence to ChangeType::baseFolderUnavailable + for (const DirWatcher::Change& change : changes) + if (change.type == DirWatcher::ChangeType::baseFolderUnavailable) + return change; + + std::erase_if(changes, [](const DirWatcher::Change& e) + { + return + endsWith(e.itemPath, Zstr(".ffs_tmp")) || //sync.8ea2.ffs_tmp + endsWith(e.itemPath, Zstr(".ffs_lock")) || //sync.ffs_lock, sync.Del.ffs_lock + endsWith(e.itemPath, Zstr(".ffs_db")); //sync.ffs_db + //no need to ignore temporary recycle bin directory: this must be caused by a file deletion anyway + }); + + if (!changes.empty()) + return changes[0]; + } + catch (FileError&) + { + try { getItemType(folderPath); } //throw FileError + catch (FileError&) { return {DirWatcher::ChangeType::baseFolderUnavailable, folderPath}; } + + throw; + } + } + + std::this_thread::sleep_for(cbInterval); + requestUiUpdate(true /*readyForSync*/); //throw X: may start sync at this presumably idle time + } +} + + +std::wstring getChangeTypeName(DirWatcher::ChangeType type) +{ + switch (type) + { + case DirWatcher::ChangeType::create: + return L"Create"; + case DirWatcher::ChangeType::update: + return L"Update"; + case DirWatcher::ChangeType::remove: + return L"Delete"; + case DirWatcher::ChangeType::baseFolderUnavailable: + return L"Base Folder Unavailable"; + } + assert(false); + return L"Error"; +} + +struct ExecCommandNowException {}; +} + + +void rts::monitorDirectories(const std::vector& folderPathPhrases, std::chrono::seconds delay, + const std::function& executeExternalCommand /*throw FileError*/, + const std::function& requestUiUpdate, + const std::function& reportError, + std::chrono::milliseconds cbInterval) +{ + assert(!folderPathPhrases.empty()); + if (folderPathPhrases.empty()) + return; + + for (;;) + try + { + std::set folderPaths = waitForMissingDirs(folderPathPhrases, [&](const Zstring& folderPath) { requestUiUpdate(&folderPath); }, cbInterval); //throw FileError + + //schedule initial execution (*after* all directories have arrived) + auto nextExecTime = std::chrono::steady_clock::now() + delay; + + for (;;) //command executions + { + DirWatcher::Change lastChangeDetected; + try + { + for (;;) //detected changes + { + lastChangeDetected = waitForChanges(folderPaths, [&](bool readyForSync) //throw FileError, ExecCommandNowException + { + requestUiUpdate(nullptr); + + if (readyForSync && std::chrono::steady_clock::now() >= nextExecTime) + throw ExecCommandNowException(); //abort wait and start sync + }, cbInterval); + + if (lastChangeDetected.type == DirWatcher::ChangeType::baseFolderUnavailable) + //don't execute the command before all directories are available! + folderPaths = waitForMissingDirs(folderPathPhrases, [&](const Zstring& folderPath) { requestUiUpdate(&folderPath); }, cbInterval); //throw FileError + + nextExecTime = std::chrono::steady_clock::now() + delay; + } + } + catch (ExecCommandNowException&) {} + + try + { + executeExternalCommand(lastChangeDetected.itemPath, getChangeTypeName(lastChangeDetected.type)); //throw FileError + } + catch (const FileError& e) { reportError(e.toString()); } + + nextExecTime = std::chrono::steady_clock::time_point::max(); + } + } + catch (const FileError& e) + { + reportError(e.toString()); + } +} diff --git a/FreeFileSync/Source/RealTimeSync/monitor.h b/FreeFileSync/Source/RealTimeSync/monitor.h new file mode 100644 index 0000000..8a660f4 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/monitor.h @@ -0,0 +1,26 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef MONITOR_H_345087425834253425 +#define MONITOR_H_345087425834253425 + +#include +#include +#include + + +namespace rts +{ +void monitorDirectories(const std::vector& folderPathPhrases, + //non-formatted paths that yet require call to getFormattedDirectoryName(); empty directories must be checked by caller! + std::chrono::seconds delay, + const std::function& executeExternalCommand, + const std::function& requestUiUpdate, //either waiting for change notifications or at least one folder is missing + const std::function& reportError, //automatically retries after return! + std::chrono::milliseconds cbInterval); +} + +#endif //MONITOR_H_345087425834253425 diff --git a/FreeFileSync/Source/RealTimeSync/tray_menu.cpp b/FreeFileSync/Source/RealTimeSync/tray_menu.cpp new file mode 100644 index 0000000..1d1e025 --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/tray_menu.cpp @@ -0,0 +1,314 @@ +// ***************************************************************************** +// * 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 "tray_menu.h" +#include +#include +#include +#include //Linux needs this +#include +#include +#include +#include +#include +#include +#include +#include +#include "monitor.h" + +using namespace zen; +using namespace rts; + + +namespace +{ +constexpr std::chrono::seconds RETRY_AFTER_ERROR_INTERVAL(15); +constexpr std::chrono::milliseconds UI_UPDATE_INTERVAL(100); //perform ui updates not more often than necessary, 100 seems to be a good value with only a minimal performance loss + + +std::chrono::steady_clock::time_point lastExec; + + +bool uiUpdateDue() +{ + const auto now = std::chrono::steady_clock::now(); + + if (now > lastExec + UI_UPDATE_INTERVAL) + { + lastExec = now; + return true; + } + return false; +} + + +enum TrayMode +{ + active, + waiting, + error, +}; + + +class TrayIcon : public wxTaskBarIcon +{ +public: + explicit TrayIcon(const wxString& jobname) : + jobName_(jobname) + { + Bind(wxEVT_TASKBAR_LEFT_UP, [this](wxTaskBarIconEvent& event) { onMouseClick(event); }); + + assert(mode_ != TrayMode::active); //setMode() supports polling! + setMode(TrayMode::active, Zstring()); + + timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { onErrorFlashIcon(event); }); + } + + //require polling: + bool resumeIsRequested() const { return resumeRequested_; } + bool abortIsRequested () const { return exitRequested_; } + + //during TrayMode::error those two functions are available: + void clearShowErrorRequested() { assert(mode_ == TrayMode::error); showErrorMsgRequested_ = false; } + bool getShowErrorRequested() const { assert(mode_ == TrayMode::error); return showErrorMsgRequested_; } + + void setMode(TrayMode m, const Zstring& missingFolderPath) + { + if (mode_ == m && missingFolderPath_ == missingFolderPath) + return; //support polling + + mode_ = m; + missingFolderPath_ = missingFolderPath; + + timer_.Stop(); + switch (m) + { + case TrayMode::active: + setTrayIcon(trayImg_, _("Directory monitoring active")); + break; + + case TrayMode::waiting: + assert(!missingFolderPath.empty()); + setTrayIcon(greyScale(trayImg_), _("Waiting until directory is available:") + L' ' + fmtPath(missingFolderPath)); + break; + + case TrayMode::error: + timer_.Start(500); //timer interval in [ms] + break; + } + } + +private: + void onErrorFlashIcon(wxEvent& event) + { + iconFlashStatusLast_ = !iconFlashStatusLast_; + setTrayIcon(greyScaleIfDisabled(trayImg_, iconFlashStatusLast_), _("Error")); + } + + void setTrayIcon(const wxImage& img, const wxString& statusTxt) + { + wxString tooltip = L"RealTimeSync"; + if (!jobName_.empty()) + tooltip += SPACED_DASH + jobName_; + + tooltip += L"\n" + statusTxt; + + SetIcon(toScaledBitmap(img), tooltip); + } + + wxMenu* CreatePopupMenu() override + { + wxMenu* contextMenu = new wxMenu; + + wxMenuItem* defaultItem = nullptr; + switch (mode_) + { + case TrayMode::active: + case TrayMode::waiting: + defaultItem = new wxMenuItem(contextMenu, wxID_ANY, _("&Configure")); //better than "Restore"? https://freefilesync.org/forum/viewtopic.php?t=2044&p=20391#p20391 + contextMenu->Bind(wxEVT_COMMAND_MENU_SELECTED, [this](wxCommandEvent& event) { resumeRequested_ = true; }, defaultItem->GetId()); + break; + + case TrayMode::error: + defaultItem = new wxMenuItem(contextMenu, wxID_ANY, _("&Show error message")); + contextMenu->Bind(wxEVT_COMMAND_MENU_SELECTED, [this](wxCommandEvent& event) { showErrorMsgRequested_ = true; }, defaultItem->GetId()); + break; + } + contextMenu->Append(defaultItem); + + contextMenu->AppendSeparator(); + + wxMenuItem* itemAbort = contextMenu->Append(wxID_ANY, _("&Quit")); + contextMenu->Bind(wxEVT_COMMAND_MENU_SELECTED, [this](wxCommandEvent& event) { exitRequested_ = true; }, itemAbort->GetId()); + + return contextMenu; //ownership transferred to caller + } + + void onMouseClick(wxEvent& event) + { + switch (mode_) + { + case TrayMode::active: + case TrayMode::waiting: + resumeRequested_ = true; //never throw exceptions through a C-Layer call stack (GUI)! + break; + case TrayMode::error: + showErrorMsgRequested_ = true; + break; + } + } + + bool resumeRequested_ = false; + bool exitRequested_ = false; + bool showErrorMsgRequested_ = false; + + TrayMode mode_ = TrayMode::waiting; + Zstring missingFolderPath_; + + bool iconFlashStatusLast_ = false; //flash try icon for TrayMode::error + wxTimer timer_; // + + const wxString jobName_; //RTS job name, may be empty + + const wxImage trayImg_ = loadImage("start_rts", dipToScreen(24)); //use 24x24 bitmap for perfect fit +}; + + +struct AbortMonitoring //exception class +{ + AbortMonitoring(CancelReason reasonCode) : reasonCode_(reasonCode) {} + CancelReason reasonCode_; +}; + + +//=> don't derive from wxEvtHandler or any other wxWidgets object unless instance is safely deleted (deferred) during idle event!!tray_icon.h +class TrayIconHolder +{ +public: + explicit TrayIconHolder(const wxString& jobname) : + trayIcon_(new TrayIcon(jobname)) {} + + ~TrayIconHolder() + { + //harmonize with tray_icon.cpp!!! + trayIcon_->RemoveIcon(); + //*schedule* for destruction: delete during next idle event (handle late window messages, e.g. when double-clicking) + trayIcon_->Destroy(); //uses wxPendingDelete + } + + void doUiRefreshNow() //throw AbortMonitoring + { + wxTheApp->Yield(); //yield is UI-layer which is represented by this tray icon + + //advantage of polling vs callbacks: we can throw exceptions! + if (trayIcon_->resumeIsRequested()) + throw AbortMonitoring(CancelReason::requestGui); + + if (trayIcon_->abortIsRequested()) + throw AbortMonitoring(CancelReason::requestExit); + } + + void setMode(TrayMode m, const Zstring& missingFolderPath) { trayIcon_->setMode(m, missingFolderPath); } + + bool getShowErrorRequested() const { return trayIcon_->getShowErrorRequested(); } + void clearShowErrorRequested() { trayIcon_->clearShowErrorRequested(); } + +private: + TrayIcon* const trayIcon_; +}; + +//############################################################################################################## +} + + +rts::CancelReason rts::runFolderMonitor(const FfsRealConfig& config, const wxString& jobname) +{ + std::vector dirNamesNonFmt = config.directories; + std::erase_if(dirNamesNonFmt, [](const Zstring& str) { return trimCpy(str).empty(); }); //remove empty entries WITHOUT formatting paths yet! + + if (dirNamesNonFmt.empty()) + { + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setMainInstructions(_("A folder input field is empty."))); + return CancelReason::requestGui; + } + + const Zstring cmdLine = trimCpy(config.commandline); + + if (cmdLine.empty()) + { + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setMainInstructions(replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLine)))); + return CancelReason::requestGui; + } + + + TrayIconHolder trayIcon(jobname); + + auto executeExternalCommand = [&](const Zstring& changedItemPath, const std::wstring& actionName) //throw FileError + { + ::wxSetEnv(L"change_path", utfTo(changedItemPath)); //crude way to report changed file + ::wxSetEnv(L"change_action", actionName); // + auto cmdLineExp = expandMacros(cmdLine); + + try + { + if (const auto& [exitCode, output] = consoleExecute(cmdLineExp, std::nullopt /*timeoutMs*/); //throw SysError, (SysErrorTimeOut) + exitCode != 0) + throw SysError(formatSystemError("", replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLineExp)), e.toString()); } + }; + + auto requestUiUpdate = [&](const Zstring* missingFolderPath) + { + if (missingFolderPath) + trayIcon.setMode(TrayMode::waiting, *missingFolderPath); + else + trayIcon.setMode(TrayMode::active, Zstring()); + + if (uiUpdateDue()) + trayIcon.doUiRefreshNow(); //throw AbortMonitoring + }; + + auto reportError = [&](const std::wstring& msg) + { + trayIcon.setMode(TrayMode::error, Zstring()); + trayIcon.clearShowErrorRequested(); + + //wait for some time, then return to retry + const auto delayUntil = std::chrono::steady_clock::now() + RETRY_AFTER_ERROR_INTERVAL; + for (auto now = std::chrono::steady_clock::now(); now < delayUntil; now = std::chrono::steady_clock::now()) + { + trayIcon.doUiRefreshNow(); //throw AbortMonitoring + + if (trayIcon.getShowErrorRequested()) + switch (showConfirmationDialog(nullptr, DialogInfoType::error, PopupDialogCfg(). + setDetailInstructions(msg), _("&Retry"))) + { + case ConfirmationButton::accept: //retry + return; + + case ConfirmationButton::cancel: + throw AbortMonitoring(CancelReason::requestGui); + } + std::this_thread::sleep_for(UI_UPDATE_INTERVAL); + } + }; + + try + { + monitorDirectories(dirNamesNonFmt, std::chrono::seconds(config.delay), + executeExternalCommand /*throw FileError*/, + requestUiUpdate, //throw AbortMonitoring + reportError, // + UI_UPDATE_INTERVAL / 2); + assert(false); + return CancelReason::requestGui; + } + catch (const AbortMonitoring& ab) + { + return ab.reasonCode_; + } +} diff --git a/FreeFileSync/Source/RealTimeSync/tray_menu.h b/FreeFileSync/Source/RealTimeSync/tray_menu.h new file mode 100644 index 0000000..01a894f --- /dev/null +++ b/FreeFileSync/Source/RealTimeSync/tray_menu.h @@ -0,0 +1,24 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TRAY_MENU_H_3967857420987534253245 +#define TRAY_MENU_H_3967857420987534253245 + +#include +#include "config.h" + + +namespace rts +{ +enum class CancelReason +{ + requestGui, + requestExit +}; +CancelReason runFolderMonitor(const FfsRealConfig& config, const wxString& jobname); //jobname may be empty +} + +#endif //TRAY_MENU_H_3967857420987534253245 diff --git a/FreeFileSync/Source/afs/abstract.cpp b/FreeFileSync/Source/afs/abstract.cpp new file mode 100644 index 0000000..da7ff9a --- /dev/null +++ b/FreeFileSync/Source/afs/abstract.cpp @@ -0,0 +1,501 @@ +// ***************************************************************************** +// * 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 "abstract.h" +#include +#include +#include +#include +#include + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +AfsPath fff::sanitizeDeviceRelativePath(Zstring relPath) +{ + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) replace(relPath, Zstr('/'), FILE_NAME_SEPARATOR); + if constexpr (FILE_NAME_SEPARATOR != Zstr('\\')) replace(relPath, Zstr('\\'), FILE_NAME_SEPARATOR); + trim(relPath, TrimSide::both, [](Zchar c) { return c == FILE_NAME_SEPARATOR; }); + return AfsPath(relPath); +} + + +std::weak_ordering AFS::compareDevice(const AbstractFileSystem& lhs, const AbstractFileSystem& rhs) +{ + //note: in worst case, order is guaranteed to be stable only during each program run + //caveat: typeid returns static type for pointers, dynamic type for references!!! + if (const std::strong_ordering cmp = std::type_index(typeid(lhs)) <=> std::type_index(typeid(rhs)); + cmp != std::strong_ordering::equal) + return cmp; + + return lhs.compareDeviceSameAfsType(rhs); +} + + +std::optional AFS::getParentPath(const AbstractPath& itemPath) +{ + if (const std::optional parentPath = getParentPath(itemPath.afsPath)) + return AbstractPath(itemPath.afsDevice, *parentPath); + + return {}; +} + + +std::optional AFS::getParentPath(const AfsPath& itemPath) +{ + if (!itemPath.value.empty()) + return AfsPath(beforeLast(itemPath.value, FILE_NAME_SEPARATOR, IfNotFoundReturn::none)); + + return {}; +} + + +namespace +{ +struct FlatTraverserCallback : public AFS::TraverserCallback +{ + FlatTraverserCallback(const std::function& onFile, + const std::function& onFolder, + const std::function& onSymlink) : + onFile_ (onFile), + onFolder_ (onFolder), + onSymlink_(onSymlink) {} + +private: + void onFile (const AFS::FileInfo& fi) override { if (onFile_) onFile_ (fi); } + std::shared_ptr onFolder (const AFS::FolderInfo& fi) override { if (onFolder_) onFolder_ (fi); return nullptr; } + HandleLink onSymlink(const AFS::SymlinkInfo& si) override { if (onSymlink_) onSymlink_(si); return TraverserCallback::HandleLink::skip; } + + HandleError reportDirError (const ErrorInfo& errorInfo) override { throw FileError(errorInfo.msg); } + HandleError reportItemError(const ErrorInfo& errorInfo, const Zstring& itemName) override { throw FileError(errorInfo.msg); } + + const std::function onFile_; + const std::function onFolder_; + const std::function onSymlink_; +}; +} + + +void AFS::traverseFolder(const AfsPath& folderPath, //throw FileError + const std::function& onFile, + const std::function& onFolder, + const std::function& onSymlink) const +{ + auto ft = std::make_shared(onFile, onFolder, onSymlink); //throw FileError + traverseFolderRecursive({{folderPath, ft}}, 1 /*parallelOps*/); //throw FileError +} + + +//already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) +AFS::FileCopyResult AFS::copyFileAsStream(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, const IoCallback& notifyUnbufferedIO /*throw X*/) const +{ + auto streamIn = getInputStream(sourcePath); //throw FileError, ErrorFileLocked + +#warning("maybe only call if deviating from attrSource!? support file append in progress") + + StreamAttributes attrSourceNew = {}; + //try to get the most current attributes if possible (input file might have changed after comparison!) + if (std::optional attr = streamIn->tryGetAttributesFast()) //throw FileError + attrSourceNew = *attr; //Native/MTP/Google Drive + else //use possibly stale ones: + attrSourceNew = attrSource; //SFTP/FTP + //TODO: evaluate: consequences of stale attributes + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + auto streamOut = getOutputStream(targetPath, attrSourceNew.fileSize, attrSourceNew.modTime); //throw FileError + + int64_t totalBytesNotified = 0; + IOCallbackDivider notifyIoDiv(notifyUnbufferedIO, totalBytesNotified); + + const uint64_t streamSize = unbufferedStreamCopy([&](void* buffer, size_t bytesToRead) + { + return streamIn->tryRead(buffer, bytesToRead, notifyIoDiv); //throw FileError, ErrorFileLocked, X + }, + streamIn->getBlockSize() /*throw FileError*/, + + [&](const void* buffer, size_t bytesToWrite) + { + return streamOut->tryWrite(buffer, bytesToWrite, notifyIoDiv); //throw FileError, X + }, + streamOut->getBlockSize() /*throw FileError*/); //throw FileError, ErrorFileLocked, X + + //check incomplete input *before* failing with (slightly) misleading error message in OutputStream::finalize() + if (streamSize != attrSourceNew.fileSize) + throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getDisplayPath(sourcePath))), + _("Unexpected size of data stream:") + L' ' + formatNumber(streamSize) + L'\n' + + _("Expected:") + L' ' + formatNumber(attrSourceNew.fileSize) + L" [unbufferedStreamCopy]"); + + const FinalizeResult finResult = streamOut->finalize(notifyIoDiv); //throw FileError, X + + ZEN_ON_SCOPE_FAIL(try { removeFilePlain(targetPath); } + catch (const FileError& e) { logExtraError(e.toString()); }); //after finalize(): not guarded by ~AFS::OutputStream() anymore! + //-------------------------------------------------------------------------------------------------------- + + //catch file I/O notification bugs => should never happen in *cross-device* context... OTOH BackupRead/BackupWrite may notify less data when copying sparse files + if (totalBytesNotified != makeSigned(2 * streamSize)) + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getDisplayPath(targetPath))), + _("Unexpected size of data stream:") + L' ' + formatNumber(totalBytesNotified) + L'\n' + + _("Expected:") + L' ' + formatNumber(2 * streamSize) + L" [IOCallbackDivider]"); + return + { + .fileSize = attrSourceNew.fileSize, + .modTime = attrSourceNew.modTime, + .sourceFilePrint = attrSourceNew.filePrint, + .targetFilePrint = finResult.filePrint, + .errorModTime = finResult.errorModTime, + /* Failing to set modification time is not a fatal error from synchronization perspective (treat like external update) + => Support additional scenarios: + - GVFS failing to set modTime for FTP: https://freefilesync.org/forum/viewtopic.php?t=2372 + - GVFS failing to set modTime for MTP: https://freefilesync.org/forum/viewtopic.php?t=2803 + - MTP failing to set modTime in general: fail non-silently rather than silently during file creation + - FTP failing to set modTime for servers without MFMT-support */ + }; +} + + +//already existing + no onDeleteTargetFile: undefined behavior! (e.g. fail/overwrite/auto-rename) +AFS::FileCopyResult AFS::copyFileTransactional(const AbstractPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, + bool copyFilePermissions, + bool transactionalCopy, + const std::function& onDeleteTargetFile, + const IoCallback& notifyUnbufferedIO /*throw X*/) +{ + auto copyFilePlain = [&](const AbstractPath& targetPathTmp) + { + //caveat: typeid returns static type for pointers, dynamic type for references!!! + if (typeid(sourcePath.afsDevice.ref()) == typeid(targetPathTmp.afsDevice.ref())) + return sourcePath.afsDevice.ref().copyFileForSameAfsType(sourcePath.afsPath, attrSource, + targetPathTmp, copyFilePermissions, notifyUnbufferedIO); //throw FileError, ErrorFileLocked, X + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + + //fall back to stream-based file copy: + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(getDisplayPath(targetPathTmp))), + _("Operation not supported between different devices.")); + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + return sourcePath.afsDevice.ref().copyFileAsStream(sourcePath.afsPath, attrSource, targetPathTmp, notifyUnbufferedIO); //throw FileError, ErrorFileLocked, X + }; + + if (transactionalCopy && !hasNativeTransactionalCopy(targetPath)) + { + const std::optional parentPath = getParentPath(targetPath); + if (!parentPath) + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getDisplayPath(targetPath))), L"Path is device root."); + const Zstring fileName = getItemName(targetPath); + + //- generate (hopefully) unique file name to avoid clashing with some remnant ffs_tmp file + //- do not loop: avoid pathological cases, e.g. https://freefilesync.org/forum/viewtopic.php?t=1592 + Zstring tmpName = beforeLast(fileName, Zstr('.'), IfNotFoundReturn::all); + + //don't make the temp name longer than the original when hitting file system name length limitations: "lpMaximumComponentLength is commonly 255 characters" + while (tmpName.size() > 200) //BUT don't trim short names! we want early failure on filename-related issues + tmpName = getUnicodeSubstring(tmpName, 0 /*uniPosFirst*/, unicodeLength(tmpName) / 2 /*uniPosLast*/); //consider UTF encoding when cutting in the middle! (e.g. for macOS) + + const Zstring& shortGuid = printNumber(Zstr("%04x"), static_cast(getCrc16(generateGUID()))); + + const AbstractPath targetPathTmp = appendRelPath(*parentPath, tmpName + Zstr('-') + //don't use '~': some FTP servers *silently* replace it with '_'! + shortGuid + TEMP_FILE_ENDING); + //------------------------------------------------------------------------------------------- + + const FileCopyResult result = copyFilePlain(targetPathTmp); //throw FileError, ErrorFileLocked + + //transactional behavior: ensure cleanup; not needed before copyFilePlain() which is already transactional + ZEN_ON_SCOPE_FAIL( try { removeFilePlain(targetPathTmp); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + //have target file deleted (after read access on source and target has been confirmed) => allow for almost transactional overwrite + if (onDeleteTargetFile) + onDeleteTargetFile(); //throw X + + //already existing: undefined behavior! (e.g. fail/overwrite) + moveAndRenameItem(targetPathTmp, targetPath); //throw FileError, (ErrorMoveUnsupported) + //perf: this call is REALLY expensive on unbuffered volumes! ~40% performance decrease on FAT USB stick! + + /* CAVEAT on FAT/FAT32: the sequence of deleting the target file and renaming "file.txt.ffs_tmp" to "file.txt" does + NOT PRESERVE the creation time of the .ffs_tmp file, but SILENTLY "reuses" whatever creation time the old "file.txt" had! + This "feature" is called "File System Tunneling": + https://devblogs.microsoft.com/oldnewthing/?p=34923 + https://support.microsoft.com/kb/172190/en-us */ + return result; + } + else + { + /* Note: non-transactional file copy solves at least four problems: + -> skydrive - doesn't allow for .ffs_tmp extension and returns ERROR_INVALID_PARAMETER + -> network renaming issues + -> allow for true delete before copy to handle low disk space problems + -> higher performance on unbuffered drives (e.g. USB-sticks) */ + if (onDeleteTargetFile) + onDeleteTargetFile(); + + return copyFilePlain(targetPath); //throw FileError, ErrorFileLocked + } +} + + +void AFS::createFolderIfMissingRecursion(const AbstractPath& folderPath) //throw FileError +{ + auto getItemType2 = [&](const AbstractPath& itemPath) //throw FileError + { + try + { return getItemType(itemPath); } //throw FileError + catch (const FileError& e) //need to add context! + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), + replaceCpy(e.toString(), L"\n\n", L'\n')); + } + }; + + try + { + //- path most likely already exists (see: versioning, base folder, log file path) => check first + //- do NOT use getItemTypeIfExists()! race condition when multiple threads are calling createDirectoryIfMissingRecursion(): https://freefilesync.org/forum/viewtopic.php?t=10137#p38062 + //- find first existing + accessible parent folder (backwards iteration): + AbstractPath folderPathEx = folderPath; + RingBuffer folderNames; //caveat: 1. might have been created in the meantime 2. getItemType2() may have failed with access error + for (;;) + try + { + if (getItemType2(folderPathEx) == ItemType::file /*obscure, but possible*/) //throw FileError + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(folderPathEx)))); + break; + } + catch (FileError&) //not yet existing or access error + { + const std::optional parentPath = getParentPath(folderPathEx); + if (!parentPath)//device root => quick access test + throw; + folderNames.push_front(getItemName(folderPathEx)); + folderPathEx = *parentPath; + } + //----------------------------------------------------------- + + AbstractPath folderPathNew = folderPathEx; + for (const Zstring& folderName : folderNames) + try + { + folderPathNew = appendRelPath(folderPathNew, folderName); + + createFolderPlain(folderPathNew); //throw FileError + } + catch (FileError&) + { + try + { + if (getItemType2(folderPathNew) == ItemType::file /*obscure, but possible*/) //throw FileError + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(folderPathNew)))); + else + continue; //already existing => possible, if createDirectoryIfMissingRecursion() is run in parallel + } + catch (FileError&) {} //not yet existing or access error + + throw; + } + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); + } +} + + +//default implementation: folder traversal +void AFS::removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, // + const std::function& onBeforeSymlinkDeletion /*throw X*/, //optional; one call for each object! + const std::function& onBeforeFolderDeletion /*throw X*/) const +{ + std::function removeFolderRecursionImpl; + removeFolderRecursionImpl = [this, &onBeforeFileDeletion, &onBeforeSymlinkDeletion, &onBeforeFolderDeletion, &removeFolderRecursionImpl](const AfsPath& folderPath2) //throw FileError + { + std::vector folderNames; + { + std::vector fileNames; + std::vector symlinkNames; + try + { + traverseFolder(folderPath2, //throw FileError + [&](const FileInfo& fi) { fileNames.push_back(fi.itemName); }, + [&](const FolderInfo& fi) { folderNames.push_back(fi.itemName); }, + [&](const SymlinkInfo& si) { symlinkNames.push_back(si.itemName); }); + } + catch (const FileError& e) //add context + { + throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath2))), + replaceCpy(e.toString(), L"\n\n", L'\n')); + } + + for (const Zstring& fileName : fileNames) + { + const AfsPath filePath(appendPath(folderPath2.value, fileName)); + if (onBeforeFileDeletion) + onBeforeFileDeletion(getDisplayPath(filePath)); //throw X + + removeFilePlain(filePath); //throw FileError + } + + for (const Zstring& symlinkName : symlinkNames) + { + const AfsPath linkPath(appendPath(folderPath2.value, symlinkName)); + if (onBeforeSymlinkDeletion) + onBeforeSymlinkDeletion(getDisplayPath(linkPath)); //throw X + + removeSymlinkPlain(linkPath); //throw FileError + } + } //=> save stack space and allow deletion of extremely deep hierarchies! + + for (const Zstring& folderName : folderNames) + removeFolderRecursionImpl(AfsPath(appendPath(folderPath2.value, folderName))); //throw FileError + + if (onBeforeFolderDeletion) + onBeforeFolderDeletion(getDisplayPath(folderPath2)); //throw X + + removeFolderPlain(folderPath2); //throw FileError + }; + //-------------------------------------------------------------------------------------------------------------- + + const std::optional type = [&] + { + try + { + return getItemTypeIfExists(folderPath); //throw FileError + } + catch (const FileError& e) //add context + { + throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), + replaceCpy(e.toString(), L"\n\n", L'\n')); + } + }(); + + if (type) + { + assert(*type != ItemType::symlink); + + if (*type == ItemType::symlink) + { + if (onBeforeSymlinkDeletion) + onBeforeSymlinkDeletion(getDisplayPath(folderPath)); //throw X + + removeSymlinkPlain(folderPath); //throw FileError + } + else + removeFolderRecursionImpl(folderPath); //throw FileError + } + else //no error situation if directory is not existing! manual deletion relies on it! significant I/O work was done => report: + if (onBeforeFolderDeletion) onBeforeFolderDeletion(getDisplayPath(folderPath)); //throw X +} + + +void AFS::removeFileIfExists(const AbstractPath& filePath) //throw FileError +{ + try + { + removeFilePlain(filePath); //throw FileError + } + catch (const FileError& e) + { + try + { + if (!itemExists(filePath)) //throw FileError + return; + } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + throw; + } +} + + +void AFS::removeSymlinkIfExists(const AbstractPath& linkPath) //throw FileError +{ + try + { + removeSymlinkPlain(linkPath); //throw FileError + } + catch (const FileError& e) + { + try + { + if (!itemExists(linkPath)) //throw FileError + return; + } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + throw; + } +} + + +void AFS::removeEmptyFolderIfExists(const AbstractPath& folderPath) //throw FileError +{ + try + { + removeFolderPlain(folderPath); //throw FileError + } + catch (const FileError& e) + { + try + { + if (!itemExists(folderPath)) //throw FileError + return; + } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + throw; + } +} + + +void AFS::RecycleSession::moveToRecycleBinIfExists(const AbstractPath& itemPath, const Zstring& logicalRelPath) //throw FileError, RecycleBinUnavailable +{ + try + { + moveToRecycleBin(itemPath, logicalRelPath); //throw FileError, RecycleBinUnavailable + } + catch (RecycleBinUnavailable&) { throw; } //[!] no need for itemExists() file access! + catch (const FileError& e) + { + try + { + if (!itemExists(itemPath)) //throw FileError + return; + } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + throw; + } +} + + +void AFS::moveToRecycleBinIfExists(const AbstractPath& itemPath) //throw FileError, RecycleBinUnavailable +{ + try + { + moveToRecycleBin(itemPath); //throw FileError, RecycleBinUnavailable + } + catch (RecycleBinUnavailable&) { throw; } //[!] no need for itemExists() file access! + catch (const FileError& e) + { + try + { + if (!itemExists(itemPath)) //throw FileError + return; + } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + throw; + } +} diff --git a/FreeFileSync/Source/afs/abstract.h b/FreeFileSync/Source/afs/abstract.h new file mode 100644 index 0000000..94c14ea --- /dev/null +++ b/FreeFileSync/Source/afs/abstract.h @@ -0,0 +1,580 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ABSTRACT_H_873450978453042524534234 +#define ABSTRACT_H_873450978453042524534234 + +#include +#include +#include +#include +#include //InputStream/OutputStream support buffered stream concept +#include //NOT a wxWidgets dependency! + + +namespace fff +{ +struct AfsPath; +AfsPath sanitizeDeviceRelativePath(Zstring relPath); + +struct AbstractFileSystem; + +//============================================================================================================== +using AfsDevice = zen::SharedRef; + +struct AfsPath //= path relative to the file system root folder (no leading/traling separator) +{ + AfsPath() {} + explicit AfsPath(const Zstring& p) : value(p) { assert(zen::isValidRelPath(value)); } + Zstring value; + + std::strong_ordering operator<=>(const AfsPath&) const = default; +}; + +struct AbstractPath //THREAD-SAFETY: like an int! +{ + AbstractPath(const AfsDevice& deviceIn, const AfsPath& pathIn) : afsDevice(deviceIn), afsPath(pathIn) {} + + //template -> don't use forwarding constructor: it circumvents AfsPath's explicit constructor! + //AbstractPath(T1&& deviceIn, T2&& pathIn) : afsDevice(std::forward(deviceIn)), afsPath(std::forward(pathIn)) {} + + AfsDevice afsDevice; //"const AbstractFileSystem" => all accesses expected to be thread-safe!!! + AfsPath afsPath; //relative to device root +}; +//============================================================================================================== + +struct AbstractFileSystem //THREAD-SAFETY: "const" member functions must model thread-safe access! +{ + //=============== convenience ================= + static Zstring getItemName(const AbstractPath& itemPath) { assert(getParentPath(itemPath)); return getItemName(itemPath.afsPath); } + static Zstring getItemName(const AfsPath& itemPath) { using namespace zen; return afterLast(itemPath.value, FILE_NAME_SEPARATOR, IfNotFoundReturn::all); } + + static bool isNullPath(const AbstractPath& itemPath) { return isNullDevice(itemPath.afsDevice) /*&& itemPath.afsPath.value.empty()*/; } + + static AbstractPath appendRelPath(const AbstractPath& itemPath, const Zstring& relPath); + + static std::optional getParentPath(const AbstractPath& itemPath); + static std::optional getParentPath(const AfsPath& itemPath); + //============================================= + + static std::weak_ordering compareDevice(const AbstractFileSystem& lhs, const AbstractFileSystem& rhs); + + static bool isNullDevice(const AfsDevice& afsDevice) { return afsDevice.ref().isNullFileSystem(); } + + static std::wstring getDisplayPath(const AbstractPath& itemPath) { return itemPath.afsDevice.ref().getDisplayPath(itemPath.afsPath); } + + static Zstring getInitPathPhrase(const AbstractPath& itemPath) { return itemPath.afsDevice.ref().getInitPathPhrase(itemPath.afsPath); } + + static std::vector getPathPhraseAliases(const AbstractPath& itemPath) { return itemPath.afsDevice.ref().getPathPhraseAliases(itemPath.afsPath); } + + //---------------------------------------------------------------------------------------------------------------- + using RequestPasswordFun = std::function; //throw X + static void authenticateAccess(const AfsDevice& afsDevice, const RequestPasswordFun& requestPassword /*throw X*/) //throw FileError, X + { return afsDevice.ref().authenticateAccess(requestPassword); } + + static bool supportPermissionCopy(const AbstractPath& sourcePath, const AbstractPath& targetPath); //throw FileError + + static bool hasNativeTransactionalCopy(const AbstractPath& itemPath) { return itemPath.afsDevice.ref().hasNativeTransactionalCopy(); } + //---------------------------------------------------------------------------------------------------------------- + + using FingerPrint = uint64_t; //AfsDevice-dependent persistent unique ID + + enum class ItemType : unsigned char + { + file, + folder, + symlink, + }; + //(hopefully) fast: does not distinguish between error/not existing + //root path? => do access test + static ItemType getItemType(const AbstractPath& itemPath) { return itemPath.afsDevice.ref().getItemType(itemPath.afsPath); } //throw FileError + + //assumes: - folder traversal access right (=> yes, because we can assume base path exist at this point; e.g. avoids problem when SFTP parent paths might deny access) + // - all child item path parts must correspond to folder traversal + // => conclude whether an item is *not* existing anymore by doing a *case-sensitive* name search => potentially SLOW! + // - root path? => do access test + static std::optional getItemTypeIfExists(const AbstractPath& itemPath) + { return itemPath.afsDevice.ref().getItemTypeIfExists(itemPath.afsPath); } //throw FileError + + static bool itemExists(const AbstractPath& itemPath) { return static_cast(getItemTypeIfExists(itemPath)); } //throw FileError + //---------------------------------------------------------------------------------------------------------------- + + //already existing: fail + //does NOT create parent directories recursively if not existing + static void createFolderPlain(const AbstractPath& folderPath) { folderPath.afsDevice.ref().createFolderPlain(folderPath.afsPath); } //throw FileError + + //creates directories recursively if not existing + //returns false if folder already exists + static void createFolderIfMissingRecursion(const AbstractPath& folderPath); //throw FileError + + static void removeFolderIfExistsRecursion(const AbstractPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, // + const std::function& onBeforeSymlinkDeletion /*throw X*/, //optional; one call for each object! + const std::function& onBeforeFolderDeletion /*throw X*/) // + { return folderPath.afsDevice.ref().removeFolderIfExistsRecursion(folderPath.afsPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion); } + + static void removeFileIfExists (const AbstractPath& filePath); // + static void removeSymlinkIfExists (const AbstractPath& linkPath); //throw FileError + static void removeEmptyFolderIfExists(const AbstractPath& folderPath); // + + static void removeFilePlain (const AbstractPath& filePath ) { filePath .afsDevice.ref().removeFilePlain (filePath .afsPath); } // + static void removeSymlinkPlain(const AbstractPath& linkPath ) { linkPath .afsDevice.ref().removeSymlinkPlain(linkPath .afsPath); } //throw FileError + static void removeFolderPlain (const AbstractPath& folderPath) { folderPath.afsDevice.ref().removeFolderPlain (folderPath.afsPath); } // + //---------------------------------------------------------------------------------------------------------------- + //static void setModTime(const AbstractPath& itemPath, time_t modTime) { itemPath.afsDevice.ref().setModTime(itemPath.afsPath, modTime); } //throw FileError, follows symlinks + + static AbstractPath getSymlinkResolvedPath(const AbstractPath& linkPath) { return linkPath.afsDevice.ref().getSymlinkResolvedPath(linkPath.afsPath); } //throw FileError + static bool equalSymlinkContent(const AbstractPath& linkPathL, const AbstractPath& linkPathR); //throw FileError + //---------------------------------------------------------------------------------------------------------------- + static zen::FileIconHolder getFileIcon (const AbstractPath& filePath, int pixelSize) { return filePath.afsDevice.ref().getFileIcon (filePath.afsPath, pixelSize); } //throw FileError; optional return value + static zen::ImageHolder getThumbnailImage(const AbstractPath& filePath, int pixelSize) { return filePath.afsDevice.ref().getThumbnailImage(filePath.afsPath, pixelSize); } //throw FileError; optional return value + //---------------------------------------------------------------------------------------------------------------- + + struct StreamAttributes + { + time_t modTime; //number of seconds since Jan. 1st 1970 GMT + uint64_t fileSize; + FingerPrint filePrint; //optional + }; + + //---------------------------------------------------------------------------------------------------------------- + struct InputStream + { + virtual ~InputStream() {} + virtual size_t getBlockSize() = 0; //throw FileError; non-zero block size is AFS contract! + virtual size_t tryRead(void* buffer, size_t bytesToRead, const zen::IoCallback& notifyUnbufferedIO /*throw X*/) = 0; //throw FileError, ErrorFileLocked, X + //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + + //only returns attributes if they are already buffered within stream handle and determination would be otherwise expensive (e.g. FTP/SFTP): + virtual std::optional tryGetAttributesFast() = 0; //throw FileError + }; + //return value always bound: + static std::unique_ptr getInputStream(const AbstractPath& filePath) { return filePath.afsDevice.ref().getInputStream(filePath.afsPath); } //throw FileError, ErrorFileLocked + + //---------------------------------------------------------------------------------------------------------------- + + struct FinalizeResult + { + FingerPrint filePrint = 0; //optional + std::optional errorModTime; + }; + + struct OutputStreamImpl + { + virtual ~OutputStreamImpl() {} + virtual size_t getBlockSize() = 0; //throw FileError; non-zero block size is AFS contract + virtual size_t tryWrite(const void* buffer, size_t bytesToWrite, const zen::IoCallback& notifyUnbufferedIO /*throw X*/) = 0; //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0 + virtual FinalizeResult finalize(const zen::IoCallback& notifyUnbufferedIO /*throw X*/) = 0; //throw FileError, X + }; + + struct OutputStream + { + OutputStream(std::unique_ptr&& outStream, const AbstractPath& filePath, std::optional streamSize); + ~OutputStream(); + size_t getBlockSize() { return outStream_->getBlockSize(); } //throw FileError + size_t tryWrite(const void* buffer, size_t bytesToWrite, const zen::IoCallback& notifyUnbufferedIO /*throw X*/); //throw FileError, X may return short! + FinalizeResult finalize(const zen::IoCallback& notifyUnbufferedIO /*throw X*/); //throw FileError, X + //call finalize when done!() when done, or (incomplete) file will be automatically deleted + + private: + std::unique_ptr outStream_; //bound! + const AbstractPath filePath_; + const std::optional bytesExpected_; + uint64_t bytesWrittenTotal_ = 0; + }; + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + static std::unique_ptr getOutputStream(const AbstractPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) + { return std::make_unique(filePath.afsDevice.ref().getOutputStream(filePath.afsPath, streamSize, modTime), filePath, streamSize); } + //---------------------------------------------------------------------------------------------------------------- + + struct SymlinkInfo + { + Zstring itemName; + time_t modTime; + }; + + struct FileInfo + { + Zstring itemName; + uint64_t fileSize; //unit: bytes! + time_t modTime; //number of seconds since Jan. 1st 1970 GMT + FingerPrint filePrint; //optional; persistent + unique (relative to device) or 0! + bool isFollowedSymlink; + }; + + struct FolderInfo + { + Zstring itemName; + bool isFollowedSymlink; + }; + + struct TraverserCallback + { + virtual ~TraverserCallback() {} + + enum class HandleLink + { + follow, //follows link, then calls "onFolder()" or "onFile()" + skip + }; + + enum class HandleError + { + retry, + ignore + }; + + virtual void onFile (const FileInfo& fi) = 0; // + virtual HandleLink onSymlink(const SymlinkInfo& si) = 0; //throw X + virtual std::shared_ptr onFolder (const FolderInfo& fi) = 0; // + //nullptr: ignore directory, non-nullptr: traverse into, using the (new) callback + + struct ErrorInfo + { + std::wstring msg; + std::chrono::steady_clock::time_point failTime; + size_t retryNumber = 0; + }; + + virtual HandleError reportDirError (const ErrorInfo& errorInfo) = 0; //failed directory traversal -> consider directory data at current level as incomplete! + virtual HandleError reportItemError(const ErrorInfo& errorInfo, const Zstring& itemName) = 0; //failed to get data for single file/dir/symlink only! + }; + + using TraverserWorkload = std::vector /*throw X*/>>; + + //- client needs to handle duplicate file reports! (FilePlusTraverser fallback, retrying to read directory contents, ...) + static void traverseFolderRecursive(const AfsDevice& afsDevice, const TraverserWorkload& workload /*throw X*/, size_t parallelOps) { afsDevice.ref().traverseFolderRecursive(workload, parallelOps); } + + static void traverseFolder(const AbstractPath& folderPath, //throw FileError + const std::function& onFile, // + const std::function& onFolder, //optional + const std::function& onSymlink) // + { folderPath.afsDevice.ref().traverseFolder(folderPath.afsPath, onFile, onFolder, onSymlink); } + //---------------------------------------------------------------------------------------------------------------- + + //already existing: undefined behavior! (e.g. fail/overwrite) + static void moveAndRenameItem(const AbstractPath& pathFrom, const AbstractPath& pathTo); //throw FileError, ErrorMoveUnsupported + + static std::wstring generateMoveErrorMsg(const AbstractPath& pathFrom, const AbstractPath& pathTo) { return pathFrom.afsDevice.ref().generateMoveErrorMsg(pathFrom.afsPath, pathTo); } + + + //Note: it MAY happen that copyFileTransactional() leaves temp files behind, e.g. temporary network drop. + // => clean them up at an appropriate time (automatically set sync directions to delete them). They have the following ending: + static inline constexpr ZstringView TEMP_FILE_ENDING = Zstr(".ffs_tmp"); //don't use Zstring as global constant: avoid static initialization order problem in global namespace! + // caveat: ending is hard-coded by RealTimeSync + + struct FileCopyResult + { + uint64_t fileSize = 0; + time_t modTime = 0; //number of seconds since Jan. 1st 1970 GMT + FingerPrint sourceFilePrint = 0; //optional + FingerPrint targetFilePrint = 0; // + std::optional errorModTime; //failure to set modification time + }; + + //symlink handling: follow + //already existing + no onDeleteTargetFile: undefined behavior! (e.g. fail/overwrite/auto-rename) + //returns current attributes at the time of copy + static FileCopyResult copyFileTransactional(const AbstractPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, + bool copyFilePermissions, + bool transactionalCopy, + //if target is existing user *must* implement deletion to avoid undefined behavior + //if transactionalCopy == true, full read access on source had been proven at this point, so it's safe to delete it. + const std::function& onDeleteTargetFile /*throw X*/, + //accummulated delta != file size! consider ADS, sparse, compressed files + const zen::IoCallback& notifyUnbufferedIO /*throw X*/); + //already existing: fail + //symlink handling: follow + static void copyNewFolder(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions); //throw FileError + + //already existing: fail + static void copySymlink(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions); //throw FileError + + //---------------------------------------------------------------------------------------------------------------- + + //- returns < 0 if not available + //- folderPath does not need to exist (yet) + static int64_t getFreeDiskSpace(const AbstractPath& folderPath) { return folderPath.afsDevice.ref().getFreeDiskSpace(folderPath.afsPath); } //throw FileError + + struct RecycleSession + { + virtual ~RecycleSession() {} + + //- multi-threaded access: internally synchronized! + void moveToRecycleBinIfExists(const AbstractPath& itemPath, const Zstring& logicalRelPath); //throw FileError, RecycleBinUnavailable + + //- fails if item is not existing: don't leave user wonder why it isn't in the recycle bin! + //- multi-threaded access: internally synchronized! + virtual void moveToRecycleBin(const AbstractPath& itemPath, const Zstring& logicalRelPath) = 0; //throw FileError, RecycleBinUnavailable + + virtual void tryCleanup(const std::function& notifyDeletionStatus /*throw X*; displayPath may be empty*/) = 0; //throw FileError, X + }; + + //- return value always bound! + //- constructor will be running on main thread => *no* file I/O! + static std::unique_ptr createRecyclerSession(const AbstractPath& folderPath) { return folderPath.afsDevice.ref().createRecyclerSession(folderPath.afsPath); } //throw FileError, RecycleBinUnavailable + + //- returns empty on success, item type if recycle bin is not available + static void moveToRecycleBinIfExists(const AbstractPath& itemPath); //throw FileError, RecycleBinUnavailable + + //fails if item is not existing + static void moveToRecycleBin(const AbstractPath& itemPath) { itemPath.afsDevice.ref().moveToRecycleBin(itemPath.afsPath); }; //throw FileError, RecycleBinUnavailable + + //================================================================================================================ + + //no need to protect access: + virtual ~AbstractFileSystem() {} + + +protected: + //default implementation: folder traversal + virtual void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion, + const std::function& onBeforeSymlinkDeletion, + const std::function& onBeforeFolderDeletion) const = 0; + + void traverseFolder(const AfsPath& folderPath, //throw FileError + const std::function& onFile, // + const std::function& onFolder, //optional + const std::function& onSymlink) const; // + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + FileCopyResult copyFileAsStream(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, const zen::IoCallback& notifyUnbufferedIO /*throw X*/) const; + + + std::wstring generateMoveErrorMsg(const AfsPath& pathFrom, const AbstractPath& pathTo) const + { + using namespace zen; + + if (getParentPath(pathFrom) == getParentPath(pathTo.afsPath)) //pure "rename" + return replaceCpy(replaceCpy(_("Cannot rename %x to %y."), + L"%x", fmtPath(getDisplayPath(pathFrom))), + L"%y", fmtPath(getItemName(pathTo))); + else //"move" or "move + rename" + return trimCpy(replaceCpy(replaceCpy(_("Cannot move %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(pathFrom))), + L"%y", L'\n' + fmtPath(getDisplayPath(pathTo)))); + } + +private: + virtual std::optional getNativeItemPath(const AfsPath& itemPath) const { return {}; }; + + virtual Zstring getInitPathPhrase(const AfsPath& itemPath) const = 0; + + virtual std::vector getPathPhraseAliases(const AfsPath& itemPath) const = 0; + + virtual std::wstring getDisplayPath(const AfsPath& itemPath) const = 0; + + virtual bool isNullFileSystem() const = 0; + + virtual std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const = 0; + + //---------------------------------------------------------------------------------------------------------------- + virtual ItemType getItemType(const AfsPath& itemPath) const = 0; //throw FileError + + virtual std::optional getItemTypeIfExists(const AfsPath& itemPath) const = 0; //throw FileError + + //already existing: fail + virtual void createFolderPlain(const AfsPath& folderPath) const = 0; //throw FileError + + //non-recursive folder deletion: + virtual void removeFilePlain (const AfsPath& filePath ) const = 0; //throw FileError + virtual void removeSymlinkPlain(const AfsPath& linkPath ) const = 0; //throw FileError + virtual void removeFolderPlain (const AfsPath& folderPath) const = 0; //throw FileError + + //---------------------------------------------------------------------------------------------------------------- + //virtual void setModTime(const AfsPath& itemPath, time_t modTime) const = 0; //throw FileError, follows symlinks + + virtual AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const = 0; //throw FileError + virtual bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const = 0; //throw FileError + + //---------------------------------------------------------------------------------------------------------------- + virtual std::unique_ptr getInputStream(const AfsPath& filePath) const = 0; //throw FileError, ErrorFileLocked + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + virtual std::unique_ptr getOutputStream(const AfsPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) const = 0; + //---------------------------------------------------------------------------------------------------------------- + virtual void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const = 0; + //---------------------------------------------------------------------------------------------------------------- + virtual bool supportsPermissions(const AfsPath& folderPath) const = 0; //throw FileError + + //already existing: undefined behavior! (e.g. fail/overwrite) + virtual void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const = 0; //throw FileError, ErrorMoveUnsupported + + //symlink handling: follow + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + virtual FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, bool copyFilePermissions, + //accummulated delta != file size! consider ADS, sparse, compressed files + const zen::IoCallback& notifyUnbufferedIO /*throw X*/) const = 0; + + + //symlink handling: follow + //already existing: fail + virtual void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const = 0; //throw FileError + + //already existing: fail + virtual void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const = 0; //throw FileError + + //---------------------------------------------------------------------------------------------------------------- + virtual zen::FileIconHolder getFileIcon (const AfsPath& filePath, int pixelSize) const = 0; //throw FileError; optional return value + virtual zen::ImageHolder getThumbnailImage(const AfsPath& filePath, int pixelSize) const = 0; //throw FileError; optional return value + + virtual void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const = 0; //throw FileError, X + + virtual bool hasNativeTransactionalCopy() const = 0; + //---------------------------------------------------------------------------------------------------------------- + + virtual int64_t getFreeDiskSpace(const AfsPath& folderPath) const = 0; //throw FileError, returns < 0 if not available + virtual std::unique_ptr createRecyclerSession(const AfsPath& folderPath) const = 0; //throw FileError, RecycleBinUnavailable + virtual void moveToRecycleBin(const AfsPath& itemPath) const = 0; //throw FileError, RecycleBinUnavailable +}; + + +inline std::weak_ordering operator<=>(const AfsDevice& lhs, const AfsDevice& rhs) { return AbstractFileSystem::compareDevice(lhs.ref(), rhs.ref()); } +inline bool operator== (const AfsDevice& lhs, const AfsDevice& rhs) { return (lhs <=> rhs) == std::weak_ordering::equivalent; } + +inline +std::weak_ordering operator<=>(const AbstractPath& lhs, const AbstractPath& rhs) +{ + return std::tie(lhs.afsDevice, lhs.afsPath) <=> + std::tie(rhs.afsDevice, rhs.afsPath); +} + +inline +bool operator==(const AbstractPath& lhs, const AbstractPath& rhs) { return lhs.afsPath == rhs.afsPath && lhs.afsDevice == rhs.afsDevice; } + + + + + + + + +//------------------------------------ implementation ----------------------------------------- +inline +AbstractPath AbstractFileSystem::appendRelPath(const AbstractPath& itemPath, const Zstring& relPath) +{ + return AbstractPath(itemPath.afsDevice, AfsPath(appendPath(itemPath.afsPath.value, relPath))); +} + +//--------------------------------------------------------------------------------------------- + +inline +AbstractFileSystem::OutputStream::OutputStream(std::unique_ptr&& outStream, const AbstractPath& filePath, std::optional streamSize) : + outStream_(std::move(outStream)), + filePath_(filePath), + bytesExpected_(streamSize) {} + + +inline +AbstractFileSystem::OutputStream::~OutputStream() +{ +} + + +inline +size_t AbstractFileSystem::OutputStream::tryWrite(const void* buffer, size_t bytesToWrite, const zen::IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + const size_t bytesWritten = outStream_->tryWrite(buffer, bytesToWrite, notifyUnbufferedIO /*throw X*/); //throw FileError, X may return short! + bytesWrittenTotal_ += bytesWritten; + return bytesWritten; +} + + +inline +AbstractFileSystem::FinalizeResult AbstractFileSystem::OutputStream::finalize(const zen::IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + using namespace zen; + + //important check: catches corrupt SFTP download with libssh2! + if (bytesExpected_ && *bytesExpected_ != bytesWrittenTotal_) + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getDisplayPath(filePath_))), //instead we should report the source file, but don't have it here... + _("Unexpected size of data stream:") + L' ' + formatNumber(bytesWrittenTotal_) + L'\n' + + _("Expected:") + L' ' + formatNumber(*bytesExpected_)); + + const FinalizeResult result = outStream_->finalize(notifyUnbufferedIO); //throw FileError, X + return result; +} + +//-------------------------------------------------------------------------- + +inline +bool AbstractFileSystem::supportPermissionCopy(const AbstractPath& sourcePath, const AbstractPath& targetPath) //throw FileError +{ + if (typeid(sourcePath.afsDevice.ref()) != typeid(targetPath.afsDevice.ref())) + return false; + + return sourcePath.afsDevice.ref().supportsPermissions(sourcePath.afsPath) && //throw FileError + targetPath.afsDevice.ref().supportsPermissions(targetPath.afsPath); +} + + +inline +bool AbstractFileSystem::equalSymlinkContent(const AbstractPath& linkPathL, const AbstractPath& linkPathR) //throw FileError +{ + if (typeid(linkPathL.afsDevice.ref()) != typeid(linkPathR.afsDevice.ref())) + return false; + + return linkPathL.afsDevice.ref().equalSymlinkContentForSameAfsType(linkPathL.afsPath, linkPathR); //throw FileError +} + + +inline +void AbstractFileSystem::moveAndRenameItem(const AbstractPath& pathFrom, const AbstractPath& pathTo) //throw FileError, ErrorMoveUnsupported +{ + using namespace zen; + + if (typeid(pathFrom.afsDevice.ref()) != typeid(pathTo.afsDevice.ref())) + throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices.")); + + //already existing: undefined behavior! (e.g. fail/overwrite) + pathFrom.afsDevice.ref().moveAndRenameItemForSameAfsType(pathFrom.afsPath, pathTo); //throw FileError, ErrorMoveUnsupported +} + + +inline +void AbstractFileSystem::copyNewFolder(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) //throw FileError +{ + using namespace zen; + + if (typeid(sourcePath.afsDevice.ref()) != typeid(targetPath.afsDevice.ref())) //fall back: + { + //already existing: fail + createFolderPlain(targetPath); //throw FileError + + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(getDisplayPath(targetPath))), + _("Operation not supported between different devices.")); + } + else + sourcePath.afsDevice.ref().copyNewFolderForSameAfsType(sourcePath.afsPath, targetPath, copyFilePermissions); //throw FileError +} + + +//already existing: fail +inline +void AbstractFileSystem::copySymlink(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) //throw FileError +{ + using namespace zen; + + if (typeid(sourcePath.afsDevice.ref()) != typeid(targetPath.afsDevice.ref())) + throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(getDisplayPath(targetPath))), _("Operation not supported between different devices.")); + + //already existing: fail + sourcePath.afsDevice.ref().copySymlinkForSameAfsType(sourcePath.afsPath, targetPath, copyFilePermissions); //throw FileError +} +} + +#endif //ABSTRACT_H_873450978453042524534234 diff --git a/FreeFileSync/Source/afs/abstract_impl.h b/FreeFileSync/Source/afs/abstract_impl.h new file mode 100644 index 0000000..ad34f79 --- /dev/null +++ b/FreeFileSync/Source/afs/abstract_impl.h @@ -0,0 +1,154 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef IMPL_HELPER_H_873450978453042524534234 +#define IMPL_HELPER_H_873450978453042524534234 + +#include "abstract.h" +#include +#include + + +namespace fff +{ +template inline //return ignored error message if available +std::wstring tryReportingDirError(Function cmd /*throw FileError, X*/, AbstractFileSystem::TraverserCallback& cb /*throw X*/) +{ + for (size_t retryNumber = 0;; ++retryNumber) + try + { + cmd(); //throw FileError, X + return std::wstring(); + } + catch (const zen::FileError& e) + { + assert(!e.toString().empty()); + switch (cb.reportDirError({e.toString(), std::chrono::steady_clock::now(), retryNumber})) //throw X + { + case AbstractFileSystem::TraverserCallback::HandleError::ignore: + return e.toString(); + case AbstractFileSystem::TraverserCallback::HandleError::retry: + break; //continue with loop + } + } +} + +template inline +bool tryReportingItemError(Command cmd, AbstractFileSystem::TraverserCallback& callback, const Zstring& itemName) //throw X, return "true" on success, "false" if error was ignored +{ + for (size_t retryNumber = 0;; ++retryNumber) + try + { + cmd(); //throw FileError + return true; + } + catch (const zen::FileError& e) + { + switch (callback.reportItemError({e.toString(), std::chrono::steady_clock::now(), retryNumber}, itemName)) //throw X + { + case AbstractFileSystem::TraverserCallback::HandleError::retry: + break; + case AbstractFileSystem::TraverserCallback::HandleError::ignore: + return false; + } + } +} + +//========================================================================================== + +//Google Drive/MTP happily create duplicate files/folders with the same names, without failing +//=> however, FFS's "check if already exists after failure" idiom *requires* failure +//=> best effort: serialize access (at path level) so that GdriveFileState existence check and file/folder creation act as a single operation +template +class PathAccessLocker +{ + struct BlockInfo + { + std::mutex m; + bool itemInUse = false; //protected by mutex! + /* can we get rid of BlockType::fail and save "bool itemInUse" "somewhere else"? + Google Drive => put dummy entry in GdriveFileState? problem: there is no fail-free removal: accessGlobalFileState() can throw! + MTP => no (buffered) state */ + }; +public: + PathAccessLocker() {} + + //how to handle *other* access attempts while holding the lock: + enum class BlockType + { + otherWait, + otherFail + }; + + class Lock + { + public: + Lock(const NativePath& nativePath, BlockType blockType) : blockType_(blockType) //throw SysError + { + using namespace zen; + + if (const std::shared_ptr pal = getGlobalInstance()) + pal->protPathLocks_.access([&](std::map>& pathLocks) + { + //clean up obsolete entries + std::erase_if(pathLocks, [](const auto& v) { return v.second.expired(); }); + + //get or create: + std::weak_ptr& weakPtr = pathLocks[nativePath]; + blockInfo_ = weakPtr.lock(); + if (!blockInfo_) + weakPtr = blockInfo_ = std::make_shared(); + }); + else + throw SysError(L"PathAccessLocker::Lock() function call not allowed during init/shutdown."); + + blockInfo_->m.lock(); + + if (blockInfo_->itemInUse) + { + blockInfo_->m.unlock(); + throw SysError(replaceCpy(_("The item %x is currently in use."), L"%x", fmtPath(getItemName(nativePath)))); + } + + if (blockType == BlockType::otherFail) + { + blockInfo_->itemInUse = true; + blockInfo_->m.unlock(); + } + } + + ~Lock() + { + if (blockType_ == BlockType::otherFail) + { + blockInfo_->m.lock(); + blockInfo_->itemInUse = false; + } + + blockInfo_->m.unlock(); + } + + private: + Lock (const Lock&) = delete; + Lock& operator=(const Lock&) = delete; + + const BlockType blockType_; //[!] needed: we can't instead check "itemInUse" (without locking first) + std::shared_ptr blockInfo_; + }; + +private: + PathAccessLocker (const PathAccessLocker&) = delete; + PathAccessLocker& operator=(const PathAccessLocker&) = delete; + + static std::shared_ptr getGlobalInstance(); + static Zstring getItemName(const NativePath& nativePath); + + zen::Protected>> protPathLocks_; +}; + +} + +#endif //IMPL_HELPER_H_873450978453042524534234 diff --git a/FreeFileSync/Source/afs/concrete.cpp b/FreeFileSync/Source/afs/concrete.cpp new file mode 100644 index 0000000..b700183 --- /dev/null +++ b/FreeFileSync/Source/afs/concrete.cpp @@ -0,0 +1,59 @@ +// ***************************************************************************** +// * 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 "concrete.h" +#include "native.h" +#include "ftp.h" +#include "sftp.h" +#include "gdrive.h" + +using namespace fff; +using namespace zen; + + +void fff::initAfs(const AfsConfig& cfg) +{ + ftpInit(); + sftpInit(); + gdriveInit(appendPath(cfg.configDirPath, Zstr("GoogleDrive")), + appendPath(cfg.resourceDirPath, Zstr("cacert.pem"))); +} + + +void fff::teardownAfs() +{ + gdriveTeardown(); + sftpTeardown(); + ftpTeardown(); +} + + +AbstractPath fff::getNullPath() +{ + return createItemPathNativeNoFormatting(Zstring()); +} + + +AbstractPath fff::createAbstractPath(const Zstring& itemPathPhrase) //noexcept +{ + //greedy: try native evaluation first + if (acceptsItemPathPhraseNative(itemPathPhrase)) //noexcept + return createItemPathNative(itemPathPhrase); //noexcept + + //then the rest: + if (acceptsItemPathPhraseFtp(itemPathPhrase)) //noexcept + return createItemPathFtp(itemPathPhrase); //noexcept + + if (acceptsItemPathPhraseSftp(itemPathPhrase)) //noexcept + return createItemPathSftp(itemPathPhrase); //noexcept + + if (acceptsItemPathPhraseGdrive(itemPathPhrase)) //noexcept + return createItemPathGdrive(itemPathPhrase); //noexcept + + + //no idea? => native! + return createItemPathNative(itemPathPhrase); +} diff --git a/FreeFileSync/Source/afs/concrete.h b/FreeFileSync/Source/afs/concrete.h new file mode 100644 index 0000000..81e2910 --- /dev/null +++ b/FreeFileSync/Source/afs/concrete.h @@ -0,0 +1,26 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FS_CONCRETE_348787329573243 +#define FS_CONCRETE_348787329573243 + +#include "abstract.h" + +namespace fff +{ +struct AfsConfig +{ + Zstring resourceDirPath; //directory to read AFS-specific files + Zstring configDirPath; //directory to store AFS-specific files +}; +void initAfs(const AfsConfig& cfg); +void teardownAfs(); + +AbstractPath getNullPath(); +AbstractPath createAbstractPath(const Zstring& itemPathPhrase); //noexcept +} + +#endif //FS_CONCRETE_348787329573243 diff --git a/FreeFileSync/Source/afs/ftp.cpp b/FreeFileSync/Source/afs/ftp.cpp new file mode 100644 index 0000000..c4f9f78 --- /dev/null +++ b/FreeFileSync/Source/afs/ftp.cpp @@ -0,0 +1,2757 @@ +// ***************************************************************************** +// * 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 "ftp.h" +#include +#include +#include +#include +#include //DON'T include directly! +#include "init_curl_libssh2.h" +#include "ftp_common.h" +#include "abstract_impl.h" + //#include + #include + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace +{ +//Extensions to FTP: https://tools.ietf.org/html/rfc3659 +//FTP commands: https://en.wikipedia.org/wiki/List_of_FTP_commands + +constexpr std::chrono::seconds FTP_SESSION_MAX_IDLE_TIME (20); +constexpr std::chrono::seconds FTP_SESSION_CLEANUP_INTERVAL(4); + +const size_t FTP_BLOCK_SIZE_DOWNLOAD = 64 * 1024; //libcurl returns blocks of only 16 kB as returned by recv() even if we request larger blocks via CURLOPT_BUFFERSIZE +const size_t FTP_BLOCK_SIZE_UPLOAD = 64 * 1024; //libcurl requests blocks of 64 kB. larger blocksizes set via CURLOPT_UPLOAD_BUFFERSIZE do not seem to make a difference +const size_t FTP_STREAM_BUFFER_SIZE = 1024 * 1024; //unit: [byte] +//stream buffer should be big enough to facilitate prefetching during alternating read/write operations => e.g. see serialize.h::unbufferedStreamCopy() + +constexpr ZstringView ftpPrefix = Zstr("ftp:"); + + +enum class ServerEncoding +{ + unknown, + utf8, + ansi, +}; + + +inline +uint16_t getEffectivePort(int portOption) +{ + if (portOption > 0) + return static_cast(portOption); + return DEFAULT_PORT_FTP; +} + + +struct FtpDeviceId //= what defines a unique FTP location +{ + FtpDeviceId(const FtpLogin& login) : + server(login.server), + port(getEffectivePort(login.portCfg)), + username(login.username) {} + + Zstring server; + uint16_t port; //must be valid port! + Zstring username; +}; +std::weak_ordering operator<=>(const FtpDeviceId& lhs, const FtpDeviceId& rhs) +{ + //exactly the type of case insensitive comparison we need for server names! https://docs.microsoft.com/en-us/windows/win32/api/ws2tcpip/nf-ws2tcpip-getaddrinfow#IDNs + if (const std::weak_ordering cmp = compareAsciiNoCase(lhs.server, rhs.server); + cmp != std::weak_ordering::equivalent) + return cmp; + + return std::tie(lhs.port, lhs.username) <=> //username: case sensitive! + std::tie(rhs.port, rhs.username); +} +//also needed by compareDeviceSameAfsType(), so can't just replace with hash and use std::unordered_map + + +struct FtpSessionCfg //= config for buffered FTP session +{ + FtpDeviceId deviceId; + Zstring password; + bool useTls = false; +}; +bool operator==(const FtpSessionCfg& lhs, const FtpSessionCfg& rhs) +{ + if (lhs.deviceId <=> rhs.deviceId != std::weak_ordering::equivalent) + return false; + + return std::tie(lhs.password, lhs.useTls) == //password: case sensitive! + std::tie(rhs.password, rhs.useTls); +} + + +Zstring concatenateFtpFolderPathPhrase(const FtpLogin& login, const AfsPath& itemPath); //noexcept + + +Zstring ansiToUtfEncoding(const std::string_view& str) //throw SysError +{ + if (str.empty()) return {}; + + gsize bytesWritten = 0; //not including the terminating null + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + //https://developer.gnome.org/glib/stable/glib-Character-Set-Conversion.html#g-convert + gchar* utfStr = ::g_convert(str.data(), //const gchar* str + str.size(), //gssize len + "UTF-8", //const gchar* to_codeset + "LATIN1", //const gchar* from_codeset + nullptr, //gsize* bytes_read + &bytesWritten, //gsize* bytes_written + &error); //GError** error + if (!utfStr) + throw SysError(formatGlibError("g_convert(" + std::string(str) + ", LATIN1 -> UTF-8)", error)); + ZEN_ON_SCOPE_EXIT(::g_free(utfStr)); + + return {utfStr, bytesWritten}; + + +} + + +std::string utfToAnsiEncoding(const Zstring& str) //throw SysError +{ + if (str.empty()) return {}; + + const Zstring& strNorm = getUnicodeNormalForm(str); //convert to pre-composed *before* attempting conversion + + gsize bytesWritten = 0; //not including the terminating null + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + //fails for: 1. broken UTF-8 2. not-ANSI-encodable Unicode + gchar* ansiStr = ::g_convert(strNorm.c_str(), //const gchar* str + strNorm.size(), //gssize len + "LATIN1", //const gchar* to_codeset + "UTF-8", //const gchar* from_codeset + nullptr, //gsize* bytes_read + &bytesWritten, //gsize* bytes_written + &error); //GError** error + if (!ansiStr) + throw SysError(formatGlibError("g_convert(" + utfTo(strNorm) + ", UTF-8 -> LATIN1)", error)); + ZEN_ON_SCOPE_EXIT(::g_free(ansiStr)); + + return {ansiStr, bytesWritten}; + +} + + +std::wstring getCurlDisplayPath(const FtpDeviceId& deviceId, const AfsPath& itemPath) +{ + Zstring displayPath = Zstring(ftpPrefix) + Zstr("//"); + + if (!deviceId.username.empty()) //show username! consider AFS::compareDeviceSameAfsType() + displayPath += deviceId.username + Zstr('@'); + + //if (parseIpv6Address(deviceId.server) && deviceId.port != DEFAULT_PORT_FTP) + // displayPath += Zstr('[') + deviceId.server + Zstr(']'); + //else + displayPath += deviceId.server; + + //if (deviceId.port != DEFAULT_PORT_FTP) + // displayPath += Zstr(':') + numberTo(deviceId.port); + + const Zstring& relPath = getServerRelPath(itemPath); + if (relPath != Zstr("/")) + displayPath += relPath; + + return utfTo(displayPath); +} + + +std::vector splitFtpResponse(std::string&&) = delete; + +std::vector splitFtpResponse(const std::string& buf) +{ + std::vector lines; + + split2(buf, [](const char c) { return isLineBreak(c) || c == '\0'; }, //is 0-char check even needed? + [&lines](const std::string_view block) + { + if (!block.empty()) //consider Windows' + lines.push_back(block); + }); + + return lines; +} + + +class FtpLineParser +{ +public: + explicit FtpLineParser(const std::string_view& line) : it_(line.begin()), itEnd_(line.end()) {} + /**/ FtpLineParser(std::string_view&&) = delete; + + template + std::string_view readRange(size_t count, Function acceptChar) //throw SysError + { + if (static_cast(count) > itEnd_ - it_) + throw SysError(L"Unexpected end of line."); + + const auto rngEnd = it_ + count; + + if (!std::all_of(it_, rngEnd, acceptChar)) + throw SysError(L"Expected char type not found."); + + return makeStringView(std::exchange(it_, rngEnd), rngEnd); + } + + template //expects non-empty range! + std::string_view readRange(Function acceptChar) //throw SysError + { + auto rngEnd = std::find_if_not(it_, itEnd_, acceptChar); + if (rngEnd == it_) + throw SysError(L"Expected char range not found."); + + return makeStringView(std::exchange(it_, rngEnd), rngEnd); + } + + char peekNextChar() const { return it_ == itEnd_ ? '\0' : *it_; } + +private: + /**/ + std::string_view::const_iterator it_; + const std::string_view::const_iterator itEnd_; +}; + +//---------------------------------------------------------------------------------------------------------------- + +std::wstring formatFtpStatus(int sc) +{ + const wchar_t* statusText = [&] //https://en.wikipedia.org/wiki/List_of_FTP_server_return_codes + { + switch (sc) + { + case 400: return L"The command was not accepted but the error condition is temporary."; + case 421: return L"Service not available, closing control connection."; + case 425: return L"Cannot open data connection."; + case 426: return L"Connection closed; transfer aborted."; + case 430: return L"Invalid username or password."; + case 431: return L"Need some unavailable resource to process security."; + case 434: return L"Requested host unavailable."; + case 450: return L"Requested file action not taken."; + case 451: return L"Local error in processing."; + case 452: return L"Insufficient storage space in system. File unavailable, e.g. file busy."; + + case 500: return L"Syntax error, command unrecognized or command line too long."; + case 501: return L"Syntax error in parameters or arguments."; + case 502: return L"Command not implemented."; + case 503: return L"Bad sequence of commands."; + case 504: return L"Command not implemented for that parameter."; + case 521: return L"Data connection cannot be opened with this PROT setting."; + case 522: return L"Server does not support the requested network protocol."; + case 530: return L"User not logged in."; + case 532: return L"Need account for storing files."; + case 533: return L"Command protection level denied for policy reasons."; + case 534: return L"Could not connect to server; issue regarding SSL."; + case 535: return L"Failed security check."; + case 536: return L"Requested PROT level not supported by mechanism."; + case 537: return L"Command protection level not supported by security mechanism."; + case 550: return L"File unavailable, e.g. file not found, no access."; + case 551: return L"Requested action aborted. Page type unknown."; + case 552: return L"Requested file action aborted. Exceeded storage allocation."; + case 553: return L"File name not allowed."; + + default: return L""; + } + }(); + + if (strLength(statusText) == 0) + return trimCpy(replaceCpy(L"FTP status %x.", L"%x", numberTo(sc))); + else + return trimCpy(replaceCpy(L"FTP status %x: ", L"%x", numberTo(sc)) + statusText); +} + +//================================================================================================================ +//================================================================================================================ + +struct SysErrorFtpProtocol : public zen::SysError +{ + SysErrorFtpProtocol(const std::wstring& msg, long ftpError) : SysError(msg), ftpErrorCode(ftpError) {} + + long ftpErrorCode; +}; + +DEFINE_NEW_SYS_ERROR(SysErrorPassword) + + +constinit Global globalFtpSessionCount; +GLOBAL_RUN_ONCE(globalFtpSessionCount.set(createUniSessionCounter())); + + +class FtpSession +{ +public: + explicit FtpSession(const FtpSessionCfg& sessionCfg) : //throw SysError + sessionCfg_(sessionCfg) + { + lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); + } + + ~FtpSession() + { + if (easyHandle_) + ::curl_easy_cleanup(easyHandle_); + } + + const FtpSessionCfg& getSessionCfg() const { return sessionCfg_; } + + //set *before* calling any of the subsequent functions; see FtpSessionManager::access() + void setContextTimeout(const std::weak_ptr& timeoutSec) { timeoutSec_ = timeoutSec; } + + //returns server response (header data) + std::string perform(const AfsPath& itemPath, bool isDir, long pathMethod, + const std::vector& extraOptions, bool requestUtf8) //throw SysError, SysErrorPassword, SysErrorFtpProtocol + { + if (requestUtf8) //avoid endless recursion + initUtf8(); //throw SysError, SysErrorFtpProtocol + + if (!easyHandle_) + { + easyHandle_ = ::curl_easy_init(); + if (!easyHandle_) + throw SysError(formatSystemError("curl_easy_init", formatCurlStatusCode(CURLE_OUT_OF_MEMORY), L"")); + } + else + ::curl_easy_reset(easyHandle_); + + auto setCurlOption = [easyHandle = easyHandle_](const CurlOption& curlOpt) //throw SysError + { + if (const CURLcode rc = ::curl_easy_setopt(easyHandle, curlOpt.option, curlOpt.value); + rc != CURLE_OK) + throw SysError(formatSystemError("curl_easy_setopt(" + numberTo(static_cast(curlOpt.option)) + ")", + formatCurlStatusCode(rc), utfTo(::curl_easy_strerror(rc)))); + }; + + char curlErrorBuf[CURL_ERROR_SIZE] = {}; + setCurlOption({CURLOPT_ERRORBUFFER, curlErrorBuf}); //throw SysError + + std::string headerData; + curl_write_callback onHeaderReceived = [](/*const*/ char* buffer, size_t size, size_t nitems, void* callbackData) + { + auto& output = *static_cast(callbackData); + output.append(buffer, size * nitems); + return size * nitems; + }; + setCurlOption({CURLOPT_HEADERDATA, &headerData}); //throw SysError + setCurlOption({CURLOPT_HEADERFUNCTION, onHeaderReceived}); //throw SysError + + setCurlOption({CURLOPT_URL, getCurlUrlPath(itemPath, isDir).c_str()}); //throw SysError + + assert(pathMethod != CURLFTPMETHOD_MULTICWD); //too slow! + setCurlOption({CURLOPT_FTP_FILEMETHOD, pathMethod}); //throw SysError + + if (!sessionCfg_.deviceId.username.empty()) //else: libcurl will default to CURL_DEFAULT_USER("anonymous") and CURL_DEFAULT_PASSWORD("ftp@example.com") + { + //ANSI or UTF encoding? + // "modern" FTP servers (implementing RFC 2640) have UTF8 enabled by default => pray and hope for the best. + // What about ANSI-FTP servers and "Microsoft FTP Service" which requires "OPTS UTF8 ON"? => *psh* + // CURLOPT_PREQUOTE to the rescue? Nope, issued long after USER/PASS + setCurlOption({CURLOPT_USERNAME, utfTo(sessionCfg_.deviceId.username).c_str()}); //throw SysError + setCurlOption({CURLOPT_PASSWORD, utfTo(sessionCfg_.password ).c_str()}); //throw SysError + //curious: libcurl will *not* default to CURL_DEFAULT_USER when setting password but no username + } + + setCurlOption({CURLOPT_PORT, sessionCfg_.deviceId.port}); //throw SysError + + //thread-safety: https://curl.haxx.se/libcurl/c/threadsafe.html + setCurlOption({CURLOPT_NOSIGNAL, 1}); //throw SysError + + //allow PASV IP: some FTP servers really use IP different from control connection + setCurlOption({CURLOPT_FTP_SKIP_PASV_IP, 0}); //throw SysError + //let's not hold our breath until Curl adds a reasonable PASV handling => patch libcurl accordingly! + //https://github.com/curl/curl/issues/1455 + //https://github.com/curl/curl/pull/1470 + //support broken servers like this one: https://freefilesync.org/forum/viewtopic.php?t=4301 + + + const std::shared_ptr timeoutSec = timeoutSec_.lock(); + assert(timeoutSec); + if (!timeoutSec) + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] FtpSession: Timeout duration was not set."); + + setCurlOption({CURLOPT_CONNECTTIMEOUT, *timeoutSec}); //throw SysError + + //CURLOPT_TIMEOUT: "Since this puts a hard limit for how long time a request is allowed to take, it has limited use in dynamic use cases with varying transfer times." + setCurlOption({CURLOPT_LOW_SPEED_TIME, *timeoutSec}); //throw SysError + setCurlOption({CURLOPT_LOW_SPEED_LIMIT, 1 /*[bytes]*/}); //throw SysError + //can't use "0" which means "inactive", so use some low number + + setCurlOption({CURLOPT_SERVER_RESPONSE_TIMEOUT, *timeoutSec}); //throw SysError + //FTP only; unlike CURLOPT_TIMEOUT, this one is NOT a limit on the total transfer time + + //CURLOPT_ACCEPTTIMEOUT_MS? => only relevant for "active" FTP connections + + //long-running file uploads require keep-alives for the TCP control connection: https://freefilesync.org/forum/viewtopic.php?t=6928 + setCurlOption({CURLOPT_TCP_KEEPALIVE, 1}); //throw SysError + //=> CURLOPT_TCP_KEEPIDLE (=delay until sending first keepalive probe) and + // CURLOPT_TCP_KEEPINTVL (interval between probes) both default to 60 sec, + // CURLOPT_TCP_KEEPCNT (number of probes with *no server response* before dropping connection) defaults to 9 + + + std::optional socketException; + //libcurl does *not* set FD_CLOEXEC for us! https://github.com/curl/curl/issues/2252 + auto onSocketCreate = [&](curl_socket_t curlfd, curlsocktype purpose) + { + assert(::fcntl(curlfd, F_GETFD) == 0); + if (::fcntl(curlfd, F_SETFD, FD_CLOEXEC) == -1) //=> RACE-condition if other thread calls fork/execv before this thread sets FD_CLOEXEC! + { + socketException = SysError(formatSystemError("fcntl(FD_CLOEXEC)", errno)); + return CURL_SOCKOPT_ERROR; + } + return CURL_SOCKOPT_OK; + }; + + using SocketCbType = decltype(onSocketCreate); + using SocketCbWrapperType = int (*)(SocketCbType* clientp, curl_socket_t curlfd, curlsocktype purpose); //needed for cdecl function pointer cast + SocketCbWrapperType onSocketCreateWrapper = [](SocketCbType* clientp, curl_socket_t curlfd, curlsocktype purpose) + { + return (*clientp)(curlfd, purpose); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + + setCurlOption({CURLOPT_SOCKOPTFUNCTION, onSocketCreateWrapper}); //throw SysError + setCurlOption({CURLOPT_SOCKOPTDATA, &onSocketCreate}); //throw SysError + + //Use share interface? https://curl.haxx.se/libcurl/c/libcurl-share.html + //perf test, 4 and 8 parallel threads: + // CURL_LOCK_DATA_DNS => no measurable total time difference + // CURL_LOCK_DATA_SSL_SESSION => freefilesync.org; not working at all: lots of CURLE_RECV_ERROR (seems nobody ever tested this with truly parallel FTP accesses!) +#if 0 + do not include this into release! + static CURLSH* curlShare = [] + { + struct ShareLocks + { + std::mutex lockIntenal; + std::mutex lockDns; + std::mutex lockSsl; + }; + static ShareLocks globalLocksTestingOnly; + + using LockFunType = void (*)(CURL* handle, curl_lock_data data, curl_lock_access access, void* userptr); //needed for cdecl function pointer cast + LockFunType lockFun = [](CURL* handle, curl_lock_data data, curl_lock_access access, void* userptr) + { + auto& locks = *static_cast(userptr); + switch (data) + { + case CURL_LOCK_DATA_SHARE: + return locks.lockIntenal.lock(); + case CURL_LOCK_DATA_DNS: + return locks.lockDns.lock(); + case CURL_LOCK_DATA_SSL_SESSION: + return locks.lockSsl.lock(); + } + assert(false); + }; + using UnlockFunType = void (*)(CURL *handle, curl_lock_data data, void* userptr); + UnlockFunType unlockFun = [](CURL *handle, curl_lock_data data, void* userptr) + { + auto& locks = *static_cast(userptr); + switch (data) + { + case CURL_LOCK_DATA_SHARE: + return locks.lockIntenal.unlock(); + case CURL_LOCK_DATA_DNS: + return locks.lockDns.unlock(); + case CURL_LOCK_DATA_SSL_SESSION: + return locks.lockSsl.unlock(); + } + assert(false); + }; + + CURLSH* cs = ::curl_share_init(); + assert(cs); + CURLSHcode rc = CURLSHE_OK; + rc = ::curl_share_setopt(cs, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); + assert(rc == CURLSHE_OK); + rc = ::curl_share_setopt(cs, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION); //buggy!? + assert(rc == CURLSHE_OK); + rc = ::curl_share_setopt(cs, CURLSHOPT_LOCKFUNC, lockFun); + assert(rc == CURLSHE_OK); + rc = ::curl_share_setopt(cs, CURLSHOPT_UNLOCKFUNC, unlockFun); + assert(rc == CURLSHE_OK); + rc = ::curl_share_setopt(cs, CURLSHOPT_USERDATA, &globalLocksTestingOnly); + assert(rc == CURLSHE_OK); + return cs; + }(); + //CURLSHcode ::curl_share_cleanup(curlShare); + setCurlOption({CURLOPT_SHARE, curlShare}); //throw SysError +#endif + + //TODO: FTP option to require certificate checking? +#if 0 + setCurlOption({CURLOPT_CAINFO, "cacert.pem"}); //throw SysError + //hopefully latest version from https://curl.haxx.se/docs/caextract.html + //libcurl forwards this char-string to OpenSSL as is, which (thank god) accepts UTF8 +#else + setCurlOption({CURLOPT_CAINFO, 0}); //throw SysError + //be explicit: "even when [CURLOPT_SSL_VERIFYPEER] is disabled [...] curl may still load the certificate file specified in CURLOPT_CAINFO." + + //check if server certificate can be trusted? (Default: 1L) + // => may fail with: "CURLE_PEER_FAILED_VERIFICATION: SSL certificate problem: certificate has expired" + setCurlOption({CURLOPT_SSL_VERIFYPEER, 0}); //throw SysError + //check that server name matches the name in the certificate? (Default: 2L) + // => may fail with: "CURLE_PEER_FAILED_VERIFICATION: SSL: no alternative certificate subject name matches target host name 'freefilesync.org'" + setCurlOption({CURLOPT_SSL_VERIFYHOST, 0}); //throw SysError +#endif + if (sessionCfg_.useTls) //https://tools.ietf.org/html/rfc4217 + { + //require SSL for both control and data: + setCurlOption({CURLOPT_USE_SSL, CURLUSESSL_ALL}); //throw SysError + //try TLS first, then SSL (currently: CURLFTPAUTH_DEFAULT == CURLFTPAUTH_SSL): + setCurlOption({CURLOPT_FTPSSLAUTH, CURLFTPAUTH_TLS}); //throw SysError + } + + //support older FTP servers with less than 2048 bit TLS keys ("CURLE_SSL_CONNECT_ERROR: TLS connect error: error:0A00018A:SSL routines::dh key too small") + //=> OpenSSL defaults to security level 2 (unless OPENSSL_TLS_SECURITY_LEVEL=level is defined during compilation) https://docs.openssl.org/master/man3/SSL_CTX_set_security_level/ + setCurlOption({CURLOPT_SSL_CIPHER_LIST, "DEFAULT:@SECLEVEL=1"}); //throw SysError + + for (const CurlOption& option : extraOptions) + setCurlOption(option); //throw SysError + + //======================================================================================================= + const CURLcode rcPerf = ::curl_easy_perform(easyHandle_); + //WTF: curl_easy_perform() considers FTP response codes >= 400 as failure, but for HTTP response codes 4XX are considered success!! CONSISTENCY, people!!! + //note: CURLOPT_FAILONERROR(default:off) is only available for HTTP => BUT at least we can prefix FTP commands with * for same effect: https://curl.se/libcurl/c/CURLOPT_QUOTE.html + + if (socketException) + throw* socketException; //throw SysError + //======================================================================================================= + + if (rcPerf != CURLE_OK) + { + std::wstring errorMsg = trimCpy(utfTo(curlErrorBuf)); //optional + + if (const std::vector& headerLines = splitFtpResponse(headerData); + !headerLines.empty()) + if (const std::string_view& response = trimCpy(headerLines.back()); //that *should* be the server's error response + !response.empty()) + errorMsg += (errorMsg.empty() ? L"" : L"\n") + utfTo(response); +#if 0 + //utfTo(::curl_easy_strerror(ec)) is uninteresting + //use CURLINFO_OS_ERRNO ?? https://curl.haxx.se/libcurl/c/CURLINFO_OS_ERRNO.html + long nativeErrorCode = 0; + if (::curl_easy_getinfo(easyHandle_, CURLINFO_OS_ERRNO, &nativeErrorCode) == CURLE_OK) + if (nativeErrorCode != 0) + errorMsg += (errorMsg.empty() ? L"" : L"\n") + std::wstring(L"Native error code: ") + numberTo(nativeErrorCode); +#endif + if (rcPerf == CURLE_LOGIN_DENIED) + throw SysErrorPassword(formatSystemError("curl_easy_perform", formatCurlStatusCode(rcPerf), errorMsg)); + + long ftpStatusCode = 0; //optional + /*const CURLcode rc =*/ ::curl_easy_getinfo(easyHandle_, CURLINFO_RESPONSE_CODE, &ftpStatusCode); + //https://en.wikipedia.org/wiki/List_of_FTP_server_return_codes + assert(rcPerf == CURLE_OPERATION_TIMEDOUT || rcPerf == CURLE_ABORTED_BY_CALLBACK || ftpStatusCode == 0 || 400 <= ftpStatusCode && ftpStatusCode < 600); + if (ftpStatusCode != 0) + throw SysErrorFtpProtocol(formatSystemError("curl_easy_perform", formatCurlStatusCode(rcPerf), errorMsg), ftpStatusCode); + + throw SysError(formatSystemError("curl_easy_perform", formatCurlStatusCode(rcPerf), errorMsg)); + } + + lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); + return headerData; + } + + //returns server response (header data) + std::string runSingleFtpCommand(const std::string& ftpCmd, bool requestUtf8) //throw SysError, SysErrorFtpProtocol + { + curl_slist* quote = nullptr; + ZEN_ON_SCOPE_EXIT(::curl_slist_free_all(quote)); + quote = ::curl_slist_append(quote, ftpCmd.c_str()); + + return perform(AfsPath(), true /*isDir*/, CURLFTPMETHOD_NOCWD /*avoid needless CWDs*/, + { + {CURLOPT_NOBODY, 1L}, + {CURLOPT_QUOTE, quote}, + }, requestUtf8); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + } + + void testConnection() //throw SysError + { + /* https://en.wikipedia.org/wiki/List_of_FTP_commands + FEAT: are there servers that don't support this command? fuck, yes: "550 FEAT: Operation not permitted" => buggy server not granting access, despite support! + PWD? will fail if last access deleted the working dir! + "TYPE I"? might interfere with libcurls internal handling, but that's an improvement, right? right? :> + => but "HELP", and "NOOP" work, right?? + Fuck my life: even "HELP" is not always implemented: https://freefilesync.org/forum/viewtopic.php?t=6002 + => are there servers supporting neither FEAT nor HELP? only time will tell... + ... and it tells! FUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU https://freefilesync.org/forum/viewtopic.php?t=8041 */ + + //=> '*' to the rescue: as long as we get an FTP response - *any* FTP response (including 550) - the connection itself is fine! + const std::string& featBuf = runSingleFtpCommand("*FEAT", false /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + + for (const std::string_view& line : splitFtpResponse(featBuf)) + if (startsWith(line, "211 ") || + startsWith(line, "500 ") || + startsWith(line, "550 ")) + return; + + //ever get here? + throw SysError(L"Unexpected FTP response. (" + utfTo(featBuf) + L')'); + } + + AfsPath getHomePath() //throw SysError + { + if (!homePathCached_) + homePathCached_ = [&] + { + if (easyHandle_) + { + const char* homePathCurl = nullptr; //not owned + /*CURLcode rc =*/ ::curl_easy_getinfo(easyHandle_, CURLINFO_FTP_ENTRY_PATH, &homePathCurl); + + if (homePathCurl && isAsciiString(homePathCurl)) + return sanitizeDeviceRelativePath(utfTo(homePathCurl)); + + //home path with non-ASCII chars: libcurl issues PWD right after login *before* server was set up for UTF8 + //=> CURLINFO_FTP_ENTRY_PATH could be in any encoding => useless! + // Test case: Windows 10 IIS FTP with non-Ascii entry path + //=> start new FTP session and parse PWD *after* UTF8 is enabled: + ::curl_easy_cleanup(easyHandle_); + easyHandle_ = nullptr; + } + + const std::string& pwdBuf = runSingleFtpCommand("PWD", true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + + for (const std::string_view& line : splitFtpResponse(pwdBuf)) + if (startsWith(line, "257 ")) + { + /* 257[rubbish]"" according to libcurl + + "The directory name can contain any character; embedded double-quotes should be escaped by + double-quotes (the "quote-doubling" convention)." https://tools.ietf.org/html/rfc959 */ + auto itBegin = std::find(line.begin(), line.end(), '"'); + if (itBegin != line.end()) + for (auto it = ++itBegin; it != line.end(); ++it) + if (*it == '"') + { + if (it + 1 != line.end() && it[1] == '"') + ++it; //skip double quote + else + { + const std::string homePathRaw = replaceCpy(std::string{itBegin, it}, "\"\"", '"'); + const Zstring homePathUtf = serverToUtfEncoding(homePathRaw); //throw SysError + return sanitizeDeviceRelativePath(homePathUtf); + } + } + break; + } + throw SysError(L"Unexpected FTP response. (" + utfTo(pwdBuf) + L')'); + }(); + return *homePathCached_; + } + + void ensureBinaryMode() //throw SysError + { + if (std::optional currentSocket = getActiveSocket()) //throw SysError + if (*currentSocket == binaryEnabledSocket_) + return; + + runSingleFtpCommand("TYPE I", false /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + + //make sure our binary-enabled session is still there (== libcurl behaves as we expect) + std::optional currentSocket = getActiveSocket(); //throw SysError + if (currentSocket) + binaryEnabledSocket_ = *currentSocket; //remember what we did + //libcurl already buffers "conn->proto.ftpc.transfertype" but selfishly keeps it for itself! + //=> pray libcurl doesn't internally set "TYPE A"! + //=> this seems to be the only place where it does: https://github.com/curl/curl/issues/4342 + else + throw SysError(L"Curl failed to cache FTP session."); //why is libcurl not caching the session??? + } + + //------------------------------------------------------------------------------------------------------------ + bool supportsMlsd() { return getFeatureSupport(&Features::mlsd); } // + bool supportsMfmt() { return getFeatureSupport(&Features::mfmt); } //throw SysError + bool supportsClnt() { return getFeatureSupport(&Features::clnt); } // + bool supportsUtf8() + { + if (getFeatureSupport(&Features::utf8)) + return true; + + initUtf8(); //vsFTPd (ftp.sunet.se): supports UTF8 via "OPTS UTF8 ON", even if "UTF8" is missing from "FEAT" + return socketUsesUtf8_; + } + + bool isHealthy() const + { + return std::chrono::steady_clock::now() - lastSuccessfulUseTime_ <= FTP_SESSION_MAX_IDLE_TIME; + } + + std::string getServerPathInternal(const AfsPath& itemPath) //throw SysError + { + const Zstring serverPath = getServerRelPath(itemPath); + + if (itemPath.value.empty()) //endless recursion caveat!! utfToServerEncoding() transitively depends on getServerPathInternal() + return utfTo(serverPath); + + return utfToServerEncoding(serverPath); //throw SysError + } + + Zstring serverToUtfEncoding(const std::string_view& str) //throw SysError + { + if (isAsciiString(str)) //fast path + return {str.begin(), str.end()}; + + switch (encoding_) //throw SysError + { + case ServerEncoding::unknown: + /* "UTF-8 encodings [2] contain enough internal structure that it is always, in practice, possible to determine whether a UTF-8 or raw encoding has been used" + - https://www.rfc-editor.org/rfc/rfc3659#section-2.2 + "encoding rules make it very unlikely that a character sequence from a different character set will be mistaken for a UTF-8 encoded character sequence." + - https://www.rfc-editor.org/rfc/rfc2640#section-2.2 + + => auto-detect encoding even if FEAT does not advertize UTF8: https://freefilesync.org/forum/viewtopic.php?t=9564 */ + encoding_ = supportsUtf8() || isValidUtf(str) ? ServerEncoding::utf8 : ServerEncoding::ansi; + return serverToUtfEncoding(str); //throw SysError + + case ServerEncoding::utf8: + if (!isValidUtf(str)) + throw SysError(_("Invalid character encoding:") + L' ' + utfTo(str) + L' ' + _("Expected:") + L" [UTF-8]"); + + return utfTo(str); + + case ServerEncoding::ansi: + return ansiToUtfEncoding(str); //throw SysError + } + assert(false); + return {}; + } + + std::string utfToServerEncoding(const Zstring& str) //throw SysError + { + if (isAsciiString(str)) //fast path + return {str.begin(), str.end()}; + switch (encoding_) //throw SysError + { + case ServerEncoding::unknown: + if (!supportsUtf8()) + throw SysError(_("Failed to auto-detect character encoding:") + L' ' + utfTo(str)); //might be ANSI or UTF8 with non-compliant server... + + encoding_ = ServerEncoding::utf8; + return utfToServerEncoding(str); //throw SysError + + case ServerEncoding::utf8: + //validate! we consider REPLACEMENT_CHAR as indication for server using ANSI encoding in serverToUtfEncoding() + if (!isValidUtf(str)) + throw SysError(_("Invalid character encoding:") + L' ' + utfTo(str) + L' ' + _("Expected:") + (sizeof(str[0]) == 1 ? L" [UTF-8]" : L" [UTF-16]")); + static_assert(sizeof(str[0]) == 1 || sizeof(str[0]) == 2); + + return utfTo(str); + + case ServerEncoding::ansi: + return utfToAnsiEncoding(str); //throw SysError + } + assert(false); + return {}; + } + +private: + FtpSession (const FtpSession&) = delete; + FtpSession& operator=(const FtpSession&) = delete; + + std::string getCurlUrlPath(const AfsPath& itemPath /*optional*/, bool isDir) //throw SysError + { + std::string curlRelPath; //libcurl expects encoded paths (except for '/' char!!!) => bug: https://github.com/curl/curl/pull/4423 + + split(getServerPathInternal(itemPath), //throw SysError + '/', [&](std::string_view comp) + { + if (!comp.empty()) + { + char* compFmt = ::curl_easy_escape(easyHandle_, comp.data(), static_cast(comp.size())); + if (!compFmt) + throw SysError(formatSystemError(std::string("curl_easy_escape(") + comp + ')', L"", L"Conversion failure")); + ZEN_ON_SCOPE_EXIT(::curl_free(compFmt)); + + if (!curlRelPath.empty()) + curlRelPath += '/'; + curlRelPath += compFmt; + } + }); + + if (trimCpy(sessionCfg_.deviceId.server).empty()) + throw SysError(_("Server name must not be empty.")); + + static_assert(LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 67)); + /* 1. CURLFTPMETHOD_NOCWD requires absolute paths to unconditionally skip CWDs: https://github.com/curl/curl/pull/4382 + 2. CURLFTPMETHOD_SINGLECWD requires absolute paths to skip one needless "CWD entry path": https://github.com/curl/curl/pull/4332 + => https://curl.se/docs/faq.html#How_do_I_list_the_root_directory + => use // because /%2f had bugs (but they should be fixed: https://github.com/curl/curl/pull/4348) */ + std::string path = utfTo(Zstring(ftpPrefix) + Zstr("//") + sessionCfg_.deviceId.server) + "//" + curlRelPath; + + if (isDir && !endsWith(path, '/')) //curl-FTP needs directory paths to end with a slash + path += '/'; + return path; + } + + void initUtf8() //throw SysError, SysErrorFtpProtocol + { + /* 1. Some RFC-2640-non-compliant servers require UTF8 to be explicitly enabled: https://wiki.filezilla-project.org/Character_Encoding#Conflicting_specification + - e.g. Microsoft FTP Service: https://freefilesync.org/forum/viewtopic.php?t=4303 + + 2. Others do not advertize "UTF8" in "FEAT", but *still* allow enabling it via "OPTS UTF8 ON": + - https://freefilesync.org/forum/viewtopic.php?t=9564 + - vsFTPd: ftp.sunet.se https://security.appspot.com/vsftpd.html#download + + "OPTS UTF8 ON" needs to be activated each time libcurl internally creates a new session + hopyfully libcurl will offer a better solution: https://github.com/curl/curl/issues/1457 */ + + if (std::optional currentSocket = getActiveSocket()) //throw SysError + if (*currentSocket == utf8RequestedSocket_) //caveat: a non-UTF8-enabled session might already exist, e.g. from a previous call to supportsMlsd() + return; + + //some (broken!?) servers require "CLNT" before accepting "OPTS UTF8 ON": https://social.msdn.microsoft.com/Forums/en-US/d602574f-8a69-4d69-b337-52b6081902cf/problem-with-ftpwebrequestopts-utf8-on-501-please-clnt-first + if (supportsClnt()) //throw SysError + runSingleFtpCommand("CLNT FreeFileSync", false /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + + //"prefix the command with an asterisk to make libcurl continue even if the command fails" + //-> ignore if server does not know this legacy command (but report all *other* issues; else getActiveSocket() below won't have a socket and we've hidden the real error!) + const std::string& optsBuf = runSingleFtpCommand("*OPTS UTF8 ON", false /*requestUtf8*/); //throw SysError, (SysErrorFtpProtocol) + + //get *last* FTP status code (can there be more than one!?) + int ftpStatusCode = 0; + for (const std::string_view& line : splitFtpResponse(optsBuf)) + if (line.size() >= 4 && + isDigit(line[0]) && + isDigit(line[1]) && + isDigit(line[2]) && + line[3] == ' ') + ftpStatusCode = stringTo(line); + + socketUsesUtf8_ = ftpStatusCode == 200 || //"200 Always in UTF8 mode." "200 UTF8 set to on" + ftpStatusCode == 202; //"202 UTF8 mode is always enabled." + + //make sure our Unicode-enabled session is still there (== libcurl behaves as we expect) + std::optional currentSocket = getActiveSocket(); //throw SysError + if (currentSocket) + utf8RequestedSocket_ = *currentSocket; //remember what we did + else + throw SysError(L"Curl failed to cache FTP session."); //why is libcurl not caching the session??? + + } + + std::optional getActiveSocket() //throw SysError + { + if (easyHandle_) + { + curl_socket_t currentSocket = 0; + const CURLcode rc = ::curl_easy_getinfo(easyHandle_, CURLINFO_ACTIVESOCKET, ¤tSocket); + if (rc != CURLE_OK) + throw SysError(formatSystemError("curl_easy_getinfo(CURLINFO_ACTIVESOCKET)", formatCurlStatusCode(rc), utfTo(::curl_easy_strerror(rc)))); + if (currentSocket != CURL_SOCKET_BAD) + return currentSocket; + } + return {}; + } + + struct Features + { + bool mlsd = false; + bool mfmt = false; + bool clnt = false; + bool utf8 = false; + }; + using FeatureList = std::unordered_map; + + bool getFeatureSupport(bool Features::* status) //throw SysError + { + if (!featureCache_) + { + static constinit FunStatGlobal> globalServerFeatures; + globalServerFeatures.setOnce([] { return std::make_unique>(); }); + + const auto sf = globalServerFeatures.get(); + if (!sf) + throw SysError(formatSystemError("FtpSession::getFeatureSupport", L"", L"Function call not allowed during application shutdown.")); + + sf->access([&](const FeatureList& featList) + { + auto it = featList.find(sessionCfg_.deviceId.server); + if (it != featList.end()) + featureCache_ = it->second; + }); + + if (!featureCache_) + { + //*: ignore error if server does not support/allow FEAT + featureCache_ = parseFeatResponse(runSingleFtpCommand("*FEAT", false /*requestUtf8*/)); //throw SysError, (SysErrorFtpProtocol) + //used by initUtf8()! => requestUtf8 = false!!! + + sf->access([&](FeatureList& feat) { feat.emplace(sessionCfg_.deviceId.server, *featureCache_); }); + } + } + return (*featureCache_).*status; + } + + static Features parseFeatResponse(const std::string& featResponse) + { + Features output; //FEAT command: https://tools.ietf.org/html/rfc2389#page-4 + std::vector lines = splitFtpResponse(featResponse); + + auto it = std::find_if(lines.begin(), lines.end(), [](const std::string_view& line) { return startsWith(line, "211-") || startsWith(line, "211 "); }); + if (it != lines.end()) + { + ++it; + for (; it != lines.end(); ++it) + { + if (equalAsciiNoCase (*it, "211 End") || //Serv-U: "211 End (for details use "HELP commmand" where command is the command of interest)" + startsWithAsciiNoCase(*it, "211 End ")) //Home Ftp Server: "211 End of extentions." + break; + + std::string line(*it); + //suppport ProFTPD with "MultilineRFC2228 = on" https://freefilesync.org/forum/viewtopic.php?t=7243 + if (startsWith(line, "211-")) + line = ' ' + afterFirst(line, '-', IfNotFoundReturn::none); + + //https://tools.ietf.org/html/rfc3659#section-7.8 + //"a server-FTP process that supports MLST, and MLSD [...] MUST indicate that this support exists" + //"there is no distinct FEAT output for MLSD. The presence of the MLST feature indicates that both MLST and MLSD are supported" + if (equalAsciiNoCase (line, " MLST") || + startsWithAsciiNoCase(line, " MLST ") || //SP "MLST" [SP factlist] CRLF + //so much the theory. In practice FTP server implementers can't read (specs): https://freefilesync.org/forum/viewtopic.php?t=6752 + equalAsciiNoCase(line, " MLSD")) + output.mlsd = true; + + //https://tools.ietf.org/html/draft-somers-ftp-mfxx-04#section-3.3 + //"Where a server-FTP process supports the MFMT command [...] it MUST include the response to the FEAT command" + else if (equalAsciiNoCase(line, " MFMT")) //SP "MFMT" CRLF + output.mfmt = true; + + else if (equalAsciiNoCase(line, " UTF8") || + equalAsciiNoCase(line, " UTF8 ON") || //support non-compliant servers: https://freefilesync.org/forum/viewtopic.php?t=7355#p24694 + equalAsciiNoCase(line, " UTF-8")) //Android 12: "File Manager" by Xiaomi + output.utf8 = true; + + else if (equalAsciiNoCase(line, " CLNT")) + output.clnt = true; + } + } + return output; + } + + const FtpSessionCfg sessionCfg_; + CURL* easyHandle_ = nullptr; + + curl_socket_t utf8RequestedSocket_ = 0; + curl_socket_t binaryEnabledSocket_ = 0; + + bool socketUsesUtf8_ = false; + + ServerEncoding encoding_ = ServerEncoding::unknown; + + std::optional featureCache_; + std::optional homePathCached_; + + const std::shared_ptr libsshCurlUnifiedInitCookie_{getLibsshCurlUnifiedInitCookie(globalFtpSessionCount)}; //throw SysError + std::chrono::steady_clock::time_point lastSuccessfulUseTime_; + std::weak_ptr timeoutSec_; +}; + +//================================================================================================================ +//================================================================================================================ + +class FtpSessionManager //reuse (healthy) FTP sessions globally +{ + struct FtpSessionCache; + +public: + FtpSessionManager() : sessionCleaner_([this] + { + setCurrentThreadName(Zstr("Session Cleaner[FTP]")); + runGlobalSessionCleanUp(); /*throw ThreadStopRequest*/ + }) {} + + void access(const FtpLogin& login, const std::function& useFtpSession /*throw X*/) //throw SysError, X + { + Protected& sessionCache = getSessionCache(login); + + std::unique_ptr ftpSession; //either or + std::optional sessionCfg; // + + sessionCache.access([&](FtpSessionCache& cache) + { + if (!cache.activeCfg) //AFS::authenticateAccess() not called => authenticate implicitly! + setActiveConfig(cache, login); + + //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use, idle sessions via worker thread) + if (!cache.idleFtpSessions.empty()) + { + ftpSession = std::move(cache.idleFtpSessions.back ()); + /**/ cache.idleFtpSessions.pop_back(); + } + else + sessionCfg = *cache.activeCfg; + }); + + //create new FTP session outside the lock: 1. don't block other threads 2. non-atomic regarding "sessionCache"! => one session too many is not a problem! + if (!ftpSession) + ftpSession = std::make_unique(*sessionCfg); //throw SysError + + const std::shared_ptr timeoutSec = std::make_shared(login.timeoutSec); //context option: valid only for duration of this call! + ftpSession->setContextTimeout(timeoutSec); + + ZEN_ON_SCOPE_EXIT + ( + //*INDENT-OFF* + if (ftpSession->isHealthy()) //thread that created the "!isHealthy()" session is responsible for clean up (avoid hitting server connection limits!) + sessionCache.access([&](FtpSessionCache& cache) + { + if (ftpSession->getSessionCfg() == *cache.activeCfg) //created outside the lock => check *again* + cache.idleFtpSessions.push_back(std::move(ftpSession)); //pass ownership + }); + //*INDENT-ON* + ); + + useFtpSession(*ftpSession); //throw X + } + + void setActiveConfig(const FtpLogin& login) + { + getSessionCache(login).access([&](FtpSessionCache& cache) { setActiveConfig(cache, login); }); + } + + void setSessionPassword(const FtpLogin& login, const Zstring& password) + { + getSessionCache(login).access([&](FtpSessionCache& cache) + { + cache.sessionPassword = password; + setActiveConfig(cache, login); + }); + } + +private: + FtpSessionManager (const FtpSessionManager&) = delete; + FtpSessionManager& operator=(const FtpSessionManager&) = delete; + + Protected& getSessionCache(const FtpDeviceId& deviceId) + { + //single global session cache per login; life-time bound to globalInstance => never remove a sessionCache!!! + Protected* sessionCache = nullptr; + + globalSessionCache_.access([&](GlobalFtpSessions& sessionsById) + { + sessionCache = &sessionsById[deviceId]; //get or create + }); + static_assert(std::is_same_v>>, "require std::map so that the pointers we return remain stable"); + + return *sessionCache; + } + + void setActiveConfig(FtpSessionCache& cache, const FtpLogin& login) + { + if (cache.activeCfg) + assert(std::all_of(cache.idleFtpSessions.begin(), cache.idleFtpSessions.end(), + [&](const std::unique_ptr& session) { return session->getSessionCfg() == cache.activeCfg; })); + else + assert(cache.idleFtpSessions.empty()); + + const std::optional prevCfg = cache.activeCfg; + + cache.activeCfg = + { + .deviceId{login}, + .password = login.password ? *login.password : cache.sessionPassword, + .useTls = login.useTls, + }; + + /* remove incompatible sessions: + - avoid hitting FTP connection limit if some config uses TLS, but not the other: https://freefilesync.org/forum/viewtopic.php?t=8532 + - logically consistent with AFS::compareDevice() + - don't allow different authentication methods, when authenticateAccess() is called *once* per device in getFolderStatusParallel() + - what user expects, e.g. when tesing changed settings in FTP login dialog */ + if (cache.activeCfg != prevCfg) + cache.idleFtpSessions.clear(); //run ~FtpSession *inside* the lock! => avoid hitting server limits! + } + + //run a dedicated clean-up thread => it's unclear when the server let's a connection time out, so we do it preemptively + //context of worker thread: + void runGlobalSessionCleanUp() //throw ThreadStopRequest + { + std::chrono::steady_clock::time_point lastCleanupTime; + for (;;) + { + const auto now = std::chrono::steady_clock::now(); + + if (now < lastCleanupTime + FTP_SESSION_CLEANUP_INTERVAL) + interruptibleSleep(lastCleanupTime + FTP_SESSION_CLEANUP_INTERVAL - now); //throw ThreadStopRequest + + lastCleanupTime = std::chrono::steady_clock::now(); + + std::vector*> sessionCaches; //pointers remain stable, thanks to std::map<> + + globalSessionCache_.access([&](GlobalFtpSessions& sessionsById) + { + for (auto& [sessionId, idleSession] : sessionsById) + sessionCaches.push_back(&idleSession); + }); + + for (Protected* sessionCache : sessionCaches) + for (;;) + { + bool done = false; + sessionCache->access([&](FtpSessionCache& cache) + { + for (std::unique_ptr& ftpSession : cache.idleFtpSessions) + if (!ftpSession->isHealthy()) //!isHealthy() sessions are destroyed after use => in this context this means they have been idle for too long + { + ftpSession.swap(cache.idleFtpSessions.back()); + /**/ cache.idleFtpSessions.pop_back(); //run ~FtpSession *inside* the lock! => avoid hitting server limits! + return; //don't hold lock for too long: delete only one session at a time, then yield... + } + done = true; + }); + if (done) + break; + std::this_thread::yield(); //outside the lock + } + } + } + + struct FtpSessionCache + { + //invariant: all cached sessions correspond to activeCfg at any time! + std::vector> idleFtpSessions; //extract *temporarily* from this list during use + std::optional activeCfg; + Zstring sessionPassword; + }; + + using GlobalFtpSessions = std::map>; + Protected globalSessionCache_; + + InterruptibleThread sessionCleaner_; +}; + +//-------------------------------------------------------------------------------------- +UniInitializer globalStartupInitFtp(*globalFtpSessionCount.get()); + +constinit Global globalFtpSessionManager; //caveat: life time must be subset of static UniInitializer! +//-------------------------------------------------------------------------------------- + +void accessFtpSession(const FtpLogin& login, const std::function& useFtpSession /*throw X*/) //throw SysError, X +{ + if (const std::shared_ptr mgr = globalFtpSessionManager.get()) + mgr->access(login, useFtpSession); //throw SysError, X + else + throw SysError(formatSystemError("accessFtpSession", L"", L"Function call not allowed during init/shutdown.")); +} + +//=========================================================================================================================== + +struct FtpItem +{ + AFS::ItemType type = AFS::ItemType::file; + Zstring itemName; + uint64_t fileSize = 0; + time_t modTime = 0; + AFS::FingerPrint filePrint = 0; //optional +}; + + +//get info about *existing* symlink! +FtpItem getFtpSymlinkInfo(const FtpLogin& login, const AfsPath& linkPath) //throw FileError +{ + try + { + FtpItem output; + assert(output.type == AFS::ItemType::file); + output.itemName = AFS::getItemName(linkPath); + + std::string mdtmBuf; + accessFtpSession(login, [&](FtpSession& session) //throw SysError + { + /* first test if we have a file; if it's a folder expect FTP code 550 + alternative: assume folder and try traversal? NOPE: this can *succeed* for file symlinks with MLSD! (e.g. on freefilesync.org FTP) + + -> can't replace SIZE + MDTM with MLSD which doesn't follow symlinks! */ + + session.ensureBinaryMode(); //throw SysError + //...or some server return ASCII size or fail with '550 SIZE not allowed in ASCII mode: https://freefilesync.org/forum/viewtopic.php?t=7669&start=30#p27742 + const std::string sizeBuf = session.runSingleFtpCommand("*SIZE " + session.getServerPathInternal(linkPath), + true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + //alternative: use libcurl + CURLINFO_CONTENT_LENGTH_DOWNLOAD_T? => nah, surprise (motherfucker)! libcurl adds needless "REST 0" command! + for (const std::string_view& line : splitFtpResponse(sizeBuf)) + if (startsWith(line, "213 ")) // 213[rubbish] according to libcurl + { + if (isDigit(line.back())) //https://tools.ietf.org/html/rfc3659#section-4 + { + auto it = std::find_if(line.rbegin(), line.rend(), [](const char c) { return !isDigit(c); }); + output.fileSize = stringTo(makeStringView(it.base(), line.end())); + + mdtmBuf = session.runSingleFtpCommand("MDTM " + session.getServerPathInternal(linkPath), + true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + return; + } + break; + } + else if (startsWith(line, "550 ")) //e.g. "550 I can only retrieve regular files" + { + output.type = AFS::ItemType::folder; + return; + } + throw SysError(L"Unexpected FTP response. (" + utfTo(sizeBuf) + L')'); + }); + + if (output.type == AFS::ItemType::folder) + return output; + + output.modTime = [&] //https://tools.ietf.org/html/rfc3659#section-3 + { + for (const std::string_view& line : splitFtpResponse(mdtmBuf)) + if (startsWith(line, "213 ")) // 213 YYYYMMDDHHMMSS[.sss] "Time values are always represented in UTC (GMT)" ...and libcurl thinks so, too + { + const auto itStart = line.begin() + 4; + const auto itEnd = std::find(itStart, line.end(), '.'); + + if (const TimeComp tc = parseTime("%Y%m%d%H%M%S", makeStringView(itStart, itEnd)); + tc != TimeComp()) + if (const auto [modTime, timeValid] = utcToTimeT(tc); + timeValid) + return modTime; + break; + } + throw SysError(L"Unexpected FTP response. (" + utfTo(mdtmBuf) + L')'); + }(); + + return output; + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(getCurlDisplayPath(login, linkPath))), e.toString()); + } +} + + +class FtpDirectoryReader +{ +public: + static std::vector execute(const FtpLogin& login, const AfsPath& dirPath) //throw SysError, SysErrorFtpProtocol + { + std::string rawListing; //get raw FTP directory listing + + curl_write_callback onBytesReceived = [](/*const*/ char* buffer, size_t size, size_t nitems, void* callbackData) + { + auto& listing = *static_cast(callbackData); + listing.append(buffer, size * nitems); + return size * nitems; + //folder reading might take up to a minute in extreme cases (50,000 files): https://freefilesync.org/forum/viewtopic.php?t=5312 + }; + + std::vector output; + + accessFtpSession(login, [&](FtpSession& session) //throw SysError + { + std::vector options = + { + {CURLOPT_WRITEDATA, &rawListing}, + {CURLOPT_WRITEFUNCTION, onBytesReceived}, + }; + long pathMethod = CURLFTPMETHOD_SINGLECWD; + + if (session.supportsMlsd()) //throw SysError + { + options.emplace_back(CURLOPT_CUSTOMREQUEST, "MLSD"); + + //some FTP servers abuse https://tools.ietf.org/html/rfc3659#section-7.1 + //and process wildcards characters inside the "dirpath"; see http://www.proftpd.org/docs/howto/Globbing.html + // [] matches any character in the character set enclosed in the brackets + // * (not between brackets) matches any string, including the empty string + // ? (not between brackets) matches any single character + // + //of course this "helpfulness" blows up with MLSD + paths that incidentally contain wildcards: https://freefilesync.org/forum/viewtopic.php?t=5575 + const bool pathHasWildcards = //=> globbing is reproducible even with freefilesync.org's FTP! + contains(afterFirst(dirPath.value, Zstr('['), IfNotFoundReturn::none), Zstr(']')) || + contains(dirPath.value, Zstr('*')) || + contains(dirPath.value, Zstr('?')); + + if (!pathHasWildcards) + pathMethod = CURLFTPMETHOD_NOCWD; //16% faster traversal compared to CURLFTPMETHOD_SINGLECWD (35% faster than CURLFTPMETHOD_MULTICWD) + } + //else: use "LIST" + CURLFTPMETHOD_SINGLECWD + //caveat: let's better not use LIST parameters: https://cr.yp.to/ftp/list.html + + session.perform(dirPath, true /*isDir*/, pathMethod, options, true /*requestUtf8*/); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + + if (session.supportsMlsd()) //throw SysError + output = parseMlsd(rawListing, session); //throw SysError + else + output = parseUnknown(rawListing, session); //throw SysError + }); + + return output; + } + +private: + FtpDirectoryReader (const FtpDirectoryReader&) = delete; + FtpDirectoryReader& operator=(const FtpDirectoryReader&) = delete; + + static std::vector parseMlsd(const std::string& buf, FtpSession& session) //throw SysError + { + std::vector output; + for (const std::string_view& line : splitFtpResponse(buf)) + { + FtpItem item = parseMlstLine(line, session); //throw SysError + if (item.itemName != Zstr(".") && + item.itemName != Zstr("..")) + output.push_back(std::move(item)); + } + return output; + } + + static FtpItem parseMlstLine(const std::string_view& rawLine, FtpSession& session) //throw SysError + { + /* https://tools.ietf.org/html/rfc3659 + type=cdir;sizd=4096;modify=20170116230740;UNIX.mode=0755;UNIX.uid=874;UNIX.gid=869;unique=902g36e1c55; . + type=pdir;sizd=4096;modify=20170116230740;UNIX.mode=0755;UNIX.uid=874;UNIX.gid=869;unique=902g36e1c55; .. + type=file;size=4;modify=20170113063314;UNIX.mode=0600;UNIX.uid=874;UNIX.gid=869;unique=902g36e1c5d; readme.txt + type=dir;sizd=4096;modify=20170117144634;UNIX.mode=0755;UNIX.uid=874;UNIX.gid=869;unique=902g36e418a; folder */ + try + { + FtpItem item; + + auto itBegin = rawLine.begin(); + if (startsWith(rawLine, ' ')) //leading blank is already trimmed if MLSD was processed by curl + ++itBegin; + auto itBlank = std::find(itBegin, rawLine.end(), ' '); + if (itBlank == rawLine.end()) + throw SysError(L"Item name not available."); + + const std::string_view facts = makeStringView(itBegin, itBlank); + item.itemName = session.serverToUtfEncoding(makeStringView(itBlank + 1, rawLine.end())); //throw SysError + + std::string_view typeFact; + std::string_view fileSize; + + split(facts, ';', [&](const std::string_view fact) + { + if (!fact.empty()) + { + if (startsWithAsciiNoCase(fact, "type=")) //must be case-insensitive!!! + { + const std::string_view tmp = afterFirst(fact, '=', IfNotFoundReturn::none); + typeFact = beforeFirst(tmp, ':', IfNotFoundReturn::all); + } + else if (startsWithAsciiNoCase(fact, "size=")) + fileSize = afterFirst(fact, '=', IfNotFoundReturn::none); + else if (startsWithAsciiNoCase(fact, "modify=")) + { + std::string_view modifyFact = afterFirst(fact, '=', IfNotFoundReturn::none); + modifyFact = beforeLast(modifyFact, '.', IfNotFoundReturn::all); //truncate millisecond precision if available + + const TimeComp tc = parseTime("%Y%m%d%H%M%S", modifyFact); + if (tc == TimeComp()) + throw SysError(L"Modification time is invalid."); + + if (const auto [modTime, timeValid] = utcToTimeT(tc); + timeValid) + item.modTime = modTime; + else + throw SysError(L"Modification time is invalid."); + } + else if (startsWithAsciiNoCase(fact, "unique=")) + { + /* https://tools.ietf.org/html/rfc3659#section-7.5.2 + "The mapping between files, and unique fact tokens should be maintained, [...] for + *at least* the lifetime of the control connection from user-PI to server-PI." + + => not necessarily *persistent* as far as the RFC goes! + BUT: practially this will be the inode ID/file index, so we can assume persistence */ + const std::string_view uniqueId = afterFirst(fact, '=', IfNotFoundReturn::none); + assert(!uniqueId.empty()); + item.filePrint = hashString(uniqueId); + //other metadata to hash e.g. create fact? => not available on Linux-hosted FTP! + } + } + }); + + if (equalAsciiNoCase(typeFact, "cdir")) + return {AFS::ItemType::folder, Zstr("."), 0, 0}; + if (equalAsciiNoCase(typeFact, "pdir")) + return {AFS::ItemType::folder, Zstr(".."), 0, 0}; + + if (equalAsciiNoCase(typeFact, "dir")) + item.type = AFS::ItemType::folder; + else if (equalAsciiNoCase(typeFact, "OS.unix=slink") || //the OS.unix=slink:/target syntax is a hack and often skips + equalAsciiNoCase(typeFact, "OS.unix=symlink")) //the target path after the colon: http://www.proftpd.org/docs/modules/mod_facts.html + item.type = AFS::ItemType::symlink; + //It may be a good idea to NOT check for type "file" explicitly: see comment in native.cpp + + //evaluate parsing errors right now (+ report raw entry in error message!) + if (item.itemName.empty()) + throw SysError(L"Item name not available."); + + if (item.type == AFS::ItemType::file) + { + if (fileSize.empty() || !std::all_of(fileSize.begin(), fileSize.end(), &isDigit)) + throw SysError(L"File size not available."); //crazy, but can be "-1": https://freefilesync.org/forum/viewtopic.php?t=9720#p35757 + item.fileSize = stringTo(fileSize); + } + return item; + } + catch (const SysError& e) + { + throw SysError(L"Unexpected FTP response. (" + utfTo(rawLine) + L") " + e.toString()); + } + } + + static std::vector parseUnknown(const std::string& buf, FtpSession& session) //throw SysError + { + if (!buf.empty() && isDigit(buf[0])) //lame test to distinguish Unix/Dos formats as internally used by libcurl + return parseWindows(buf, session); //throw SysError + return parseUnix(buf, session); // + } + + //"ls -l" + static std::vector parseUnix(const std::string& buf, FtpSession& session) //throw SysError + { + const std::vector lines = splitFtpResponse(buf); + auto it = lines.begin(); + + if (it != lines.end() && startsWith(*it, "total ")) + ++it; + + const time_t utcTimeNow = std::time(nullptr); + const TimeComp tc = getUtcTime(utcTimeNow); + if (tc == TimeComp()) + throw SysError(L"Failed to determine current time: " + numberTo(utcTimeNow)); + + const int utcCurrentYear = tc.year; + + //different listing formats: better store at session level!? + std::optional dirOwnerGroupCount; // + std::optional fileOwnerGroupCount; //caveat: differentiate per item type: see alternative formats! + std::optional linkOwnerGroupCount; // + + std::vector output; + + std::for_each(it, lines.end(), [&](const std::string_view line) + { + auto& ownerGroupCount = [&]() -> std::optional& + { + assert(!line.empty()); //see splitFtpResponse() + switch (line[0]) + { + case 'd': return dirOwnerGroupCount; + case 'l': return linkOwnerGroupCount; + default : return fileOwnerGroupCount; + } + }(); + + //unix listing without group: https://freefilesync.org/forum/viewtopic.php?t=4306 + if (!ownerGroupCount) + ownerGroupCount = [&] + { + std::optional firstError; + + for (int i = 3; i-- > 0;) + try + { + parseUnixLine(line, utcTimeNow, utcCurrentYear, i /*ownerGroupCount*/, session); //throw SysError + return i; + } + catch (const SysError& e) + { + if (!firstError) + firstError = e; + } + throw* firstError; //most likely the relevant one: https://freefilesync.org/forum/viewtopic.php?t=10798 + }(); + + const FtpItem item = parseUnixLine(line, utcTimeNow, utcCurrentYear, *ownerGroupCount, session); //throw SysError + if (item.itemName != Zstr(".") && + item.itemName != Zstr("..")) + output.push_back(item); + }); + + return output; + } + + static FtpItem parseUnixLine(const std::string_view& rawLine, time_t utcTimeNow, int utcCurrentYear, int ownerGroupCount, FtpSession& session) //throw SysError + { + /* Unix standard listing: "ls -l --all" + + total 4953 <- optional first line + drwxr-xr-x 1 root root 4096 Jan 10 11:58 version + -rwxr-xr-x 1 root root 1084 Sep 2 01:17 Unit Test.vcxproj.user + -rwxr-xr-x 1 1000 300 2217 Feb 28 2016 win32.manifest + lrwxr-xr-x 1 root root 18 Apr 26 15:17 Projects -> /mnt/hgfs/Projects + + file type: -:file l:symlink d:directory b:block device p:named pipe c:char device s:socket + + permissions: (r|-)(w|-)(x|s|S|-) user + (r|-)(w|-)(x|s|S|-) group s := S + x S = Setgid + (r|-)(w|-)(x|t|T|-) others t := T + x T = sticky bit + + Alternative formats + ------------------- + No group: "ls -l --no-group" https://freefilesync.org/forum/viewtopic.php?t=4306 + dr-xr-xr-x 2 root 512 Apr 8 1994 etc + + No owner, no group, trailing slash (but only for directories!????): "ls -g --no-group --file-type" https://freefilesync.org/forum/viewtopic.php?t=10227 + -rwxrwxrwx 1 ownername groupname 8064383 Mar 30 11:58 file.mp3 + drwxrwxrwx 1 0 Jan 1 00:00 dirname/ + + Yet to be seen in the wild: + Netware: + d [R----F--] supervisor 512 Jan 16 18:53 login + - [R----F--] rhesus 214059 Oct 20 15:27 cx.exe + + NetPresenz for the Mac: + -------r-- 326 1391972 1392298 Nov 22 1995 MegaPhone.sit + drwxrwxr-x folder 2 May 10 1996 network */ + try + { + FtpLineParser parser(rawLine); + + const std::string_view typeTag = parser.readRange(1, [](const char c) //throw SysError + { + return c == '-' || c == 'b' || c == 'c' || c == 'd' || c == 'l' || c == 'p' || c == 's'; + }); + //------------------------------------------------------------------------------------ + //permissions + parser.readRange(9, [](const char c) //throw SysError + { + return c == '-' || c == 'r' || c == 'w' || c == 'x' || c == 's' || c == 'S' || c == 't' || c == 'T'; + }); + parser.readRange(&isWhiteSpace); //throw SysError + //------------------------------------------------------------------------------------ + //hard-link count (no separators) + parser.readRange(&isDigit); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + //------------------------------------------------------------------------------------ + //both owner + group, owner only, or none at all + assert(0 <= ownerGroupCount && ownerGroupCount <=2); + for (int i = 0; i < ownerGroupCount; ++i) + { + parser.readRange(std::not_fn(isWhiteSpace)); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + } + //------------------------------------------------------------------------------------ + //file size (no separators) + const uint64_t fileSize = stringTo(parser.readRange(&isDigit)); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + //------------------------------------------------------------------------------------ + const std::string_view monthStr = parser.readRange(std::not_fn(isWhiteSpace)); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + + const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + auto itMonth = std::find_if(std::begin(months), std::end(months), [&](const char* name) { return equalAsciiNoCase(name, monthStr); }); + if (itMonth == std::end(months)) + throw SysError(L"Failed to parse month name."); + //------------------------------------------------------------------------------------ + const int day = stringTo(parser.readRange(&isDigit)); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + if (day < 1 || day > 31) + throw SysError(L"Failed to parse day of month."); + //------------------------------------------------------------------------------------ + const std::string_view timeOrYear = parser.readRange([](const char c) { return c == ':' || isDigit(c); }); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + + TimeComp timeComp; + timeComp.month = 1 + static_cast(itMonth - std::begin(months)); + timeComp.day = day; + + if (contains(timeOrYear, ':')) + { + const int hour = stringTo(beforeFirst(timeOrYear, ':', IfNotFoundReturn::none)); + const int minute = stringTo(afterFirst (timeOrYear, ':', IfNotFoundReturn::none)); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + throw SysError(L"Failed to parse modification time."); + + timeComp.hour = hour; + timeComp.minute = minute; + timeComp.year = utcCurrentYear; //tentatively + + const auto [serverLocalTime, timeValid] = utcToTimeT(timeComp); + if (!timeValid) + throw SysError(L"Modification time is invalid."); + + if (serverLocalTime > utcTimeNow + 24 * 3600 ) //time-zones range from UTC-12:00 to UTC+14:00, consider DST; FileZilla uses 1 day tolerance + --timeComp.year; //"more likely" this time is from last year + } + else if (timeOrYear.size() == 4) + { + timeComp.year = stringTo(timeOrYear); + + if (timeComp.year < 1600 || timeComp.year >= 3000) + throw SysError(L"Failed to parse modification time."); + } + else + throw SysError(L"Failed to parse modification time."); + + //let's pretend the time listing is UTC (same behavior as FileZilla): hopefully MLSD will make this mess obsolete soon... + // => find exact offset with some MDTM hackery? yes, could do that, but this doesn't solve the bigger problem of imprecise LIST file times, so why bother? + const auto [modTime, timeValid] = utcToTimeT(timeComp); + if (!timeValid) + throw SysError(L"Modification time is invalid."); + //------------------------------------------------------------------------------------ + const std::string_view trail = parser.readRange([](char) { return true; }); //throw SysError + std::string_view itemName; + if (typeTag == "l") + itemName = beforeFirst(trail, " -> ", IfNotFoundReturn::none); + else + itemName = trail; + if (itemName.empty()) + throw SysError(L"Item name not available."); + + if (itemName == "." || itemName == "..") //sometimes returned, e.g. by freefilesync.org + return {AFS::ItemType::folder, utfTo(itemName), 0, 0}; + //------------------------------------------------------------------------------------ + FtpItem item; + if (typeTag == "d") + item.type = AFS::ItemType::folder; + else if (typeTag == "l") + item.type = AFS::ItemType::symlink; + else + item.fileSize = fileSize; + + item.itemName = session.serverToUtfEncoding(itemName); //throw SysError + if (item.type == AFS::ItemType::folder && endsWith(item.itemName, Zstr('/'))) + item.itemName.pop_back(); + + item.modTime = modTime; + + return item; + } + catch (const SysError& e) + { + throw SysError(L"Unexpected FTP response. (" + utfTo(rawLine) + L") [ownerGroupCount: " + numberTo(ownerGroupCount) + L"] " + e.toString()); + } + } + + + //"dir" + static std::vector parseWindows(const std::string& buf, FtpSession& session) //throw SysError + { + /* Test server: test.rebex.net username:demo pw:password useTls = true + + listing supported by libcurl (US server) + 10-27-15 03:46AM

pub + 04-08-14 03:09PM 11,399 readme.txt + + Datalogic Windows CE 5.0 + 01-01-98 13:00 Storage Card + + IIS option "four-digit years" + 06-22-2017 04:25PM test + 06-20-2017 12:50PM 1875499 zstring.obj + + Alternative formats (yet to be seen in the wild) + "dir" on Windows, US: + 10/27/2015 03:46 AM pub + 04/08/2014 03:09 PM 11,399 readme.txt + + "dir" on Windows, German: + 21.09.2016 18:31 Favorites + 12.01.2017 19:57 11.399 gsview64.ini */ + + const TimeComp tc = getUtcTime(); + if (tc == TimeComp()) + throw SysError(L"Failed to determine current time: " + numberTo(std::time(nullptr))); + const int utcCurrentYear = tc.year; + + std::vector output; + for (const std::string_view& line : splitFtpResponse(buf)) + { + try + { + FtpLineParser parser(line); + + const int month = stringTo(parser.readRange(2, &isDigit)); //throw SysError + parser.readRange(1, [](const char c) { return c == '-' || c == '/'; }); //throw SysError + const int day = stringTo(parser.readRange(2, &isDigit)); //throw SysError + parser.readRange(1, [](const char c) { return c == '-' || c == '/'; }); //throw SysError + const std::string_view yearString = parser.readRange(&isDigit); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + + if (month < 1 || month > 12 || day < 1 || day > 31) + throw SysError(L"Failed to parse modification time."); + + int year = 0; + if (yearString.size() == 2) + { + year = (utcCurrentYear / 100) * 100 + stringTo(yearString); + if (year > utcCurrentYear + 1 /*local time leeway*/) + year -= 100; + } + else if (yearString.size() == 4) + year = stringTo(yearString); + else + throw SysError(L"Failed to parse modification time."); + //------------------------------------------------------------------------------------ + int hour = stringTo(parser.readRange(2, &isDigit)); //throw SysError + parser.readRange(1, [](const char c) { return c == ':'; }); //throw SysError + const int minute = stringTo(parser.readRange(2, &isDigit)); //throw SysError + if (!isWhiteSpace(parser.peekNextChar())) + { + const std::string_view period = parser.readRange(2, [](const char c) { return c == 'A' || c == 'P' || c == 'M'; }); //throw SysError + if (period == "PM") + { + if (0 <= hour && hour < 12) + hour += 12; + } + else if (hour == 12) + hour = 0; + } + parser.readRange(&isWhiteSpace); //throw SysError + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) + throw SysError(L"Failed to parse modification time."); + //------------------------------------------------------------------------------------ + TimeComp timeComp; + timeComp.year = year; + timeComp.month = month; + timeComp.day = day; + timeComp.hour = hour; + timeComp.minute = minute; + //let's pretend the time listing is UTC (same behavior as FileZilla): hopefully MLSD will make this mess obsolete soon... + // => find exact offset with some MDTM hackery? yes, could do that, but this doesn't solve the bigger problem of imprecise LIST file times, so why bother? + const auto [modTime, timeValid] = utcToTimeT(timeComp); + if (!timeValid) + throw SysError(L"Modification time is invalid."); + //------------------------------------------------------------------------------------ + const std::string_view dirTagOrSize = parser.readRange(std::not_fn(isWhiteSpace)); //throw SysError + parser.readRange(&isWhiteSpace); //throw SysError + + const bool isDir = dirTagOrSize == ""; + uint64_t fileSize = 0; + if (!isDir) + { + std::string sizeStr(dirTagOrSize); + replace(sizeStr, ',', ""); + replace(sizeStr, '.', ""); + if (sizeStr.empty() || !std::all_of(sizeStr.begin(), sizeStr.end(), &isDigit)) + throw SysError(L"Failed to parse file size."); + fileSize = stringTo(sizeStr); + } + //------------------------------------------------------------------------------------ + const std::string_view itemName = parser.readRange([](char) { return true; }); //throw SysError + if (itemName.empty()) + throw SysError(L"Folder contains an item without name."); + + //------------------------------------------------------------------------------------ + if (itemName != "." && + itemName != "..") + { + FtpItem item; + if (isDir) + item.type = AFS::ItemType::folder; + item.itemName = session.serverToUtfEncoding(itemName); //throw SysError + item.fileSize = fileSize; + item.modTime = modTime; + + output.push_back(item); + } + } + catch (const SysError& e) + { + throw SysError(L"Unexpected FTP response. (" + utfTo(line) + L") " + e.toString()); + } + } + + return output; + } +}; + + +class SingleFolderTraverser +{ +public: + SingleFolderTraverser(const FtpLogin& login, const std::vector>>& workload /*throw X*/) + : workload_(workload), login_(login) + { + while (!workload_.empty()) + { + auto wi = std::move(workload_. back()); //yes, no strong exception guarantee (std::bad_alloc) + /**/ workload_.pop_back(); // + const auto& [folderPath, cb] = wi; + + tryReportingDirError([&] //throw X + { + traverseWithException(folderPath, *cb); //throw FileError, X + }, *cb); + } + } + +private: + SingleFolderTraverser (const SingleFolderTraverser&) = delete; + SingleFolderTraverser& operator=(const SingleFolderTraverser&) = delete; + + void traverseWithException(const AfsPath& dirPath, AFS::TraverserCallback& cb) //throw FileError, X + { + std::vector items; + try + { + items = FtpDirectoryReader::execute(login_, dirPath); //throw SysError, SysErrorFtpProtocol + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getCurlDisplayPath(login_, dirPath))), e.toString()); + } + + for (const FtpItem& item : items) + { + const AfsPath itemPath(appendPath(dirPath.value, item.itemName)); + + switch (item.type) + { + case AFS::ItemType::file: + cb.onFile({item.itemName, item.fileSize, item.modTime, item.filePrint, false /*isFollowedSymlink*/}); //throw X + break; + + case AFS::ItemType::folder: + if (std::shared_ptr cbSub = cb.onFolder({item.itemName, false /*isFollowedSymlink*/})) //throw X + workload_.push_back({itemPath, std::move(cbSub)}); + break; + + case AFS::ItemType::symlink: + switch (cb.onSymlink({item.itemName, item.modTime})) //throw X + { + case AFS::TraverserCallback::HandleLink::follow: + { + FtpItem target = {}; + if (!tryReportingItemError([&] //throw X + { + target = getFtpSymlinkInfo(login_, itemPath); //throw FileError + }, cb, item.itemName)) + continue; + + if (target.type == AFS::ItemType::folder) + { + if (std::shared_ptr cbSub = cb.onFolder({item.itemName, true /*isFollowedSymlink*/})) //throw X + workload_.push_back({itemPath, std::move(cbSub)}); + } + else //a file or named pipe, etc. + cb.onFile({item.itemName, target.fileSize, target.modTime, item.filePrint, true /*isFollowedSymlink*/}); //throw X + } + break; + + case AFS::TraverserCallback::HandleLink::skip: + break; + } + break; + } + } + } + + std::vector>> workload_; + const FtpLogin login_; +}; + + +void traverseFolderRecursiveFTP(const FtpLogin& login, const std::vector>>& workload /*throw X*/, size_t) //throw X +{ + SingleFolderTraverser dummy(login, workload); //throw X +} +//=========================================================================================================================== +//=========================================================================================================================== + +void ftpFileDownload(const FtpLogin& login, const AfsPath& afsFilePath, //throw FileError, X + const std::function& writeBlock /*throw X*/) +{ + std::exception_ptr exception; + + auto onBytesReceived = [&](const void* buffer, size_t bytesToWrite) + { + try + { + writeBlock(buffer, bytesToWrite); //throw X + //[!] let's NOT use "incomplete write Posix semantics" for libcurl! + //who knows if libcurl buffers properly, or if it sends incomplete packages!? + return bytesToWrite; + } + catch (...) + { + exception = std::current_exception(); + return bytesToWrite + 1; //signal error condition => CURLE_WRITE_ERROR + } + }; + curl_write_callback onBytesReceivedWrapper = [](char* buffer, size_t size, size_t nitems, void* callbackData) + { + return (*static_cast(callbackData))(buffer, size * nitems); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + + try + { + accessFtpSession(login, [&](FtpSession& session) //throw SysError + { + session.perform(afsFilePath, false /*isDir*/, CURLFTPMETHOD_NOCWD, //are there any servers that require CURLFTPMETHOD_SINGLECWD? let's find out + { + {CURLOPT_WRITEDATA, &onBytesReceived}, + {CURLOPT_WRITEFUNCTION, onBytesReceivedWrapper}, + {CURLOPT_IGNORE_CONTENT_LENGTH, 1L}, //skip FTP "SIZE" command before download (=> download until actual EOF if file size changes) + + //{CURLOPT_BUFFERSIZE, 256 * 1024} -> default is 16 kB which seems to correspond to TLS packet size + //=> setting larger buffer size does nothing (recv still returns only 16 kB) + }, true /*requestUtf8*/); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + if (exception) + std::rethrow_exception(exception); + + throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getCurlDisplayPath(login, afsFilePath))), e.toString()); + } +} + + +/* File already existing: + freefilesync.org: overwrites + FileZilla Server: overwrites + Windows IIS: overwrites */ +void ftpFileUpload(const FtpLogin& login, const AfsPath& afsFilePath, + const std::function& readBlock /*throw X*/) //throw FileError, X; return "bytesToRead" bytes unless end of stream +{ + std::exception_ptr exception; + + auto getBytesToSend = [&](void* buffer, size_t bytesToRead) -> size_t + { + try + { + /* libcurl calls back until 0 bytes are returned (Posix read() semantics), or, + if CURLOPT_INFILESIZE_LARGE was set, after exactly this amount of bytes + + [!] let's NOT use "incomplete read Posix semantics" for libcurl! + who knows if libcurl buffers properly, or if it requests incomplete packages!? */ + const size_t bytesRead = readBlock(buffer, bytesToRead); //throw X; return "bytesToRead" bytes unless end of stream + assert(bytesRead == bytesToRead || bytesRead == 0 || readBlock(buffer, bytesToRead) == 0); + return bytesRead; + } + catch (...) + { + exception = std::current_exception(); + return CURL_READFUNC_ABORT; //signal error condition => CURLE_ABORTED_BY_CALLBACK + } + }; + curl_read_callback getBytesToSendWrapper = [](char* buffer, size_t size, size_t nitems, void* callbackData) + { + return (*static_cast(callbackData))(buffer, size * nitems); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + + try + { + accessFtpSession(login, [&](FtpSession& session) //throw SysError + { + /* curl_slist* quote = nullptr; + ZEN_ON_SCOPE_EXIT(::curl_slist_free_all(quote)); + + //"prefix the command with an asterisk to make libcurl continue even if the command fails" + quote = ::curl_slist_append(quote, ("*DELE " + session.getServerPathInternal(afsFilePath)).c_str()); //throw SysError + + //optimize fail-safe copy with RNFR/RNTO as CURLOPT_POSTQUOTE? -> even slightly *slower* than RNFR/RNTO as additional curl_easy_perform() */ + + session.perform(afsFilePath, false /*isDir*/, CURLFTPMETHOD_NOCWD, //are there any servers that require CURLFTPMETHOD_SINGLECWD? let's find out + { + {CURLOPT_UPLOAD, 1L}, + {CURLOPT_READDATA, &getBytesToSend}, + {CURLOPT_READFUNCTION, getBytesToSendWrapper}, + + //{CURLOPT_UPLOAD_BUFFERSIZE, 256 * 1024} -> defaults is 64 kB. apparently no performance improvement for larger buffers like 256 kB + + //{CURLOPT_INFILESIZE_LARGE, static_cast(inputBuffer.size())}, + //=> CURLOPT_INFILESIZE_LARGE does not issue a specific FTP command, but is used by libcurl only! + + //{CURLOPT_PREQUOTE, quote}, + //{CURLOPT_POSTQUOTE, quote}, + }, true /*requestUtf8*/); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + if (exception) + std::rethrow_exception(exception); + + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getCurlDisplayPath(login, afsFilePath))), e.toString()); + } +} + +//=========================================================================================================================== + +struct InputStreamFtp : public AFS::InputStream +{ + InputStreamFtp(const FtpLogin& login, const AfsPath& filePath) + { + worker_ = InterruptibleThread([asyncStreamOut = this->asyncStreamIn_, login, filePath] + { + setCurrentThreadName(Zstr("Istream ") + utfTo(getCurlDisplayPath(login, filePath))); + try + { + auto writeBlock = [&](const void* buffer, size_t bytesToWrite) + { + asyncStreamOut->write(buffer, bytesToWrite); //throw ThreadStopRequest + }; + ftpFileDownload(login, filePath, writeBlock); //throw FileError, ThreadStopRequest + + asyncStreamOut->closeStream(); + } + catch (FileError&) { asyncStreamOut->setWriteError(std::current_exception()); } //let ThreadStopRequest pass through! + }); + } + + ~InputStreamFtp() + { + asyncStreamIn_->setReadError(std::make_exception_ptr(ThreadStopRequest())); + } + + size_t getBlockSize() override { return FTP_BLOCK_SIZE_DOWNLOAD; } //throw (FileError) + + //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + size_t tryRead(void* buffer, size_t bytesToRead, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, (ErrorFileLocked), X + { + const size_t bytesRead = asyncStreamIn_->tryRead(buffer, bytesToRead); //throw FileError + reportBytesProcessed(notifyUnbufferedIO); //throw X + return bytesRead; + //no need for asyncStreamIn_->checkWriteErrors(): once end of stream is reached, asyncStreamOut->closeStream() was called => no errors occured + } + + std::optional tryGetAttributesFast() override { return {}; }//throw FileError + //there is no stream handle => no buffered attribute access! + //PERF: get attributes during file download? + // CURLOPT_FILETIME: test case 77 files, 4MB: overall copy time increases by 12% + // CURLOPT_PREQUOTE/CURLOPT_PREQUOTE/CURLOPT_POSTQUOTE + MDTM: test case 77 files, 4MB: overall copy time increases by 12% + +private: + void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X + { + const int64_t bytesDelta = makeSigned(asyncStreamIn_->getTotalBytesWritten()) - totalBytesReported_; + totalBytesReported_ += bytesDelta; + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X + } + + int64_t totalBytesReported_ = 0; + std::shared_ptr asyncStreamIn_ = std::make_shared(FTP_STREAM_BUFFER_SIZE); + InterruptibleThread worker_; +}; + +//=========================================================================================================================== + +//CAVEAT: if upload fails due to already existing, OutputStreamFtp constructor does not fail, but OutputStreamFtp::write() does! +// => ~OutputStreamImpl() will delete the already existing file! +struct OutputStreamFtp : public AFS::OutputStreamImpl +{ + OutputStreamFtp(const FtpLogin& login, + const AfsPath& filePath, + std::optional modTime) : + login_(login), + filePath_(filePath), + modTime_(modTime) + { + std::promise promUploadDone; + futUploadDone_ = promUploadDone.get_future(); + + worker_ = InterruptibleThread([login, filePath, + asyncStreamIn = this->asyncStreamOut_, + pUploadDone = std::move(promUploadDone)]() mutable + { + setCurrentThreadName(Zstr("Ostream ") + utfTo(getCurlDisplayPath(login, filePath))); + try + { + auto readBlock = [&](void* buffer, size_t bytesToRead) + { + return asyncStreamIn->read(buffer, bytesToRead); //throw ThreadStopRequest + }; + ftpFileUpload(login, filePath, readBlock); //throw FileError, ThreadStopRequest + assert(asyncStreamIn->getTotalBytesRead() == asyncStreamIn->getTotalBytesWritten()); + + pUploadDone.set_value(); + } + catch (FileError&) + { + const std::exception_ptr exptr = std::current_exception(); + asyncStreamIn->setReadError(exptr); //set both! + pUploadDone.set_exception(exptr); // + } + //let ThreadStopRequest pass through! + }); + } + + ~OutputStreamFtp() + { + if (asyncStreamOut_) //=> cleanup non-finalized output file + { + asyncStreamOut_->setWriteError(std::make_exception_ptr(ThreadStopRequest())); + worker_.join(); + + try //see removeFilePlain() + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + session.runSingleFtpCommand("DELE " + session.getServerPathInternal(filePath_), true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + logExtraError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getCurlDisplayPath(login_, filePath_))) + L"\n\n" + e.toString()); + } + } + } + + size_t getBlockSize() override { return FTP_BLOCK_SIZE_UPLOAD; } //throw (FileError) + + size_t tryWrite(const void* buffer, size_t bytesToWrite, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0 + { + const size_t bytesWritten = asyncStreamOut_->tryWrite(buffer, bytesToWrite); //throw FileError + reportBytesProcessed(notifyUnbufferedIO); //throw X + return bytesWritten; + } + + AFS::FinalizeResult finalize(const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X + { + if (!asyncStreamOut_) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + asyncStreamOut_->closeStream(); + + while (futUploadDone_.wait_for(std::chrono::milliseconds(25)) == std::future_status::timeout) + reportBytesProcessed(notifyUnbufferedIO); //throw X + reportBytesProcessed(notifyUnbufferedIO); //[!] once more, now that *all* bytes were written + + assert(isReady(futUploadDone_)); + futUploadDone_.get(); //throw FileError + + //asyncStreamOut_->checkReadErrors(); //throw FileError -> not needed after *successful* upload + asyncStreamOut_.reset(); //output finalized => no more exceptions from here on! + //-------------------------------------------------------------------- + + AFS::FinalizeResult result; + //result.filePrint = ... -> yet unknown at this point + try + { + setModTimeIfAvailable(); //throw FileError, follows symlinks + /* is setting modtime after closing the file handle a pessimization? + FTP: no: could set modtime via CURLOPT_POSTQUOTE (but this would internally trigger an extra round-trip anyway!) */ + } + catch (const FileError& e) { result.errorModTime = e; /*might slice derived class?*/ } + + return result; + } + +private: + void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X + { + const int64_t bytesDelta = makeSigned(asyncStreamOut_->getTotalBytesRead()) - totalBytesReported_; + totalBytesReported_ += bytesDelta; + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X + } + + void setModTimeIfAvailable() const //throw FileError, follows symlinks + { + //assert(isReady(futUploadDone_)); => MUST NOT CALL *after* std::future<>::get()! + if (modTime_) + try + { + const std::string isoTime = utfTo(formatTime(Zstr("%Y%m%d%H%M%S"), getUtcTime(*modTime_))); //returns empty string on error + if (isoTime.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(*modTime_) + L')'); + + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + if (!session.supportsMfmt()) //throw SysError + throw SysError(L"Server does not support the MFMT command."); + + session.runSingleFtpCommand("MFMT " + isoTime + ' ' + session.getServerPathInternal(filePath_), + true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + //not relevant for OutputStreamFtp, but: does MFMT follow symlinks? for Linux FTP server (using utime) it does + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot write modification time of %x."), L"%x", fmtPath(getCurlDisplayPath(login_, filePath_))), e.toString()); + } + } + + const FtpLogin login_; + const AfsPath filePath_; + const std::optional modTime_; + int64_t totalBytesReported_ = 0; + std::shared_ptr asyncStreamOut_ = std::make_shared(FTP_STREAM_BUFFER_SIZE); + InterruptibleThread worker_; + std::future futUploadDone_; +}; + +//--------------------------------------------------------------------------------------------------------------------------- +//=========================================================================================================================== + +class FtpFileSystem : public AbstractFileSystem +{ +public: + explicit FtpFileSystem(const FtpLogin& login) : login_(login) {} + + const FtpLogin& getLogin() const { return login_; } + +private: + Zstring getInitPathPhrase(const AfsPath& itemPath) const override { return concatenateFtpFolderPathPhrase(login_, itemPath); } + + std::vector getPathPhraseAliases(const AfsPath& itemPath) const override { return {getInitPathPhrase(itemPath)}; } + + std::wstring getDisplayPath(const AfsPath& itemPath) const override { return getCurlDisplayPath(login_, itemPath); } + + bool isNullFileSystem() const override { return login_.server.empty(); } + + std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const override + { + const FtpLogin& lhs = login_; + const FtpLogin& rhs = static_cast(afsRhs).login_; + + return FtpDeviceId(lhs) <=> FtpDeviceId(rhs); + } + + //---------------------------------------------------------------------------------------------------------------- + + ItemType getItemType(const AfsPath& itemPath) const override //throw FileError + { + try + { + const std::optional parentPath = getParentPath(itemPath); + if (!parentPath) //device root => quick access test + { + try { accessFtpSession(login_, [](FtpSession& session) { session.testConnection(); }); /*throw SysError*/ } + catch (const SysError& e) { throw SysError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(login_.server)) + L'\n' + e.toString()); } + + return ItemType::folder; + } + + const std::vector items = [&] + { + try + { + //don't use MLST: broken for Pure-FTPd: https://freefilesync.org/forum/viewtopic.php?t=4287 + return FtpDirectoryReader::execute(login_, *parentPath); //throw SysError, SysErrorFtpProtocol + } + catch (const SysError& e) //add context: error might be folder-specific + { throw SysError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(parentPath->value.empty() ? Zstr("/") : getItemName(*parentPath))) + L'\n' + e.toString()); } + }(); + + const Zstring itemName = getItemName(itemPath); + assert(!itemName.empty()); + //is the underlying file system case-sensitive? we don't know => assume "case-sensitive" + //all path components (except the base folder part!) can be expected to have the right case anyway after directory traversal + for (const FtpItem& item : items) + if (item.itemName == itemName) + return item.type; + + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(itemName))); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); + } + } + + std::optional getItemTypeIfExistsImpl(const AfsPath& itemPath) const //throw SysError + { + const std::optional parentPath = getParentPath(itemPath); + if (!parentPath) //device root => quick access test + { + try { accessFtpSession(login_, [](FtpSession& session) { session.testConnection(); }); /*throw SysError*/ } + catch (const SysError& e) { throw SysError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(login_.server)) + L'\n' + e.toString()); } + + return ItemType::folder; + } + + std::optional lastFtpError; + try + { + try + { + const Zstring itemName = getItemName(itemPath); + assert(!itemName.empty()); + + for (const FtpItem& item : FtpDirectoryReader::execute(login_, *parentPath)) //throw SysError, SysErrorFtpProtocol + if (item.itemName == itemName) //case-sensitive comparison! itemPath must be normalized! + return item.type; + + return std::nullopt; + } + catch (const SysErrorFtpProtocol& e) + { + //let's dig deeper, but *only* for SysErrorFtpProtocol, not for general connection issues + //+ check if FTP error code sounds like "not existing" + if (e.ftpErrorCode == 550) //FTP 550 No such file or directory + //501? "pathname that exists but is not a directory to a MLSD command generates a 501 reply": https://www.rfc-editor.org/rfc/rfc3659 + //=> really? cannot reproduce, getting: "550 '/filename.txt' is not a directory" or "550 Can't check for file existence" + lastFtpError = e; //-> get out of catch clause + else + throw; + } + } + catch (const SysError& e) //add context: error might be folder-specific + { throw SysError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(parentPath->value.empty() ? Zstr("/") : getItemName(*parentPath))) + L'\n' + e.toString()); } + + //---------------------------------------------------------------- + if (const std::optional parentType = getItemTypeIfExistsImpl(*parentPath)) //throw SysError + { + if (*parentType == ItemType::file /*obscure, but possible*/) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(*parentPath)))); + + throw* lastFtpError; //throw SysError; parent path existing, so traversal should not have failed! + } + else + return std::nullopt; + } + + std::optional getItemTypeIfExists(const AfsPath& itemPath) const override //throw FileError + { + try + { + return getItemTypeIfExistsImpl(itemPath); //throw SysError + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); + } + } + + //---------------------------------------------------------------------------------------------------------------- + //already existing: fail + //=> FTP will (most likely) fail and give a clear error message: + // freefilesync.org: "550 Can't create directory: File exists" + // FileZilla Server: "550 Directory already exists" + // Windows IIS: "550 Cannot create a file when that file already exists" + void createFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + session.runSingleFtpCommand("MKD " + session.getServerPathInternal(folderPath), true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); + } + } + + void removeFilePlain(const AfsPath& filePath) const override //throw FileError + { + try + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + session.runSingleFtpCommand("DELE " + session.getServerPathInternal(filePath), true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); + } + } + + void removeSymlinkPlain(const AfsPath& linkPath) const override //throw FileError + { + try + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + //works fine for Linux hosts, but what about Windows-hosted FTP??? Distinguish DELE/RMD? + //Windows test, FileZilla Server and Windows IIS FTP: all symlinks are reported as regular folders + session.runSingleFtpCommand("DELE " + session.getServerPathInternal(linkPath), true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete symbolic link %x."), L"%x", fmtPath(getDisplayPath(linkPath))), e.toString()); + } + } + + void removeFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + //Windows server: FileZilla Server and Windows IIS FTP: all symlinks are reported as regular folders + //Linux server (freefilesync.org): RMD will fail for symlinks! + session.runSingleFtpCommand("RMD " + session.getServerPathInternal(folderPath), true /*requestUtf8*/); //throw SysError, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); + } + } + + void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, + const std::function& onBeforeSymlinkDeletion/*throw X*/, + const std::function& onBeforeFolderDeletion /*throw X*/) const override + { + //default implementation: folder traversal + AFS::removeFolderIfExistsRecursion(folderPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion); //throw FileError, X + } + + //---------------------------------------------------------------------------------------------------------------- + AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const override //throw FileError + { + throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getDisplayPath(linkPath))), _("Operation not supported by device.")); + } + + bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const override //throw FileError + { + throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(getDisplayPath(linkPathL))), _("Operation not supported by device.")); + } + //---------------------------------------------------------------------------------------------------------------- + + //return value always bound: + std::unique_ptr getInputStream(const AfsPath& filePath) const override //throw FileError, (ErrorFileLocked) + { + return std::make_unique(login_, filePath); + } + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: fail(+delete!)/overwrite/auto-rename + std::unique_ptr getOutputStream(const AfsPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) const override + { + /* most FTP servers overwrite, but some (e.g. IIS) can be configured to fail, others (pureFTP) can be configured to auto-rename: + https://download.pureftpd.org/pub/pure-ftpd/doc/README + '-r': Never overwrite existing files. Uploading a file whose name already exists causes an automatic rename. Files are called xyz, xyz.1, xyz.2, xyz.3, etc. */ + + //already existing: fail (+ delete!!!) + return std::make_unique(login_, filePath, modTime); + } + + //---------------------------------------------------------------------------------------------------------------- + void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const override + { + traverseFolderRecursiveFTP(login_, workload, parallelOps); //throw X + } + //---------------------------------------------------------------------------------------------------------------- + + //symlink handling: follow + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, (ErrorFileLocked), X + const AbstractPath& targetPath, bool copyFilePermissions, const IoCallback& notifyUnbufferedIO /*throw X*/) const override + { + //no native FTP file copy => use stream-based file copy: + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + return copyFileAsStream(sourcePath, attrSource, targetPath, notifyUnbufferedIO); //throw FileError, (ErrorFileLocked), X + } + + //symlink handling: follow + //already existing: fail + void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + //already existing: fail + AFS::createFolderPlain(targetPath); //throw FileError + + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + } + + //already existing: fail + void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override + { + throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + } + + //already existing: undefined behavior! (e.g. fail/overwrite) + //=> actual behavior: most linux-based FTP servers overwrite, Windows-based servers fail (but most can be configured to behave differently) + // freefilesync.org: silent overwrite + // Windows IIS: CURLE_QUOTE_ERROR: QUOT command failed with 550 Cannot create a file when that file already exists. + // FileZilla Server: CURLE_QUOTE_ERROR: QUOT command failed with 553 file exists + void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const override //throw FileError, ErrorMoveUnsupported + { + if (compareDeviceSameAfsType(pathTo.afsDevice.ref()) != std::weak_ordering::equivalent) + throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices.")); + + try + { + accessFtpSession(login_, [&](FtpSession& session) //throw SysError + { + curl_slist* quote = nullptr; + ZEN_ON_SCOPE_EXIT(::curl_slist_free_all(quote)); + quote = ::curl_slist_append(quote, ("RNFR " + session.getServerPathInternal(pathFrom )).c_str()); //throw SysError + quote = ::curl_slist_append(quote, ("RNTO " + session.getServerPathInternal(pathTo.afsPath)).c_str()); // + + session.perform(AfsPath(), true /*isDir*/, CURLFTPMETHOD_NOCWD, //avoid needless CWDs + { + {CURLOPT_NOBODY, 1L}, + {CURLOPT_QUOTE, quote}, + }, true /*requestUtf8*/); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + }); + } + catch (const SysError& e) + { + throw FileError(generateMoveErrorMsg(pathFrom, pathTo), e.toString()); + } + } + + bool supportsPermissions(const AfsPath& folderPath) const override { return false; } //throw FileError + //wait until there is real demand for copying from and to FTP with permissions => use stream-based file copy: + + //---------------------------------------------------------------------------------------------------------------- + FileIconHolder getFileIcon (const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + ImageHolder getThumbnailImage(const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + + void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const override //throw FileError, X + { + auto connectServer = [&] //throw SysError, SysErrorPassword + { + accessFtpSession(login_, [](FtpSession& session) //connect with FTP server, *unless* already connected (in which case *nothing* is sent) + { + session.perform(AfsPath(), true /*isDir*/, CURLFTPMETHOD_NOCWD, + {{CURLOPT_NOBODY, 1L}, {CURLOPT_SERVER_RESPONSE_TIMEOUT, 0}}, false /*requestUtf8*/); + //caveat: connection phase only, so disable CURLOPT_SERVER_RESPONSE_TIMEOUT, or next access may fail with CURLE_OPERATION_TIMEDOUT! + }); //throw SysError, SysErrorPassword, SysErrorFtpProtocol + }; + + try + { + const std::shared_ptr mgr = globalFtpSessionManager.get(); + if (!mgr) + throw SysError(formatSystemError("getSessionPassword", L"", L"Function call not allowed during init/shutdown.")); + + mgr->setActiveConfig(login_); + + if (!login_.password) + { + try //1. test for connection error *before* bothering user to enter a password + { + connectServer(); //throw SysError, SysErrorPassword + return; //got new FtpSession (connected in constructor) or already connected session from cache + } + catch (const SysErrorPassword& e) + { + if (!requestPassword) + throw SysError(e.toString() + L'\n' + _("Password prompt not permitted by current settings.")); + } + + std::wstring lastErrorMsg; + for (;;) + { + //2. request (new) password + std::wstring msg = replaceCpy(_("Please enter your password to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))); + if (lastErrorMsg.empty()) + msg += L"\n" + _("The password will only be remembered until FreeFileSync is closed."); + + const Zstring password = requestPassword(msg, lastErrorMsg); //throw X + mgr->setSessionPassword(login_, password); + + try //3. test access: + { + connectServer(); //throw SysError, SysErrorPassword + return; + } + catch (const SysErrorPassword& e) { lastErrorMsg = e.toString(); } + } + } + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))), e.toString()); } + } + + bool hasNativeTransactionalCopy() const override { return false; } + //---------------------------------------------------------------------------------------------------------------- + + int64_t getFreeDiskSpace(const AfsPath& folderPath) const override { return -1; } //throw FileError, returns < 0 if not available + + std::unique_ptr createRecyclerSession(const AfsPath& folderPath) const override //throw FileError, RecycleBinUnavailable + { + throw RecycleBinUnavailable(replaceCpy(_("The recycle bin is not available for %x."), L"%x", fmtPath(getDisplayPath(folderPath)))); + } + + void moveToRecycleBin(const AfsPath& itemPath) const override //throw FileError, RecycleBinUnavailable + { + throw RecycleBinUnavailable(replaceCpy(_("The recycle bin is not available for %x."), L"%x", fmtPath(getDisplayPath(itemPath)))); + } + + const FtpLogin login_; +}; + +//=========================================================================================================================== + +//expects "clean" login data +Zstring concatenateFtpFolderPathPhrase(const FtpLogin& login, const AfsPath& folderPath) //noexcept +{ + Zstring username; + if (!login.username.empty()) + username = encodeFtpUsername(login.username) + Zstr("@"); + + Zstring server = login.server; + if (parseIpv6Address(server) && login.portCfg > 0) + server = Zstr('[') + server + Zstr(']'); //e.g. [::1]:80 + + Zstring port; + if (login.portCfg > 0) + port = Zstr(':') + numberTo(login.portCfg); + + Zstring relPath = getServerRelPath(folderPath); + if (relPath == Zstr("/")) + relPath.clear(); + + Zstring options; + if (login.timeoutSec != FtpLogin().timeoutSec) + options += Zstr("|timeout=") + numberTo(login.timeoutSec); + + if (login.useTls) + options += Zstr("|ssl"); + + if (login.password) + { + if (!login.password->empty()) //password always last => visually truncated by folder input field + options += Zstr("|pass64=") + encodePasswordBase64(*login.password); + } + else + options += Zstr("|pwprompt"); + + return Zstring(ftpPrefix) + Zstr("//") + username + server + port + relPath + options; +} +} + + +void fff::ftpInit() +{ + assert(!globalFtpSessionManager.get()); + globalFtpSessionManager.set(std::make_unique()); +} + + +void fff::ftpTeardown() +{ + assert(globalFtpSessionManager.get()); + globalFtpSessionManager.set(nullptr); +} + + +AfsPath fff::getFtpHomePath(const FtpLogin& login) //throw FileError +{ + try + { + AfsPath homePath; + + accessFtpSession(login, [&](FtpSession& session) //throw SysError + { + homePath = session.getHomePath(); //throw SysError + }); + return homePath; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getCurlDisplayPath(login, AfsPath(Zstr("~"))))), e.toString()); } +} + + +AfsDevice fff::condenseToFtpDevice(const FtpLogin& login) //noexcept +{ + //clean up input: + FtpLogin loginTmp = login; + trim(loginTmp.server); + trim(loginTmp.username); + + loginTmp.timeoutSec = std::max(1, loginTmp.timeoutSec); + + if (startsWithAsciiNoCase(loginTmp.server, "http:" ) || + startsWithAsciiNoCase(loginTmp.server, "https:") || + startsWithAsciiNoCase(loginTmp.server, "ftp:" ) || + startsWithAsciiNoCase(loginTmp.server, "ftps:" ) || + startsWithAsciiNoCase(loginTmp.server, "sftp:" )) + loginTmp.server = afterFirst(loginTmp.server, Zstr(':'), IfNotFoundReturn::none); + trim(loginTmp.server, TrimSide::both, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); }); + + if (std::optional> ip6AndPort = parseIpv6Address(loginTmp.server)) + loginTmp.server = ip6AndPort->first; //remove leading/trailing brackets + + return makeSharedRef(loginTmp); +} + + +FtpLogin fff::extractFtpLogin(const AfsDevice& afsDevice) //noexcept +{ + if (const auto ftpDevice = dynamic_cast(&afsDevice.ref())) + return ftpDevice->getLogin(); + + assert(false); + return {}; +} + + +bool fff::acceptsItemPathPhraseFtp(const Zstring& itemPathPhrase) //noexcept +{ + Zstring path = expandMacros(itemPathPhrase); //expand before trimming! + trim(path); + return startsWithAsciiNoCase(path, ftpPrefix); //check for explicit FTP path +} + + +/* syntax: ftp://[[:]@][:port]/[|option_name=value] + + e.g. ftp://user001:secretpassword@private.example.com:222/mydirectory/ + ftp://user001:secretpassword@[::1]:80/ipv6folder/ + ftp://user001:secretpassword@::1/ipv6withoutPort/ + ftp://user001@private.example.com/mydirectory|pass64=c2VjcmV0cGFzc3dvcmQ */ +AbstractPath fff::createItemPathFtp(const Zstring& itemPathPhrase) //noexcept +{ + Zstring pathPhrase = expandMacros(itemPathPhrase); //expand before trimming! + trim(pathPhrase); + + if (startsWithAsciiNoCase(pathPhrase, ftpPrefix)) + pathPhrase = pathPhrase.c_str() + strLength(ftpPrefix); + trim(pathPhrase, TrimSide::left, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); }); + + const ZstringView credentials = beforeFirst(pathPhrase, Zstr('@'), IfNotFoundReturn::none); + const ZstringView fullPathOpt = afterFirst(pathPhrase, Zstr('@'), IfNotFoundReturn::all); + + FtpLogin login; + login.username = decodeFtpUsername(Zstring(beforeFirst(credentials, Zstr(':'), IfNotFoundReturn::all))); //support standard FTP syntax, even though + login.password = Zstring( afterFirst(credentials, Zstr(':'), IfNotFoundReturn::none)); //concatenateFtpFolderPathPhrase() uses "pass64" instead + + const ZstringView fullPath = beforeFirst(fullPathOpt, Zstr('|'), IfNotFoundReturn::all); + const ZstringView options = afterFirst(fullPathOpt, Zstr('|'), IfNotFoundReturn::none); + + auto it = std::find_if(fullPath.begin(), fullPath.end(), [](Zchar c) { return c == '/' || c == '\\'; }); + const ZstringView serverPort = makeStringView(fullPath.begin(), it); + const AfsPath serverRelPath = sanitizeDeviceRelativePath({it, fullPath.end()}); + + if (std::optional> ip6AndPort = parseIpv6Address(serverPort)) //e.g. 2001:db8::ff00:42:8329 or [::1]:80 + { + login.server = ip6AndPort->first; + login.portCfg = ip6AndPort->second; //0 if empty + } + else + { + login.server = Zstring(beforeLast(serverPort, Zstr(':'), IfNotFoundReturn::all)); + const ZstringView port = afterLast(serverPort, Zstr(':'), IfNotFoundReturn::none); + login.portCfg = stringTo(port); //0 if empty + } + + split(options, Zstr('|'), [&](ZstringView optPhrase) + { + optPhrase = trimCpy(optPhrase); + if (!optPhrase.empty()) + { + if (startsWith(optPhrase, Zstr("timeout="))) + login.timeoutSec = stringTo(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else if (optPhrase == Zstr("ssl")) + login.useTls = true; + else if (startsWith(optPhrase, Zstr("pass64="))) + login.password = decodePasswordBase64(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else if (optPhrase == Zstr("pwprompt")) + login.password = std::nullopt; + else + assert(false); + } + }); + return AbstractPath(makeSharedRef(login), serverRelPath); +} diff --git a/FreeFileSync/Source/afs/ftp.h b/FreeFileSync/Source/afs/ftp.h new file mode 100644 index 0000000..f8df01f --- /dev/null +++ b/FreeFileSync/Source/afs/ftp.h @@ -0,0 +1,41 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FTP_H_745895742383425326568678 +#define FTP_H_745895742383425326568678 + +#include "abstract.h" + + +namespace fff +{ +bool acceptsItemPathPhraseFtp(const Zstring& itemPathPhrase); //noexcept +AbstractPath createItemPathFtp(const Zstring& itemPathPhrase); //noexcept + +void ftpInit(); +void ftpTeardown(); + +//------------------------------------------------------- + +const int DEFAULT_PORT_FTP = 21; //TLS enabled? => same for explicit FTP, but *implicit* FTP uses port 990 + +struct FtpLogin +{ + Zstring server; + int portCfg = 0; //use if > 0, DEFAULT_PORT_FTP otherwise + Zstring username; + std::optional password = Zstr(""); //none given => prompt during AFS::authenticateAccess() + bool useTls = false; + //other settings not specific to FTP session: + int timeoutSec = 10; +}; +AfsDevice condenseToFtpDevice(const FtpLogin& login); //noexcept; potentially messy user input +FtpLogin extractFtpLogin(const AfsDevice& afsDevice); //noexcept + +AfsPath getFtpHomePath(const FtpLogin& login); //throw FileError +} + +#endif //FTP_H_745895742383425326568678 diff --git a/FreeFileSync/Source/afs/ftp_common.h b/FreeFileSync/Source/afs/ftp_common.h new file mode 100644 index 0000000..5df0054 --- /dev/null +++ b/FreeFileSync/Source/afs/ftp_common.h @@ -0,0 +1,113 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FTP_COMMON_H_92889457091324321454 +#define FTP_COMMON_H_92889457091324321454 + +#include +#include +#include "abstract.h" + + +namespace fff +{ +inline +Zstring encodePasswordBase64(const ZstringView pass) +{ + using namespace zen; + return utfTo(stringEncodeBase64(utfTo(pass))); //nothrow +} + + +inline +Zstring decodePasswordBase64(const ZstringView pass) +{ + using namespace zen; + return utfTo(stringDecodeBase64(utfTo(pass))); //nothrow +} + + +//according to the SFTP path syntax, the username must not contain raw @ and : +//-> we don't need a full urlencode! +inline +Zstring encodeFtpUsername(Zstring name) +{ + using namespace zen; + replace(name, Zstr('%'), Zstr("%25")); //first! + replace(name, Zstr('@'), Zstr("%40")); + replace(name, Zstr(':'), Zstr("%3A")); + return name; +} + + +inline +Zstring decodeFtpUsername(Zstring name) +{ + using namespace zen; + replace(name, Zstr("%40"), Zstr('@')); + replace(name, Zstr("%3A"), Zstr(':')); + replace(name, Zstr("%3a"), Zstr(':')); + replace(name, Zstr("%25"), Zstr('%')); //last! + return name; +} + + +inline +std::optional> parseIpv6Address(ZstringView str) +{ + using namespace zen; + + str = trimCpy(str); + + int port = 0; + + //https://en.wikipedia.org/wiki/IPv6#Address_representation + if (startsWith(str, Zstr('['))) + { + str = str.substr(1); + if (!contains(str, Zstr(']'))) + return std::nullopt; + + ZstringView portStr = afterLast (str, Zstr(']'), IfNotFoundReturn::none); + str = beforeLast(str, Zstr(']'), IfNotFoundReturn::none); + + if (!portStr.empty()) + { + if (!startsWith(portStr, Zstr(':'))) + return std::nullopt; + portStr = portStr.substr(1); + + if (!std::all_of(portStr.begin(), portStr.end(), &isDigit)) + return std::nullopt; + + port = stringTo(portStr); //valid range: [0, 65535] + } + } + + if (!contains(str, Zstr(':')) || + !std::all_of(str.begin(), str.end(), [](Zchar c) +{ + return isHexDigit(c) || c == Zstr(':'); + })) + return std::nullopt; + + return std::make_pair(Zstring(str), port); +} + + +//(S)FTP path relative to server root using Unix path separators and with leading slash +inline +Zstring getServerRelPath(const AfsPath& itemPath) +{ + using namespace zen; + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) + return Zstr('/') + replaceCpy(itemPath.value, FILE_NAME_SEPARATOR, Zstr('/')); + else + return Zstr('/') + itemPath.value; +} +} + +#endif //FTP_COMMON_H_92889457091324321454 diff --git a/FreeFileSync/Source/afs/gdrive.cpp b/FreeFileSync/Source/afs/gdrive.cpp new file mode 100644 index 0000000..8cc23f0 --- /dev/null +++ b/FreeFileSync/Source/afs/gdrive.cpp @@ -0,0 +1,4118 @@ +// ***************************************************************************** +// * 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 "gdrive.h" +#include +#include //needed by clang +#include // +#include //DON'T include directly! +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "abstract_impl.h" +#include "init_curl_libssh2.h" + #include + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace fff +{ +struct GdrivePath +{ + GdriveLogin gdriveLogin; + AfsPath itemPath; //path relative to drive root +}; + +struct GdriveRawPath +{ + std::string parentId; //Google Drive item IDs are *globally* unique! + Zstring itemName; +}; +inline +std::weak_ordering operator<=>(const GdriveRawPath& lhs, const GdriveRawPath& rhs) +{ + if (const std::strong_ordering cmp = lhs.parentId <=> rhs.parentId; + cmp != std::strong_ordering::equal) + return cmp; + + return compareNativePath(lhs.itemName, rhs.itemName); +} + +constinit Global> globalGdrivePathAccessLocker; +GLOBAL_RUN_ONCE(globalGdrivePathAccessLocker.set(std::make_unique>())); + +template <> std::shared_ptr> PathAccessLocker::getGlobalInstance() { return globalGdrivePathAccessLocker.get(); } +template <> Zstring PathAccessLocker::getItemName(const GdriveRawPath& nativePath) { return nativePath.itemName; } + +using PathAccessLock = PathAccessLocker::Lock; //throw SysError +using PathBlockType = PathAccessLocker::BlockType; +} + + +namespace +{ +//Google Drive REST API Overview: https://developers.google.com/drive/api/v3/about-sdk +//Google Drive REST API Reference: https://developers.google.com/drive/api/v3/reference +const Zchar* GOOGLE_REST_API_SERVER = Zstr("www.googleapis.com"); + +constexpr std::chrono::seconds HTTP_SESSION_MAX_IDLE_TIME (20); +constexpr std::chrono::seconds HTTP_SESSION_CLEANUP_INTERVAL(4); +constexpr std::chrono::seconds GDRIVE_SYNC_INTERVAL (5); + +const size_t GDRIVE_BLOCK_SIZE_DOWNLOAD = 64 * 1024; //libcurl returns blocks of only 16 kB as returned by recv() even if we request larger blocks via CURLOPT_BUFFERSIZE +const size_t GDRIVE_BLOCK_SIZE_UPLOAD = 64 * 1024; //libcurl requests blocks of 64 kB. larger blocksizes set via CURLOPT_UPLOAD_BUFFERSIZE do not seem to make a difference +const size_t GDRIVE_STREAM_BUFFER_SIZE = 1024 * 1024; //unit: [byte] +//stream buffer should be big enough to facilitate prefetching during alternating read/write operations => e.g. see serialize.h::unbufferedStreamCopy() + +constexpr ZstringView gdrivePrefix = Zstr("gdrive:"); +const char gdriveFolderMimeType [] = "application/vnd.google-apps.folder"; +const char gdriveShortcutMimeType[] = "application/vnd.google-apps.shortcut"; //= symbolic link! + +const char DB_FILE_DESCR[] = "FreeFileSync"; +const int DB_FILE_VERSION = 5; //2021-05-15 + +std::string getGdriveClientId () { return ""; } // => replace with live credentials +std::string getGdriveClientSecret() { return ""; } // + + + + +struct HttpSessionId +{ + explicit HttpSessionId(const Zstring& serverName) : + server(serverName) {} + + Zstring server; +}; + +inline +bool operator==(const HttpSessionId& lhs, const HttpSessionId& rhs) { return equalAsciiNoCase(lhs.server, rhs.server); } +} + +//exactly the type of case insensitive comparison we need for server names! +//https://docs.microsoft.com/en-us/windows/win32/api/ws2tcpip/nf-ws2tcpip-getaddrinfow#IDNs +template<> struct std::hash { size_t operator()(const HttpSessionId& sessionId) const { return StringHashAsciiNoCase()(sessionId.server); } }; + + +namespace +{ +Zstring concatenateGdriveFolderPathPhrase(const GdrivePath& gdrivePath); //noexcept + + +//e.g.: gdrive:/john@gmail.com:SharedDrive/folder/file.txt +std::wstring getGdriveDisplayPath(const GdrivePath& gdrivePath) +{ + Zstring displayPath = Zstring(gdrivePrefix) + FILE_NAME_SEPARATOR; + + displayPath += utfTo(gdrivePath.gdriveLogin.email); + + if (!gdrivePath.gdriveLogin.locationName.empty()) + displayPath += Zstr(':') + gdrivePath.gdriveLogin.locationName; + + if (!gdrivePath.itemPath.value.empty()) + displayPath += FILE_NAME_SEPARATOR + gdrivePath.itemPath.value; + + return utfTo(displayPath); +} + + +std::wstring formatGdriveErrorRaw(std::string serverResponse) +{ + /* e.g.: { "error": { "errors": [{ "domain": "global", + "reason": "invalidSharingRequest", + "message": "Bad Request. User message: \"ACL change not allowed.\"" }], + "code": 400, + "message": "Bad Request" }} + + or: { "error": "invalid_client", + "error_description": "Unauthorized" } + + or merely: { "error": "invalid_token" } */ + trim(serverResponse); + + assert(!serverResponse.empty()); + if (serverResponse.empty()) + return L"<" + _("empty") + L">"; //at least give some indication + + try + { + const JsonValue jresponse = parseJson(serverResponse); //throw JsonParsingError + + if (const JsonValue* error = getChildFromJsonObject(jresponse, "error")) + { + if (error->type == JsonValue::Type::string) + return utfTo(error->primVal); + //the inner message is generally more descriptive! + else if (const JsonValue* errors = getChildFromJsonObject(*error, "errors")) + if (errors->type == JsonValue::Type::array && !errors->arrayVal.empty()) + if (const JsonValue* message = getChildFromJsonObject(errors->arrayVal[0], "message")) + if (message->type == JsonValue::Type::string) + return utfTo(message->primVal); + } + } + catch (JsonParsingError&) {} //not JSON? + + return utfTo(serverResponse); +} + + +AFS::FingerPrint getGdriveFilePrint(const std::string& itemId) +{ + assert(!itemId.empty()); + //Google Drive item ID is persistent and globally unique! :) + return hashString(itemId); +} + +//---------------------------------------------------------------------------------------------------------------- + +constinit Global httpSessionCount; +GLOBAL_RUN_ONCE(httpSessionCount.set(createUniSessionCounter())); +UniInitializer globalInitHttp(*httpSessionCount.get()); + +//---------------------------------------------------------------------------------------------------------------- + +class HttpSessionManager //reuse (healthy) HTTP sessions globally +{ +public: + explicit HttpSessionManager(const Zstring& caCertFilePath) : + caCertFilePath_(caCertFilePath), + sessionCleaner_([this] + { + setCurrentThreadName(Zstr("Session Cleaner[HTTP]")); + runGlobalSessionCleanUp(); //throw ThreadStopRequest + }) {} + + void access(const HttpSessionId& sessionId, const std::function& useHttpSession /*throw X*/) //throw SysError, X + { + Protected& sessionCache = getSessionCache(sessionId); + + std::unique_ptr httpSession; + + sessionCache.access([&](HttpSessionManager::HttpSessionCache& sessions) + { + //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use, idle sessions via worker thread) + if (!sessions.empty()) + { + httpSession = std::move(sessions.back ()); + /**/ sessions.pop_back(); + } + }); + + //create new HTTP session outside the lock: 1. don't block other threads 2. non-atomic regarding "sessionCache"! => one session too many is not a problem! + if (!httpSession) + httpSession = std::make_unique(sessionId.server, caCertFilePath_); //throw SysError + + ZEN_ON_SCOPE_EXIT( + if (isHealthy(httpSession->session)) //thread that created the "!isHealthy()" session is responsible for clean up (avoid hitting server connection limits!) + sessionCache.access([&](HttpSessionManager::HttpSessionCache& sessions) { sessions.push_back(std::move(httpSession)); }); ); + + useHttpSession(httpSession->session); //throw X + } + +private: + HttpSessionManager (const HttpSessionManager&) = delete; + HttpSessionManager& operator=(const HttpSessionManager&) = delete; + + //associate session counting (for initialization/teardown) + struct HttpInitSession + { + HttpInitSession(const Zstring& server, const Zstring& caCertFilePath) : + session(server, true /*useTls*/, caCertFilePath) {} + + const std::shared_ptr cookie{getLibsshCurlUnifiedInitCookie(httpSessionCount)}; //throw SysError + HttpSession session; //life time must be subset of UniCounterCookie + }; + static bool isHealthy(const HttpSession& s) { return std::chrono::steady_clock::now() - s.getLastUseTime() <= HTTP_SESSION_MAX_IDLE_TIME; } + + using HttpSessionCache = std::vector>; + + Protected& getSessionCache(const HttpSessionId& sessionId) + { + //single global session store per sessionId; life-time bound to globalInstance => never remove a sessionCache!!! + Protected* sessionCache = nullptr; + + globalSessionCache_.access([&](GlobalHttpSessions& sessionsById) + { + sessionCache = &sessionsById[sessionId]; //get or create + }); + static_assert(std::is_same_v>>, "require std::unordered_map so that the pointers we return remain stable"); + + return *sessionCache; + } + + //run a dedicated clean-up thread => it's unclear when the server let's a connection time out, so we do it preemptively + //context of worker thread: + void runGlobalSessionCleanUp() //throw ThreadStopRequest + { + std::chrono::steady_clock::time_point lastCleanupTime; + for (;;) + { + const auto now = std::chrono::steady_clock::now(); + + if (now < lastCleanupTime + HTTP_SESSION_CLEANUP_INTERVAL) + interruptibleSleep(lastCleanupTime + HTTP_SESSION_CLEANUP_INTERVAL - now); //throw ThreadStopRequest + + lastCleanupTime = std::chrono::steady_clock::now(); + + std::vector*> sessionCaches; //pointers remain stable, thanks to std::unordered_map<> + + globalSessionCache_.access([&](GlobalHttpSessions& sessionsByCfg) + { + for (auto& [sessionCfg, idleSession] : sessionsByCfg) + sessionCaches.push_back(&idleSession); + }); + + for (Protected* sessionCache : sessionCaches) + for (;;) + { + bool done = false; + sessionCache->access([&](HttpSessionCache& sessions) + { + for (std::unique_ptr& sshSession : sessions) + if (!isHealthy(sshSession->session)) //!isHealthy() sessions are destroyed after use => in this context this means they have been idle for too long + { + sshSession.swap(sessions.back()); + /**/ sessions.pop_back(); //run ~HttpSession *inside* the lock! => avoid hitting server limits! + return; //don't hold lock for too long: delete only one session at a time, then yield... + } + done = true; + }); + if (done) + break; + std::this_thread::yield(); + } + } + } + + using GlobalHttpSessions = std::unordered_map>; + + Protected globalSessionCache_; + const Zstring caCertFilePath_; + InterruptibleThread sessionCleaner_; +}; + +//-------------------------------------------------------------------------------------- +constinit Global globalHttpSessionManager; //caveat: life time must be subset of static UniInitializer! +//-------------------------------------------------------------------------------------- + +struct GdriveAccess +{ + std::string token; + int timeoutSec = 0; +}; + +//=========================================================================================================================== + +HttpSession::Result googleHttpsRequest(const Zstring& serverName, const std::string& serverRelPath, //throw SysError, X + const std::vector& extraHeaders, + std::vector extraOptions, + const std::function buf)>& writeResponse /*throw X*/, //optional + const std::function buf)>& readRequest /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream! + const std::function& receiveHeader /*throw X*/, //optional + int timeoutSec) +{ + //https://developers.google.com/drive/api/v3/performance + //"In order to receive a gzip-encoded response you must do two things: Set an Accept-Encoding header, ["gzip" automatically set by HttpSession] + extraOptions.emplace_back(CURLOPT_USERAGENT, "FreeFileSync (gzip)"); //and modify your user agent to contain the string gzip." + + const std::shared_ptr mgr = globalHttpSessionManager.get(); + if (!mgr) + throw SysError(formatSystemError("googleHttpsRequest", L"", L"Function call not allowed during init/shutdown.")); + + HttpSession::Result httpResult; + + mgr->access(HttpSessionId(serverName), [&](HttpSession& session) //throw SysError + { + httpResult = session.perform(serverRelPath, extraHeaders, extraOptions, writeResponse, readRequest, receiveHeader, timeoutSec); //throw SysError, X + }); + return httpResult; +} + + +//try to get a grip on this crazy REST API: - parameters are passed via query string, header, or body, using GET, POST, PUT, PATCH, DELETE, ... it's a dice roll +HttpSession::Result gdriveHttpsRequest(const std::string& serverRelPath, //throw SysError, X + std::vector extraHeaders, + const std::vector& extraOptions, + const std::function buf)>& writeResponse /*throw X*/, //optional + const std::function buf)>& readRequest /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream! + const std::function& receiveHeader /*throw X*/, //optional + const GdriveAccess& access) +{ + extraHeaders.push_back("Authorization: Bearer " + access.token); + + return googleHttpsRequest(GOOGLE_REST_API_SERVER, serverRelPath, + extraHeaders, + extraOptions, + writeResponse /*throw X*/, + readRequest /*throw X*/, + receiveHeader /*throw X*/, access.timeoutSec); //throw SysError, X +} + +//======================================================================================================== + +struct GdriveUser +{ + std::wstring displayName; + std::string email; +}; +GdriveUser getGdriveUser(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/about + const std::string& queryParams = xWwwFormUrlEncode( + { + {"fields", "user/displayName,user/emailAddress"}, + }); + std::string response; + gdriveHttpsRequest("/drive/v3/about?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + if (const JsonValue* user = getChildFromJsonObject(jresponse, "user")) + { + const std::optional displayName = getPrimitiveFromJsonObject(*user, "displayName"); + const std::optional email = getPrimitiveFromJsonObject(*user, "emailAddress"); + if (displayName && email) + return {utfTo(*displayName), *email}; + } + + throw SysError(formatGdriveErrorRaw(response)); +} + + +struct GdriveAuthCode +{ + std::string code; + std::string redirectUrl; + std::string codeChallenge; +}; + +struct GdriveAccessToken +{ + std::string value; + time_t validUntil = 0; //remaining lifetime of the access token +}; + +struct GdriveAccessInfo +{ + GdriveAccessToken accessToken; + std::string refreshToken; + GdriveUser userInfo; +}; + +GdriveAccessInfo gdriveExchangeAuthCode(const GdriveAuthCode& authCode, int timeoutSec) //throw SysError +{ + //https://developers.google.com/identity/protocols/OAuth2InstalledApp#exchange-authorization-code + const std::string postBuf = xWwwFormUrlEncode( + { + {"code", authCode.code}, + {"client_id", getGdriveClientId()}, + {"client_secret", getGdriveClientSecret()}, + {"redirect_uri", authCode.redirectUrl}, + {"grant_type", "authorization_code"}, + {"code_verifier", authCode.codeChallenge}, + }); + std::string response; + googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/token", {} /*extraHeaders*/, {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, timeoutSec); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional accessToken = getPrimitiveFromJsonObject(jresponse, "access_token"); + const std::optional refreshToken = getPrimitiveFromJsonObject(jresponse, "refresh_token"); + const std::optional expiresIn = getPrimitiveFromJsonObject(jresponse, "expires_in"); //e.g. 3600 seconds + if (!accessToken || !refreshToken || !expiresIn) + throw SysError(formatGdriveErrorRaw(response)); + + const GdriveUser userInfo = getGdriveUser({*accessToken, timeoutSec}); //throw SysError + + return {{*accessToken, std::time(nullptr) + stringTo(*expiresIn)}, *refreshToken, userInfo}; +} + + +GdriveAccessInfo gdriveAuthorizeAccess(const std::string& gdriveLoginHint, const std::function& updateGui /*throw X*/, int timeoutSec) //throw SysError, X +{ + //spin up a web server to wait for the HTTP GET after Google authentication + const addrinfo hints + { + .ai_flags = + AI_ADDRCONFIG | //no such issue on Linux: https://bugs.chromium.org/p/chromium/issues/detail?id=5234 + AI_PASSIVE, //the returned socket addresses will be suitable for bind(2)ing a socket that will accept(2) connections. + .ai_family = AF_UNSPEC, //don't care if AF_INET or AF_INET6 + .ai_socktype = SOCK_STREAM, //we *do* care about this one! + }; + addrinfo* servinfo = nullptr; + ZEN_ON_SCOPE_EXIT(if (servinfo) ::freeaddrinfo(servinfo)); + + //ServiceName == "0": open the next best free port + const int rcGai = ::getaddrinfo(nullptr, //_In_opt_ PCSTR pNodeName + "0", //_In_opt_ PCSTR pServiceName + &hints, //_In_opt_ const ADDRINFOA* pHints + &servinfo); //_Outptr_ PADDRINFOA* ppResult + if (rcGai != 0) + THROW_LAST_SYS_ERROR_GAI(rcGai); + if (!servinfo) + throw SysError(formatSystemError("getaddrinfo", L"" /*errorCode*/, L"No local IP address available")); + + + const auto getBoundSocket = [](const auto& /*::addrinfo*/ ai) + { + SocketType testSocket = ::socket(ai.ai_family, //int socket_family + SOCK_CLOEXEC | + ai.ai_socktype, //int socket_type + ai.ai_protocol); //int protocol + if (testSocket == invalidSocket) + THROW_LAST_SYS_ERROR_WSA("socket"); + ZEN_ON_SCOPE_FAIL(closeSocket(testSocket)); + + if (::bind(testSocket, ai.ai_addr, static_cast(ai.ai_addrlen)) != 0) + THROW_LAST_SYS_ERROR_WSA("bind"); + + return testSocket; + }; + + + SocketType socket = invalidSocket; + std::optional firstError; + + for (const auto* /*::addrinfo*/ si = servinfo; si; si = si->ai_next) + if (si->ai_family == AF_INET || + si->ai_family == AF_INET6) + try + { + socket = getBoundSocket(*si); //throw SysError; pass ownership + break; + } + catch (const SysError& e) { if (!firstError) firstError = e; } + + if (socket == invalidSocket) + { + if (firstError) + throw* firstError; + throw SysError(formatSystemError("getaddrinfo", L"" /*errorCode*/, L"No local IPv4 or IPv6 address available")); + } + ZEN_ON_SCOPE_EXIT(closeSocket(socket)); + + + sockaddr_storage addr = {}; //"sufficiently large to store address information for IPv4 (AF_INET/sockaddr_in) or IPv6 (AF_INET6/sockaddr_in6)" + socklen_t addrLen = sizeof(addr); + if (::getsockname(socket, reinterpret_cast(&addr), &addrLen) != 0) + THROW_LAST_SYS_ERROR_WSA("getsockname"); + + std::string redirectUrl; + if (addr.ss_family == AF_INET) + { + //the socket is not bound to a specific local IP: + // char buf[INET_ADDRSTRLEN] = {}; //inet_ntop -> "0.0.0.0" + // inet_ntop(AF_INET, &reinterpret_cast(addr).sin_addr, buf, std::size(buf)); + const int port = ntohs(reinterpret_cast(addr).sin_port); + redirectUrl = "http://127.0.0.1:" + numberTo(port); + } + else if (addr.ss_family == AF_INET6) //inet_ntop() == "::" + { + const int port = ntohs(reinterpret_cast(addr).sin6_port); + redirectUrl = "http://[::1]:" + numberTo(port); + } + else + throw SysError(formatSystemError("getsockname", L"", L"Unexpected protocol family: " + numberTo(addr.ss_family))); + + if (::listen(socket, SOMAXCONN) != 0) + THROW_LAST_SYS_ERROR_WSA("listen"); + + + //"A code_verifier is a high-entropy cryptographic random string using the unreserved characters:" + //[A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length of 43 characters and a maximum length of 128 characters. + std::string codeChallenge = stringEncodeBase64(generateGUID() + generateGUID()); + replace(codeChallenge, '+', '-'); // + replace(codeChallenge, '/', '.'); //base64 is almost a perfect fit for code_verifier! + replace(codeChallenge, '=', '_'); // + assert(codeChallenge.size() == 44); + + //authenticate Google Drive via browser: https://developers.google.com/identity/protocols/OAuth2InstalledApp#step-2-send-a-request-to-googles-oauth-20-server + const std::string oauthUrl = "https://accounts.google.com/o/oauth2/v2/auth?" + xWwwFormUrlEncode( + { + {"client_id", getGdriveClientId()}, + {"redirect_uri", redirectUrl}, + {"response_type", "code"}, + {"scope", "https://www.googleapis.com/auth/drive"}, + {"code_challenge", codeChallenge}, + {"code_challenge_method", "plain"}, + {"login_hint", gdriveLoginHint}, + }); + try + { + openWithDefaultApp(utfTo(oauthUrl)); //throw FileError + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //errors should be further enriched by context info => SysError + + //process incoming HTTP requests + for (;;) + { + for (;;) //::accept() blocks forever if no client connects (e.g. user just closes the browser window!) => wait for incoming traffic with a time-out via ::select() + { + if (updateGui) updateGui(); //throw X + + const int waitTimeMs = 100; + pollfd fds[] = {{socket, POLLIN}}; + + const char* functionName = "poll"; + const int rv = ::poll(fds, std::size(fds), waitTimeMs); //int timeout + if (rv < 0) + THROW_LAST_SYS_ERROR_WSA(functionName); + else if (rv != 0) + break; + //else: time-out! + } + //potential race! if the connection is gone right after ::select() and before ::accept(), latter will hang + const int clientSocket = ::accept4(socket, //int sockfd + nullptr, //sockaddr* addr + nullptr, //socklen_t* addrlen + SOCK_CLOEXEC); //int flags + if (clientSocket == invalidSocket) + THROW_LAST_SYS_ERROR_WSA("accept"); + + + //receive first line of HTTP request + std::string reqLine; + for (;;) + { + const size_t blockSize = 64 * 1024; + reqLine.resize(reqLine.size() + blockSize); + const size_t bytesReceived = tryReadSocket(clientSocket, &*(reqLine.end() - blockSize), blockSize); //throw SysError + reqLine.resize(reqLine.size() - (blockSize - bytesReceived)); //caveat: unsigned arithmetics + + if (contains(reqLine, "\r\n")) + { + reqLine = beforeFirst(reqLine, "\r\n", IfNotFoundReturn::none); + break; + } + if (bytesReceived == 0 || reqLine.size() >= 100'000 /*bogus line length*/) + break; + } + + //get OAuth2.0 authorization result from Google, either: + std::string code; + std::string error; + + //parse header; e.g.: GET http://127.0.0.1:62054/?code=4/ZgBRsB9k68sFzc1Pz1q0__Kh17QK1oOmetySrGiSliXt6hZtTLUlYzm70uElNTH9vt1OqUMzJVeFfplMsYsn4uI HTTP/1.1 + const std::vector statusItems = splitCpy(reqLine, ' ', SplitOnEmpty::allow); //Method SP Request-URI SP HTTP-Version CRLF + + if (statusItems.size() == 3 && statusItems[0] == "GET" && startsWith(statusItems[2], "HTTP/")) + { + for (const auto& [name, value] : xWwwFormUrlDecode(afterFirst(statusItems[1], "?", IfNotFoundReturn::none))) + if (name == "code") + code = value; + else if (name == "error") + error = value; //e.g. "access_denied" => no more detailed error info available :( + } //"add explicit braces to avoid dangling else [-Wdangling-else]" + + std::variant authResult; + + //send HTTP response; https://www.w3.org/Protocols/HTTP/1.0/spec.html#Request-Line + std::string httpResponse; + if (code.empty() && error.empty()) //parsing error or unrelated HTTP request + httpResponse = "HTTP/1.0 400 Bad Request" "\r\n" "\r\n" "400 Bad Request\n" + reqLine; + else + { + std::string htmlMsg = R"( + + + + + TITLE_PLACEHOLDER + + + +

TITLE_PLACEHOLDER

+
MESSAGE_PLACEHOLDER
+ + + )"; + try + { + if (!error.empty()) + throw SysError(replaceCpy(_("Error code %x"), L"%x", + L"\"" + utfTo(error) + L"\"")); + + //do as many login-related tasks as possible while we have the browser as an error output device! + //see AFS::connectNetworkFolder() => errors will be lost after time out in dir_exist_async.h! + authResult = gdriveExchangeAuthCode({code, redirectUrl, codeChallenge}, timeoutSec); //throw SysError + replace(htmlMsg, "TITLE_PLACEHOLDER", utfTo(_("Authentication completed."))); + replace(htmlMsg, "MESSAGE_PLACEHOLDER", utfTo(_("You may close this page now and continue with FreeFileSync."))); + } + catch (const SysError& e) + { + authResult = e; + replace(htmlMsg, "TITLE_PLACEHOLDER", utfTo(_("Authentication failed."))); + replace(htmlMsg, "MESSAGE_PLACEHOLDER", utfTo(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive") + L"\n\n" + e.toString())); + } + httpResponse = "HTTP/1.0 200 OK" "\r\n" + "Content-Type: text/html" "\r\n" + "Content-Length: " + numberTo(strLength(htmlMsg)) + "\r\n" + "\r\n" + htmlMsg; + } + + for (size_t bytesToSend = httpResponse.size(); bytesToSend > 0;) + bytesToSend -= tryWriteSocket(clientSocket, &*(httpResponse.end() - bytesToSend), bytesToSend); //throw SysError + + shutdownSocketSend(clientSocket); //throw SysError + //--------------------------------------------------------------- + + if (const SysError* e = std::get_if(&authResult)) + throw *e; + if (const GdriveAccessInfo* res = std::get_if(&authResult)) + return *res; + } +} + + +GdriveAccessToken gdriveRefreshAccess(const std::string& refreshToken, int timeoutSec) //throw SysError +{ + //https://developers.google.com/identity/protocols/OAuth2InstalledApp#offline + const std::string postBuf = xWwwFormUrlEncode( + { + {"refresh_token", refreshToken}, + {"client_id", getGdriveClientId()}, + {"client_secret", getGdriveClientSecret()}, + {"grant_type", "refresh_token"}, + }); + std::string response; + googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/token", {} /*extraHeaders*/, {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, timeoutSec); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional accessToken = getPrimitiveFromJsonObject(jresponse, "access_token"); + const std::optional expiresIn = getPrimitiveFromJsonObject(jresponse, "expires_in"); //e.g. 3600 seconds + if (!accessToken || !expiresIn) + throw SysError(formatGdriveErrorRaw(response)); + + return {*accessToken, std::time(nullptr) + stringTo(*expiresIn)}; +} + + +void gdriveRevokeAccess(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/identity/protocols/OAuth2InstalledApp#tokenrevoke + std::string response; + const HttpSession::Result httpResult = googleHttpsRequest(Zstr("oauth2.googleapis.com"), "/revoke?token=" + access.token, + {"Content-Type: application/x-www-form-urlencoded"}, {{ CURLOPT_POSTFIELDS, ""}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access.timeoutSec); //throw SysError + + if (httpResult.statusCode != 200) + throw SysError(formatGdriveErrorRaw(response)); +} + + +int64_t gdriveGetMyDriveFreeSpace(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/about + std::string response; + gdriveHttpsRequest("/drive/v3/about?fields=storageQuota", {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + if (const JsonValue* storageQuota = getChildFromJsonObject(jresponse, "storageQuota")) + { + const std::optional usage = getPrimitiveFromJsonObject(*storageQuota, "usage"); + const std::optional limit = getPrimitiveFromJsonObject(*storageQuota, "limit"); + if (usage) + { + if (!limit) //"will not be present if the user has unlimited storage." + return std::numeric_limits::max(); + + const auto bytesUsed = stringTo(*usage); + const auto bytesLimit = stringTo(*limit); + + if (0 <= bytesUsed && bytesUsed <= bytesLimit) + return bytesLimit - bytesUsed; + } + } + throw SysError(formatGdriveErrorRaw(response)); +} + + +//instead of the "root" alias Google uses an actual ID in file metadata +std::string /*itemId*/ getMyDriveId(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/get + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "id"}, + }); + std::string response; + gdriveHttpsRequest("/drive/v3/files/root?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + + return *itemId; +} + + +struct DriveDetails +{ + std::string driveId; + Zstring driveName; +}; +std::vector getSharedDrives(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/drives/list + std::vector sharedDrives; + { + std::optional nextPageToken; + do + { + std::string queryParams = xWwwFormUrlEncode( + { + {"pageSize", "100"}, //"[1, 100] Default: 10" + {"fields", "nextPageToken,drives(id,name)"}, + }); + if (nextPageToken) + queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}}); + + std::string response; + gdriveHttpsRequest("/drive/v3/drives?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + /**/ nextPageToken = getPrimitiveFromJsonObject(jresponse, "nextPageToken"); + const JsonValue* drives = getChildFromJsonObject (jresponse, "drives"); + if (!drives || drives->type != JsonValue::Type::array) + throw SysError(formatGdriveErrorRaw(response)); + + for (const JsonValue& driveVal : drives->arrayVal) + { + std::optional driveId = getPrimitiveFromJsonObject(driveVal, "id"); + std::optional driveName = getPrimitiveFromJsonObject(driveVal, "name"); + if (!driveId || !driveName || driveName->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(driveVal))); + + sharedDrives.push_back({std::move(*driveId), utfTo(*driveName)}); + } + } + while (nextPageToken); + } + return sharedDrives; +} + + +struct StarredFolderDetails +{ + std::string folderId; + Zstring folderName; + std::string sharedDriveId; //empty if on "My Drive" +}; +std::vector getStarredFolders(const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/list + std::vector starredFolders; + { + std::optional nextPageToken; + do + { + std::string queryParams = xWwwFormUrlEncode( + { + {"corpora", "allDrives"}, //"The 'user' corpus includes all files in "My Drive" and "Shared with me" https://developers.google.com/drive/api/v3/reference/files/list + {"includeItemsFromAllDrives", "true"}, + {"pageSize", "1000"}, //"[1, 1000] Default: 100" + {"q", std::string("starred and mimeType = '") + gdriveFolderMimeType + "' and not trashed"}, + {"spaces", "drive"}, + {"supportsAllDrives", "true"}, + {"fields", "nextPageToken,incompleteSearch,files(id,name,driveId)"}, //https://developers.google.com/drive/api/v3/reference/files + }); + if (nextPageToken) + queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}}); + + std::string response; + gdriveHttpsRequest("/drive/v3/files?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + /**/ nextPageToken = getPrimitiveFromJsonObject(jresponse, "nextPageToken"); + const std::optional incompleteSearch = getPrimitiveFromJsonObject(jresponse, "incompleteSearch"); + const JsonValue* files = getChildFromJsonObject (jresponse, "files"); + if (!incompleteSearch || *incompleteSearch != "false" || !files || files->type != JsonValue::Type::array) + throw SysError(formatGdriveErrorRaw(response)); + + for (const JsonValue& childVal : files->arrayVal) + { + assert(childVal.type == JsonValue::Type::object); + const std::optional itemId = getPrimitiveFromJsonObject(childVal, "id"); + const std::optional itemName = getPrimitiveFromJsonObject(childVal, "name"); + const std::optional driveId = getPrimitiveFromJsonObject(childVal, "driveId"); + + if (!itemId || itemId->empty() || !itemName || itemName->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + starredFolders.push_back({*itemId, + utfTo(*itemName), + driveId ? *driveId : ""}); + } + } + while (nextPageToken); + } + return starredFolders; +} + + +enum class GdriveItemType : unsigned char +{ + file, + folder, + shortcut, +}; +enum class FileOwner : unsigned char +{ + none, //"ownedByMe" not populated for items in Shared Drives. + me, + other, +}; +struct GdriveItemDetails +{ + Zstring itemName; + uint64_t fileSize = 0; + time_t modTime = 0; + //--- minimize padding --- + GdriveItemType type = GdriveItemType::file; + FileOwner owner = FileOwner::none; + //------------------------ + std::string targetId; //for GdriveItemType::shortcut: https://developers.google.com/drive/api/v3/shortcuts + std::vector parentIds; + + bool operator==(const GdriveItemDetails&) const = default; +}; + + +GdriveItemDetails extractItemDetails(const JsonValue& jvalue) //throw SysError +{ + assert(jvalue.type == JsonValue::Type::object); + + /**/ std::optional itemName = getPrimitiveFromJsonObject(jvalue, "name"); + const std::optional mimeType = getPrimitiveFromJsonObject(jvalue, "mimeType"); + const std::optional ownedByMe = getPrimitiveFromJsonObject(jvalue, "ownedByMe"); + const std::optional size = getPrimitiveFromJsonObject(jvalue, "size"); + const std::optional modifiedTime = getPrimitiveFromJsonObject(jvalue, "modifiedTime"); + const JsonValue* parents = getChildFromJsonObject (jvalue, "parents"); + const JsonValue* shortcut = getChildFromJsonObject (jvalue, "shortcutDetails"); + + if (!itemName || itemName->empty() || !mimeType || !modifiedTime) + throw SysError(formatGdriveErrorRaw(serializeJson(jvalue))); + + const GdriveItemType type = *mimeType == gdriveFolderMimeType ? GdriveItemType::folder : + *mimeType == gdriveShortcutMimeType ? GdriveItemType::shortcut : + GdriveItemType::file; + + const FileOwner owner = ownedByMe ? (*ownedByMe == "true" ? FileOwner::me : FileOwner::other) : FileOwner::none; //"Not populated for items in Shared Drives" + const uint64_t fileSize = size ? stringTo(*size) : 0; //not available for folders and shortcuts + + //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + const TimeComp tc = parseTime("%Y-%m-%dT%H:%M:%S", beforeLast(*modifiedTime, '.', IfNotFoundReturn::all)); + if (tc == TimeComp() || !endsWith(*modifiedTime, 'Z')) //'Z' means "UTC" => it seems Google doesn't use the time-zone offset postfix + throw SysError(L"Modification time is invalid. (" + utfTo(*modifiedTime) + L')'); + + const auto [modTime, timeValid] = utcToTimeT(tc); + if (!timeValid) + throw SysError(L"Modification time is invalid. (" + utfTo(*modifiedTime) + L')'); + + std::vector parentIds; + if (parents) //item without "parents" array is possible! e.g. 1. shared item located in "Shared with me", referenced via a Shortcut 2. root folder under "Computers" + for (const JsonValue& parentVal : parents->arrayVal) + { + if (parentVal.type != JsonValue::Type::string) + throw SysError(formatGdriveErrorRaw(serializeJson(jvalue))); + parentIds.emplace_back(parentVal.primVal); + } + + if (!!shortcut != (type == GdriveItemType::shortcut)) + throw SysError(formatGdriveErrorRaw(serializeJson(jvalue))); + + std::string targetId; + if (shortcut) + { + std::optional targetItemId = getPrimitiveFromJsonObject(*shortcut, "targetId"); + if (!targetItemId || targetItemId->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(jvalue))); + + targetId = std::move(*targetItemId); + //evaluate "targetMimeType" ? don't bother: "The MIME type of a shortcut can become stale"! + } + + return {utfTo(*itemName), fileSize, modTime, type, owner, std::move(targetId), std::move(parentIds)}; +} + + +GdriveItemDetails getItemDetails(const std::string& itemId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/get + const std::string& queryParams = xWwwFormUrlEncode( + { + {"fields", "trashed,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId)"}, + {"supportsAllDrives", "true"}, + }); + std::string response; + gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + try + { + const JsonValue jvalue = parseJson(response); //throw JsonParsingError + + //careful: do NOT return details about trashed items! they don't exist as far as FFS is concerned!!! + const std::optional trashed = getPrimitiveFromJsonObject(jvalue, "trashed"); + if (!trashed) + throw SysError(formatGdriveErrorRaw(response)); + else if (*trashed == "true") + throw SysError(L"Item has been trashed."); + + return extractItemDetails(jvalue); //throw SysError + } + catch (JsonParsingError&) { throw SysError(formatGdriveErrorRaw(response)); } +} + + +struct GdriveItem +{ + std::string itemId; + GdriveItemDetails details; +}; +std::vector readFolderContent(const std::string& folderId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/list + std::vector childItems; + { + std::optional nextPageToken; + do + { + std::string queryParams = xWwwFormUrlEncode( + { + {"corpora", "allDrives"}, //"The 'user' corpus includes all files in "My Drive" and "Shared with me" https://developers.google.com/drive/api/v3/reference/files/list + {"includeItemsFromAllDrives", "true"}, + {"pageSize", "1000"}, //"[1, 1000] Default: 100" + {"q", "'" + folderId + "' in parents and not trashed"}, + {"spaces", "drive"}, + {"supportsAllDrives", "true"}, + {"fields", "nextPageToken,incompleteSearch,files(id,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId))"}, //https://developers.google.com/drive/api/v3/reference/files + }); + if (nextPageToken) + queryParams += '&' + xWwwFormUrlEncode({{"pageToken", *nextPageToken}}); + + std::string response; + gdriveHttpsRequest("/drive/v3/files?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + /**/ nextPageToken = getPrimitiveFromJsonObject(jresponse, "nextPageToken"); + const std::optional incompleteSearch = getPrimitiveFromJsonObject(jresponse, "incompleteSearch"); + const JsonValue* files = getChildFromJsonObject (jresponse, "files"); + if (!incompleteSearch || *incompleteSearch != "false" || !files || files->type != JsonValue::Type::array) + throw SysError(formatGdriveErrorRaw(response)); + + for (const JsonValue& childVal : files->arrayVal) + { + std::optional itemId = getPrimitiveFromJsonObject(childVal, "id"); + if (!itemId || itemId->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + GdriveItemDetails itemDetails(extractItemDetails(childVal)); //throw SysError + assert(std::find(itemDetails.parentIds.begin(), itemDetails.parentIds.end(), folderId) != itemDetails.parentIds.end()); + + childItems.push_back({std::move(*itemId), std::move(itemDetails)}); + } + } + while (nextPageToken); + } + return childItems; +} + + +struct FileChange +{ + std::string itemId; + std::optional details; //empty if item was deleted/trashed +}; +struct DriveChange +{ + std::string driveId; + Zstring driveName; //empty if shared drive was deleted +}; +struct ChangesDelta +{ + std::string newStartPageToken; + std::vector fileChanges; + std::vector driveChanges; +}; +ChangesDelta getChangesDelta(const std::string& sharedDriveId /*empty for "My Drive"*/, const std::string& startPageToken, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/changes/list + ChangesDelta delta; + std::optional nextPageToken = startPageToken; + for (;;) + { + std::string queryParams = xWwwFormUrlEncode( + { + {"pageToken", *nextPageToken}, + {"fields", "kind,nextPageToken,newStartPageToken,changes(kind,changeType,removed,fileId,file(trashed,name,mimeType,ownedByMe,size,modifiedTime,parents,shortcutDetails(targetId)),driveId,drive(name))"}, + {"includeItemsFromAllDrives", "true"}, //semantics are a mess https://developers.google.com/drive/api/v3/enable-shareddrives https://freefilesync.org/forum/viewtopic.php?t=7827&start=30#p29712 + //in short: if driveId is set: required, but blatant lie; only drive-specific file changes returned + // if no driveId set: optional, but blatant lie; only changes to drive objects are returned, but not contained files (with a few exceptions) + {"pageSize", "1000"}, //"[1, 1000] Default: 100" + {"spaces", "drive"}, + {"supportsAllDrives", "true"}, + //do NOT "restrictToMyDrive": we're also interested in "Shared with me" items, which might be referenced by a shortcut in "My Drive" + }); + if (!sharedDriveId.empty()) + queryParams += '&' + xWwwFormUrlEncode({{"driveId", sharedDriveId}}); //only allowed for shared drives! + + std::string response; + gdriveHttpsRequest("/drive/v3/changes?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + /**/ nextPageToken = getPrimitiveFromJsonObject(jresponse, "nextPageToken"); + const std::optional newStartPageToken = getPrimitiveFromJsonObject(jresponse, "newStartPageToken"); + const std::optional listKind = getPrimitiveFromJsonObject(jresponse, "kind"); + const JsonValue* changes = getChildFromJsonObject (jresponse, "changes"); + + if (!!nextPageToken == !!newStartPageToken || //there can be only one + !listKind || *listKind != "drive#changeList" || + !changes || changes->type != JsonValue::Type::array) + throw SysError(formatGdriveErrorRaw(response)); + + for (const JsonValue& childVal : changes->arrayVal) + { + const std::optional kind = getPrimitiveFromJsonObject(childVal, "kind"); + const std::optional changeType = getPrimitiveFromJsonObject(childVal, "changeType"); + const std::optional removed = getPrimitiveFromJsonObject(childVal, "removed"); + if (!kind || *kind != "drive#change" || !changeType || !removed) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + if (*changeType == "file") + { + std::optional fileId = getPrimitiveFromJsonObject(childVal, "fileId"); + if (!fileId || fileId->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + FileChange change; + change.itemId = std::move(*fileId); + if (*removed != "true") + { + const JsonValue* file = getChildFromJsonObject(childVal, "file"); + if (!file) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + const std::optional trashed = getPrimitiveFromJsonObject(*file, "trashed"); + if (!trashed) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + if (*trashed != "true") + change.details = extractItemDetails(*file); //throw SysError + } + delta.fileChanges.push_back(std::move(change)); + } + else if (*changeType == "drive") + { + std::optional driveId = getPrimitiveFromJsonObject(childVal, "driveId"); + if (!driveId || driveId->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + DriveChange change; + change.driveId = std::move(*driveId); + if (*removed != "true") + { + const JsonValue* drive = getChildFromJsonObject(childVal, "drive"); + if (!drive) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + const std::optional name = getPrimitiveFromJsonObject(*drive, "name"); + if (!name || name->empty()) + throw SysError(formatGdriveErrorRaw(serializeJson(childVal))); + + change.driveName = utfTo(*name); + } + delta.driveChanges.push_back(std::move(change)); + } + else assert(false); //no other types (yet!) + } + + if (!nextPageToken) + { + delta.newStartPageToken = *newStartPageToken; + return delta; + } + } +} + + +std::string /*startPageToken*/ getChangesCurrentToken(const std::string& sharedDriveId /*empty for "My Drive"*/, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/changes/getStartPageToken + std::string queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + }); + if (!sharedDriveId.empty()) + queryParams += '&' + xWwwFormUrlEncode({{"driveId", sharedDriveId}}); //only allowed for shared drives! + + std::string response; + gdriveHttpsRequest("/drive/v3/changes/startPageToken?" + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional startPageToken = getPrimitiveFromJsonObject(jresponse, "startPageToken"); + if (!startPageToken) + throw SysError(formatGdriveErrorRaw(response)); + + return *startPageToken; +} + + +//- if item is a folder: deletes recursively!!! +//- even deletes a hardlink with multiple parents => use gdriveUnlinkParent() first +void gdriveDeleteItem(const std::string& itemId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/delete + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + }); + std::string response; + const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, + {} /*extraHeaders*/, {{CURLOPT_CUSTOMREQUEST, "DELETE"}}, [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + if (response.empty() && httpResult.statusCode == 204) + return; //"If successful, this method returns an empty response body" + + throw SysError(formatGdriveErrorRaw(response)); +} + + +//item is NOT deleted when last parent is removed: it is just not accessible via the "My Drive" hierarchy but still adds to quota! => use for hard links only! +void gdriveUnlinkParent(const std::string& itemId, const std::string& parentId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/update + const std::string& queryParams = xWwwFormUrlEncode( + { + {"removeParents", parentId}, + {"supportsAllDrives", "true"}, + {"fields", "id,parents"}, //for test if operation was successful + }); + std::string response; + const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, { CURLOPT_POSTFIELDS, "{}"}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + if (response.empty() && httpResult.statusCode == 204) + return; //removing last parent of item not owned by us returns "204 No Content" (instead of 200 + file body) + + JsonValue jresponse; + try { jresponse = parseJson(response); /*throw JsonParsingError*/ } + catch (const JsonParsingError&) {} + + const std::optional id = getPrimitiveFromJsonObject(jresponse, "id"); //id is returned on "success", unlike "parents", see below... + const JsonValue* parents = getChildFromJsonObject(jresponse, "parents"); + if (!id || *id != itemId) + throw SysError(formatGdriveErrorRaw(response)); + + if (parents) //when last parent is removed, Google does NOT return the parents array (not even an empty one!) + if (parents->type != JsonValue::Type::array || + std::any_of(parents->arrayVal.begin(), parents->arrayVal.end(), + [&](const JsonValue& jval) { return jval.type == JsonValue::Type::string && jval.primVal == parentId; })) + throw SysError(L"gdriveUnlinkParent: Google Drive internal failure"); //user should never see this... +} + + +//- if item is a folder: trashes recursively!!! +//- a hardlink with multiple parents will NOT be accessible anymore via any of its path aliases! +void gdriveMoveToTrash(const std::string& itemId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/update + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "trashed"}, + }); + const std::string postBuf = R"({ "trashed": true })"; + + std::string response; + gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); /*throw JsonParsingError*/ } + catch (const JsonParsingError&) {} + + const std::optional trashed = getPrimitiveFromJsonObject(jresponse, "trashed"); + if (!trashed || *trashed != "true") + throw SysError(formatGdriveErrorRaw(response)); +} + + +//folder name already existing? will (happily) create duplicate => caller must check! +std::string /*folderId*/ gdriveCreateFolderPlain(const Zstring& folderName, const std::string& parentId, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/folder#creating_a_folder + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "id"}, + }); + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.set("mimeType", gdriveFolderMimeType); + postParams.objectVal.set("name", utfTo(folderName)); + postParams.objectVal.set("parents", std::vector {JsonValue(parentId)}); + const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + + std::string response; + gdriveHttpsRequest("/drive/v3/files?" + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + return *itemId; +} + + +//shortcut name already existing? will (happily) create duplicate => caller must check! +std::string /*shortcutId*/ gdriveCreateShortcutPlain(const Zstring& shortcutName, const std::string& parentId, const std::string& targetId, const GdriveAccess& access) //throw SysError +{ + /* https://developers.google.com/drive/api/v3/shortcuts + - targetMimeType is determined automatically (ignored if passed) + - creating shortcuts to shortcuts fails with "Internal Error" */ + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "id"}, + }); + JsonValue shortcutDetails(JsonValue::Type::object); + shortcutDetails.objectVal.set("targetId", targetId); + + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.set("mimeType", gdriveShortcutMimeType); + postParams.objectVal.set("name", utfTo(shortcutName)); + postParams.objectVal.set("parents", std::vector {JsonValue(parentId)}); + postParams.objectVal.set("shortcutDetails", std::move(shortcutDetails)); + const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + + std::string response; + gdriveHttpsRequest("/drive/v3/files?" + queryParams, {"Content-Type: application/json; charset=UTF-8"}, + {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + return *itemId; +} + + +//target name already existing? will (happily) create duplicate items => caller must check! +//can copy files + shortcuts (but fails for folders) + Google-specific file types (.gdoc, .gsheet, .gslides) +std::string /*fileId*/ gdriveCopyFile(const std::string& fileId, const std::string& parentIdTo, const Zstring& newName, time_t newModTime, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/copy + const std::string queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "id"}, + }); + + //more Google Drive peculiarities: changing the file name changes modifiedTime!!! => workaround: + + //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + const std::string modTimeRfc = utfTo(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(newModTime))); //returns empty string on error + if (modTimeRfc.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(newModTime) + L')'); + + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.set("name", utfTo(newName)); + postParams.objectVal.set("parents", std::vector {JsonValue(parentIdTo)}); + postParams.objectVal.set("modifiedTime", modTimeRfc); + const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + + std::string response; + gdriveHttpsRequest("/drive/v3/files/" + fileId + "/copy?" + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); /*throw JsonParsingError*/ } + catch (const JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + + return *itemId; + +} + + +//target name already existing? will (happily) create duplicate items => caller must check! +void gdriveMoveAndRenameItem(const std::string& itemId, const std::string& parentIdFrom, const std::string& parentIdTo, + const Zstring& newName, time_t newModTime, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/folder#moving_files_between_folders + std::string queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "name,parents"}, //for test if operation was successful + }); + + if (parentIdFrom != parentIdTo) + queryParams += '&' + xWwwFormUrlEncode( + { + {"removeParents", parentIdFrom}, + {"addParents", parentIdTo}, + }); + + //more Google Drive peculiarities: changing the file name changes modifiedTime!!! => workaround: + + //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + const std::string modTimeRfc = utfTo(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(newModTime))); //returns empty string on error + if (modTimeRfc.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(newModTime) + L')'); + + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.set("name", utfTo(newName)); + postParams.objectVal.set("modifiedTime", modTimeRfc); + const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + + std::string response; + gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); /*throw JsonParsingError*/ } + catch (const JsonParsingError&) {} + + const std::optional name = getPrimitiveFromJsonObject(jresponse, "name"); + const JsonValue* parents = getChildFromJsonObject(jresponse, "parents"); + if (!name || *name != utfTo(newName) || + !parents || parents->type != JsonValue::Type::array) + throw SysError(formatGdriveErrorRaw(response)); + + if (!std::any_of(parents->arrayVal.begin(), parents->arrayVal.end(), + [&](const JsonValue& jval) { return jval.type == JsonValue::Type::string && jval.primVal == parentIdTo; })) + throw SysError(formatSystemError("gdriveMoveAndRenameItem", L"", L"Google Drive internal failure.")); //user should never see this... +} + + +#if 0 +void setModTime(const std::string& itemId, time_t modTime, const GdriveAccess& access) //throw SysError +{ + //https://developers.google.com/drive/api/v3/reference/files/update + //RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + const std::string& modTimeRfc = formatTime("%Y-%m-%dT%H:%M:%S.000Z", getUtcTime2(modTime)); //returns empty string on error + if (modTimeRfc.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(modTime) + L')'); + + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"fields", "modifiedTime"}, + }); + const std::string postBuf = R"({ "modifiedTime": ")" + modTimeRfc + "\" }"; + + std::string response; + gdriveHttpsRequest("/drive/v3/files/" + itemId + '?' + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_CUSTOMREQUEST, "PATCH"}, {CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, nullptr /*receiveHeader*/, access); //throw SysError + + JsonValue jresponse; + try { jresponse = parseJson(response); /*throw JsonParsingError*/ } + catch (const JsonParsingError&) {} + + const std::optional modifiedTime = getPrimitiveFromJsonObject(jresponse, "modifiedTime"); + if (!modifiedTime || *modifiedTime != modTimeRfc) + throw SysError(formatGdriveErrorRaw(response)); +} +#endif + + +DEFINE_NEW_SYS_ERROR(SysErrorAbusiveFile) +void gdriveDownloadFileImpl(const std::string& fileId, const std::function& writeBlock /*throw X*/, //throw SysError, SysErrorAbusiveFile, X + bool acknowledgeAbuse, const GdriveAccess& access) +{ + /* https://developers.google.com/drive/api/v3/manage-downloads + doesn't work for Google-specific file types, but Google Backup & Sync still "downloads" them: + - in some JSON-like file format: + {"url": "https://docs.google.com/open?id=FILE_ID", "doc_id": "FILE_ID", "email": "ACCOUNT_EMAIL"} + + - adds artificial file extensions: .gdoc, .gsheet, .gslides, ... + + - 2022-10-10: In "Google Drive for Desktop" the file content now looks like: + {"":"WARNING! DO NOT EDIT THIS FILE! ANY CHANGES MADE WILL BE LOST!","doc_id":"FILE_ID","resource_key":"","email":"ACCOUNT_EMAIL"} */ + + std::string queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"alt", "media"}, + }); + if (acknowledgeAbuse) //apply on demand only! https://freefilesync.org/forum/viewtopic.php?t=7520") + queryParams += '&' + xWwwFormUrlEncode({{"acknowledgeAbuse", "true"}}); + + std::string headBytes; + bool headBytesWritten = false; + + const HttpSession::Result httpResult = gdriveHttpsRequest("/drive/v3/files/" + fileId + '?' + queryParams, {} /*extraHeaders*/, {} /*extraOptions*/, + [&](std::span buf) + /* libcurl feeds us a shitload of tiny kB-sized zlib-decompressed pieces of data! + libcurl's zlib buffer is sized at ridiculous 16 kB! + => if this ever becomes a perf issue: roll our own zlib decompression! */ + { + if (headBytes.size() < 16 * 1024) //don't access writeBlock() yet in case of error! (=> support acknowledgeAbuse retry handling) + headBytes.append(buf.data(), buf.size()); + else + { + if (!headBytesWritten) + { + headBytesWritten = true; + writeBlock(headBytes.c_str(), headBytes.size()); //throw X + } + + writeBlock(buf.data(), buf.size()); //throw X + } + }, nullptr /*tryReadRequest*/, nullptr /*receiveHeader*/, access); //throw SysError, X + + if (httpResult.statusCode / 100 != 2) + { + /* https://freefilesync.org/forum/viewtopic.php?t=7463 => HTTP status code 403 + body: + { "error": { "errors": [{ "domain": "global", + "reason": "cannotDownloadAbusiveFile", + "message": "This file has been identified as malware or spam and cannot be downloaded." }], + "code": 403, + "message": "This file has been identified as malware or spam and cannot be downloaded." }} */ + if (!headBytesWritten && httpResult.statusCode == 403 && contains(headBytes, "\"cannotDownloadAbusiveFile\"")) + throw SysErrorAbusiveFile(formatGdriveErrorRaw(headBytes)); + + throw SysError(formatGdriveErrorRaw(headBytes)); + } + + if (!headBytesWritten && !headBytes.empty()) + writeBlock(headBytes.c_str(), headBytes.size()); //throw X +} + + +void gdriveDownloadFile(const std::string& fileId, const std::function& writeBlock /*throw X*/, //throw SysError, X + const GdriveAccess& access) +{ + try + { + gdriveDownloadFileImpl(fileId, writeBlock /*throw X*/, false /*acknowledgeAbuse*/, access); //throw SysError, SysErrorAbusiveFile, X + } + catch (SysErrorAbusiveFile&) + { + gdriveDownloadFileImpl(fileId, writeBlock /*throw X*/, true /*acknowledgeAbuse*/, access); //throw SysError, (SysErrorAbusiveFile), X + } +} + + +#if 0 +//file name already existing? => duplicate file created! +//note: Google Drive upload is already transactional! +//upload "small files" (5 MB or less; enforced by Google?) in a single round-trip +std::string /*itemId*/ gdriveUploadSmallFile(const Zstring& fileName, const std::string& parentId, uint64_t streamSize, std::optional modTime, //throw SysError, X + const std::function& readBlock /*throw X; return "bytesToRead" bytes unless end of stream*/, + const GdriveAccess& access) +{ + //https://developers.google.com/drive/api/v3/folder#inserting_a_file_in_a_folder + //https://developers.google.com/drive/api/v3/manage-uploads#http_1 + + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.emplace("name", utfTo(fileName)); + postParams.objectVal.emplace("parents", std::vector {JsonValue(parentId)}); + if (modTime) //convert to RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + { + const std::string& modTimeRfc = utfTo(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime2(*modTime))); //returns empty string on error + if (modTimeRfc.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(*modTime) + L')'); + + postParams.objectVal.emplace("modifiedTime", modTimeRfc); + } + const std::string& metaDataBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + + //allowed chars for border: DIGIT ALPHA ' ( ) + _ , - . / : = ? + const std::string boundaryString = stringEncodeBase64(generateGUID() + generateGUID()); + + const std::string postBufHead = "--" + boundaryString + "\r\n" + "Content-Type: application/json; charset=UTF-8" "\r\n" + /**/ "\r\n" + + metaDataBuf + "\r\n" + "--" + boundaryString + "\r\n" + "Content-Type: application/octet-stream" "\r\n" + /**/ "\r\n"; + + const std::string postBufTail = "\r\n--" + boundaryString + "--"; + + auto readMultipartBlock = [&, headPos = size_t(0), eof = false, tailPos = size_t(0)](void* buffer, size_t bytesToRead) mutable -> size_t + { + const auto bufStart = buffer; + + if (headPos < postBufHead.size()) + { + const size_t junkSize = std::min(postBufHead.size() - headPos, bytesToRead); + std::memcpy(buffer, postBufHead.c_str() + headPos, junkSize); + headPos += junkSize; + buffer = static_cast(buffer) + junkSize; + bytesToRead -= junkSize; + } + if (bytesToRead > 0) + { + if (!eof) //don't assume readBlock() will return streamSize bytes as promised => exhaust and let Google Drive fail if there is a mismatch in Content-Length! + { + const size_t bytesRead = readBlock(buffer, bytesToRead); //throw X; return "bytesToRead" bytes unless end of stream + buffer = static_cast(buffer) + bytesRead; + bytesToRead -= bytesRead; + + if (bytesToRead > 0) + eof = true; + } + if (bytesToRead > 0) + if (tailPos < postBufTail.size()) + { + const size_t junkSize = std::min(postBufTail.size() - tailPos, bytesToRead); + std::memcpy(buffer, postBufTail.c_str() + tailPos, junkSize); + tailPos += junkSize; + buffer = static_cast(buffer) + junkSize; + bytesToRead -= junkSize; + } + } + return static_cast(buffer) - + static_cast(bufStart); + }; + +TODO: + gzip-compress HTTP request body! + + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"uploadType", "multipart"}, + }); + std::string response; + const HttpSession::Result httpResult = gdriveHttpsRequest("/upload/drive/v3/files?" + queryParams, + { + "Content-Type: multipart/related; boundary=" + boundaryString, + "Content-Length: " + numberTo(postBufHead.size() + streamSize + postBufTail.size()) + }, + {{CURLOPT_POST, 1}}, //otherwise HttpSession::perform() will PUT + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + readMultipartBlock, nullptr /*receiveHeader*/, access); //throw SysError, X + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + + return *itemId; +} +#endif + + +//file name already existing? => duplicate file created! +//note: Google Drive upload is already transactional! +std::string /*itemId*/ gdriveUploadFile(const Zstring& fileName, const std::string& parentId, std::optional modTime, //throw SysError, X + const std::function& tryReadBlock /*throw X*/, //returning 0 signals EOF: Posix read() semantics + const GdriveAccess& access) +{ + //https://developers.google.com/drive/api/v3/folder#inserting_a_file_in_a_folder + //https://developers.google.com/drive/api/v3/manage-uploads#resumable + + //step 1: initiate resumable upload session + std::string uploadUrlRelative; + { + const std::string& queryParams = xWwwFormUrlEncode( + { + {"supportsAllDrives", "true"}, + {"uploadType", "resumable"}, + }); + JsonValue postParams(JsonValue::Type::object); + postParams.objectVal.set("name", utfTo(fileName)); + postParams.objectVal.set("parents", std::vector {JsonValue(parentId)}); + if (modTime) //convert to RFC 3339 date-time: e.g. "2018-09-29T08:39:12.053Z" + { + const std::string& modTimeRfc = utfTo(formatTime(Zstr("%Y-%m-%dT%H:%M:%S.000Z"), getUtcTime(*modTime))); //returns empty string on error + if (modTimeRfc.empty()) + throw SysError(L"Invalid modification time (time_t: " + numberTo(*modTime) + L')'); + + postParams.objectVal.set("modifiedTime", modTimeRfc); + } + const std::string& postBuf = serializeJson(postParams, "" /*lineBreak*/, "" /*indent*/); + //--------------------------------------------------- + + std::string uploadUrl; + + auto onHeaderData = [&](const std::string_view& header) + { + //"The callback will be called once for each header and only complete header lines are passed on to the callback" (including \r\n at the end) + if (startsWithAsciiNoCase(header, "Location:")) + { + uploadUrl = header; + uploadUrl = afterFirst(uploadUrl, ':', IfNotFoundReturn::none); + trim(uploadUrl); + } + }; + + std::string response; + const HttpSession::Result httpResult = gdriveHttpsRequest("/upload/drive/v3/files?" + queryParams, + {"Content-Type: application/json; charset=UTF-8"}, {{CURLOPT_POSTFIELDS, postBuf.c_str()}}, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, + nullptr /*readRequest*/, onHeaderData, access); //throw SysError + + if (httpResult.statusCode != 200) + throw SysError(formatGdriveErrorRaw(response)); + + if (!startsWith(uploadUrl, "https://www.googleapis.com/")) + throw SysError(L"Invalid upload URL: " + utfTo(uploadUrl)); //user should never see this + + uploadUrlRelative = afterFirst(uploadUrl, "googleapis.com", IfNotFoundReturn::none); + } + //--------------------------------------------------- + //step 2: upload file content + + //not officially documented, but Google Drive supports compressed file upload when "Content-Encoding: gzip" is set! :))) + InputStreamAsGzip gzipStream(tryReadBlock, GDRIVE_BLOCK_SIZE_UPLOAD); //throw SysError + + auto readRequest = [&](std::span buf) { return gzipStream.read(buf.data(), buf.size()); }; //throw SysError, X + + std::string response; //don't need "Authorization: Bearer": + googleHttpsRequest(GOOGLE_REST_API_SERVER, uploadUrlRelative, { "Content-Encoding: gzip" }, {} /*extraOptions*/, + [&](std::span buf) { response.append(buf.data(), buf.size()); }, readRequest, + nullptr /*receiveHeader*/, access.timeoutSec); //throw SysError, X + + JsonValue jresponse; + try { jresponse = parseJson(response); } + catch (JsonParsingError&) {} + + const std::optional itemId = getPrimitiveFromJsonObject(jresponse, "id"); + if (!itemId) + throw SysError(formatGdriveErrorRaw(response)); + + return *itemId; +} + + +class GdriveAccessBuffer //per-user-session & drive! => serialize access (perf: amortized fully buffered!) +{ +public: + //GdriveDrivesBuffer constructor calls GdriveAccessBuffer::getAccessToken() + explicit GdriveAccessBuffer(const GdriveAccessInfo& accessInfo) : + accessInfo_(accessInfo) {} + + GdriveAccessBuffer(MemoryStreamIn& stream) //throw SysError + { + accessInfo_.accessToken.validUntil = readNumber(stream); // + accessInfo_.accessToken.value = readContainer(stream); // + accessInfo_.refreshToken = readContainer(stream); //SysErrorUnexpectedEos + accessInfo_.userInfo.displayName = utfTo(readContainer(stream)); // + accessInfo_.userInfo.email = readContainer(stream); // + } + + void serialize(MemoryStreamOut& stream) const + { + writeNumber(stream, accessInfo_.accessToken.validUntil); + static_assert(sizeof(accessInfo_.accessToken.validUntil) <= sizeof(int64_t)); //ensure cross-platform compatibility! + writeContainer(stream, accessInfo_.accessToken.value); + writeContainer(stream, accessInfo_.refreshToken); + writeContainer(stream, utfTo(accessInfo_.userInfo.displayName)); + writeContainer(stream, accessInfo_.userInfo.email); + } + + //set *before* calling any of the subsequent functions; see GdrivePersistentSessions::accessUserSession() + void setContextTimeout(const std::weak_ptr& timeoutSec) { timeoutSec_ = timeoutSec; } + + GdriveAccess getAccessToken() //throw SysError + { + const int timeoutSec = getTimeoutSec(); + + if (accessInfo_.accessToken.validUntil <= std::time(nullptr) + timeoutSec + 5 /*some leeway*/) //expired/will expire + { + GdriveAccessToken token = gdriveRefreshAccess(accessInfo_.refreshToken, timeoutSec); //throw SysError + + //"there are limits on the number of refresh tokens that will be issued" + //Google Drive access token is usually valid for one hour => fail on pathologic user-defined time out: + if (token.validUntil <= std::time(nullptr) + 2 * timeoutSec) + throw SysError(_("Please set up a shorter time out for Google Drive.") + L" [" + _P("1 sec", "%x sec", timeoutSec) + L']'); + + accessInfo_.accessToken = std::move(token); + } + + return {accessInfo_.accessToken.value, timeoutSec}; + } + + const std::string& getUserEmail() const { return accessInfo_.userInfo.email; } + + void update(const GdriveAccessInfo& accessInfo) + { + if (!equalAsciiNoCase(accessInfo.userInfo.email, accessInfo_.userInfo.email)) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + accessInfo_ = accessInfo; + } + +private: + GdriveAccessBuffer (const GdriveAccessBuffer&) = delete; + GdriveAccessBuffer& operator=(const GdriveAccessBuffer&) = delete; + + int getTimeoutSec() const + { + const std::shared_ptr timeoutSec = timeoutSec_.lock(); + assert(timeoutSec); + if (!timeoutSec) + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] GdriveAccessBuffer: Timeout duration was not set."); + + return *timeoutSec; + } + + GdriveAccessInfo accessInfo_; + std::weak_ptr timeoutSec_; +}; + + +class GdriveDrivesBuffer; + + +class GdriveFileState //per-user-session! => serialize access (perf: amortized fully buffered!) +{ +public: + GdriveFileState(const std::string& driveId, //ID of shared drive or "My Drive": never empty! + const Zstring& sharedDriveName, //*empty* for "My Drive" + GdriveAccessBuffer& accessBuf) : //throw SysError + /* issue getChangesCurrentToken() as the very first Google Drive query! */ + lastSyncToken_(getChangesCurrentToken(sharedDriveName.empty() ? std::string() : driveId, accessBuf.getAccessToken())), //throw SysError + driveId_(driveId), + sharedDriveName_(sharedDriveName), + accessBuf_(accessBuf) { assert(!driveId.empty() && sharedDriveName != Zstr("My Drive")); } + + GdriveFileState(MemoryStreamIn& stream, GdriveAccessBuffer& accessBuf) : //throw SysError + accessBuf_(accessBuf) + { + lastSyncToken_ = readContainer(stream); // + driveId_ = readContainer(stream); //SysErrorUnexpectedEos + sharedDriveName_ = utfTo(readContainer(stream)); // + + for (;;) + { + const std::string folderId = readContainer(stream); //SysErrorUnexpectedEos + if (folderId.empty()) + break; + folderContents_[folderId].isKnownFolder = true; + } + + for (;;) + { + const std::string itemId = readContainer(stream); //SysErrorUnexpectedEos + if (itemId.empty()) + break; + + GdriveItemDetails details = {}; //read in correct sequence! + details.itemName = utfTo(readContainer(stream)); // + details.type = readNumber(stream); // + details.owner = readNumber (stream); // + details.fileSize = readNumber (stream); //SysErrorUnexpectedEos + details.modTime = static_cast(readNumber(stream)); // + details.targetId = readContainer(stream); // + + size_t parentsCount = readNumber(stream); //SysErrorUnexpectedEos + while (parentsCount-- != 0) + details.parentIds.push_back(readContainer(stream)); //SysErrorUnexpectedEos + + updateItemState(itemId, &details); + } + } + + void serialize(MemoryStreamOut& stream) const + { + writeContainer(stream, lastSyncToken_); + writeContainer(stream, driveId_); + writeContainer(stream, utfTo(sharedDriveName_)); + + for (const auto& [folderId, content] : folderContents_) + if (folderId.empty()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + else if (content.isKnownFolder) + writeContainer(stream, folderId); + writeContainer(stream, std::string()); //sentinel + + auto serializeItem = [&](const std::string& itemId, const GdriveItemDetails& details) + { + writeContainer (stream, itemId); + writeContainer (stream, utfTo(details.itemName)); + writeNumber(stream, details.type); + writeNumber (stream, details.owner); + writeNumber (stream, details.fileSize); + writeNumber (stream, details.modTime); + static_assert(sizeof(details.modTime) <= sizeof(int64_t)); //ensure cross-platform compatibility! + writeContainer(stream, details.targetId); + + writeNumber(stream, static_cast(details.parentIds.size())); + for (const std::string& parentId : details.parentIds) + writeContainer(stream, parentId); + }; + + //serialize + clean up: only save items in "known folders" + items referenced by shortcuts + for (const auto& [folderId, content] : folderContents_) + if (content.isKnownFolder) + for (const auto& itItem : content.childItems) + { + const auto& [itemId, details] = *itItem; + if (itemId.empty()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + serializeItem(itemId, details); + + if (details.type == GdriveItemType::shortcut) + { + if (details.targetId.empty()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + if (auto it = itemDetails_.find(details.targetId); + it != itemDetails_.end()) + serializeItem(details.targetId, it->second); + } + } + writeContainer(stream, std::string()); //sentinel + } + + std::string getDriveId() const { return driveId_; } + + Zstring getSharedDriveName() const { return sharedDriveName_; } //*empty* for "My Drive" + + void setSharedDriveName(const Zstring& sharedDriveName) { sharedDriveName_ = sharedDriveName; } + + struct PathStatus + { + std::string existingItemId; + GdriveItemType existingType = GdriveItemType::file; + AfsPath existingPath; //input path =: existingPath + relPath + std::vector relPath; // + }; + PathStatus getPathStatus(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + const std::vector relPath = splitCpy(itemPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip); + if (relPath.empty()) + return {locationRootId, GdriveItemType::folder, AfsPath(), {}}; + else + return getPathStatusSub(locationRootId, AfsPath(), relPath, followLeafShortcut); //throw SysError + } + + std::string /*itemId*/ getItemId(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + const GdriveFileState::PathStatus& ps = getPathStatus(locationRootId, itemPath, followLeafShortcut); //throw SysError + if (ps.relPath.empty()) + return ps.existingItemId; + + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + } + + std::pair getFileAttributes(const std::string& locationRootId, const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + if (itemPath.value.empty()) //location root not covered by itemDetails_ + { + GdriveItemDetails rootDetails + { + .type = GdriveItemType::folder, + //.itemName =... => better leave empty for a root item! + .owner = sharedDriveName_.empty() ? FileOwner::me : FileOwner::none, + }; + return {locationRootId, std::move(rootDetails)}; + } + + const std::string itemId = getItemId(locationRootId, itemPath, followLeafShortcut); //throw SysError + if (auto it = itemDetails_.find(itemId); + it != itemDetails_.end()) + return *it; + + //itemId was already found! => (must either be a location root) or buffered in itemDetails_ + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + + std::optional tryGetBufferedItemDetails(const std::string& itemId) const + { + if (auto it = itemDetails_.find(itemId); + it != itemDetails_.end()) + return it->second; + return {}; + } + + std::optional> tryGetBufferedFolderContent(const std::string& folderId) const + { + auto it = folderContents_.find(folderId); + if (it == folderContents_.end() || !it->second.isKnownFolder) + return std::nullopt; + + std::vector childItems; + for (auto itChild : it->second.childItems) + { + const auto& [childId, childDetails] = *itChild; + childItems.push_back({childId, childDetails}); + } + return std::move(childItems); //[!] need std::move! + } + + //-------------- notifications -------------- + using ItemIdDelta = std::unordered_set; + + struct FileStateDelta //as long as instance exists, GdriveItem will log all changed items + { + FileStateDelta() {} + private: + explicit FileStateDelta(const std::shared_ptr& cids) : changedIds(cids) {} + friend class GdriveFileState; + std::shared_ptr changedIds; //lifetime is managed by caller; access *only* by GdriveFileState! + }; + + void notifyFolderContent(const FileStateDelta& stateDelta, const std::string& folderId, const std::vector& childItems) + { + folderContents_[folderId].isKnownFolder = true; + + for (const GdriveItem& item : childItems) + notifyItemUpdated(stateDelta, item.itemId, &item.details); + + //- should we remove parent links for items that are not children of folderId anymore (as of this update)?? => fringe case during first update! (still: maybe trigger sync?) + //- what if there are multiple folder state updates incoming in wrong order!? => notifyItemUpdated() will sort it out! + } + + void notifyItemCreated(const FileStateDelta& stateDelta, const GdriveItem& item) + { + notifyItemUpdated(stateDelta, item.itemId, &item.details); + } + + void notifyItemUpdated(const FileStateDelta& stateDelta, const GdriveItem& item) + { + notifyItemUpdated(stateDelta, item.itemId, &item.details); + } + + void notifyFolderCreated(const FileStateDelta& stateDelta, const std::string& folderId, const Zstring& folderName, const std::string& parentId) + { + GdriveItemDetails details + { + .itemName = folderName, + .modTime = std::time(nullptr), + .type = GdriveItemType::folder, + .owner = FileOwner::me, + .parentIds{parentId}, + }; + + //avoid needless conflicts due to different Google Drive folder modTime! + if (auto it = itemDetails_.find(folderId); it != itemDetails_.end()) + details.modTime = it->second.modTime; + + notifyItemUpdated(stateDelta, folderId, &details); + } + + void notifyShortcutCreated(const FileStateDelta& stateDelta, const std::string& shortcutId, const Zstring& shortcutName, const std::string& parentId, const std::string& targetId) + { + GdriveItemDetails details + { + .itemName = shortcutName, + .modTime = std::time(nullptr), + .type = GdriveItemType::shortcut, + .owner = FileOwner::me, + .targetId = targetId, + .parentIds{parentId}, + }; + + //avoid needless conflicts due to different Google Drive folder modTime! + if (auto it = itemDetails_.find(shortcutId); it != itemDetails_.end()) + details.modTime = it->second.modTime; + + notifyItemUpdated(stateDelta, shortcutId, &details); + } + + + void notifyItemDeleted(const FileStateDelta& stateDelta, const std::string& itemId) + { + notifyItemUpdated(stateDelta, itemId, nullptr); + } + + void notifyParentRemoved(const FileStateDelta& stateDelta, const std::string& itemId, const std::string& parentIdOld) + { + if (auto it = itemDetails_.find(itemId); it != itemDetails_.end()) + { + GdriveItemDetails detailsNew = it->second; + std::erase(detailsNew.parentIds, parentIdOld); + notifyItemUpdated(stateDelta, itemId, &detailsNew); + } + else //conflict!!! + markSyncDue(); + } + + void notifyMoveAndRename(const FileStateDelta& stateDelta, const std::string& itemId, const std::string& parentIdFrom, const std::string& parentIdTo, const Zstring& newName) + { + if (auto it = itemDetails_.find(itemId); it != itemDetails_.end()) + { + GdriveItemDetails detailsNew = it->second; + detailsNew.itemName = newName; + + std::erase_if(detailsNew.parentIds, [&](const std::string& id) { return id == parentIdFrom || id == parentIdTo; }); // + detailsNew.parentIds.push_back(parentIdTo); //not a duplicate + + notifyItemUpdated(stateDelta, itemId, &detailsNew); + } + else //conflict!!! + markSyncDue(); + } + +private: + GdriveFileState (const GdriveFileState&) = delete; + GdriveFileState& operator=(const GdriveFileState&) = delete; + + friend class GdriveDrivesBuffer; + + void notifyItemUpdated(const FileStateDelta& stateDelta, const std::string& itemId, const GdriveItemDetails* details) + { + if (!stateDelta.changedIds->contains(itemId)) //no conflicting changes in the meantime? + updateItemState(itemId, details); //=> accept new state data + else //conflict? + { + auto it = itemDetails_.find(itemId); + if (!details == (it == itemDetails_.end())) + if (!details || *details == it->second) + return; //notified changes match our current file state + //else: conflict!!! unclear which has the more recent data! + markSyncDue(); + } + } + + FileStateDelta registerFileStateDelta() + { + auto deltaPtr = std::make_shared(); + changeLog_.push_back(deltaPtr); + return FileStateDelta(deltaPtr); + } + + bool syncIsDue() const { return std::chrono::steady_clock::now() >= lastSyncTime_ + GDRIVE_SYNC_INTERVAL; } + + void markSyncDue() { lastSyncTime_ = std::chrono::steady_clock::now() - GDRIVE_SYNC_INTERVAL; } + + void syncWithGoogle() //throw SysError + { + const ChangesDelta delta = getChangesDelta(sharedDriveName_.empty() ? std::string() : driveId_, lastSyncToken_, accessBuf_.getAccessToken()); //throw SysError + + for (const FileChange& change : delta.fileChanges) + updateItemState(change.itemId, get(change.details)); + + lastSyncToken_ = delta.newStartPageToken; + lastSyncTime_ = std::chrono::steady_clock::now(); + + //good to know: if item is created and deleted between polling for changes it is still reported as deleted by Google! + //Same goes for any other change that is undone in between change notification syncs. + } + + PathStatus getPathStatusSub(const std::string& folderId, const AfsPath& folderPath, const std::vector& relPath, bool followLeafShortcut) //throw SysError + { + assert(!relPath.empty()); + + auto itKnown = folderContents_.find(folderId); + if (itKnown == folderContents_.end() || !itKnown->second.isKnownFolder) + { + notifyFolderContent(registerFileStateDelta(), folderId, readFolderContent(folderId, accessBuf_.getAccessToken())); //throw SysError + //perf: always buffered, except for direct, first-time folder access! + itKnown = folderContents_.find(folderId); + assert(itKnown != folderContents_.end()); + if (!itKnown->second.isKnownFolder) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + + auto itFound = itemDetails_.cend(); + for (const DetailsIterator& itChild : itKnown->second.childItems) + //Since Google Drive has no concept of a file path, we have to roll our own "path to ID" mapping => let's use the platform-native style + if (equalNativePath(itChild->second.itemName, relPath.front())) + { + if (itFound != itemDetails_.end()) + throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(relPath.front()))); + + itFound = itChild; + } + + if (itFound == itemDetails_.end()) + return {folderId, GdriveItemType::folder, folderPath, relPath}; //always a folder, see check before recursion above + else + { + auto getItemDetailsBuffered = [&](const std::string& itemId) -> const GdriveItemDetails& + { + auto it = itemDetails_.find(itemId); + if (it == itemDetails_.end()) + { + notifyItemUpdated(registerFileStateDelta(), {itemId, getItemDetails(itemId, accessBuf_.getAccessToken())}); //throw SysError + //perf: always buffered, except for direct, first-time folder access! + it = itemDetails_.find(itemId); + assert(it != itemDetails_.end()); + } + return it->second; + }; + + const auto& [childId, childDetails] = *itFound; + const AfsPath childItemPath(appendPath(folderPath.value, relPath.front())); + const std::vector childRelPath(relPath.begin() + 1, relPath.end()); + + if (childRelPath.empty()) + { + if (childDetails.type == GdriveItemType::shortcut && followLeafShortcut) + return {childDetails.targetId, getItemDetailsBuffered(childDetails.targetId).type, childItemPath, childRelPath}; + else + return {childId, childDetails.type, childItemPath, childRelPath}; + } + + switch (childDetails.type) + { + case GdriveItemType::file: //parent/file/child-rel-path... => obscure, but possible + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(AFS::getItemName(childItemPath)))); + + case GdriveItemType::folder: + return getPathStatusSub(childId, childItemPath, childRelPath, followLeafShortcut); //throw SysError + + case GdriveItemType::shortcut: + switch (getItemDetailsBuffered(childDetails.targetId).type) + { + case GdriveItemType::file: //parent/file-symlink/child-rel-path... => obscure, but possible + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(AFS::getItemName(childItemPath)))); + + case GdriveItemType::folder: //parent/folder-symlink/child-rel-path... => always follow + return getPathStatusSub(childDetails.targetId, childItemPath, childRelPath, followLeafShortcut); //throw SysError + + case GdriveItemType::shortcut: //should never happen: creating shortcuts to shortcuts fails with "Internal Error" + throw SysError(replaceCpy(L"Google Drive Shortcut %x is pointing to another Shortcut.", L"%x", fmtPath(AFS::getItemName(childItemPath)))); + } + break; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + } + + void updateItemState(const std::string& itemId, const GdriveItemDetails* details) + { + auto it = itemDetails_.find(itemId); + if (!details == (it == itemDetails_.end())) + if (!details || *details == it->second) //notified changes match our current file state + return; //=> avoid misleading changeLog_ entries after Google Drive sync!!! + + //update change logs (and clean up obsolete entries) + std::erase_if(changeLog_, [&](std::weak_ptr& weakPtr) + { + if (std::shared_ptr iid = weakPtr.lock()) + { + (*iid).insert(itemId); + return false; + } + else + return true; + }); + + //update file state + if (details) + { + if (it != itemDetails_.end()) //update + { + if (it->second.type != details->type) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); //WTF!? + + std::vector parentIdsNew = details->parentIds; + std::vector parentIdsRemoved = it->second.parentIds; + std::erase_if(parentIdsNew, [&](const std::string& id) { return std::find(it->second.parentIds.begin(), it->second.parentIds.end(), id) != it->second.parentIds.end(); }); + std::erase_if(parentIdsRemoved, [&](const std::string& id) { return std::find(details->parentIds.begin(), details->parentIds.end(), id) != details->parentIds.end(); }); + + for (const std::string& parentId : parentIdsNew) + folderContents_[parentId].childItems.push_back(it); //new insert => no need for duplicate check + + for (const std::string& parentId : parentIdsRemoved) + if (auto itP = folderContents_.find(parentId); itP != folderContents_.end()) + std::erase(itP->second.childItems, it); + //if all parents are removed, Google Drive will (recursively) delete the item => don't prematurely do this now: wait for change notifications! + //OR: item without parents located in "Shared with me", but referenced via Shortcut => don't remove!!! + + it->second = *details; + } + else //create + { + auto itNew = itemDetails_.emplace(itemId, *details).first; + + for (const std::string& parentId : details->parentIds) + folderContents_[parentId].childItems.push_back(itNew); //new insert => no need for duplicate check + } + } + else //delete + { + if (it != itemDetails_.end()) + { + for (const std::string& parentId : it->second.parentIds) //1. delete from parent folders + if (auto itP = folderContents_.find(parentId); itP != folderContents_.end()) + std::erase(itP->second.childItems, it); + + itemDetails_.erase(it); + } + + if (auto itP = folderContents_.find(itemId); itP != folderContents_.end()) + { + //2. delete as parent from child items (don't wait for change notifications of children) + // what if e.g. single change notification "folder removed", then folder reapears, + // and no notifications for child items: possible with Google drive!? + // => no problem: FolderContent::isKnownFolder will be false for this restored folder => only a rescan needed + for (auto itChild : itP->second.childItems) + std::erase(itChild->second.parentIds, itemId); + folderContents_.erase(itP); + } + } + } + + using DetailsIterator = std::unordered_map::iterator; + + struct FolderContent + { + bool isKnownFolder = false; //:= we've seen its full content at least once; further changes are calculated via change notifications + std::vector childItems; + }; + std::unordered_map folderContents_; + std::unordered_map itemDetails_; //contains ALL known, existing items! + + std::string lastSyncToken_; //drive-specific(!) marker corresponding to last sync with Google's change notifications + std::chrono::steady_clock::time_point lastSyncTime_ = std::chrono::steady_clock::now() - GDRIVE_SYNC_INTERVAL; //... with Google Drive (default: sync is due) + + std::vector> changeLog_; //track changed items since FileStateDelta was created (includes sync with Google + our own intermediate change notifications) + + std::string driveId_; //ID of shared drive or "My Drive": never empty! + Zstring sharedDriveName_; //name of shared drive: empty for "My Drive"! + + GdriveAccessBuffer& accessBuf_; +}; + + +class GdriveFileStateAtLocation +{ +public: + GdriveFileStateAtLocation(GdriveFileState& fileState, const std::string& locationRootId) : fileState_(fileState), locationRootId_(locationRootId) {} + + GdriveFileState::PathStatus getPathStatus(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + return fileState_.getPathStatus(locationRootId_, itemPath, followLeafShortcut); //throw SysError + } + + std::string /*itemId*/ getItemId(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + return fileState_.getItemId(locationRootId_, itemPath, followLeafShortcut); //throw SysError + } + + std::pair getFileAttributes(const AfsPath& itemPath, bool followLeafShortcut) //throw SysError + { + return fileState_.getFileAttributes(locationRootId_, itemPath, followLeafShortcut); //throw SysError + } + + GdriveFileState& all() { return fileState_; } + +private: + GdriveFileState& fileState_; + const std::string locationRootId_; +}; + + +class GdriveDrivesBuffer +{ +public: + explicit GdriveDrivesBuffer(GdriveAccessBuffer& accessBuf) : + accessBuf_(accessBuf), + myDrive_(getMyDriveId(accessBuf.getAccessToken()), Zstring() /*sharedDriveName*/, accessBuf) {} //throw SysError + + GdriveDrivesBuffer(MemoryStreamIn& stream, GdriveAccessBuffer& accessBuf) : //throw SysError + accessBuf_(accessBuf), + myDrive_(stream, accessBuf) //throw SysError + { + size_t sharedDrivesCount = readNumber(stream); //SysErrorUnexpectedEos + while (sharedDrivesCount-- != 0) + { + auto fileState = makeSharedRef(stream, accessBuf); //throw SysError + sharedDrives_.emplace(fileState.ref().getDriveId(), fileState); + } + } + + void serialize(MemoryStreamOut& stream) const + { + myDrive_.serialize(stream); + + writeNumber(stream, static_cast(sharedDrives_.size())); + for (const auto& [driveId, fileState] : sharedDrives_) + fileState.ref().serialize(stream); + + //starredFolders_? no, will be fully restored by syncWithGoogle() + } + + std::vector listLocations() //throw SysError + { + if (syncIsDue()) + syncWithGoogle(); //throw SysError + + std::vector locationNames; + + for (const auto& [driveId, fileState] : sharedDrives_) + locationNames.push_back(fileState.ref().getSharedDriveName()); + + for (const StarredFolderDetails& sfd : starredFolders_) + locationNames.push_back(sfd.folderName); + + return locationNames; + } + + std::pair prepareAccess(const Zstring& locationName) //throw SysError + { + //checking for added/renamed/deleted shared drives *every* GDRIVE_SYNC_INTERVAL is needlessly excessive! + // => check 1. once per FFS run + // 2. on drive access error + if (lastSyncTime_ == std::chrono::steady_clock::time_point()) + syncWithGoogle(); //throw SysError + + GdriveFileStateAtLocation fileState = [&] + { + try + { + return getFileState(locationName); //throw SysError + } + catch (SysError&) + { + if (syncIsDue()) + syncWithGoogle(); //throw SysError + + return getFileState(locationName); //throw SysError + } + }(); + + //manage last sync time here so that "lastSyncToken" remains stable while accessing GdriveFileState in the callback + if (fileState.all().syncIsDue()) + fileState.all().syncWithGoogle(); //throw SysError + + return {fileState, fileState.all().registerFileStateDelta()}; + } + +private: + bool syncIsDue() const { return std::chrono::steady_clock::now() >= lastSyncTime_ + GDRIVE_SYNC_INTERVAL; } + + void syncWithGoogle() //throw SysError + { + //run in parallel with getSharedDrives() + auto ftStarredFolders = runAsync([access = accessBuf_.getAccessToken() /*throw SysError*/] { return getStarredFolders(access); /*throw SysError*/ }); + + decltype(sharedDrives_) currentDrives; + + //getSharedDrives() should be fast enough to avoid the unjustified complexity of change notifications: https://freefilesync.org/forum/viewtopic.php?t=7827&start=30#p29712 + for (const auto& [driveId, driveName] : getSharedDrives(accessBuf_.getAccessToken())) //throw SysError + { + auto fileState = [&, &driveId /*clang bug*/= driveId, &driveName /*clang bug*/= driveName] + { + if (auto it = sharedDrives_.find(driveId); + it != sharedDrives_.end()) + { + it->second.ref().setSharedDriveName(driveName); + return it->second; + } + else + return makeSharedRef(driveId, driveName, accessBuf_); //throw SysError + }(); + currentDrives.emplace(driveId, fileState); + } + + starredFolders_ = ftStarredFolders.get(); //throw SysError // + sharedDrives_.swap(currentDrives); //transaction! + lastSyncTime_ = std::chrono::steady_clock::now(); //...(uhm, mostly, except for setSharedDriveName()) + } + + GdriveFileStateAtLocation getFileState(const Zstring& locationName) //throw SysError + { + if (locationName.empty()) + return {myDrive_, myDrive_.getDriveId()}; + + GdriveFileState* fileState = nullptr; + std::string locationRootId; + + for (auto& [driveId, fileStateRef] : sharedDrives_) + if (equalNativePath(fileStateRef.ref().getSharedDriveName(), locationName)) + { + if (fileState) + throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(locationName))); + + fileState = &fileStateRef.ref(); + locationRootId = driveId; + } + + for (const StarredFolderDetails& sfd : starredFolders_) + if (equalNativePath(sfd.folderName, locationName)) + { + if (fileState) + throw SysError(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(locationName))); + + if (sfd.sharedDriveId.empty()) //=> My Drive + fileState = &myDrive_; + else + { + auto it = sharedDrives_.find(sfd.sharedDriveId); + if (it == sharedDrives_.end()) + break; + + fileState = &it->second.ref(); + } + locationRootId = sfd.folderId; + } + + if (!fileState) + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(locationName))); + + return {*fileState, locationRootId}; + } + + GdriveAccessBuffer& accessBuf_; + std::chrono::steady_clock::time_point lastSyncTime_; //... with Google Drive (default: sync is due) + + GdriveFileState myDrive_; + std::unordered_map> sharedDrives_; + + std::vector starredFolders_; +}; + +//========================================================================================== +//========================================================================================== + +class GdrivePersistentSessions +{ +public: + explicit GdrivePersistentSessions(const Zstring& configDirPath) : configDirPath_(configDirPath) + { + onSystemShutdownRegister(onBeforeSystemShutdownCookie_); + } + + void saveActiveSessions() //throw FileError + { + std::vector*> protectedSessions; //pointers remain stable, thanks to std::unordered_map<> + globalSessions_.access([&](GlobalSessions& sessions) + { + for (auto& [accountEmail, protectedSession] : sessions) + protectedSessions.push_back(&protectedSession); + }); + + if (!protectedSessions.empty()) + { + createDirectoryIfMissingRecursion(configDirPath_); //throw FileError + + std::exception_ptr firstError; + + //access each session outside the globalSessions_ lock! + for (Protected* protectedSession : protectedSessions) + protectedSession->access([&](SessionHolder& holder) + { + if (holder.session) + try + { + const Zstring dbFilePath = getDbFilePath(holder.session->accessBuf.ref().getUserEmail()); + saveSession(dbFilePath, *holder.session); //throw FileError + } + catch (FileError&) { if (!firstError) firstError = std::current_exception(); } + }); + + if (firstError) + std::rethrow_exception(firstError); //throw FileError + } + } + + std::string addUserSession(const std::string& gdriveLoginHint, const std::function& updateGui /*throw X*/, int timeoutSec) //throw SysError, X + { + const GdriveAccessInfo accessInfo = gdriveAuthorizeAccess(gdriveLoginHint, updateGui, timeoutSec); //throw SysError, X + + accessUserSession(accessInfo.userInfo.email, timeoutSec, [&](std::optional& userSession) //throw SysError + { + if (userSession) + userSession->accessBuf.ref().update(accessInfo); //redundant? + else + { + const std::shared_ptr timeoutSec2 = std::make_shared(timeoutSec); //context option: valid only for duration of this call! + auto accessBuf = makeSharedRef(accessInfo); + accessBuf.ref().setContextTimeout(timeoutSec2); //[!] used by GdriveDrivesBuffer()! + auto drivesBuf = makeSharedRef(accessBuf.ref()); //throw SysError + userSession = {accessBuf, drivesBuf}; + } + }); + + return accessInfo.userInfo.email; + } + + void removeUserSession(const std::string& accountEmail, int timeoutSec) //throw SysError + { + try + { + accessUserSession(accountEmail, timeoutSec, [&](std::optional& userSession) //throw SysError + { + if (userSession) + gdriveRevokeAccess(userSession->accessBuf.ref().getAccessToken()); //throw SysError + }); + } + catch ([[maybe_unused]] const SysError& e) { assert(false); } //best effort: try to invalidate the access token + //=> expected to fail 1. if offline => not worse than removing FFS via "Uninstall Programs" 2. already revoked 3. if DB is corrupted + + try + { + //start with deleting the DB file (1. maybe it's corrupted? 2. skip unnecessary lazy-load) + const Zstring dbFilePath = getDbFilePath(accountEmail); + try + { + removeFilePlain(dbFilePath); //throw FileError + } + catch (FileError&) + { + if (itemExists(dbFilePath)) //throw FileError + throw; + } + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //file access errors should be further enriched by context info => SysError + + + accessUserSession(accountEmail, timeoutSec, [&](std::optional& userSession) //throw SysError + { + userSession.reset(); + }); + } + + std::vector listAccounts() //throw SysError + { + std::vector emails; + + std::vector*> protectedSessions; //pointers remain stable, thanks to std::unordered_map<> + globalSessions_.access([&](GlobalSessions& sessions) + { + for (auto& [accountEmail, protectedSession] : sessions) + protectedSessions.push_back(&protectedSession); + }); + + //access each session outside the globalSessions_ lock! + for (Protected* protectedSession : protectedSessions) + protectedSession->access([&](SessionHolder& holder) + { + if (holder.session) + emails.push_back(holder.session->accessBuf.ref().getUserEmail()); + }); + + //also include available, but not-yet-loaded sessions + try + { + traverseFolder(configDirPath_, + [&](const FileInfo& fi) { if (endsWith(fi.itemName, Zstr(".db"))) emails.push_back(utfTo(beforeLast(fi.itemName, Zstr('.'), IfNotFoundReturn::none))); }, + [&](const FolderInfo& fi) {}, + [&](const SymlinkInfo& si) {}); //throw FileError + } + catch (FileError&) + { + try + { + if (itemExists(configDirPath_)) //throw FileError + throw; + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //file access errors should be further enriched by context info => SysError + } + + removeDuplicates(emails, LessAsciiNoCase()); + return emails; + } + + std::vector listLocations(const std::string& accountEmail, int timeoutSec) //throw SysError + { + std::vector locationNames; + + accessUserSession(accountEmail, timeoutSec, [&](std::optional& userSession) //throw SysError + { + if (!userSession) + throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo(accountEmail))); + + locationNames = userSession->drivesBuf.ref().listLocations(); //throw SysError + }); + return locationNames; + } + + struct AsyncAccessInfo + { + GdriveAccess access; //don't allow (long-running) web requests while holding the global session lock! + GdriveFileState::FileStateDelta stateDelta; + }; + //perf: amortized fully buffered! + AsyncAccessInfo accessGlobalFileState(const GdriveLogin& login, const std::function& useFileState /*throw X*/) //throw SysError, X + { + GdriveAccess access; + GdriveFileState::FileStateDelta stateDelta; + + accessUserSession(login.email, login.timeoutSec, [&](std::optional& userSession) //throw SysError + { + if (!userSession) + throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo(login.email))); + + access = userSession->accessBuf.ref().getAccessToken(); //throw SysError + auto [fileState, stateDelta2] = userSession->drivesBuf.ref().prepareAccess(login.locationName); //throw SysError + stateDelta = std::move(stateDelta2); + + useFileState(fileState); //throw X + }); + return {access, stateDelta}; + } + +private: + GdrivePersistentSessions (const GdrivePersistentSessions&) = delete; + GdrivePersistentSessions& operator=(const GdrivePersistentSessions&) = delete; + + struct UserSession; + + Zstring getDbFilePath(std::string accountEmail) const + { + for (char& c : accountEmail) + c = asciiToLower(c); + //return appendPath(configDirPath_, utfTo(formatAsHexString(getMd5(utfTo(accountEmail)))) + Zstr(".db")); + return appendPath(configDirPath_, utfTo(accountEmail) + Zstr(".db")); + } + + void accessUserSession(const std::string& accountEmail, int timeoutSec, const std::function& userSession)>& useSession /*throw X*/) //throw SysError, X + { + Protected* protectedSession = nullptr; //pointers remain stable, thanks to std::unordered_map<> + globalSessions_.access([&](GlobalSessions& sessions) { protectedSession = &sessions[accountEmail]; }); + + protectedSession->access([&](SessionHolder& holder) + { + if (!holder.dbWasLoaded) //let's NOT load the DB files under the globalSessions_ lock, but the session-specific one! + try + { + holder.session = loadSession(getDbFilePath(accountEmail), timeoutSec); //throw SysError + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //GdrivePersistentSessions errors should be further enriched with context info => SysError + holder.dbWasLoaded = true; + + const std::shared_ptr timeoutSec2 = std::make_shared(timeoutSec); //context option: valid only for duration of this call! + if (holder.session) + holder.session->accessBuf.ref().setContextTimeout(timeoutSec2); + + useSession(holder.session); //throw X + }); + } + + static void saveSession(const Zstring& dbFilePath, const UserSession& userSession) //throw FileError + { + MemoryStreamOut streamOut; + writeArray(streamOut, DB_FILE_DESCR, sizeof(DB_FILE_DESCR)); + writeNumber(streamOut, DB_FILE_VERSION); + + MemoryStreamOut streamOutBody; + userSession.accessBuf.ref().serialize(streamOutBody); + userSession.drivesBuf.ref().serialize(streamOutBody); + + try + { + streamOut.ref() += compress(streamOutBody.ref(), 3 /*best compression level: see db_file.cpp*/); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(dbFilePath)), e.toString()); } + + setFileContent(dbFilePath, streamOut.ref(), nullptr /*notifyUnbufferedIO*/); //throw FileError + } + + static std::optional loadSession(const Zstring& dbFilePath, int timeoutSec) //throw FileError + { + std::string byteStream; + try + { + byteStream = getFileContent(dbFilePath, nullptr /*notifyUnbufferedIO*/); //throw FileError + } + catch (FileError&) + { + if (itemExists(dbFilePath)) //throw FileError + throw; + + return std::nullopt; + } + + try + { + MemoryStreamIn streamIn(byteStream); + //-------- file format header -------- + char tmp[sizeof(DB_FILE_DESCR)] = {}; + readArray(streamIn, &tmp, sizeof(tmp)); //throw SysErrorUnexpectedEos + + const std::shared_ptr timeoutSec2 = std::make_shared(timeoutSec); //context option: valid only for duration of this call! + + //TODO: remove migration code at some time! 2020-07-03 + if (!std::equal(std::begin(tmp), std::end(tmp), std::begin(DB_FILE_DESCR))) + { + const std::string& uncompressedStream = decompress(byteStream); //throw SysError + MemoryStreamIn streamIn2(uncompressedStream); + //-------- file format header -------- + const char DB_FILE_DESCR_OLD[] = "FreeFileSync: Google Drive Database"; + char tmp2[sizeof(DB_FILE_DESCR_OLD)] = {}; + readArray(streamIn2, &tmp2, sizeof(tmp2)); //throw SysErrorUnexpectedEos + + if (!std::equal(std::begin(tmp2), std::end(tmp2), std::begin(DB_FILE_DESCR_OLD))) + throw SysError(_("File content is corrupted.") + L" (invalid header)"); + + const int version = readNumber(streamIn2); //throw SysErrorUnexpectedEos + if (version != 1 && //TODO: remove migration code at some time! 2019-12-05 + version != 2 && //TODO: remove migration code at some time! 2020-06-11 + version != 3) //TODO: remove migration code at some time! 2020-07-03 + throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo(version))); + + //version 1 + 2: fully discard old state due to missing "ownedByMe" attribute + shortcut support + //version 3: fully discard old state due to revamped shared drive handling + auto accessBuf = makeSharedRef(streamIn2); //throw SysError + accessBuf.ref().setContextTimeout(timeoutSec2); //not used by GdriveDrivesBuffer(), but let's be consistent + auto drivesBuf = makeSharedRef(accessBuf.ref()); //throw SysError + return UserSession{accessBuf, drivesBuf}; + } + else + { + if (!std::equal(std::begin(tmp), std::end(tmp), std::begin(DB_FILE_DESCR))) + throw SysError(_("File content is corrupted.") + L" (invalid header)"); + + const int version = readNumber(streamIn); //throw SysErrorUnexpectedEos + if (version != 4 && + version != DB_FILE_VERSION) + throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo(version))); + + const std::string& uncompressedStream = decompress(makeStringView(byteStream.begin() + streamIn.pos(), byteStream.end())); //throw SysError + MemoryStreamIn streamInBody(uncompressedStream); + + auto accessBuf = makeSharedRef(streamInBody); //throw SysError + accessBuf.ref().setContextTimeout(timeoutSec2); //not used by GdriveDrivesBuffer(), but let's be consistent + auto drivesBuf = [&] + { + //TODO: remove migration code at some time! 2021-05-15 + if (version <= 4) //fully discard old state due to revamped shared drive handling + return makeSharedRef(accessBuf.ref()); //throw SysError + else + return makeSharedRef(streamInBody, accessBuf.ref()); //throw SysError + }(); + + return UserSession{accessBuf, drivesBuf}; + } + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read database file %x."), L"%x", fmtPath(dbFilePath)), e.toString()); + } + } + + struct UserSession + { + SharedRef accessBuf; + SharedRef drivesBuf; + }; + + struct SessionHolder + { + bool dbWasLoaded = false; + std::optional session; + }; + using GlobalSessions = std::unordered_map, StringHashAsciiNoCase, StringEqualAsciiNoCase>; + + Protected globalSessions_; + const Zstring configDirPath_; + + const SharedRef> onBeforeSystemShutdownCookie_ = makeSharedRef>([this] + { + try //let's not lose Google Drive data due to unexpected system shutdown: + { saveActiveSessions(); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + }); +}; +//========================================================================================== +constinit Global globalGdriveSessions; +//========================================================================================== + +GdrivePersistentSessions::AsyncAccessInfo accessGlobalFileState(const GdriveLogin& login, const std::function& useFileState /*throw X*/) //throw SysError, X +{ + if (const std::shared_ptr gps = globalGdriveSessions.get()) + return gps->accessGlobalFileState(login, useFileState); //throw SysError, X + + throw SysError(formatSystemError("accessGlobalFileState", L"", L"Function call not allowed during init/shutdown.")); +} + +//========================================================================================== +//========================================================================================== + +struct GetDirDetails +{ + GetDirDetails(const GdrivePath& folderPath) : folderPath_(folderPath) {} + + struct Result + { + std::vector childItems; + GdrivePath folderPath; + }; + Result operator()() const + { + try + { + std::string folderId; + std::optional> childItemsBuf; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(folderPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const auto& [itemId, itemDetails] = fileState.getFileAttributes(folderPath_.itemPath, true /*followLeafShortcut*/); //throw SysError + + if (itemDetails.type != GdriveItemType::folder) //check(!) or readFolderContent() will return empty (without failing!) + throw SysError(replaceCpy(L"%x is not a directory.", L"%x", fmtPath(utfTo(itemDetails.itemName)))); + + folderId = itemId; + childItemsBuf = fileState.all().tryGetBufferedFolderContent(folderId); + }); + + if (!childItemsBuf) + { + childItemsBuf = readFolderContent(folderId, aai.access); //throw SysError + + //buffer new file state ASAP => make sure accessGlobalFileState() has amortized constant access (despite the occasional internal readFolderContent() on non-leaf folders) + accessGlobalFileState(folderPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyFolderContent(aai.stateDelta, folderId, *childItemsBuf); + }); + } + + for (const GdriveItem& item : *childItemsBuf) + if (item.details.itemName.empty()) + throw SysError(L"Folder contains an item without name."); //mostly an issue for FFS's folder traversal, but NOT for globalGdriveSessions! + + return {std::move(*childItemsBuf), folderPath_}; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getGdriveDisplayPath(folderPath_))), e.toString()); } + } + +private: + GdrivePath folderPath_; +}; + + +struct GetShortcutTargetDetails +{ + GetShortcutTargetDetails(const GdrivePath& shortcutPath, const GdriveItemDetails& shortcutDetails) : shortcutPath_(shortcutPath), shortcutDetails_(shortcutDetails) {} + + struct Result + { + GdriveItemDetails target; + GdriveItemDetails shortcut; + GdrivePath shortcutPath; + }; + Result operator()() const + { + try + { + std::optional targetDetailsBuf; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(shortcutPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + targetDetailsBuf = fileState.all().tryGetBufferedItemDetails(shortcutDetails_.targetId); + }); + if (!targetDetailsBuf) + { + targetDetailsBuf = getItemDetails(shortcutDetails_.targetId, aai.access); //throw SysError + + //buffer new file state ASAP + accessGlobalFileState(shortcutPath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyItemUpdated(aai.stateDelta, {shortcutDetails_.targetId, *targetDetailsBuf}); + }); + } + + assert(targetDetailsBuf->targetId.empty()); + if (targetDetailsBuf->type == GdriveItemType::shortcut) //should never happen: creating shortcuts to shortcuts fails with "Internal Error" + throw SysError(L"Google Drive Shortcut points to another Shortcut."); + + return {std::move(*targetDetailsBuf), shortcutDetails_, shortcutPath_}; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(getGdriveDisplayPath(shortcutPath_))), e.toString()); } + } + +private: + GdrivePath shortcutPath_; + GdriveItemDetails shortcutDetails_; +}; + + +class SingleFolderTraverser +{ +public: + SingleFolderTraverser(const GdriveLogin& gdriveLogin, const std::vector>>& workload /*throw X*/) : + gdriveLogin_(gdriveLogin), workload_(workload) + { + while (!workload_.empty()) + { + auto wi = std::move(workload_. back()); //yes, no strong exception guarantee (std::bad_alloc) + /**/ workload_.pop_back(); // + const auto& [folderPath, cb] = wi; + + tryReportingDirError([&] //throw X + { + traverseWithException(folderPath, *cb); //throw FileError, X + }, *cb); + } + } + +private: + SingleFolderTraverser (const SingleFolderTraverser&) = delete; + SingleFolderTraverser& operator=(const SingleFolderTraverser&) = delete; + + void traverseWithException(const AfsPath& folderPath, AFS::TraverserCallback& cb) //throw FileError, X + { + const std::vector& childItems = GetDirDetails({gdriveLogin_, folderPath})().childItems; //throw FileError + + for (const GdriveItem& item : childItems) + { + const Zstring itemName = utfTo(item.details.itemName); + + switch (item.details.type) + { + case GdriveItemType::file: + cb.onFile({itemName, item.details.fileSize, item.details.modTime, getGdriveFilePrint(item.itemId), false /*isFollowedSymlink*/}); //throw X + break; + + case GdriveItemType::folder: + if (std::shared_ptr cbSub = cb.onFolder({itemName, false /*isFollowedSymlink*/})) //throw X + { + const AfsPath afsItemPath(appendPath(folderPath.value, itemName)); + workload_.push_back({afsItemPath, std::move(cbSub)}); + } + break; + + case GdriveItemType::shortcut: + switch (cb.onSymlink({itemName, item.details.modTime})) //throw X + { + case AFS::TraverserCallback::HandleLink::follow: + { + const AfsPath afsItemPath(appendPath(folderPath.value, itemName)); + + GdriveItemDetails targetDetails = {}; + if (!tryReportingItemError([&] //throw X + { + targetDetails = GetShortcutTargetDetails({gdriveLogin_, afsItemPath}, item.details)().target; //throw FileError + }, cb, itemName)) + continue; + + if (targetDetails.type == GdriveItemType::folder) + { + if (std::shared_ptr cbSub = cb.onFolder({itemName, true /*isFollowedSymlink*/})) //throw X + workload_.push_back({afsItemPath, std::move(cbSub)}); + } + else //a file or named pipe, etc. + cb.onFile({itemName, targetDetails.fileSize, targetDetails.modTime, getGdriveFilePrint(item.details.targetId), true /*isFollowedSymlink*/}); //throw X + } + break; + + case AFS::TraverserCallback::HandleLink::skip: + break; + } + break; + } + } + } + + const GdriveLogin gdriveLogin_; + std::vector>> workload_; +}; + + +void gdriveTraverseFolderRecursive(const GdriveLogin& gdriveLogin, const std::vector>>& workload /*throw X*/, size_t) //throw X +{ + SingleFolderTraverser dummy(gdriveLogin, workload); //throw X +} +//========================================================================================== +//========================================================================================== + +struct InputStreamGdrive : public AFS::InputStream +{ + explicit InputStreamGdrive(const GdrivePath& gdrivePath) : + gdrivePath_(gdrivePath) + { + worker_ = InterruptibleThread([asyncStreamOut = this->asyncStreamIn_, gdrivePath] + { + setCurrentThreadName(Zstr("Istream ") + utfTo(getGdriveDisplayPath(gdrivePath))); + try + { + GdriveAccess access; + std::string fileId; + try + { + access = accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileId = fileState.getItemId(gdrivePath.itemPath, true /*followLeafShortcut*/); //throw SysError + }).access; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString()); } + + try + { + auto writeBlock = [&](const void* buffer, size_t bytesToWrite) + { + asyncStreamOut->write(buffer, bytesToWrite); //throw ThreadStopRequest + }; + gdriveDownloadFile(fileId, writeBlock, access); //throw SysError, ThreadStopRequest + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString()); } + + asyncStreamOut->closeStream(); + } + catch (FileError&) { asyncStreamOut->setWriteError(std::current_exception()); } //let ThreadStopRequest pass through! + }); + } + + ~InputStreamGdrive() + { + asyncStreamIn_->setReadError(std::make_exception_ptr(ThreadStopRequest())); + } + + size_t getBlockSize() override { return GDRIVE_BLOCK_SIZE_DOWNLOAD; } //throw (FileError) + + //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + size_t tryRead(void* buffer, size_t bytesToRead, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, (ErrorFileLocked), X + { + const size_t bytesRead = asyncStreamIn_->tryRead(buffer, bytesToRead); //throw FileError + reportBytesProcessed(notifyUnbufferedIO); //throw X + return bytesRead; + //no need for asyncStreamIn_->checkWriteErrors(): once end of stream is reached, asyncStreamOut->closeStream() was called => no errors occured + } + + std::optional tryGetAttributesFast() override //throw FileError + { + AFS::StreamAttributes attr = {}; + try + { + accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const auto& [itemId, itemDetails] = fileState.getFileAttributes(gdrivePath_.itemPath, true /*followLeafShortcut*/); //throw SysError + attr.modTime = itemDetails.modTime; + attr.fileSize = itemDetails.fileSize; + attr.filePrint = getGdriveFilePrint(itemId); + }); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath_))), e.toString()); } + return std::move(attr); //[!] + } + +private: + void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X + { + const int64_t bytesDelta = makeSigned(asyncStreamIn_->getTotalBytesWritten()) - totalBytesReported_; + totalBytesReported_ += bytesDelta; + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X + } + + const GdrivePath gdrivePath_; + int64_t totalBytesReported_ = 0; + std::shared_ptr asyncStreamIn_ = std::make_shared(GDRIVE_STREAM_BUFFER_SIZE); + InterruptibleThread worker_; +}; + +//========================================================================================== + +//already existing: 1. fails or 2. creates duplicate +struct OutputStreamGdrive : public AFS::OutputStreamImpl +{ + OutputStreamGdrive(const GdrivePath& gdrivePath, + std::optional /*streamSize*/, + std::optional modTime, + std::unique_ptr&& pal) : //throw SysError + gdrivePath_(gdrivePath) + { + std::promise promFilePrint; + futFilePrint_ = promFilePrint.get_future(); + + //CAVEAT: if file is already existing, OutputStreamGdrive *constructor* must fail, not OutputStreamGdrive::write(), + // otherwise ~OutputStreamImpl() will delete the already existing file! => don't check asynchronously! + const Zstring fileName = AFS::getItemName(gdrivePath.itemPath); + std::string parentId; + /*const*/ GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus& ps = fileState.getPathStatus(gdrivePath.itemPath, false /*followLeafShortcut*/); //throw SysError + if (ps.relPath.empty()) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(fileName))); + + if (ps.relPath.size() > 1) //parent folder missing + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + + parentId = ps.existingItemId; + }); + + worker_ = InterruptibleThread([gdrivePath, modTime, fileName, asyncStreamIn = this->asyncStreamOut_, + pFilePrint = std::move(promFilePrint), + parentId = std::move(parentId), + aai = std::move(aai), + pal = std::move(pal)]() mutable + { + assert(pal); //bind life time to worker thread! + setCurrentThreadName(Zstr("Ostream ") + utfTo(getGdriveDisplayPath(gdrivePath))); + try + { + auto tryReadBlock = [&](void* buffer, size_t bytesToRead) //may return short, only 0 means EOF! + { + return asyncStreamIn->tryRead(buffer, bytesToRead); //throw ThreadStopRequest + }; + //for whatever reason, gdriveUploadFile() is slightly faster than gdriveUploadSmallFile()! despite its two roundtrips! even when file sizes are 0! + //=> 1. issue likely on Google's side => 2. persists even after having fixed "Expect: 100-continue" + const std::string fileIdNew = //streamSize && *streamSize < 5 * 1024 * 1024 ? + //gdriveUploadSmallFile(fileName, parentId, *streamSize, modTime, readBlock, aai.access) : //throw SysError, ThreadStopRequest + gdriveUploadFile (fileName, parentId, modTime, tryReadBlock, aai.access); //throw SysError, ThreadStopRequest + assert(asyncStreamIn->getTotalBytesRead() == asyncStreamIn->getTotalBytesWritten()); + //already existing: creates duplicate + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + GdriveItem newFileItem + { + .itemId = fileIdNew, + .details{ + .itemName = fileName, + .fileSize = asyncStreamIn->getTotalBytesRead(), + .type = GdriveItemType::file, + .owner = FileOwner::me, + } + }; + if (modTime) //else: whatever modTime Google Drive selects will be notified after GDRIVE_SYNC_INTERVAL + newFileItem.details.modTime = *modTime; + newFileItem.details.parentIds.push_back(parentId); + + accessGlobalFileState(gdrivePath.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyItemCreated(aai.stateDelta, newFileItem); + }); + + pFilePrint.set_value(getGdriveFilePrint(fileIdNew)); + } + catch (const SysError& e) + { + FileError fe(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath))), e.toString()); + const std::exception_ptr exptr = std::make_exception_ptr(std::move(fe)); + asyncStreamIn->setReadError(exptr); //set both! + pFilePrint.set_exception(exptr); // + } + //let ThreadStopRequest pass through! + }); + } + + ~OutputStreamGdrive() + { + if (asyncStreamOut_) //=> cleanup non-finalized output file + { + asyncStreamOut_->setWriteError(std::make_exception_ptr(ThreadStopRequest())); + worker_.join(); + + try //see removeFilePlain() + { + std::optional itemId; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus ps = fileState.getPathStatus(gdrivePath_.itemPath, false /*followLeafShortcut*/); //throw SysError + if (ps.relPath.empty()) + itemId = ps.existingItemId; + }); + if (itemId) + { + gdriveDeleteItem(*itemId, aai.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(gdrivePath_.gdriveLogin, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyItemDeleted(aai.stateDelta, *itemId); + }); + } + } + catch (const SysError& e) + { + logExtraError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getGdriveDisplayPath(gdrivePath_))) + L"\n\n" + e.toString()); + } + } + } + + size_t getBlockSize() override { return GDRIVE_BLOCK_SIZE_UPLOAD; } //throw (FileError) + + size_t tryWrite(const void* buffer, size_t bytesToWrite, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0 + { + const size_t bytesWritten = asyncStreamOut_->tryWrite(buffer, bytesToWrite); //throw FileError + reportBytesProcessed(notifyUnbufferedIO); //throw X + return bytesWritten; + } + + AFS::FinalizeResult finalize(const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X + { + if (!asyncStreamOut_) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + asyncStreamOut_->closeStream(); + + while (futFilePrint_.wait_for(std::chrono::milliseconds(25)) == std::future_status::timeout) + reportBytesProcessed(notifyUnbufferedIO); //throw X + reportBytesProcessed(notifyUnbufferedIO); //[!] once more, now that *all* bytes were written + + AFS::FinalizeResult result; + assert(isReady(futFilePrint_)); + result.filePrint = futFilePrint_.get(); //throw FileError + + //asyncStreamOut_->checkReadErrors(); //throw FileError -> not needed after *successful* upload + asyncStreamOut_.reset(); //output finalized => no more exceptions from here on! + //-------------------------------------------------------------------- + + //result.errorModTime -> already (successfully) set during file creation + return result; + } + +private: + void reportBytesProcessed(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw X + { + const int64_t bytesDelta = makeSigned(asyncStreamOut_->getTotalBytesRead()) - totalBytesReported_; + totalBytesReported_ += bytesDelta; + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesDelta); //throw X + } + + const GdrivePath gdrivePath_; + int64_t totalBytesReported_ = 0; + std::shared_ptr asyncStreamOut_ = std::make_shared(GDRIVE_STREAM_BUFFER_SIZE); + InterruptibleThread worker_; + std::future futFilePrint_; +}; + +//========================================================================================== + +class GdriveFileSystem : public AbstractFileSystem +{ +public: + explicit GdriveFileSystem(const GdriveLogin& gdriveLogin) : gdriveLogin_(gdriveLogin) {} + + const GdriveLogin& getGdriveLogin() const { return gdriveLogin_; } + + Zstring getFolderUrl(const AfsPath& folderPath) const //throw FileError + { + try + { + GdriveFileState::PathStatus ps; + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + ps = fileState.getPathStatus(folderPath, true /*followLeafShortcut*/); //throw SysError + }); + + if (!ps.relPath.empty()) + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + + if (ps.existingType != GdriveItemType::folder) + throw SysError(replaceCpy(L"%x is not a folder.", L"%x", fmtPath(getItemName(folderPath)))); + + return Zstr("https://drive.google.com/drive/folders/") + utfTo(ps.existingItemId); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); } + } + +private: + GdrivePath getGdrivePath(const AfsPath& itemPath) const { return {gdriveLogin_, itemPath}; } + + GdriveRawPath getGdriveRawPath(const AfsPath& itemPath) const //throw SysError + { + const std::optional parentPath = getParentPath(itemPath); + if (!parentPath) + throw SysError(L"Item is device root"); + + std::string parentId; + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + parentId = fileState.getItemId(*parentPath, true /*followLeafShortcut*/); //throw SysError + }); + return { std::move(parentId), getItemName(itemPath)}; + } + + Zstring getInitPathPhrase(const AfsPath& itemPath) const override { return concatenateGdriveFolderPathPhrase(getGdrivePath(itemPath)); } + + std::vector getPathPhraseAliases(const AfsPath& itemPath) const override { return {getInitPathPhrase(itemPath)}; } + + std::wstring getDisplayPath(const AfsPath& itemPath) const override { return getGdriveDisplayPath(getGdrivePath(itemPath)); } + + bool isNullFileSystem() const override { return gdriveLogin_.email.empty(); } + + std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const override + { + const GdriveLogin& lhs = gdriveLogin_; + const GdriveLogin& rhs = static_cast(afsRhs).gdriveLogin_; + + if (const std::weak_ordering cmp = compareAsciiNoCase(lhs.email, rhs.email); + cmp != std::weak_ordering::equivalent) + return cmp; + + return compareNativePath(lhs.locationName, rhs.locationName); + } + + //---------------------------------------------------------------------------------------------------------------- + ItemType getItemType(const AfsPath& itemPath) const override //throw FileError + { + try + { + GdriveFileState::PathStatus ps; + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError + }); + if (ps.relPath.empty()) + switch (ps.existingType) + { + case GdriveItemType::file: return ItemType::file; + case GdriveItemType::folder: return ItemType::folder; + case GdriveItemType::shortcut: return ItemType::symlink; + } + + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(Zstring(ps.relPath.front())))); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); } + } + + std::optional getItemTypeIfExists(const AfsPath& itemPath) const override //throw FileError + { + try + { + GdriveFileState::PathStatus ps; + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError + }); + if (ps.relPath.empty()) + switch (ps.existingType) + { + case GdriveItemType::file: return ItemType::file; + case GdriveItemType::folder: return ItemType::folder; + case GdriveItemType::shortcut: return ItemType::symlink; + } + return std::nullopt; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); } + } + + //---------------------------------------------------------------------------------------------------------------- + //already existing: 1. fails or 2. creates duplicate (unlikely) + void createFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try + { + //avoid duplicate Google Drive item creation by multiple threads + PathAccessLock pal(getGdriveRawPath(folderPath), PathBlockType::otherWait); //throw SysError + + const Zstring folderName = getItemName(folderPath); + std::string parentId; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus& ps = fileState.getPathStatus(folderPath, false /*followLeafShortcut*/); //throw SysError + if (ps.relPath.empty()) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(folderName))); + + if (ps.relPath.size() > 1) //parent folder missing + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + + parentId = ps.existingItemId; + }); + + //already existing: creates duplicate + const std::string folderIdNew = gdriveCreateFolderPlain(folderName, parentId, aai.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyFolderCreated(aai.stateDelta, folderIdNew, folderName, parentId); + }); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); } + } + + void removeItemPlainImpl(const AfsPath& itemPath, std::optional expectedType, bool permanent /*...or move to trash*/, bool failIfNotExist) const //throw SysError + { + const std::optional parentPath = getParentPath(itemPath); + if (!parentPath) throw SysError(L"Item is device root"); + + std::string itemId; + std::optional parentIdToUnlink; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus ps = fileState.getPathStatus(itemPath, false /*followLeafShortcut*/); //throw SysError + if (!ps.relPath.empty()) + { + if (failIfNotExist) + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + else + return; + } + + GdriveItemDetails itemDetails; + std::tie(itemId, itemDetails) = fileState.getFileAttributes(itemPath, false /*followLeafShortcut*/); //throw SysError + assert(std::find(itemDetails.parentIds.begin(), itemDetails.parentIds.end(), fileState.getItemId(*parentPath, true /*followLeafShortcut*/)) != itemDetails.parentIds.end()); + + if (expectedType && itemDetails.type != *expectedType) + switch (*expectedType) + { + case GdriveItemType::file: throw SysError(L"Item is not a file"); + case GdriveItemType::folder: throw SysError(L"Item is not a folder"); + case GdriveItemType::shortcut: throw SysError(L"Item is not a shortcut"); + } + + //hard-link handling applies to shared files as well: 1. it's the right thing (TM) 2. if we're not the owner: deleting would fail + if (itemDetails.parentIds.size() > 1 || itemDetails.owner == FileOwner::other) //FileOwner::other behaves like a followed symlink! i.e. vanishes if owner deletes it! + parentIdToUnlink = fileState.getItemId(*parentPath, true /*followLeafShortcut*/); //throw SysError + }); + if (itemId.empty()) + return; + + if (parentIdToUnlink) + { + gdriveUnlinkParent(itemId, *parentIdToUnlink, aai.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyParentRemoved(aai.stateDelta, itemId, *parentIdToUnlink); + }); + } + else + { + if (permanent) + gdriveDeleteItem(itemId, aai.access); //throw SysError + else + gdriveMoveToTrash(itemId, aai.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyItemDeleted(aai.stateDelta, itemId); + }); + } + } + + void removeFilePlain(const AfsPath& filePath) const override //throw FileError + { + try { removeItemPlainImpl(filePath, GdriveItemType::file, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); } + } + + void removeSymlinkPlain(const AfsPath& linkPath) const override //throw FileError + { + try { removeItemPlainImpl(linkPath, GdriveItemType::shortcut, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete symbolic link %x."), L"%x", fmtPath(getDisplayPath(linkPath))), e.toString()); } + } + + void removeFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try { removeItemPlainImpl(folderPath, GdriveItemType::folder, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); } + } + + void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, + const std::function& onBeforeSymlinkDeletion/*throw X*/, + const std::function& onBeforeFolderDeletion /*throw X*/) const override + { + if (onBeforeFolderDeletion) onBeforeFolderDeletion(getDisplayPath(folderPath)); //throw X + + try { removeItemPlainImpl(folderPath, GdriveItemType::folder, true /*permanent*/, false /*failIfNotExist*/); /*throw SysError*/ } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); } + } + + //---------------------------------------------------------------------------------------------------------------- + AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const override //throw FileError + { + //this function doesn't make sense for Google Drive: Shortcuts do not refer by path, but ID! + //even if it were possible to determine a path, doing anything with the target file (e.g. delete + recreate) would break other Shortcuts! + throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getDisplayPath(linkPath))), _("Operation not supported by device.")); + } + + bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const override //throw FileError + { + auto getTargetId = [](const GdriveFileSystem& gdriveFs, const AfsPath& linkPath) + { + try + { + std::string targetId; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveFs.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveItemDetails& itemDetails = fileState.getFileAttributes(linkPath, false /*followLeafShortcut*/).second; //throw SysError + if (itemDetails.type != GdriveItemType::shortcut) + throw SysError(L"Not a Google Drive Shortcut."); + + targetId = itemDetails.targetId; + }); + return targetId; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(gdriveFs.getDisplayPath(linkPath))), e.toString()); } + }; + + return getTargetId(*this, linkPathL) == getTargetId(static_cast(linkPathR.afsDevice.ref()), linkPathR.afsPath); + } + + //---------------------------------------------------------------------------------------------------------------- + + //return value always bound: + std::unique_ptr getInputStream(const AfsPath& filePath) const override //throw FileError, (ErrorFileLocked) + { + return std::make_unique(getGdrivePath(filePath)); + } + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: 1. fails or 2. creates duplicate (unlikely) + std::unique_ptr getOutputStream(const AfsPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) const override + { + try + { + //avoid duplicate item creation by multiple threads + auto pal = std::make_unique(getGdriveRawPath(filePath), PathBlockType::otherFail); //throw SysError + //don't block during a potentially long-running file upload! + + //already existing: 1. fails or 2. creates duplicate + return std::make_unique(getGdrivePath(filePath), streamSize, modTime, std::move(pal)); //throw SysError + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); + } + } + + //---------------------------------------------------------------------------------------------------------------- + void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const override + { + gdriveTraverseFolderRecursive(gdriveLogin_, workload, parallelOps); //throw X + } + //---------------------------------------------------------------------------------------------------------------- + + //symlink handling: follow + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: 1. fails or 2. creates duplicate (unlikely) + FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, (ErrorFileLocked), (X) + const AbstractPath& targetPath, bool copyFilePermissions, const IoCallback& notifyUnbufferedIO /*throw X*/) const override + { + //no native Google Drive file copy => use stream-based file copy: + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + + const GdriveFileSystem& fsTarget = static_cast(targetPath.afsDevice.ref()); + + if (!equalAsciiNoCase(gdriveLogin_.email, fsTarget.gdriveLogin_.email)) + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: 1. fails or 2. creates duplicate (unlikely) + return copyFileAsStream(sourcePath, attrSource, targetPath, notifyUnbufferedIO); //throw FileError, (ErrorFileLocked), X + //else: copying files within account works, e.g. between My Drive <-> shared drives + + try + { + //avoid duplicate Google Drive item creation by multiple threads (blocking is okay: gdriveCopyFile() should complete instantly!) + PathAccessLock pal(fsTarget.getGdriveRawPath(targetPath.afsPath), PathBlockType::otherWait); //throw SysError + + const Zstring itemNameNew = getItemName(targetPath); + std::string itemIdSrc; + GdriveItemDetails itemDetailsSrc; + /*const GdrivePersistentSessions::AsyncAccessInfo aaiSrc =*/ accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + std::tie(itemIdSrc, itemDetailsSrc) = fileState.getFileAttributes(sourcePath, true /*followLeafShortcut*/); //throw SysError + + assert(itemDetailsSrc.type == GdriveItemType::file); //Google Drive *should* fail trying to copy folder: "This file cannot be copied by the user." + if (itemDetailsSrc.type != GdriveItemType::file) //=> don't trust + improve error message + throw SysError(replaceCpy(L"%x is not a file.", L"%x", fmtPath(getItemName(sourcePath)))); + }); + + std::string parentIdTrg; + const GdrivePersistentSessions::AsyncAccessInfo aaiTrg = accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus psTo = fileState.getPathStatus(targetPath.afsPath, false /*followLeafShortcut*/); //throw SysError + if (psTo.relPath.empty()) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(itemNameNew))); + + if (psTo.relPath.size() > 1) //parent folder missing + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(psTo.relPath.front()))); + + parentIdTrg = psTo.existingItemId; + }); + + //already existing: creates duplicate + const std::string fileIdTrg = gdriveCopyFile(itemIdSrc, parentIdTrg, itemNameNew, itemDetailsSrc.modTime, aaiTrg.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveItem newFileItem + { + .itemId = fileIdTrg, + .details{ + .itemName = itemNameNew, + .fileSize = itemDetailsSrc.fileSize, + .modTime = itemDetailsSrc.modTime, + .type = GdriveItemType::file, + .owner = fileState.all().getSharedDriveName().empty() ? FileOwner::me : FileOwner::none, + .parentIds{parentIdTrg}, + } + }; + fileState.all().notifyItemCreated(aaiTrg.stateDelta, newFileItem); + }); + + return + { + .fileSize = itemDetailsSrc.fileSize, + .modTime = itemDetailsSrc.modTime, + .sourceFilePrint = getGdriveFilePrint(itemIdSrc), + .targetFilePrint = getGdriveFilePrint(fileIdTrg), + /*.errorModTime = */ + }; + } + catch (const SysError& e) + { + throw FileError(replaceCpy(replaceCpy(_("Cannot copy file %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), e.toString()); + } + } + + //symlink handling: follow + //already existing: fail + void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + //already existing: 1. fails or 2. creates duplicate (unlikely) + AFS::createFolderPlain(targetPath); //throw FileError + + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + } + + //already existing: fail + void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + try + { + std::string targetId; + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveItemDetails& itemDetails = fileState.getFileAttributes(sourcePath, false /*followLeafShortcut*/).second; //throw SysError + if (itemDetails.type != GdriveItemType::shortcut) + throw SysError(L"Not a Google Drive Shortcut."); + + targetId = itemDetails.targetId; + }); + + const GdriveFileSystem& fsTarget = static_cast(targetPath.afsDevice.ref()); + + //avoid duplicate Google Drive item creation by multiple threads + PathAccessLock pal(fsTarget.getGdriveRawPath(targetPath.afsPath), PathBlockType::otherWait); //throw SysError + + const Zstring shortcutName = getItemName(targetPath.afsPath); + std::string parentId; + const GdrivePersistentSessions::AsyncAccessInfo aaiTrg = accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + const GdriveFileState::PathStatus& ps = fileState.getPathStatus(targetPath.afsPath, false /*followLeafShortcut*/); //throw SysError + if (ps.relPath.empty()) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(shortcutName))); + + if (ps.relPath.size() > 1) //parent folder missing + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(ps.relPath.front()))); + + parentId = ps.existingItemId; + }); + + //already existing: creates duplicate + const std::string shortcutIdNew = gdriveCreateShortcutPlain(shortcutName, parentId, targetId, aaiTrg.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(fsTarget.gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyShortcutCreated(aaiTrg.stateDelta, shortcutIdNew, shortcutName, parentId, targetId); + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), e.toString()); + } + } + + //already existing: undefined behavior! (e.g. fail/overwrite) + //=> actual behavior: 1. fails or 2. creates duplicate (unlikely) + void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const override //throw FileError, ErrorMoveUnsupported + { + if (compareDeviceSameAfsType(pathTo.afsDevice.ref()) != std::weak_ordering::equivalent) + throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices.")); + //note: moving files within account works, e.g. between My Drive <-> shared drives + // BUT: not supported by our model with separate GdriveFileStates; e.g. how to handle complexity of a moved folder (tree)? + try + { + const GdriveFileSystem& fsTarget = static_cast(pathTo.afsDevice.ref()); + + //avoid duplicate Google Drive item creation by multiple threads + PathAccessLock pal(fsTarget.getGdriveRawPath(pathTo.afsPath), PathBlockType::otherWait); //throw SysError + + const Zstring itemNameOld = getItemName(pathFrom); + const Zstring itemNameNew = getItemName(pathTo); + const std::optional parentPathFrom = getParentPath(pathFrom); + const std::optional parentPathTo = getParentPath(pathTo.afsPath); + if (!parentPathFrom) throw SysError(L"Source is device root"); + if (!parentPathTo ) throw SysError(L"Target is device root"); + + std::string itemId; + GdriveItemDetails itemDetails; + std::string parentIdFrom; + std::string parentIdTo; + const GdrivePersistentSessions::AsyncAccessInfo aai = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + std::tie(itemId, itemDetails) = fileState.getFileAttributes(pathFrom, false /*followLeafShortcut*/); //throw SysError + + parentIdFrom = fileState.getItemId(*parentPathFrom, true /*followLeafShortcut*/); //throw SysError + + const GdriveFileState::PathStatus psTo = fileState.getPathStatus(pathTo.afsPath, false /*followLeafShortcut*/); //throw SysError + + //e.g. changing file name case only => this is not an "already exists" situation! + //also: hardlink referenced by two different paths, the source one will be unlinked + if (psTo.relPath.empty() && psTo.existingItemId == itemId) + parentIdTo = fileState.getItemId(*parentPathTo, true /*followLeafShortcut*/); //throw SysError + else + { + if (psTo.relPath.empty()) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(itemNameNew))); + + if (psTo.relPath.size() > 1) //parent folder missing + throw SysError(replaceCpy(_("%x does not exist."), L"%x", fmtPath(psTo.relPath.front()))); + + parentIdTo = psTo.existingItemId; + } + }); + + if (parentIdFrom == parentIdTo && itemNameOld == itemNameNew) + return; //nothing to do + + //already existing: creates duplicate + gdriveMoveAndRenameItem(itemId, parentIdFrom, parentIdTo, itemNameNew, itemDetails.modTime, aai.access); //throw SysError + + //buffer new file state ASAP (don't wait GDRIVE_SYNC_INTERVAL) + accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) //throw SysError + { + fileState.all().notifyMoveAndRename(aai.stateDelta, itemId, parentIdFrom, parentIdTo, itemNameNew); + }); + } + catch (const SysError& e) { throw FileError(generateMoveErrorMsg(pathFrom, pathTo), e.toString()); } + } + + bool supportsPermissions(const AfsPath& folderPath) const override { return false; } //throw FileError + + //---------------------------------------------------------------------------------------------------------------- + FileIconHolder getFileIcon (const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + ImageHolder getThumbnailImage(const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + + void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const override //throw FileError, (X) + { + try + { + const std::shared_ptr gps = globalGdriveSessions.get(); + if (!gps) + throw SysError(formatSystemError("GdriveFileSystem::authenticateAccess", L"", L"Function call not allowed during init/shutdown.")); + + for (const std::string& accountEmail : gps->listAccounts()) //throw SysError + if (equalAsciiNoCase(accountEmail, gdriveLogin_.email)) + return; + + const bool allowUserInteraction = static_cast(requestPassword); + if (allowUserInteraction) + gps->addUserSession(gdriveLogin_.email /*gdriveLoginHint*/, nullptr /*updateGui*/, gdriveLogin_.timeoutSec); //throw SysError + //error messages will be lost if user cancels in dir_exist_async.h! However: + //The most-likely-to-fail parts (web access) are reported by gdriveAuthorizeAccess() via the browser! + else + throw SysError(replaceCpy(_("Please add a connection to user account %x first."), L"%x", utfTo(gdriveLogin_.email))); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))), e.toString()); } + } + + bool hasNativeTransactionalCopy() const override { return true; } + //---------------------------------------------------------------------------------------------------------------- + + int64_t getFreeDiskSpace(const AfsPath& folderPath) const override //throw FileError, returns < 0 if not available + { + bool onMyDrive = false; + try + { + const GdriveAccess& access = accessGlobalFileState(gdriveLogin_, [&](GdriveFileStateAtLocation& fileState) + { onMyDrive = fileState.all().getSharedDriveName().empty(); }).access; //throw SysError + + if (onMyDrive) + return gdriveGetMyDriveFreeSpace(access); //throw SysError + else + return -1; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine free disk space for %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); } + } + + std::unique_ptr createRecyclerSession(const AfsPath& folderPath) const override //throw FileError, (RecycleBinUnavailable) + { + struct RecycleSessionGdrive : public RecycleSession + { + //fails if item is not existing + void moveToRecycleBin(const AbstractPath& itemPath, const Zstring& logicalRelPath) override { AFS::moveToRecycleBin(itemPath); } //throw FileError, (RecycleBinUnavailable) + void tryCleanup(const std::function& notifyDeletionStatus) override {}; //throw FileError + }; + + return std::make_unique(); + } + + //fails if item is not existing + void moveToRecycleBin(const AfsPath& itemPath) const override //throw FileError, (RecycleBinUnavailable) + { + try + { + removeItemPlainImpl(itemPath, std::nullopt /*expectedType*/, false /*permanent*/, true /*failIfNotExist*/); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to move %x to the recycle bin."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); } + } + + const GdriveLogin gdriveLogin_; +}; +//=========================================================================================================================== + +//expects "clean" input data +Zstring concatenateGdriveFolderPathPhrase(const GdrivePath& gdrivePath) //noexcept +{ + Zstring emailAndDrive = utfTo(gdrivePath.gdriveLogin.email); + if (!gdrivePath.gdriveLogin.locationName.empty()) + emailAndDrive += Zstr(':') + gdrivePath.gdriveLogin.locationName; + + Zstring options; + if (gdrivePath.gdriveLogin.timeoutSec != GdriveLogin().timeoutSec) + options += Zstr("|timeout=") + numberTo(gdrivePath.gdriveLogin.timeoutSec); + + Zstring itemPath; + if (!gdrivePath.itemPath.value.empty()) + itemPath += FILE_NAME_SEPARATOR + gdrivePath.itemPath.value; + + if (endsWith(itemPath, Zstr(' ')) && options.empty()) //path phrase concept must survive trimming! + itemPath += FILE_NAME_SEPARATOR; + + return Zstring(gdrivePrefix) + FILE_NAME_SEPARATOR + emailAndDrive + itemPath + options; +} +} + + +void fff::gdriveInit(const Zstring& configDirPath, const Zstring& caCertFilePath) +{ + assert(!globalHttpSessionManager.get()); + globalHttpSessionManager.set(std::make_unique(caCertFilePath)); + + assert(!globalGdriveSessions.get()); + globalGdriveSessions.set(std::make_unique(configDirPath)); +} + + +void fff::gdriveTeardown() +{ + try //don't use ~GdrivePersistentSessions() to save! Might never happen, e.g. detached thread waiting for Google Drive authentication; terminated on exit! + { + if (const std::shared_ptr gps = globalGdriveSessions.get()) + gps->saveActiveSessions(); //throw FileError + } + catch (const FileError& e) { logExtraError(e.toString()); } + + assert(globalGdriveSessions.get()); + globalGdriveSessions.set(nullptr); + + assert(globalHttpSessionManager.get()); + globalHttpSessionManager.set(nullptr); +} + + +std::string fff::gdriveAddUser(const std::function& updateGui /*throw X*/, int timeoutSec) //throw FileError, X +{ + try + { + if (const std::shared_ptr gps = globalGdriveSessions.get()) + return gps->addUserSession("" /*gdriveLoginHint*/, updateGui, timeoutSec); //throw SysError, X + + throw SysError(formatSystemError("gdriveAddUser", L"", L"Function call not allowed during init/shutdown.")); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive"), e.toString()); } +} + + +void fff::gdriveRemoveUser(const std::string& accountEmail, int timeoutSec) //throw FileError +{ + try + { + if (const std::shared_ptr gps = globalGdriveSessions.get()) + return gps->removeUserSession(accountEmail, timeoutSec); //throw SysError + + throw SysError(formatSystemError("gdriveRemoveUser", L"", L"Function call not allowed during init/shutdown.")); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to disconnect from %x."), L"%x", fmtPath(getGdriveDisplayPath({{accountEmail, Zstr("")}, AfsPath()}))), e.toString()); } +} + + +std::vector fff::gdriveListAccounts() //throw FileError +{ + try + { + if (const std::shared_ptr gps = globalGdriveSessions.get()) + return gps->listAccounts(); //throw SysError + + throw SysError(formatSystemError("gdriveListAccounts", L"", L"Function call not allowed during init/shutdown.")); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", L"Google Drive"), e.toString()); } +} + + +std::vector fff::gdriveListLocations(const std::string& accountEmail, int timeoutSec) //throw FileError +{ + try + { + if (const std::shared_ptr gps = globalGdriveSessions.get()) + return gps->listLocations(accountEmail, timeoutSec); //throw SysError + + throw SysError(formatSystemError("gdriveListLocations", L"", L"Function call not allowed during init/shutdown.")); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getGdriveDisplayPath({{accountEmail, Zstr("")}, AfsPath()}))), e.toString()); } +} + + +AfsDevice fff::condenseToGdriveDevice(const GdriveLogin& login) //noexcept +{ + //clean up input: + GdriveLogin loginTmp = login; + trim(loginTmp.email); + + loginTmp.timeoutSec = std::max(1, loginTmp.timeoutSec); + + return makeSharedRef(loginTmp); +} + + +GdriveLogin fff::extractGdriveLogin(const AfsDevice& afsDevice) //noexcept +{ + if (const auto gdriveDevice = dynamic_cast(&afsDevice.ref())) + return gdriveDevice ->getGdriveLogin(); + + assert(false); + return {}; +} + + +Zstring fff::getGoogleDriveFolderUrl(const AbstractPath& folderPath) //throw FileError +{ + if (const auto gdriveDevice = dynamic_cast(&folderPath.afsDevice.ref())) + return gdriveDevice->getFolderUrl(folderPath.afsPath); //throw FileError + //assert(false); + return {}; +} + + +bool fff::acceptsItemPathPhraseGdrive(const Zstring& itemPathPhrase) //noexcept +{ + Zstring path = expandMacros(itemPathPhrase); //expand before trimming! + trim(path); + return startsWithAsciiNoCase(path, gdrivePrefix); +} + + +/* syntax: gdrive:\[:]\[|option_name=value] + + e.g.: gdrive:\john@gmail.com\folder\file.txt + gdrive:\john@gmail.com:location\folder\file.txt|option_name=value */ +AbstractPath fff::createItemPathGdrive(const Zstring& itemPathPhrase) //noexcept +{ + Zstring pathPhrase = expandMacros(itemPathPhrase); //expand before trimming! + trim(pathPhrase); + + if (startsWithAsciiNoCase(pathPhrase, gdrivePrefix)) + pathPhrase = pathPhrase.c_str() + strLength(gdrivePrefix); + trim(pathPhrase, TrimSide::left, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); }); + + const ZstringView fullPath = beforeFirst(pathPhrase, Zstr('|'), IfNotFoundReturn::all); + const ZstringView options = afterFirst(pathPhrase, Zstr('|'), IfNotFoundReturn::none); + + auto it = std::find_if(fullPath.begin(), fullPath.end(), [](Zchar c) { return c == '/' || c == '\\'; }); + const ZstringView emailAndDrive = makeStringView(fullPath.begin(), it); + const AfsPath itemPath = sanitizeDeviceRelativePath({it, fullPath.end()}); + + GdriveLogin login + { + .email = utfTo(beforeFirst(emailAndDrive, Zstr(':'), IfNotFoundReturn::all)), + .locationName = Zstring(afterFirst (emailAndDrive, Zstr(':'), IfNotFoundReturn::none)), + }; + + split(options, Zstr('|'), [&](ZstringView optPhrase) + { + optPhrase = trimCpy(optPhrase); + if (!optPhrase.empty()) + { + if (startsWith(optPhrase, Zstr("timeout="))) + login.timeoutSec = stringTo(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else + assert(false); + } + }); + return AbstractPath(makeSharedRef(login), itemPath); +} diff --git a/FreeFileSync/Source/afs/gdrive.h b/FreeFileSync/Source/afs/gdrive.h new file mode 100644 index 0000000..78f7d31 --- /dev/null +++ b/FreeFileSync/Source/afs/gdrive.h @@ -0,0 +1,44 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FS_GDRIVE_9238425018342701356 +#define FS_GDRIVE_9238425018342701356 + +#include "abstract.h" + +namespace fff +{ +bool acceptsItemPathPhraseGdrive(const Zstring& itemPathPhrase); //noexcept +AbstractPath createItemPathGdrive (const Zstring& itemPathPhrase); //noexcept + +void gdriveInit(const Zstring& configDirPath, //directory to store Google-Drive-specific files + const Zstring& caCertFilePath); //cacert.pem +void gdriveTeardown(); + +//------------------------------------------------------- + +//caveat: gdriveAddUser() blocks indefinitely if user doesn't log in with Google! timeoutSec is only regarding HTTP requests +std::string /*account email*/ gdriveAddUser(const std::function& updateGui /*throw X*/, int timeoutSec); //throw FileError, X +void gdriveRemoveUser(const std::string& accountEmail, int timeoutSec); //throw FileError + +std::vector gdriveListAccounts(); //throw FileError +std::vector gdriveListLocations(const std::string& accountEmail, int timeoutSec); //throw FileError + +struct GdriveLogin +{ + std::string email; + Zstring locationName; //empty for "My Drive"; can be a shared drive or starred folder name + int timeoutSec = 10; //Gdrive can "hang" for 20 seconds when "scanning for viruses": https://freefilesync.org/forum/viewtopic.php?t=9116 +}; + +AfsDevice condenseToGdriveDevice(const GdriveLogin& login); //noexcept; potentially messy user input +GdriveLogin extractGdriveLogin(const AfsDevice& afsDevice); //noexcept + +//return empty, if not a Google Drive path +Zstring getGoogleDriveFolderUrl(const AbstractPath& folderPath); //throw FileError +} + +#endif //FS_GDRIVE_9238425018342701356 diff --git a/FreeFileSync/Source/afs/init_curl_libssh2.cpp b/FreeFileSync/Source/afs/init_curl_libssh2.cpp new file mode 100644 index 0000000..40026a9 --- /dev/null +++ b/FreeFileSync/Source/afs/init_curl_libssh2.cpp @@ -0,0 +1,155 @@ +// ***************************************************************************** +// * 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 "init_curl_libssh2.h" +#include +#include //DON'T include directly! +#include //DON'T include directly! + +using namespace zen; + + +namespace +{ +int uniInitLevel = 0; //support interleaving initialization calls! (e.g. use for libssh2 and libcurl) +//zero-initialized POD => not subject to static initialization order fiasco + +void libsshCurlUnifiedInit() +{ + assert(runningOnMainThread()); + assert(uniInitLevel >= 0); + if (++uniInitLevel != 1) //non-atomic => require call from main thread + return; + + libcurlInit(); //includes WSAStartup() also needed by libssh2 + + [[maybe_unused]] const int rc = ::libssh2_init(0); //includes OpenSSL-related initialization which might be needed (and hopefully won't hurt...) + assert(rc == 0); //libssh2 unconditionally returns 0 => why then have a return value in first place??? +} + + +void libsshCurlUnifiedTearDown() +{ + assert(runningOnMainThread()); + assert(uniInitLevel >= 1); + if (--uniInitLevel != 0) + return; + + ::libssh2_exit(); + libcurlTearDown(); +} +} + + +class zen::UniSessionCounter::Impl +{ +public: + void inc() //throw SysError + { + { + std::unique_lock dummy(lockCount_); + assert(sessionCount_ >= 0); + + if (!newSessionsAllowed_) + throw SysError(formatSystemError("UniSessionCounter::inc", L"", L"Function call not allowed during init/shutdown.")); + + ++sessionCount_; + } + conditionCountChanged_.notify_all(); + } + + void dec() //noexcept + { + { + std::unique_lock dummy(lockCount_); + assert(sessionCount_ >= 1); + --sessionCount_; + } + conditionCountChanged_.notify_all(); + } + + void onInitCompleted() //noexcept + { + std::unique_lock dummy(lockCount_); + newSessionsAllowed_ = true; + } + + void onBeforeTearDown() //noexcept + { + std::unique_lock dummy(lockCount_); + newSessionsAllowed_ = false; + conditionCountChanged_.wait(dummy, [this] { return sessionCount_ == 0; }); + } + + Impl() {} + ~Impl() + { + } + +private: + Impl (const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + + std::mutex lockCount_; + int sessionCount_ = 0; + std::condition_variable conditionCountChanged_; + + bool newSessionsAllowed_ = false; +}; + + +UniSessionCounter::UniSessionCounter() : pimpl(std::make_unique()) {} +UniSessionCounter::~UniSessionCounter() {} + + +std::unique_ptr zen::createUniSessionCounter() +{ + return std::make_unique(); +} + + +class zen::UniCounterCookie +{ +public: + UniCounterCookie(const std::shared_ptr& sessionCounter) : sessionCounter_(sessionCounter) {} + ~UniCounterCookie() { sessionCounter_->pimpl->dec(); } + +private: + UniCounterCookie (const UniCounterCookie&) = delete; + UniCounterCookie& operator=(const UniCounterCookie&) = delete; + + const std::shared_ptr sessionCounter_; +}; + + +std::shared_ptr zen::getLibsshCurlUnifiedInitCookie(Global& globalSftpSessionCount) //throw SysError +{ + std::shared_ptr sessionCounter = globalSftpSessionCount.get(); + if (!sessionCounter) + throw SysError(formatSystemError("getLibsshCurlUnifiedInitCookie", L"", L"Function call not allowed during init/shutdown.")); //=> ~UniCounterCookie() *not* called! + sessionCounter->pimpl->inc(); //throw SysError // + + //pass "ownership" of having to call UniSessionCounter::dec() + return std::make_shared(sessionCounter); //throw SysError +} + + +UniInitializer::UniInitializer(UniSessionCounter& sessionCount) : sessionCount_(sessionCount) +{ + libsshCurlUnifiedInit(); + sessionCount_.pimpl->onInitCompleted(); +} + + +UniInitializer::~UniInitializer() +{ + //wait until all (S)FTP sessions running on detached threads have ended! otherwise they'll crash during ::WSACleanup()! + sessionCount_.pimpl->onBeforeTearDown(); + /* alternatively we could use a Global and have each session own a shared_ptr: + drawback 1: SFTP clean-up may happen on worker thread => probably not supported!!! + drawback 2: cleanup will not happen when the C++ runtime on Windows kills all worker threads during shutdown */ + libsshCurlUnifiedTearDown(); +} diff --git a/FreeFileSync/Source/afs/init_curl_libssh2.h b/FreeFileSync/Source/afs/init_curl_libssh2.h new file mode 100644 index 0000000..93efb9f --- /dev/null +++ b/FreeFileSync/Source/afs/init_curl_libssh2.h @@ -0,0 +1,51 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef INIT_CURL_LIBSSH2_H_4570285702375915765 +#define INIT_CURL_LIBSSH2_H_4570285702375915765 + +#include +#include + + +namespace zen +{ +//(S)FTP initialization/shutdown dance: + +//1. create "Global globalSftpSessionCount(createUniSessionCounter());" to have a waitable counter of existing (S)FTP sessions +struct UniSessionCounter +{ + UniSessionCounter(); + ~UniSessionCounter(); + + class Impl; + const std::unique_ptr pimpl; +}; +std::unique_ptr createUniSessionCounter(); + + +//2. count number of existing (S)FTP sessions => tie to (S)FTP session instances! +class UniCounterCookie; +std::shared_ptr getLibsshCurlUnifiedInitCookie(Global& globalSftpSessionCount); //throw SysError + + +//3. Create static "UniInitializer globalInitSftp(*globalSftpSessionCount.get());" instance *before* constructing objects like "SftpSessionManager" +// => ~SftpSessionManager will run first and all remaining sessions are on non-main threads => can be waited on in ~UniInitializer +class UniInitializer +{ +public: + UniInitializer(UniSessionCounter& sessionCount); + ~UniInitializer(); + +private: + UniInitializer (const UniInitializer&) = delete; + UniInitializer& operator=(const UniInitializer&) = delete; + + UniSessionCounter& sessionCount_; +}; +} + +#endif //INIT_CURL_LIBSSH2_H_4570285702375915765 diff --git a/FreeFileSync/Source/afs/native.cpp b/FreeFileSync/Source/afs/native.cpp new file mode 100644 index 0000000..9e5e8f6 --- /dev/null +++ b/FreeFileSync/Source/afs/native.cpp @@ -0,0 +1,758 @@ +// ***************************************************************************** +// * 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 "native.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "abstract_impl.h" +#include "../base/icon_loader.h" + + #include //statfs + + #include + #include + #include //fallocate, fcntl + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace +{ + +void initComForThread() //throw FileError +{ +} + +//==================================================================================================== +//==================================================================================================== + +//persistent + unique (relative to volume) or 0! +inline +AFS::FingerPrint getFileFingerprint(FileIndex fileIndex) +{ + static_assert(sizeof(fileIndex) == sizeof(AFS::FingerPrint)); + return fileIndex; //== 0 if not supported + /* File details + ------------ + st_mtim (Linux) + st_mtimespec (macOS): nanosecond-precision for improved uniqueness? + => essentially unknown after file copy (e.g. to FAT) without extra directory traversal :( + + macOS st_birthtimespec: "if not supported by file system, holds the ctime instead" + ctime: inode modification time => changed on* rename*! => FU... + + Volume details + -------------- + st_dev: "st_dev value is not necessarily consistent across reboots or system crashes" https://freefilesync.org/forum/viewtopic.php?t=8054 + only locally unique and depends on device mount point! => FU... + + f_fsid: "Some operating systems use the device number..." => fuck! + "Several OSes restrict giving out the f_fsid field to the superuser only" + + f_bsize macOS: "fundamental file system block size" + Linux: "optimal transfer block size" -> no! for all intents and purposes this *is* the "fundamental file system block size": https://stackoverflow.com/a/54835515 + f_blocks => meh... + + f_type Linux: documented values, nice! https://linux.die.net/man/2/statfs + macOS: - not stable between macOS releases: https://developer.apple.com/forums/thread/87745 + - Apple docs say: "generally not a useful value" + - f_fstypename can be used as alternative + + DADiskGetBSDName(): macOS only */ +} + + +struct NativeFileInfo +{ + FileTimeNative modTime; + uint64_t fileSize; + AFS::FingerPrint filePrint; +}; +NativeFileInfo getNativeFileInfo(FileBase& file) //throw FileError +{ + const struct stat& fileInfo = file.getStatBuffered(); //throw FileError + return + { + fileInfo.st_mtim, + makeUnsigned(fileInfo.st_size), + getFileFingerprint(fileInfo.st_ino) + }; +} + + +struct FsItem +{ + Zstring itemName; +}; +std::vector getDirContentFlat(const Zstring& dirPath) //throw FileError +{ + //no need to check for endless recursion: + //1. Linux has a fixed limit on the number of symbolic links in a path + //2. fails with "too many open files" or "path too long" before reaching stack overflow + + DIR* folder = ::opendir(dirPath.c_str()); //directory must NOT end with path separator, except "/" + if (!folder) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot open directory %x."), L"%x", fmtPath(dirPath)), "opendir"); + ZEN_ON_SCOPE_EXIT(::closedir(folder)); //never close nullptr handles! -> crash + + std::vector output; + for (;;) + { + /* Linux: https://man7.org/linux/man-pages/man3/readdir_r.3.html + "It is recommended that applications use readdir(3) instead of readdir_r" + "... in modern implementations (including the glibc implementation), concurrent calls to readdir(3) that specify different directory streams are thread-safe" + + macOS: - libc: readdir thread-safe already in code from 2000: https://opensource.apple.com/source/Libc/Libc-166/gen.subproj/readdir.c.auto.html + - and in the latest version from 2017: https://opensource.apple.com/source/Libc/Libc-1244.30.3/gen/FreeBSD/readdir.c.auto.html */ + errno = 0; + const dirent* dirEntry = ::readdir(folder); + if (!dirEntry) + { + if (errno == 0) //errno left unchanged => no more items + return output; + + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(dirPath)), "readdir"); + //don't retry but restart dir traversal on error! https://devblogs.microsoft.com/oldnewthing/20140612-00/?p=753 + } + + const char* itemNameRaw = dirEntry->d_name; + + //skip "." and ".." + if (itemNameRaw[0] == '.' && + (itemNameRaw[1] == 0 || (itemNameRaw[1] == '.' && itemNameRaw[2] == 0))) + continue; + + if (itemNameRaw[0] == 0) //show error instead of endless recursion!!! + throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(dirPath)), formatSystemError("readdir", L"", L"Folder contains an item without name.")); + + output.push_back({itemNameRaw}); + + /* Unicode normalization is file-system-dependent: + + OS Accepts Gives back + ---------- ------- ---------- + macOS (HFS+) all NFD + Linux all + Windows (NTFS, FAT) all + + some file systems return precomposed others decomposed UTF8: https://developer.apple.com/library/archive/qa/qa1173/_index.html + - OS X edit controls and text fields may return precomposed UTF as directly received by keyboard or decomposed UTF that was copy & pasted! + - Posix APIs require decomposed form: https://freefilesync.org/forum/viewtopic.php?t=2480 + + => General recommendation: always preserve input UNCHANGED (both unicode normalization and case sensitivity) + => normalize only when needed during string comparison + + Create sample files on Linux: touch decomposed-$'\x6f\xcc\x81'.txt + touch precomposed-$'\xc3\xb3'.txt + + - list file name hex chars in terminal: ls | od -c -t x1 + + - SMB sharing case-sensitive or NFD file names is fundamentally broken on macOS: + => the macOS SMB manager internally buffers file names as case-insensitive and NFC (= just like NTFS on Windows) + => test: create SMB share from Linux => *boom* on macOS: "Error Code 2: No such file or directory [lstat]" + or WORSE: folders "test" and "Test" *both* incorrectly return the content of one of the two + => Update 2020-04-24: converting to NFC doesn't help: both NFD/NFC forms fail(ENOENT) lstat in FFS, AS WELL AS IN FINDER (silently skipped!) => macOS bug! */ + } +} + + +struct FsItemDetails +{ + ItemType type; + time_t modTime; //number of seconds since Jan. 1st 1970 GMT + uint64_t fileSize; //unit: bytes! + AFS::FingerPrint filePrint; +}; +FsItemDetails getItemDetails(const Zstring& itemPath) //throw FileError +{ + struct stat itemInfo = {}; + if (::lstat(itemPath.c_str(), &itemInfo) != 0) //lstat() does not resolve symlinks + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(itemPath)), "lstat"); + + return {S_ISLNK(itemInfo.st_mode) ? ItemType::symlink : //on Linux there is no distinction between file and directory symlinks! + /**/ (S_ISDIR(itemInfo.st_mode) ? ItemType::folder : ItemType::file), //a file or named pipe, etc. S_ISREG, S_ISCHR, S_ISBLK, S_ISFIFO, S_ISSOCK + //=> dont't check using S_ISREG(): see comment in file_traverser.cpp + itemInfo.st_mtime, + makeUnsigned(itemInfo.st_size), + getFileFingerprint(itemInfo.st_ino)}; +} + + +FsItemDetails getSymlinkTargetDetails(const Zstring& linkPath) //throw FileError +{ + try + { + struct stat itemInfo = {}; + if (::stat(linkPath.c_str(), &itemInfo) != 0) + THROW_LAST_SYS_ERROR("stat"); + + const ItemType targetType = S_ISDIR(itemInfo.st_mode) ? ItemType::folder : ItemType::file; + + const AFS::FingerPrint filePrint = targetType == ItemType::folder ? 0 : getFileFingerprint(itemInfo.st_ino); + return {targetType, + itemInfo.st_mtime, + makeUnsigned(itemInfo.st_size), + filePrint}; + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(linkPath)), e.toString()); + } +} + + +class SingleFolderTraverser +{ +public: + SingleFolderTraverser(const std::vector>>& workload /*throw X*/) + { + for (const auto& [folderPath, cb] : workload) + workload_.push_back({folderPath, cb}); + + while (!workload_.empty()) + { + WorkItem wi = std::move(workload_. back()); //yes, no strong exception guarantee (std::bad_alloc) + /**/ workload_.pop_back(); // + + tryReportingDirError([&] //throw X + { + traverseWithException(wi.dirPath, *wi.cb); //throw FileError, X + }, *wi.cb); + } + } + +private: + SingleFolderTraverser (const SingleFolderTraverser&) = delete; + SingleFolderTraverser& operator=(const SingleFolderTraverser&) = delete; + + void traverseWithException(const Zstring& dirPath, AFS::TraverserCallback& cb) //throw FileError, X + { + for (const auto& [itemName] : getDirContentFlat(dirPath)) //throw FileError + { + const Zstring itemPath = appendPath(dirPath, itemName); + + FsItemDetails itemDetails = {}; + if (!tryReportingItemError([&] //throw X + { + itemDetails = getItemDetails(itemPath); //throw FileError + }, cb, itemName)) + continue; //ignore error: skip file + + switch (itemDetails.type) + { + case ItemType::file: + cb.onFile({itemName, itemDetails.fileSize, itemDetails.modTime, itemDetails.filePrint, false /*isFollowedSymlink*/}); //throw X + break; + + case ItemType::folder: + if (std::shared_ptr cbSub = cb.onFolder({itemName, false /*isFollowedSymlink*/})) //throw X + workload_.push_back({itemPath, std::move(cbSub)}); + break; + + case ItemType::symlink: + switch (cb.onSymlink({itemName, itemDetails.modTime})) //throw X + { + case AFS::TraverserCallback::HandleLink::follow: + { + FsItemDetails targetDetails = {}; + if (!tryReportingItemError([&] //throw X + { + targetDetails = getSymlinkTargetDetails(itemPath); //throw FileError + }, cb, itemName)) + continue; + + if (targetDetails.type == ItemType::folder) + { + if (std::shared_ptr cbSub = cb.onFolder({itemName, true /*isFollowedSymlink*/})) //throw X + workload_.push_back({itemPath, std::move(cbSub)}); //symlink may link to different volume! + } + else //a file or named pipe, etc. + cb.onFile({itemName, targetDetails.fileSize, targetDetails.modTime, targetDetails.filePrint, true /*isFollowedSymlink*/}); //throw X + } + break; + + case AFS::TraverserCallback::HandleLink::skip: + break; + } + break; + } + } + } + + struct WorkItem + { + Zstring dirPath; + std::shared_ptr cb; + }; + std::vector workload_; +}; + + +void traverseFolderRecursiveNative(const std::vector>>& workload /*throw X*/, size_t) //throw X +{ + SingleFolderTraverser dummy(workload); //throw X +} +//==================================================================================================== +//==================================================================================================== + +class RecycleSessionNative : public AFS::RecycleSession +{ +public: + explicit RecycleSessionNative(const Zstring& baseFolderPath) : baseFolderPath_(baseFolderPath) {} + //constructor will be running on main thread => keep trivial and defer work to getRecyclerTempPath()! + + void moveToRecycleBin(const AbstractPath& itemPath, const Zstring& logicalRelPath) override; //throw FileError, RecycleBinUnavailable + void tryCleanup(const std::function& notifyDeletionStatus /*throw X*/) override; //throw FileError, X + +private: + const Zstring baseFolderPath_; +}; + +//=========================================================================================================================== + +struct InputStreamNative : public AFS::InputStream +{ + explicit InputStreamNative(const Zstring& filePath) : fileIn_(filePath) {} //throw FileError, ErrorFileLocked + + size_t getBlockSize() override { return fileIn_.getBlockSize(); } //throw FileError; non-zero block size is AFS contract! + + //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + size_t tryRead(void* buffer, size_t bytesToRead, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, ErrorFileLocked, X + { + const size_t bytesRead = fileIn_.tryRead(buffer, bytesToRead); //throw FileError, ErrorFileLocked + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesRead); //throw X + return bytesRead; + } + + std::optional tryGetAttributesFast() override //throw FileError + { + const NativeFileInfo& fileInfo = getNativeFileInfo(fileIn_); //throw FileError + + return AFS::StreamAttributes({nativeFileTimeToTimeT(fileInfo.modTime), + fileInfo.fileSize, + fileInfo.filePrint}); + } + +private: + FileInputPlain fileIn_; +}; + +//=========================================================================================================================== + +struct OutputStreamNative : public AFS::OutputStreamImpl +{ + OutputStreamNative(const Zstring& filePath, + std::optional streamSize, + std::optional modTime) : + fileOut_(filePath), //throw FileError, ErrorTargetExisting + modTime_(modTime) + { + if (streamSize) //preallocate disk space + reduce fragmentation + fileOut_.reserveSpace(*streamSize); //throw FileError + } + + size_t getBlockSize() override { return fileOut_.getBlockSize(); } //throw FileError + + size_t tryWrite(const void* buffer, size_t bytesToWrite, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0 + { + const size_t bytesWritten = fileOut_.tryWrite(buffer, bytesToWrite); //throw FileError + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesWritten); //throw X + return bytesWritten; + } + + AFS::FinalizeResult finalize(const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X + { + AFS::FinalizeResult result; + + result.filePrint = getNativeFileInfo(fileOut_).filePrint; //throw FileError + + fileOut_.close(); //throw FileError + //output finalized => no more exceptions from here on! + //-------------------------------------------------------------------- + + /* is setting modtime after closing the file handle a pessimization? + no, needed for functional correctness, see file_access.cpp::copyNewFile() for macOS/Linux + even required on Windows: https://freefilesync.org/forum/viewtopic.php?t=10781 */ + try + { + if (modTime_) + setFileTime(fileOut_.getFilePath(), *modTime_, ProcSymlink::follow); //throw FileError + } + catch (const FileError& e) { result.errorModTime = e; /*might slice derived class?*/ } + + return result; + } + +private: + FileOutputPlain fileOut_; + const std::optional modTime_; +}; + +//=========================================================================================================================== + +class NativeFileSystem : public AbstractFileSystem +{ +public: + explicit NativeFileSystem(const Zstring& rootPath) : rootPath_(rootPath) {} + + Zstring getNativePath(const AfsPath& itemPath) const { return isNullFileSystem() ? Zstring{} : appendPath(rootPath_, itemPath.value); } + +private: + Zstring getInitPathPhrase(const AfsPath& itemPath) const override { return makePathPhrase(getNativePath(itemPath)); } + + std::vector getPathPhraseAliases(const AfsPath& itemPath) const override + { + if (isNullFileSystem()) + return {}; + + return ::getPathPhraseAliases(getNativePath(itemPath)); + } + + std::wstring getDisplayPath(const AfsPath& itemPath) const override { return utfTo(getNativePath(itemPath)); } + + bool isNullFileSystem() const override { return rootPath_.empty(); } + + std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const override + { + return compareNativePath(rootPath_, static_cast(afsRhs).rootPath_); + } + + //---------------------------------------------------------------------------------------------------------------- + static ItemType zenToAfsItemType(zen::ItemType type) + { + switch (type) + { + case zen::ItemType::file: + return AFS::ItemType::file; + case zen::ItemType::folder: + return AFS::ItemType::folder; + case zen::ItemType::symlink: + return AFS::ItemType::symlink; + } + assert(false); + return static_cast(type); + } + + ItemType getItemType(const AfsPath& itemPath) const override //throw FileError + { + initComForThread(); //throw FileError + return zenToAfsItemType(zen::getItemType(getNativePath(itemPath))); //throw FileError + } + + std::optional getItemTypeIfExists(const AfsPath& itemPath) const override //throw FileError + { + initComForThread(); //throw FileError + if (const std::optional type = zen::getItemTypeIfExists(getNativePath(itemPath))) //throw FileError + return zenToAfsItemType(*type); + return std::nullopt; + } + + //---------------------------------------------------------------------------------------------------------------- + //already existing: fail + void createFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + initComForThread(); //throw FileError + createDirectory(getNativePath(folderPath)); //throw FileError, ErrorTargetExisting + } + + void removeFilePlain(const AfsPath& filePath) const override //throw FileError + { + initComForThread(); //throw FileError + zen::removeFilePlain(getNativePath(filePath)); //throw FileError + } + + void removeSymlinkPlain(const AfsPath& linkPath) const override //throw FileError + { + initComForThread(); //throw FileError + zen::removeSymlinkPlain(getNativePath(linkPath)); //throw FileError + } + + void removeFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + initComForThread(); //throw FileError + zen::removeDirectoryPlain(getNativePath(folderPath)); //throw FileError + } + + void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, + const std::function& onBeforeSymlinkDeletion/*throw X*/, + const std::function& onBeforeFolderDeletion /*throw X*/) const override + { + //default implementation: folder traversal + AFS::removeFolderIfExistsRecursion(folderPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion); //throw FileError, X + } + + //---------------------------------------------------------------------------------------------------------------- + AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const override //throw FileError + { + initComForThread(); //throw FileError + const Zstring nativePath = getNativePath(linkPath); + + const Zstring resolvedPath = zen::getSymlinkResolvedPath(nativePath); //throw FileError + const std::optional comp = parsePathComponents(resolvedPath); + if (!comp) + throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(nativePath)), + replaceCpy(L"Invalid path %x.", L"%x", fmtPath(resolvedPath))); + + return AbstractPath(makeSharedRef(comp->rootPath), AfsPath(comp->relPath)); + } + + bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const override //throw FileError + { + initComForThread(); //throw FileError + + const NativeFileSystem& nativeFsR = static_cast(linkPathR.afsDevice.ref()); + + const SymlinkRawContent linkContentL = getSymlinkRawContent(getNativePath(linkPathL)); //throw FileError + const SymlinkRawContent linkContentR = getSymlinkRawContent(nativeFsR.getNativePath(linkPathR.afsPath)); //throw FileError + + if (linkContentL.targetPath != linkContentR.targetPath) + return false; + + return true; + } + //---------------------------------------------------------------------------------------------------------------- + + //return value always bound: + std::unique_ptr getInputStream(const AfsPath& filePath) const override //throw FileError, ErrorFileLocked + { + initComForThread(); //throw FileError + return std::make_unique(getNativePath(filePath)); //throw FileError, ErrorFileLocked + } + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: fail with clear error message + std::unique_ptr getOutputStream(const AfsPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) const override + { + initComForThread(); //throw FileError + return std::make_unique(getNativePath(filePath), streamSize, modTime); //throw FileError, ErrorTargetExisting + } + + //---------------------------------------------------------------------------------------------------------------- + void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const override + { + //initComForThread() -> done on traverser worker threads + + std::vector>> initialWorkItems; + for (const auto& [folderPath, cb] : workload) + initialWorkItems.emplace_back(getNativePath(folderPath), cb); + + traverseFolderRecursiveNative(initialWorkItems, parallelOps); //throw X + } + //---------------------------------------------------------------------------------------------------------------- + + //symlink handling: follow + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: fail with clear error message + FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, bool copyFilePermissions, const IoCallback& notifyUnbufferedIO /*throw X*/) const override + { + const Zstring nativePathTarget = static_cast(targetPath.afsDevice.ref()).getNativePath(targetPath.afsPath); + + initComForThread(); //throw FileError + + const zen::FileCopyResult nativeResult = copyNewFile(getNativePath(sourcePath), nativePathTarget, notifyUnbufferedIO); //throw FileError, ErrorTargetExisting, ErrorFileLocked, X + + //at this point we know we created a new file, so it's fine to delete it for cleanup! + ZEN_ON_SCOPE_FAIL(try { zen::removeFilePlain(nativePathTarget); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + if (copyFilePermissions) + copyItemPermissions(getNativePath(sourcePath), nativePathTarget, ProcSymlink::follow); //throw FileError + + FileCopyResult result; + result.fileSize = nativeResult.fileSize; + //caveat: modTime will be incorrect for file systems with imprecise file times, e.g. see FAT_FILE_TIME_PRECISION_SEC + result.modTime = nativeFileTimeToTimeT(nativeResult.sourceModTime); + result.sourceFilePrint = getFileFingerprint(nativeResult.sourceFileIdx); + result.targetFilePrint = getFileFingerprint(nativeResult.targetFileIdx); + result.errorModTime = nativeResult.errorModTime; + return result; + } + + //symlink handling: follow + //already existing: fail + void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + initComForThread(); //throw FileError + + const Zstring& sourcePathNative = getNativePath(sourcePath); + const Zstring& targetPathNative = static_cast(targetPath.afsDevice.ref()).getNativePath(targetPath.afsPath); + + zen::createDirectory(targetPathNative); //throw FileError, ErrorTargetExisting + + try + { + copyDirectoryAttributes(sourcePathNative, targetPathNative); //throw FileError + } + catch (FileError&) {} //[!] too unimportant + too frequent for external devices, e.g. "ERROR_INVALID_PARAMETER [SetFileInformationByHandle(FileBasicInfo)]" on Samba share + + if (copyFilePermissions) + copyItemPermissions(sourcePathNative, targetPathNative, ProcSymlink::follow); //throw FileError + } + + //already existing: fail + void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + const Zstring targetPathNative = static_cast(targetPath.afsDevice.ref()).getNativePath(targetPath.afsPath); + + initComForThread(); //throw FileError + zen::copySymlink(getNativePath(sourcePath), targetPathNative); //throw FileError + + ZEN_ON_SCOPE_FAIL(try { zen::removeSymlinkPlain(targetPathNative); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + if (copyFilePermissions) + copyItemPermissions(getNativePath(sourcePath), targetPathNative, ProcSymlink::asLink); //throw FileError + } + + //already existing: undefined behavior! (e.g. fail/overwrite) + //=> actual behavior: fail with clear error message + void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const override //throw FileError, ErrorMoveUnsupported + { + //perf test: detecting different volumes by path is ~30 times faster than having ::MoveFileEx() fail with ERROR_NOT_SAME_DEVICE (6µs vs 190µs) + //=> maybe we can even save some actual I/O in some cases? + if (compareDeviceSameAfsType(pathTo.afsDevice.ref()) != std::weak_ordering::equivalent) + throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices.")); + + initComForThread(); //throw FileError + const Zstring nativePathTarget = static_cast(pathTo.afsDevice.ref()).getNativePath(pathTo.afsPath); + + zen::moveAndRenameItem(getNativePath(pathFrom), nativePathTarget, false /*replaceExisting*/); //throw FileError, ErrorTargetExisting, ErrorMoveUnsupported + //may fail with ERROR_ALREADY_EXISTS despite previously existing file already deleted + //=> reason: corrupted disk, fixable via Windows error checking! https://freefilesync.org/forum/viewtopic.php?t=9776 + } + + bool supportsPermissions(const AfsPath& folderPath) const override //throw FileError + { + initComForThread(); //throw FileError + return zen::supportsPermissions(getNativePath(folderPath)); + } + + //---------------------------------------------------------------------------------------------------------------- + FileIconHolder getFileIcon(const AfsPath& filePath, int pixelSize) const override //throw FileError; (optional return value) + { + initComForThread(); //throw FileError + try + { + return fff::getFileIcon(getNativePath(filePath), pixelSize); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); } + } + + ImageHolder getThumbnailImage(const AfsPath& filePath, int pixelSize) const override //throw FileError; (optional return value) + { + initComForThread(); //throw FileError + try + { + return fff::getThumbnailImage(getNativePath(filePath), pixelSize); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); } + } + + void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const override //throw FileError, (X) + { + } + + bool hasNativeTransactionalCopy() const override { return false; } + //---------------------------------------------------------------------------------------------------------------- + + int64_t getFreeDiskSpace(const AfsPath& folderPath) const override //throw FileError, returns < 0 if not available + { + initComForThread(); //throw FileError + return zen::getFreeDiskSpace(getNativePath(folderPath)); //throw FileError + } + + std::unique_ptr createRecyclerSession(const AfsPath& folderPath) const override //throw FileError, (RecycleBinUnavailable) + { + initComForThread(); //throw FileError + return std::make_unique(getNativePath(folderPath)); + } + + //fails if item is not existing + void moveToRecycleBin(const AfsPath& itemPath) const override //throw FileError, RecycleBinUnavailable + { + initComForThread(); //throw FileError + zen::moveToRecycleBin(getNativePath(itemPath)); //throw FileError, RecycleBinUnavailable + } + + const Zstring rootPath_; +}; + +//=========================================================================================================================== + + + +//- fails if item is not existing +//- multi-threaded access: internally synchronized! +void RecycleSessionNative::moveToRecycleBin(const AbstractPath& itemPath, const Zstring& logicalRelPath) //throw FileError, RecycleBinUnavailable +{ + const Zstring& itemPathNative = getNativeItemPath(itemPath); + if (itemPathNative.empty()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + zen::moveToRecycleBin(itemPathNative); //throw FileError, RecycleBinUnavailable +} + + +void RecycleSessionNative::tryCleanup(const std::function& notifyDeletionStatus /*throw X*/) //throw FileError, X +{ +} +} + + +//coordinate changes with getResolvedFilePath()! +bool fff::acceptsItemPathPhraseNative(const Zstring& itemPathPhrase) //noexcept +{ + Zstring path = expandMacros(itemPathPhrase); //expand before trimming! + trim(path); + + if (path.empty()) //eat up empty paths before other AFS implementations get a chance! + return true; + + + if (startsWith(path, Zstr('['))) //drive letter by volume name syntax + return true; + + //don't accept relative paths!!! indistinguishable from MTP paths as shown in Explorer's address bar! + return static_cast(parsePathComponents(path)); +} + + +AbstractPath fff::createItemPathNative(const Zstring& itemPathPhrase) //noexcept +{ + const Zstring& itemPath = getResolvedFilePath(itemPathPhrase); + return createItemPathNativeNoFormatting(itemPath); +} + + +AbstractPath fff::createItemPathNativeNoFormatting(const Zstring& nativePath) //noexcept +{ + if (const std::optional pc = parsePathComponents(nativePath)) + return AbstractPath(makeSharedRef(pc->rootPath), AfsPath(pc->relPath)); + + assert(nativePath.empty()); //broken path syntax + return AbstractPath(makeSharedRef(nativePath), AfsPath()); +} + + +Zstring fff::getNativeItemPath(const AbstractPath& itemPath) +{ + if (const auto nativeDevice = dynamic_cast(&itemPath.afsDevice.ref())) + return nativeDevice->getNativePath(itemPath.afsPath); + return {}; +} diff --git a/FreeFileSync/Source/afs/native.h b/FreeFileSync/Source/afs/native.h new file mode 100644 index 0000000..905edd7 --- /dev/null +++ b/FreeFileSync/Source/afs/native.h @@ -0,0 +1,25 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FS_NATIVE_183247018532434563465 +#define FS_NATIVE_183247018532434563465 + +#include "abstract.h" + +namespace fff +{ +bool acceptsItemPathPhraseNative(const Zstring& itemPathPhrase); //noexcept +AbstractPath createItemPathNative(const Zstring& itemPathPhrase); //noexcept + +//------------------------------------------------------- + +AbstractPath createItemPathNativeNoFormatting(const Zstring& nativePath); //noexcept + +//return empty, if not a native path +Zstring getNativeItemPath(const AbstractPath& itemPath); +} + +#endif //FS_NATIVE_183247018532434563465 diff --git a/FreeFileSync/Source/afs/sftp.cpp b/FreeFileSync/Source/afs/sftp.cpp new file mode 100644 index 0000000..dc32d3a --- /dev/null +++ b/FreeFileSync/Source/afs/sftp.cpp @@ -0,0 +1,2209 @@ +// ***************************************************************************** +// * 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 "sftp.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include //DON'T include directly! +#include "init_curl_libssh2.h" +#include "ftp_common.h" +#include "abstract_impl.h" + #include + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace +{ +/* +SFTP specification version 3 (implemented by libssh2): https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt + +libssh2: prefer OpenSSL over WinCNG backend: + +WinCNG supports the following ciphers: + rijndael-cbc@lysator.liu.se + aes256-cbc + aes192-cbc + aes128-cbc + arcfour128 + arcfour + 3des-cbc + +OpenSSL supports the same ciphers like WinCNG plus the following: + aes256-ctr + aes192-ctr + aes128-ctr + cast128-cbc + blowfish-cbc */ + +constexpr ZstringView sftpPrefix = Zstr("sftp:"); + +constexpr std::chrono::seconds SFTP_SESSION_MAX_IDLE_TIME (20); +constexpr std::chrono::seconds SFTP_SESSION_CLEANUP_INTERVAL (4); //facilitate default of 5-seconds delay for error retry +constexpr std::chrono::seconds SFTP_CHANNEL_LIMIT_DETECTION_TIME_OUT(30); + +//permissions for new files: rw- rw- rw- [0666] => consider umask! (e.g. 0022 for ffs.org) +const long SFTP_DEFAULT_PERMISSION_FILE = LIBSSH2_SFTP_S_IRUSR | LIBSSH2_SFTP_S_IWUSR | + LIBSSH2_SFTP_S_IRGRP | LIBSSH2_SFTP_S_IWGRP | + LIBSSH2_SFTP_S_IROTH | LIBSSH2_SFTP_S_IWOTH; + +//permissions for new folders: rwx rwx rwx [0777] => consider umask! (e.g. 0022 for ffs.org) +const long SFTP_DEFAULT_PERMISSION_FOLDER = LIBSSH2_SFTP_S_IRWXU | + LIBSSH2_SFTP_S_IRWXG | + LIBSSH2_SFTP_S_IRWXO; + +//attention: if operation fails due to time out, e.g. file copy, the cleanup code may hang, too => total delay = 2 x time out interval + +const size_t SFTP_OPTIMAL_BLOCK_SIZE_READ = 16 * MAX_SFTP_READ_SIZE; //https://github.com/libssh2/libssh2/issues/90 +const size_t SFTP_OPTIMAL_BLOCK_SIZE_WRITE = 16 * MAX_SFTP_OUTGOING_SIZE; //need large buffer to mitigate libssh2 stupidly waiting on "acks": https://www.libssh2.org/libssh2_sftp_write.html +static_assert(MAX_SFTP_READ_SIZE == 30000 && MAX_SFTP_OUTGOING_SIZE == 30000, "reevaluate optimal block sizes if these constants change!"); + +/* Perf Test, Sourceforge frs, SFTP upload, compressed 25 MB test file: + +SFTP_OPTIMAL_BLOCK_SIZE_READ: SFTP_OPTIMAL_BLOCK_SIZE_WRITE: + multiples of multiples of + MAX_SFTP_READ_SIZE KB/s MAX_SFTP_OUTGOING_SIZE KB/s + 1 650 1 140 + 2 1000 2 280 + 4 1800 4 320 + 8 1800 8 320 + 16 1800 16 320 + 32 1800 32 320 + Filezilla download speed: 1800 KB/s Filezilla upload speed: 560 KB/s + DSL maximum download speed: 3060 KB/s DSL maximum upload speed: 620 KB/s + + +Perf Test 2: FFS hompage (2022-09-22) + +SFTP_OPTIMAL_BLOCK_SIZE_READ: SFTP_OPTIMAL_BLOCK_SIZE_WRITE: + multiples of multiples of + MAX_SFTP_READ_SIZE MB/s MAX_SFTP_OUTGOING_SIZE MB/s + 1 0,77 1 0.25 + 2 1,63 2 0.50 + 4 3,43 4 0.97 + 8 6,93 8 1.86 + 16 9,41 16 3.60 + 32 9,58 32 3.83 + Filezilla download speed: 12,2 MB/s Filezilla upload speed: 4.4 MB/s -> unfair comparison: FFS seems slower because it includes setup work, e.g. open file handle + DSL maximum download speed: 12,9 MB/s DSL maximum upload speed: 4,7 MB/s + +=> libssh2_sftp_read/libssh2_sftp_write may take quite long for 16x and larger => use smallest multiple that fills bandwidth! */ + + +inline +uint16_t getEffectivePort(int portOption) +{ + if (portOption > 0) + return static_cast(portOption); + return DEFAULT_PORT_SFTP; +} + + +struct SshDeviceId //= what defines a unique SFTP location +{ + /*explicit*/ SshDeviceId(const SftpLogin& login) : + server(login.server), + port(getEffectivePort(login.portCfg)), + username(login.username) {} + + Zstring server; + uint16_t port; //must be valid port! + Zstring username; +}; +std::weak_ordering operator<=>(const SshDeviceId& lhs, const SshDeviceId& rhs) +{ + //exactly the type of case insensitive comparison we need for server names! https://docs.microsoft.com/en-us/windows/win32/api/ws2tcpip/nf-ws2tcpip-getaddrinfow#IDNs + if (const std::weak_ordering cmp = compareAsciiNoCase(lhs.server, rhs.server); + cmp != std::weak_ordering::equivalent) + return cmp; + + return std::tie(lhs.port, lhs.username) <=> //username: case sensitive! + std::tie(rhs.port, rhs.username); +} +//also needed by compareDeviceSameAfsType(), so can't just replace with hash and use std::unordered_map + + +struct SshSessionCfg //= config for buffered SFTP session +{ + SshDeviceId deviceId; + SftpAuthType authType = SftpAuthType::password; + Zstring password; //authType == password or keyFile + Zstring privateKeyFilePath; //authType == keyFile: use PEM-encoded private key (protected by password) for authentication + bool allowZlib = false; +}; +bool operator==(const SshSessionCfg& lhs, const SshSessionCfg& rhs) +{ + if (lhs.deviceId <=> rhs.deviceId != std::weak_ordering::equivalent) + return false; + + if (std::tie(lhs.authType, lhs.allowZlib) != + std::tie(rhs.authType, rhs.allowZlib)) + return false; + + switch (lhs.authType) + { + case SftpAuthType::password: + return lhs.password == rhs.password; //case sensitive! + + case SftpAuthType::keyFile: + return std::tie(lhs.password, lhs.privateKeyFilePath) == //case sensitive! + std::tie(rhs.password, rhs.privateKeyFilePath); // + + case SftpAuthType::agent: + return true; + } + assert(false); + return true; +} + + +Zstring concatenateSftpFolderPathPhrase(const SftpLogin& login, const AfsPath& itemPath); //noexcept + + +std::string getLibssh2Path(const AfsPath& itemPath) +{ + return utfTo(getServerRelPath(itemPath)); +} + + +std::wstring getSftpDisplayPath(const SshDeviceId& deviceId, const AfsPath& itemPath) +{ + Zstring displayPath = Zstring(sftpPrefix) + Zstr("//"); + + if (!deviceId.username.empty()) //show username! consider AFS::compareDeviceSameAfsType() + displayPath += deviceId.username + Zstr('@'); + + //if (parseIpv6Address(deviceId.server) && deviceId.port != DEFAULT_PORT_SFTP) + // displayPath += Zstr('[') + deviceId.server + Zstr(']'); + //else + displayPath += deviceId.server; + + //if (deviceId.port != DEFAULT_PORT_SFTP) + // displayPath += Zstr(':') + numberTo(deviceId.port); + + const Zstring& relPath = getServerRelPath(itemPath); + if (relPath != Zstr("/")) + displayPath += relPath; + + return utfTo(displayPath); +} + +//=========================================================================================================================== + +//=> most likely *not* a connection issue +struct SysErrorSftpProtocol : public zen::SysError +{ + SysErrorSftpProtocol(const std::wstring& msg, unsigned long sftpError) : SysError(msg), sftpErrorCode(sftpError) {} + + const unsigned long sftpErrorCode; +}; + +DEFINE_NEW_SYS_ERROR(SysErrorPassword) + + +constinit Global globalSftpSessionCount; +GLOBAL_RUN_ONCE(globalSftpSessionCount.set(createUniSessionCounter())); + + +class SshSession +{ +public: + SshSession(const SshSessionCfg& sessionCfg, int timeoutSec) : //throw SysError, SysErrorPassword + sessionCfg_(sessionCfg) + { + ZEN_ON_SCOPE_FAIL(cleanup()); //destructor call would lead to member double clean-up!!! + + const Zstring& serviceName = numberTo(sessionCfg_.deviceId.port); + + socket_.emplace(sessionCfg_.deviceId.server, serviceName, timeoutSec); //throw SysError + + sshSession_ = ::libssh2_session_init(); + if (!sshSession_) //does not set ssh last error; source: only memory allocation may fail + throw SysError(formatSystemError("libssh2_session_init", formatSshStatusCode(LIBSSH2_ERROR_ALLOC), L"")); + + //if zlib compression causes trouble, make it a user setting: https://freefilesync.org/forum/viewtopic.php?t=6663 + //=> surprise: it IS causing trouble: slow-down in local syncs: https://freefilesync.org/forum/viewtopic.php?t=7244#p24250 + if (sessionCfg_.allowZlib) + if (const int rc = ::libssh2_session_flag(sshSession_, LIBSSH2_FLAG_COMPRESS, 1); + rc != 0) //does not set SSH last error + throw SysError(formatSystemError("libssh2_session_flag", formatSshStatusCode(rc), L"")); + + ::libssh2_session_set_blocking(sshSession_, 1); + + //we don't consider the timeout part of the session when it comes to reuse! but we already require it during initialization + ::libssh2_session_set_timeout(sshSession_, timeoutSec * 1000 /*ms*/); + + + if (::libssh2_session_handshake(sshSession_, socket_->get()) != 0) + throw SysError(formatLastSshError("libssh2_session_handshake", nullptr)); + + //evaluate fingerprint = libssh2_hostkey_hash(sshSession_, LIBSSH2_HOSTKEY_HASH_SHA1) ??? + + const auto usernameUtf8 = utfTo(sessionCfg_.deviceId.username); + const auto passwordUtf8 = utfTo(sessionCfg_.password); + + const char* authList = ::libssh2_userauth_list(sshSession_, usernameUtf8); + if (!authList) + { + if (::libssh2_userauth_authenticated(sshSession_) != 1) + throw SysError(formatLastSshError("libssh2_userauth_list", nullptr)); + //else: SSH_USERAUTH_NONE has authenticated successfully => we're already done + } + else + { + bool supportAuthPassword = false; + bool supportAuthKeyfile = false; + bool supportAuthInteractive = false; + split(authList, ',', [&](std::string_view authMethod) + { + authMethod = trimCpy(authMethod); + if (!authMethod.empty()) + { + if (authMethod == "password") + supportAuthPassword = true; + else if (authMethod == "publickey") + supportAuthKeyfile = true; + else if (authMethod == "keyboard-interactive") + supportAuthInteractive = true; + } + }); + + switch (sessionCfg_.authType) + { + case SftpAuthType::password: + { + if (supportAuthPassword) + { + if (::libssh2_userauth_password(sshSession_, usernameUtf8, passwordUtf8) != 0) + throw SysErrorPassword(formatLastSshError("libssh2_userauth_password", nullptr)); + } + else if (supportAuthInteractive) //some servers, e.g. web.sourceforge.net, support "keyboard-interactive", but not "password" + { + std::wstring unexpectedPrompts; + + auto authCallback = [&](int num_prompts, const LIBSSH2_USERAUTH_KBDINT_PROMPT* prompts, LIBSSH2_USERAUTH_KBDINT_RESPONSE* responses) + { + //note: FileZilla assumes password requests when it finds "num_prompts == 1" and "!echo" -> prompt may be localized! + //test case: sourceforge.net sends a single "Password: " prompt with "!echo" + if (num_prompts == 1 && prompts[0].echo == 0) + { + responses[0].text = //pass ownership; will be ::free()d + ::strdup(passwordUtf8.c_str()); + responses[0].length = static_cast(passwordUtf8.size()); + } + else + for (int i = 0; i < num_prompts; ++i) + unexpectedPrompts += (unexpectedPrompts.empty() ? L"" : L"|") + utfTo(makeStringView(reinterpret_cast(prompts[i].text), prompts[i].length)); + }; + using AuthCbType = decltype(authCallback); + + auto authCallbackWrapper = [](const char* name, int name_len, const char* instruction, int instruction_len, + int num_prompts, const LIBSSH2_USERAUTH_KBDINT_PROMPT* prompts, LIBSSH2_USERAUTH_KBDINT_RESPONSE* responses, void** abstract) + { + try + { + AuthCbType* callback = *reinterpret_cast(abstract); //free this poor little C-API from its shackles and redirect to a proper lambda + (*callback)(num_prompts, prompts, responses); //name, instruction are nullptr for sourceforge.net + } + catch (...) { assert(false); } + }; + + if (*::libssh2_session_abstract(sshSession_)) + throw SysError(L"libssh2_session_abstract: non-null value"); + + *reinterpret_cast(::libssh2_session_abstract(sshSession_)) = &authCallback; + ZEN_ON_SCOPE_EXIT(*::libssh2_session_abstract(sshSession_) = nullptr); + + if (::libssh2_userauth_keyboard_interactive(sshSession_, usernameUtf8, authCallbackWrapper) != 0) + throw SysErrorPassword(formatLastSshError("libssh2_userauth_keyboard_interactive", nullptr) + + (unexpectedPrompts.empty() ? L"" : L"\nUnexpected prompts: " + unexpectedPrompts)); + } + else + throw SysError(replaceCpy(_("The server does not support authentication via %x."), L"%x", L"\"username/password\"") + + L'\n' +_("Required:") + L' ' + utfTo(authList)); + } + break; + + case SftpAuthType::keyFile: + { + if (!supportAuthKeyfile) + throw SysError(replaceCpy(_("The server does not support authentication via %x."), L"%x", L"\"key file\"") + + L'\n' +_("Required:") + L' ' + utfTo(authList)); + + std::string passphrase = passwordUtf8; + std::string pkStream; + try + { + pkStream = getFileContent(sessionCfg_.privateKeyFilePath, nullptr /*notifyUnbufferedIO*/); //throw FileError + trim(pkStream); + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //errors should be further enriched by context info => SysError + + //libssh2 doesn't support the PuTTY key file format, but we do! + if (isPuttyKeyStream(pkStream)) + try + { + pkStream = convertPuttyKeyToPkix(pkStream, passphrase); //throw SysError + passphrase.clear(); + } + catch (const SysError& e) //add context + { + throw SysErrorPassword(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(sessionCfg_.privateKeyFilePath)) + L' ' + e.toString()); + } + + if (::libssh2_userauth_publickey_frommemory(sshSession_, usernameUtf8, pkStream, passphrase) != 0) //const char* passphrase + { + //libssh2_userauth_publickey_frommemory()'s "Unable to extract public key from private key" isn't exactly *helpful* + //=> detect invalid key files and give better error message: + const wchar_t* invalidKeyFormat = [&]() -> const wchar_t* + { + //"-----BEGIN PUBLIC KEY-----" OpenSSH SSH-2 public key (X.509 SubjectPublicKeyInfo) = PKIX + //"-----BEGIN RSA PUBLIC KEY-----" OpenSSH SSH-2 public key (PKCS#1 RSAPublicKey) + //"---- BEGIN SSH2 PUBLIC KEY ----" SSH-2 public key (RFC 4716 format) + const std::string_view firstLine = makeStringView(pkStream.begin(), std::find_if(pkStream.begin(), pkStream.end(), isLineBreak)); + if (contains(firstLine, "PUBLIC KEY")) + return L"OpenSSH public key"; + + if (startsWith(pkStream, "rsa-") || //rsa-sha2-256, rsa-sha2-512 + startsWith(pkStream, "ssh-") || //ssh-rsa, ssh-dss, ssh-ed25519, ssh-ed448 + startsWith(pkStream, "ecdsa-")) //ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521 + return L"OpenSSH public key"; //OpenSSH SSH-2 public key + + if (std::count(pkStream.begin(), pkStream.end(), ' ') == 2 && + /**/std::all_of(pkStream.begin(), pkStream.end(), [](const char c) { return isDigit(c) || c == ' '; })) + return L"SSH-1 public key"; + + //"-----BEGIN PRIVATE KEY-----" => OpenSSH SSH-2 private key (PKCS#8 PrivateKeyInfo) => should work + //"-----BEGIN ENCRYPTED PRIVATE KEY-----" => OpenSSH SSH-2 private key (PKCS#8 EncryptedPrivateKeyInfo) => should work + //"-----BEGIN RSA PRIVATE KEY-----" => OpenSSH SSH-2 private key (PKCS#1 RSAPrivateKey) => should work + //"-----BEGIN DSA PRIVATE KEY-----" => OpenSSH SSH-2 private key (PKCS#1 DSAPrivateKey) => should work + //"-----BEGIN EC PRIVATE KEY-----" => OpenSSH SSH-2 private key (PKCS#1 ECPrivateKey) => should work + //"-----BEGIN OPENSSH PRIVATE KEY-----" => OpenSSH SSH-2 private key (new format) => should work + //"---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----" => ssh.com SSH-2 private key => unclear + //"SSH PRIVATE KEY FILE FORMAT 1.1" => SSH-1 private key => unclear + return nullptr; //other: maybe invalid, maybe not + }(); + if (invalidKeyFormat) + throw SysError(_("Authentication failed.") + L' ' + + replaceCpy(L"%x is not an OpenSSH or PuTTY private key file.", L"%x", + fmtPath(sessionCfg_.privateKeyFilePath) + L" [" + invalidKeyFormat + L']')); + if (isPuttyKeyStream(pkStream)) + throw SysError(formatLastSshError("libssh2_userauth_publickey_frommemory", nullptr)); + else + //can't rely on LIBSSH2_ERROR_AUTHENTICATION_FAILED: https://github.com/libssh2/libssh2/pull/789 + throw SysErrorPassword(formatLastSshError("libssh2_userauth_publickey_frommemory", nullptr)); + } + } + break; + + case SftpAuthType::agent: + { + LIBSSH2_AGENT* sshAgent = ::libssh2_agent_init(sshSession_); + if (!sshAgent) + throw SysError(formatLastSshError("libssh2_agent_init", nullptr)); + ZEN_ON_SCOPE_EXIT(::libssh2_agent_free(sshAgent)); + + if (::libssh2_agent_connect(sshAgent) != 0) + throw SysError(formatLastSshError("libssh2_agent_connect", nullptr)); + ZEN_ON_SCOPE_EXIT(::libssh2_agent_disconnect(sshAgent)); + + if (::libssh2_agent_list_identities(sshAgent) != 0) + throw SysError(formatLastSshError("libssh2_agent_list_identities", nullptr)); + + for (libssh2_agent_publickey* prev = nullptr;;) + { + libssh2_agent_publickey* identity = nullptr; + const int rc = ::libssh2_agent_get_identity(sshAgent, &identity, prev); + if (rc == 0) //public key returned + ; + else if (rc == 1) //no more public keys + throw SysError(L"SSH agent contains no matching public key."); + else + throw SysError(formatLastSshError("libssh2_agent_get_identity", nullptr)); + + if (::libssh2_agent_userauth(sshAgent, usernameUtf8.c_str(), identity) == 0) + break; //authentication successful + + //else: failed => try next public key + prev = identity; + } + } + break; + } + } + + lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); + } + + ~SshSession() { cleanup(); } + + const SshSessionCfg& getSessionCfg() const + { + static_assert(std::is_const_v, "keep this function thread-safe!"); + return sessionCfg_; + } + + bool isHealthy() const + { + for (const SftpChannelInfo& ci : sftpChannels_) + if (ci.nbInfo.commandPending) + return false; + + if (nbInfo_.commandPending) + return false; + + if (possiblyCorrupted_) + return false; + + if (std::chrono::steady_clock::now() > lastSuccessfulUseTime_ + SFTP_SESSION_MAX_IDLE_TIME) + return false; + + return true; + } + + void markAsCorrupted() { possiblyCorrupted_ = true; } + + struct Details + { + LIBSSH2_SESSION* sshSession; + LIBSSH2_SFTP* sftpChannel; + }; + + size_t getSftpChannelCount() const { return sftpChannels_.size(); } + + //return "false" if pending + bool tryNonBlocking(size_t channelNo, std::chrono::steady_clock::time_point commandStartTime, const char* functionName, + const std::function& sftpCommand /*noexcept!*/, int timeoutSec) //throw SysError, SysErrorSftpProtocol + { + assert(::libssh2_session_get_blocking(sshSession_)); + ::libssh2_session_set_blocking(sshSession_, 0); + ZEN_ON_SCOPE_EXIT(::libssh2_session_set_blocking(sshSession_, 1)); + + //yes, we're non-blocking, still won't hurt to set the timeout in case libssh2 decides to use it nevertheless + ::libssh2_session_set_timeout(sshSession_, timeoutSec * 1000 /*ms*/); + + LIBSSH2_SFTP* sftpChannel = channelNo < sftpChannels_.size() ? sftpChannels_[channelNo].sftpChannel : nullptr; + SftpNonBlockInfo& nbInfo = channelNo < sftpChannels_.size() ? sftpChannels_[channelNo].nbInfo : nbInfo_; + + if (!nbInfo.commandPending) + assert(nbInfo.commandStartTime != commandStartTime); + else if (nbInfo.commandStartTime == commandStartTime && nbInfo.functionName == functionName) + ; //continue pending SFTP call + else + { + assert(false); //pending sftp command is not completed by client: e.g. libssh2_sftp_close() cleaning up after a timed-out libssh2_sftp_read() + possiblyCorrupted_ = true; //=> start new command (with new start time), but remember to not trust this session anymore! + } + nbInfo.commandPending = true; + nbInfo.commandStartTime = commandStartTime; + nbInfo.functionName = functionName; + + int rc = LIBSSH2_ERROR_NONE; + try + { + rc = sftpCommand({sshSession_, sftpChannel}); //noexcept + } + catch (...) { assert(false); rc = LIBSSH2_ERROR_BAD_USE; } + + assert(rc >= 0 || ::libssh2_session_last_errno(sshSession_) == rc); + if (rc < 0 && ::libssh2_session_last_errno(sshSession_) != rc) //when libssh2 fails to properly set last error; e.g. https://github.com/libssh2/libssh2/pull/123 + ::libssh2_session_set_last_error(sshSession_, rc, nullptr); + + if (rc >= LIBSSH2_ERROR_NONE || + (rc == LIBSSH2_ERROR_SFTP_PROTOCOL && ::libssh2_sftp_last_error(sftpChannel) != LIBSSH2_FX_OK)) + //libssh2 source: LIBSSH2_ERROR_SFTP_PROTOCOL *without* setting LIBSSH2_SFTP::last_errno indicates a corrupted connection! + { + nbInfo.commandPending = false; // + lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); //[!] LIBSSH2_ERROR_SFTP_PROTOCOL is NOT an SSH error => the SSH session is just fine! + + if (rc == LIBSSH2_ERROR_SFTP_PROTOCOL) + throw SysErrorSftpProtocol(formatLastSshError(functionName, sftpChannel), ::libssh2_sftp_last_error(sftpChannel)); + return true; + } + else if (rc == LIBSSH2_ERROR_EAGAIN) + { + if (std::chrono::steady_clock::now() > nbInfo.commandStartTime + std::chrono::seconds(timeoutSec)) + //consider SSH session corrupted! => isHealthy() will see pending command + throw SysError(formatSystemError(functionName, formatSshStatusCode(LIBSSH2_ERROR_TIMEOUT), + _P("Operation timed out after 1 second.", "Operation timed out after %x seconds.", timeoutSec))); + return false; + } + else //=> SSH session errors only (hopefully!) e.g. LIBSSH2_ERROR_SOCKET_RECV + //consider SSH session corrupted! => isHealthy() will see pending command + throw SysError(formatLastSshError(functionName, sftpChannel)); + } + + //returns when traffic is available or time out: both cases are handled by next tryNonBlocking() call + static void waitForTraffic(const std::vector& sshSessions, int timeoutSec) //throw SysError + { + //reference: session.c: _libssh2_wait_socket() + std::vector fds; + std::chrono::steady_clock::time_point startTimeMin = std::chrono::steady_clock::time_point::max(); + + for (SshSession* session : sshSessions) + { + assert(::libssh2_session_last_errno(session->sshSession_) == LIBSSH2_ERROR_EAGAIN); + assert(session->nbInfo_.commandPending || std::any_of(session->sftpChannels_.begin(), session->sftpChannels_.end(), [](SftpChannelInfo& ci) { return ci.nbInfo.commandPending; })); + + pollfd pfd{.fd = session->socket_->get()}; + + const int dir = ::libssh2_session_block_directions(session->sshSession_); + assert(dir != 0); //we assert a blocked direction after libssh2 returned LIBSSH2_ERROR_EAGAIN! + if (dir & LIBSSH2_SESSION_BLOCK_INBOUND) + pfd.events |= POLLIN; + if (dir & LIBSSH2_SESSION_BLOCK_OUTBOUND) + pfd.events |= POLLOUT; + + if (pfd.events != 0) + fds.push_back(pfd); + + for (const SftpChannelInfo& ci : session->sftpChannels_) + if (ci.nbInfo.commandPending) + startTimeMin = std::min(startTimeMin, ci.nbInfo.commandStartTime); + if (session->nbInfo_.commandPending) + startTimeMin = std::min(startTimeMin, session->nbInfo_.commandStartTime); + } + + if (!fds.empty()) + { + assert(startTimeMin != std::chrono::steady_clock::time_point::max()); + const auto now = std::chrono::steady_clock::now(); + const auto stopTime = startTimeMin + std::chrono::seconds(timeoutSec); + if (now >= stopTime) + return; //time-out! => let next tryNonBlocking() call fail with detailed error! + const auto waitTimeMs = std::chrono::duration_cast(stopTime - now).count(); + + //is poll() on macOS broken? https://daniel.haxx.se/blog/2016/10/11/poll-on-mac-10-12-is-broken/ + // it seems Daniel only takes issue with "empty" input handling!? => not an issue for us + const char* functionName = "poll"; + const int rv = ::poll(fds.data(), //struct pollfd* fds + fds.size(), //nfds_t nfds + waitTimeMs); //int timeout [ms] + if (rv < 0) //consider SSH sessions corrupted! => isHealthy() will see pending commands + throw SysError(formatSystemError(functionName, getLastError())); + + if (rv == 0) //time-out! => let next tryNonBlocking() call fail with detailed error! + return; + } + else assert(false); + } + + static void addSftpChannel(const std::vector& sshSessions, int timeoutSec) //throw SysError + { + auto addChannelDetails = [](const std::wstring& msg, SshSession& sshSession) //when hitting the server's SFTP channel limit, inform user about channel number + { + if (sshSession.sftpChannels_.empty()) + return msg; + return msg + L' ' + replaceCpy(_("Failed to open SFTP channel number %x."), L"%x", formatNumber(sshSession.sftpChannels_.size() + 1)); + }; + + std::optional firstSysError; + + std::vector pendingSessions = sshSessions; + const auto sftpCommandStartTime = std::chrono::steady_clock::now(); + + for (;;) + { + //create all SFTP sessions in parallel => non-blocking + //note: each libssh2_sftp_init() consists of multiple round-trips => poll until all sessions are finished, don't just init and then block on each! + for (size_t pos = pendingSessions.size(); pos-- > 0 ; ) //CAREFUL WITH THESE ERASEs (invalidate positions!!!) + try + { + if (pendingSessions[pos]->tryNonBlocking(static_cast(-1), sftpCommandStartTime, "libssh2_sftp_init", + [&](const SshSession::Details& sd) //noexcept! + { + LIBSSH2_SFTP* sftpChannelNew = ::libssh2_sftp_init(sd.sshSession); + if (!sftpChannelNew) + return std::min(::libssh2_session_last_errno(sd.sshSession), LIBSSH2_ERROR_SOCKET_NONE); + //just in case libssh2 failed to properly set last error; e.g. https://github.com/libssh2/libssh2/pull/123 + + pendingSessions[pos]->sftpChannels_.emplace_back(sftpChannelNew); + return LIBSSH2_ERROR_NONE; + }, timeoutSec)) //throw SysError, (SysErrorSftpProtocol) + pendingSessions.erase(pendingSessions.begin() + pos); //= not pending + } + catch (const SysError& e) + { + if (!firstSysError) //don't throw yet and corrupt other valid, but pending SshSessions! We also don't want to leak LIBSSH2_SFTP* waiting in libssh2 code + firstSysError = SysError(addChannelDetails(e.toString(), *pendingSessions[pos])); + //SysErrorSftpProtocol? unexpected during libssh2_sftp_init() + //-> still occuring for whatever reason!? => "slice" down to SysError + pendingSessions.erase(pendingSessions.begin() + pos); + } + + if (pendingSessions.empty()) + { + if (firstSysError) + throw* firstSysError; + return; + } + + waitForTraffic(pendingSessions, timeoutSec); //throw SysError + } + } + +private: + SshSession (const SshSession&) = delete; + SshSession& operator=(const SshSession&) = delete; + + void cleanup() //attention: may block heavily after error! + { + for (SftpChannelInfo& ci : sftpChannels_) + //ci.nbInfo.commandPending? => may "legitimately" happen when an SFTP command times out + if (::libssh2_sftp_shutdown(ci.sftpChannel) != LIBSSH2_ERROR_NONE) + assert(false); + + if (sshSession_) + { + //*INDENT-OFF* + if (!nbInfo_.commandPending && std::all_of(sftpChannels_.begin(), sftpChannels_.end(), + [](const SftpChannelInfo& ci) { return !ci.nbInfo.commandPending; })) + if (::libssh2_session_disconnect(sshSession_, "FreeFileSync says \"bye\"!") != LIBSSH2_ERROR_NONE) //= server notification only! no local cleanup apparently + assert(false); + //else: avoid further stress on the broken SSH session and take French leave + + //nbInfo_.commandPending? => have to clean up, no matter what! + if (::libssh2_session_free(sshSession_) != LIBSSH2_ERROR_NONE) + assert(false); + //*INDENT-ON* + } + } + + std::wstring formatLastSshError(const char* functionName, LIBSSH2_SFTP* sftpChannel /*optional*/) const + { + char* lastErrorMsg = nullptr; //owned by "sshSession" + const int sshStatusCode = ::libssh2_session_last_error(sshSession_, &lastErrorMsg, nullptr, false /*want_buf*/); + assert(lastErrorMsg); + + std::wstring errorMsg; + if (lastErrorMsg) + errorMsg = trimCpy(utfTo(lastErrorMsg)); + + //LIBSSH2_ERROR_SFTP_PROTOCOL does *not* mean libssh2_sftp_last_error() is also available! + //But if it's not, we have a broken connection, and lastErrorMsg contains meaningful details! + if (sshStatusCode == LIBSSH2_ERROR_SFTP_PROTOCOL && ::libssh2_sftp_last_error(sftpChannel) != LIBSSH2_FX_OK) + { + if (errorMsg == L"SFTP Protocol Error") //that's trite! + errorMsg.clear(); + return formatSystemError(functionName, formatSftpStatusCode(::libssh2_sftp_last_error(sftpChannel)), errorMsg); + } + + return formatSystemError(functionName, formatSshStatusCode(sshStatusCode), errorMsg); + } + + struct SftpNonBlockInfo + { + bool commandPending = false; + std::chrono::steady_clock::time_point commandStartTime; //specified by client, try to detect libssh2 usage errors + std::string functionName; + }; + + struct SftpChannelInfo + { + explicit SftpChannelInfo(LIBSSH2_SFTP* sc) : sftpChannel(sc) {} + + LIBSSH2_SFTP* sftpChannel = nullptr; + SftpNonBlockInfo nbInfo; + }; + + std::optional socket_; //*bound* after constructor has run + LIBSSH2_SESSION* sshSession_ = nullptr; + std::vector sftpChannels_; + bool possiblyCorrupted_ = false; + + SftpNonBlockInfo nbInfo_; //for SSH session, e.g. libssh2_sftp_init() + + const SshSessionCfg sessionCfg_; + const std::shared_ptr libsshCurlUnifiedInitCookie_{(getLibsshCurlUnifiedInitCookie(globalSftpSessionCount))}; //throw SysError + std::chrono::steady_clock::time_point lastSuccessfulUseTime_; //...of the SSH session (but not necessarily the SFTP functionality!) +}; + +//=========================================================================================================================== +//=========================================================================================================================== + +class SftpSessionManager //reuse (healthy) SFTP sessions globally +{ + struct SshSessionCache; + +public: + SftpSessionManager() : sessionCleaner_([this] + { + setCurrentThreadName(Zstr("Session Cleaner[SFTP]")); + runGlobalSessionCleanUp(); /*throw ThreadStopRequest*/ + }) {} + + struct ReUseOnDelete + { + void operator()(SshSession* s) const; + }; + + class SshSessionShared + { + public: + SshSessionShared(std::unique_ptr&& idleSession, int timeoutSec) : + session_(std::move(idleSession)) /*bound!*/, timeoutSec_(timeoutSec) { /*assert(session_->isHealthy());*/ } + + //we need two-step initialization: 1. constructor is FAST and noexcept 2. init() is SLOW and throws + void initSftpChannel() //throw SysError + { + if (session_->getSftpChannelCount() == 0) //make sure the SSH session contains at least one SFTP channel + SshSession::addSftpChannel({session_.get()}, timeoutSec_); //throw SysError + } + + void executeBlocking(const char* functionName, const std::function& sftpCommand /*noexcept!*/) //throw SysError, SysErrorSftpProtocol + { + assert(threadId_ == std::this_thread::get_id()); + assert(session_->getSftpChannelCount() > 0); + const auto sftpCommandStartTime = std::chrono::steady_clock::now(); + + for (;;) + if (session_->tryNonBlocking(0 /*channelNo*/, sftpCommandStartTime, functionName, sftpCommand, timeoutSec_)) //throw SysError, SysErrorSftpProtocol + return; + else //pending + SshSession::waitForTraffic({session_.get()}, timeoutSec_); //throw SysError + } + + const SshSessionCfg& getSessionCfg() const { return session_->getSessionCfg(); } //thread-safe + + private: + std::unique_ptr session_; //bound! + const std::thread::id threadId_ = std::this_thread::get_id(); + const int timeoutSec_; + }; + + class SshSessionExclusive + { + public: + SshSessionExclusive(std::unique_ptr&& idleSession, int timeoutSec) : + session_(std::move(idleSession)) /*bound!*/, timeoutSec_(timeoutSec) { /*assert(session_->isHealthy());*/ } + + bool tryNonBlocking(size_t channelNo, std::chrono::steady_clock::time_point commandStartTime, const char* functionName, //throw SysError, SysErrorSftpProtocol + const std::function& sftpCommand /*noexcept!*/) + { + return session_->tryNonBlocking(channelNo, commandStartTime, functionName, sftpCommand, timeoutSec_); //throw SysError, SysErrorSftpProtocol + } + + void waitForTraffic() //throw SysError + { + SshSession::waitForTraffic({session_.get()}, timeoutSec_); //throw SysError + } + + size_t getSftpChannelCount() const { return session_->getSftpChannelCount(); } + + void markAsCorrupted() { session_->markAsCorrupted(); } + + static void addSftpChannel(const std::vector& exSessions) //throw SysError + { + std::vector sshSessions; + for (SshSessionExclusive* exSession : exSessions) + sshSessions.push_back(exSession->session_.get()); + + int timeoutSec = 0; + for (SshSessionExclusive* exSession : exSessions) + timeoutSec = std::max(timeoutSec, exSession->timeoutSec_); + + SshSession::addSftpChannel(sshSessions, timeoutSec); //throw SysError + } + + static void waitForTraffic(const std::vector& exSessions) //throw SysError + { + std::vector sshSessions; + for (SshSessionExclusive* exSession : exSessions) + sshSessions.push_back(exSession->session_.get()); + + int timeoutSec = 0; + for (SshSessionExclusive* exSession : exSessions) + timeoutSec = std::max(timeoutSec, exSession->timeoutSec_); + + SshSession::waitForTraffic(sshSessions, timeoutSec); //throw SysError + } + + const SshSessionCfg& getSessionCfg() const { return session_->getSessionCfg(); } //thread-safe + + private: + std::unique_ptr session_; //bound! + const int timeoutSec_; + }; + + + std::shared_ptr getSharedSession(const SftpLogin& login) //throw SysError, SysErrorPassword + { + Protected& sessionCache = getSessionCache(login); + + const std::thread::id threadId = std::this_thread::get_id(); + std::shared_ptr sharedSession; //either or + std::optional sessionCfg; // + + sessionCache.access([&](SshSessionCache& cache) + { + if (!cache.activeCfg) //AFS::authenticateAccess() not called => authenticate implicitly! + setActiveConfig(cache, login); + + std::weak_ptr& sharedSessionWeak = cache.sshSessionsWithThreadAffinity[threadId]; //get or create + if (auto session = sharedSessionWeak.lock()) + //dereference session ONLY after affinity to THIS thread was confirmed!!! + //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use; idle sessions via worker thread) + sharedSession = session; + + if (!sharedSession) + //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use; idle sessions via worker thread) + if (!cache.idleSshSessions.empty()) + { + std::unique_ptr sshSession(cache.idleSshSessions.back().release()); + /**/ cache.idleSshSessions.pop_back(); + sharedSessionWeak = sharedSession = std::make_shared(std::move(sshSession), login.timeoutSec); //still holding lock => constructor must be *fast*! + } + if (!sharedSession) + sessionCfg = *cache.activeCfg; + }); + + //create new SFTP session outside the lock: 1. don't block other threads 2. non-atomic regarding "sessionCache"! => one session too many is not a problem! + if (!sharedSession) + { + sharedSession = std::make_shared(std::unique_ptr(new SshSession(*sessionCfg, login.timeoutSec)), login.timeoutSec); //throw SysError, SysErrorPassword + + sessionCache.access([&](SshSessionCache& cache) + { + if (sharedSession->getSessionCfg() == *cache.activeCfg) //created outside the lock => check *again* + cache.sshSessionsWithThreadAffinity[threadId] = sharedSession; + }); + } + + //finish two-step initialization outside the lock: BLOCKING! + sharedSession->initSftpChannel(); //throw SysError + + return sharedSession; + } + + + std::unique_ptr getExclusiveSession(const SftpLogin& login) //throw SysError + { + std::unique_ptr sshSession; //either or + std::optional sessionCfg; // + + getSessionCache(login).access([&](SshSessionCache& cache) + { + if (!cache.activeCfg) //AFS::authenticateAccess() not called => authenticate implicitly! + setActiveConfig(cache, login); + + //assume "isHealthy()" to avoid hitting server connection limits: (clean up of !isHealthy() after use, idle sessions via worker thread) + if (!cache.idleSshSessions.empty()) + { + sshSession.reset(cache.idleSshSessions.back().release()); + /**/ cache.idleSshSessions.pop_back(); + } + else + sessionCfg = *cache.activeCfg; + }); + + //create new SFTP session outside the lock: 1. don't block other threads 2. non-atomic regarding "sessionCache"! => one session too many is not a problem! + if (!sshSession) + sshSession.reset(new SshSession(*sessionCfg, login.timeoutSec)); //throw SysError, SysErrorPassword + + return std::make_unique(std::move(sshSession), login.timeoutSec); + } + + void setActiveConfig(const SftpLogin& login) + { + getSessionCache(login).access([&](SshSessionCache& cache) { setActiveConfig(cache, login); }); + } + + void setSessionPassword(const SftpLogin& login, const Zstring& password, SftpAuthType authType) + { + getSessionCache(login).access([&](SshSessionCache& cache) + { + (authType == SftpAuthType::password ? cache.sessionPassword : cache.sessionPassphrase) = password; + setActiveConfig(cache, login); + }); + } + +private: + SftpSessionManager (const SftpSessionManager&) = delete; + SftpSessionManager& operator=(const SftpSessionManager&) = delete; + + Protected& getSessionCache(const SshDeviceId& deviceId) + { + //single global session store per login; life-time bound to globalInstance => never remove a sessionCache!!! + Protected* sessionCache = nullptr; + + globalSessionCache_.access([&](GlobalSshSessions& sessionsById) + { + sessionCache = &sessionsById[deviceId]; //get or create + }); + static_assert(std::is_same_v>>, "require std::map so that the pointers we return remain stable"); + + return *sessionCache; + } + + void setActiveConfig(SshSessionCache& cache, const SftpLogin& login) + { + const Zstring password = [&] + { + if (login.authType == SftpAuthType::password || + login.authType == SftpAuthType::keyFile) + { + if (login.password) + return *login.password; + + return login.authType == SftpAuthType::password ? cache.sessionPassword : cache.sessionPassphrase; + } + return Zstring(); + }(); + + if (cache.activeCfg) + { + assert(std::all_of(cache.idleSshSessions.begin(), cache.idleSshSessions.end(), + [&](const std::unique_ptr& session) { return session->getSessionCfg() == cache.activeCfg; })); + + assert(std::all_of(cache.sshSessionsWithThreadAffinity.begin(), cache.sshSessionsWithThreadAffinity.end(), [&](const auto& v) + { + if (std::shared_ptr sharedSession = v.second.lock()) + return sharedSession->getSessionCfg() /*thread-safe!*/ == cache.activeCfg; + return true; + })); + } + else + assert(cache.idleSshSessions.empty() && cache.sshSessionsWithThreadAffinity.empty()); + + const std::optional prevCfg = cache.activeCfg; + + cache.activeCfg = + { + .deviceId{login}, + .authType = login.authType, + .password = password, + .privateKeyFilePath = login.privateKeyFilePath, + .allowZlib = login.allowZlib, + }; + + /* remove incompatible sessions: + - avoid hitting FTP connection limit if some config uses TLS, but not the other: https://freefilesync.org/forum/viewtopic.php?t=8532 + - logically consistent with AFS::compareDevice() + - don't allow different authentication methods, when authenticateAccess() is called *once* per device in getFolderStatusParallel() + - what user expects, e.g. when tesing changed settings in SFTP login dialog */ + if (cache.activeCfg != prevCfg) + { + cache.idleSshSessions .clear(); //run ~SshSession *inside* the lock! => avoid hitting server limits! + cache.sshSessionsWithThreadAffinity.clear(); // + //=> incompatible sessions will be deleted by ReUseOnDelete(); until then: additionally counts towards SFTP connection limit :( + } + } + + //run a dedicated clean-up thread => it's unclear when the server let's a connection time out, so we do it preemptively + //context of worker thread: + void runGlobalSessionCleanUp() //throw ThreadStopRequest + { + std::chrono::steady_clock::time_point lastCleanupTime; + for (;;) + { + const auto now = std::chrono::steady_clock::now(); + + if (now < lastCleanupTime + SFTP_SESSION_CLEANUP_INTERVAL) + interruptibleSleep(lastCleanupTime + SFTP_SESSION_CLEANUP_INTERVAL - now); //throw ThreadStopRequest + + lastCleanupTime = std::chrono::steady_clock::now(); + + std::vector*> sessionCaches; //pointers remain stable, thanks to std::map<> + + globalSessionCache_.access([&](GlobalSshSessions& sessionsById) + { + for (auto& [sessionId, idleSession] : sessionsById) + sessionCaches.push_back(&idleSession); + }); + for (Protected* sessionCache : sessionCaches) + for (;;) + { + bool done = false; + sessionCache->access([&](SshSessionCache& cache) + { + for (std::unique_ptr& sshSession : cache.idleSshSessions) + if (!sshSession->isHealthy()) //!isHealthy() sessions are destroyed after use => in this context this means they have been idle for too long + { + sshSession.swap(cache.idleSshSessions.back()); + /**/ cache.idleSshSessions.pop_back(); //run ~SshSession *inside* the lock! => avoid hitting server limits! + return; //don't hold lock for too long: delete only one session at a time, then yield... + } + std::erase_if(cache.sshSessionsWithThreadAffinity, [](const auto& v) { return v.second.expired(); }); //clean up dangling weak pointer + done = true; + }); + if (done) + break; + std::this_thread::yield(); //outside the lock + } + } + } + + struct SshSessionCache + { + //invariant: all cached sessions correspond to activeCfg at any time! + std::vector> idleSshSessions; //extract *temporarily* from this list during use + std::unordered_map> sshSessionsWithThreadAffinity; //Win32 thread IDs may be REUSED! still, shouldn't be a problem... + + std::optional activeCfg; + + Zstring sessionPassword; //user/password + Zstring sessionPassphrase; //keyfile/passphrase + }; + + using GlobalSshSessions = std::map>; + Protected globalSessionCache_; + + InterruptibleThread sessionCleaner_; +}; + +//-------------------------------------------------------------------------------------- +UniInitializer globalInitSftp(*globalSftpSessionCount.get()); + +constinit Global globalSftpSessionManager; //caveat: life time must be subset of static UniInitializer! +//-------------------------------------------------------------------------------------- + + +void SftpSessionManager::ReUseOnDelete::operator()(SshSession* session) const +{ + //assert(session); -> custom deleter is only called on non-null pointer + if (session->isHealthy()) //thread that created the "!isHealthy()" session is responsible for clean up (avoid hitting server connection limits!) + if (std::shared_ptr mgr = globalSftpSessionManager.get()) + mgr->getSessionCache(session->getSessionCfg().deviceId).access([&](SshSessionCache& cache) + { + assert(cache.activeCfg); + if (cache.activeCfg && session->getSessionCfg() == *cache.activeCfg) + cache.idleSshSessions.emplace_back(std::exchange(session, nullptr)); //pass ownership + }); + delete session; +} + + +std::shared_ptr getSharedSftpSession(const SftpLogin& login) //throw SysError +{ + if (const std::shared_ptr mgr = globalSftpSessionManager.get()) + return mgr->getSharedSession(login); //throw SysError, SysErrorPassword + + throw SysError(formatSystemError("getSharedSftpSession", L"", L"Function call not allowed during init/shutdown.")); +} + + +std::unique_ptr getExclusiveSftpSession(const SftpLogin& login) //throw SysError +{ + if (const std::shared_ptr mgr = globalSftpSessionManager.get()) + return mgr->getExclusiveSession(login); //throw SysError + + throw SysError(formatSystemError("getExclusiveSftpSession", L"", L"Function call not allowed during init/shutdown.")); +} + + +void runSftpCommand(const SftpLogin& login, const char* functionName, + const std::function& sftpCommand /*noexcept!*/) //throw SysError, SysErrorSftpProtocol +{ + std::shared_ptr asyncSession = getSharedSftpSession(login); //throw SysError + //no need to protect against concurrency: shared session is (temporarily) bound to current thread + + asyncSession->executeBlocking(functionName, sftpCommand); //throw SysError, SysErrorSftpProtocol +} + +//=========================================================================================================================== +//=========================================================================================================================== +struct SftpItemDetails +{ + AFS::ItemType type; + uint64_t fileSize; + time_t modTime; +}; +struct SftpItem +{ + Zstring itemName; + SftpItemDetails details; +}; +std::vector getDirContentFlat(const SftpLogin& login, const AfsPath& dirPath) //throw FileError +{ + LIBSSH2_SFTP_HANDLE* dirHandle = nullptr; + try + { + runSftpCommand(login, "libssh2_sftp_opendir", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + dirHandle = ::libssh2_sftp_opendir(sd.sftpChannel, getLibssh2Path(dirPath)); + if (!dirHandle) + return std::min(::libssh2_session_last_errno(sd.sshSession), LIBSSH2_ERROR_SOCKET_NONE); + return LIBSSH2_ERROR_NONE; + }); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open directory %x."), L"%x", fmtPath(getSftpDisplayPath(login, dirPath))), e.toString()); } + + ZEN_ON_SCOPE_EXIT(try + { + runSftpCommand(login, "libssh2_sftp_closedir", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_closedir(dirHandle); }); //noexcept! + } + catch (const SysError& e) { logExtraError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getSftpDisplayPath(login, dirPath))) + L"\n\n" + e.toString()); }); + + std::vector output; + for (;;) + { + std::array buf; //libssh2 sample code uses 512; in practice NAME_MAX(255)+1 should suffice: https://serverfault.com/questions/9546/filename-length-limits-on-linux + LIBSSH2_SFTP_ATTRIBUTES attribs = {}; + int rc = 0; + try + { + runSftpCommand(login, "libssh2_sftp_readdir", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return rc = ::libssh2_sftp_readdir(dirHandle, buf.data(), buf.size(), &attribs); }); //noexcept! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(getSftpDisplayPath(login, dirPath))), e.toString()); } + + if (rc == 0) //no more items + return output; + + const std::string_view sftpItemName = makeStringView(buf.data(), rc); + + if (sftpItemName == "." || sftpItemName == "..") //check needed for SFTP, too! + continue; + + const Zstring& itemName = utfTo(sftpItemName); + const AfsPath itemPath(appendPath(dirPath.value, itemName)); + + if ((attribs.flags & LIBSSH2_SFTP_ATTR_PERMISSIONS) == 0) //server probably does not support these attributes => fail at folder level + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, itemPath))), L"File attributes not available."); + + if (LIBSSH2_SFTP_S_ISLNK(attribs.permissions)) + { + if ((attribs.flags & LIBSSH2_SFTP_ATTR_ACMODTIME) == 0) //server probably does not support these attributes => fail at folder level + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, itemPath))), L"Modification time not supported."); + output.push_back({itemName, {AFS::ItemType::symlink, 0, static_cast(attribs.mtime)}}); + } + else if (LIBSSH2_SFTP_S_ISDIR(attribs.permissions)) + output.push_back({itemName, {AFS::ItemType::folder, 0, static_cast(attribs.mtime)}}); + else //a file or named pipe, ect: LIBSSH2_SFTP_S_ISREG, LIBSSH2_SFTP_S_ISCHR, LIBSSH2_SFTP_S_ISBLK, LIBSSH2_SFTP_S_ISFIFO, LIBSSH2_SFTP_S_ISSOCK + { + if ((attribs.flags & LIBSSH2_SFTP_ATTR_ACMODTIME) == 0) //server probably does not support these attributes => fail at folder level + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, itemPath))), L"Modification time not supported."); + if ((attribs.flags & LIBSSH2_SFTP_ATTR_SIZE) == 0) + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, itemPath))), L"File size not supported."); + output.push_back({itemName, {AFS::ItemType::file, attribs.filesize, static_cast(attribs.mtime)}}); + } + } +} + + +SftpItemDetails getSymlinkTargetDetails(const SftpLogin& login, const AfsPath& linkPath) //throw FileError +{ + LIBSSH2_SFTP_ATTRIBUTES attribsTrg = {}; + try + { + runSftpCommand(login, "libssh2_sftp_stat", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_stat(sd.sftpChannel, getLibssh2Path(linkPath), &attribsTrg); }); //noexcept! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(getSftpDisplayPath(login, linkPath))), e.toString()); } + + if ((attribsTrg.flags & LIBSSH2_SFTP_ATTR_PERMISSIONS) == 0) + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, linkPath))), L"File attributes not available."); + + if (LIBSSH2_SFTP_S_ISDIR(attribsTrg.permissions)) + return {AFS::ItemType::folder, 0, static_cast(attribsTrg.mtime)}; + else + { + if ((attribsTrg.flags & LIBSSH2_SFTP_ATTR_ACMODTIME) == 0) //server probably does not support these attributes => should fail at folder level! + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, linkPath))), L"Modification time not supported."); + if ((attribsTrg.flags & LIBSSH2_SFTP_ATTR_SIZE) == 0) + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getSftpDisplayPath(login, linkPath))), L"File size not supported."); + + return {AFS::ItemType::file, attribsTrg.filesize, static_cast(attribsTrg.mtime)}; + } +} + + +class SingleFolderTraverser +{ +public: + using WorkItem = std::pair>; + + SingleFolderTraverser(const SftpLogin& login, const std::vector>>& workload /*throw X*/) : + login_(login) + { + for (const auto& [folderPath, cb] : workload) + workload_.push_back(WorkItem{folderPath, cb}); + + while (!workload_.empty()) + { + auto wi = std::move(workload_. front()); //yes, no strong exception guarantee (std::bad_alloc) + /**/ workload_.pop_front(); // + const auto& [folderPath, cb] = wi; + + tryReportingDirError([&] //throw X + { + traverseWithException(folderPath, *cb); //throw FileError, X + }, *cb); + } + } + +private: + SingleFolderTraverser (const SingleFolderTraverser&) = delete; + SingleFolderTraverser& operator=(const SingleFolderTraverser&) = delete; + + void traverseWithException(const AfsPath& dirPath, AFS::TraverserCallback& cb) //throw FileError, X + { + for (const SftpItem& item : getDirContentFlat(login_, dirPath)) //throw FileError + { + const AfsPath itemPath(appendPath(dirPath.value, item.itemName)); + + switch (item.details.type) + { + case AFS::ItemType::file: + cb.onFile({item.itemName, item.details.fileSize, item.details.modTime, AFS::FingerPrint() /*not supported by SFTP*/, false /*isFollowedSymlink*/}); //throw X + break; + + case AFS::ItemType::folder: + if (std::shared_ptr cbSub = cb.onFolder({item.itemName, false /*isFollowedSymlink*/})) //throw X + workload_.push_back(WorkItem{itemPath, std::move(cbSub)}); + break; + + case AFS::ItemType::symlink: + switch (cb.onSymlink({item.itemName, item.details.modTime})) //throw X + { + case AFS::TraverserCallback::HandleLink::follow: + { + SftpItemDetails targetDetails = {}; + if (!tryReportingItemError([&] //throw X + { + targetDetails = getSymlinkTargetDetails(login_, itemPath); //throw FileError + }, cb, item.itemName)) + continue; + + if (targetDetails.type == AFS::ItemType::folder) + { + if (std::shared_ptr cbSub = cb.onFolder({item.itemName, true /*isFollowedSymlink*/})) //throw X + workload_.push_back(WorkItem{itemPath, std::move(cbSub)}); + } + else //a file or named pipe, etc. + cb.onFile({item.itemName, targetDetails.fileSize, targetDetails.modTime, AFS::FingerPrint() /*not supported by SFTP*/, true /*isFollowedSymlink*/}); //throw X + } + break; + + case AFS::TraverserCallback::HandleLink::skip: + break; + } + break; + } + } + } + + const SftpLogin login_; + RingBuffer workload_; +}; + + +void traverseFolderRecursiveSftp(const SftpLogin& login, const std::vector>>& workload /*throw X*/, size_t) //throw X +{ + SingleFolderTraverser dummy(login, workload); //throw X +} + +//=========================================================================================================================== + +struct InputStreamSftp : public AFS::InputStream +{ + InputStreamSftp(const SftpLogin& login, const AfsPath& filePath) : //throw FileError + displayPath_(getSftpDisplayPath(login, filePath)) + { + try + { + session_ = getSharedSftpSession(login); //throw SysError + + session_->executeBlocking("libssh2_sftp_open", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + fileHandle_ = ::libssh2_sftp_open(sd.sftpChannel, getLibssh2Path(filePath), LIBSSH2_FXF_READ, 0); + if (!fileHandle_) + return std::min(::libssh2_session_last_errno(sd.sshSession), LIBSSH2_ERROR_SOCKET_NONE); + return LIBSSH2_ERROR_NONE; + }); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(displayPath_)), e.toString()); } + } + + ~InputStreamSftp() + { + try + { + session_->executeBlocking("libssh2_sftp_close", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_close(fileHandle_); }); //noexcept! + } + catch (const SysError& e) { logExtraError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(displayPath_)) + L"\n\n" + e.toString()); } + } + + size_t getBlockSize() override { return SFTP_OPTIMAL_BLOCK_SIZE_READ; } //throw (FileError); non-zero block size is AFS contract! + + //may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + size_t tryRead(void* buffer, size_t bytesToRead, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, (ErrorFileLocked), X + { + //libssh2_sftp_read has same semantics as Posix read: + if (bytesToRead == 0) //"read() with a count of 0 returns zero" => indistinguishable from end of file! => check! + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + assert(bytesToRead % getBlockSize() == 0); + + ssize_t bytesRead = 0; + try + { + session_->executeBlocking("libssh2_sftp_read", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + bytesRead = ::libssh2_sftp_read(fileHandle_, static_cast(buffer), bytesToRead); + return static_cast(bytesRead); + }); + + ASSERT_SYSERROR(makeUnsigned(bytesRead) <= bytesToRead); //better safe than sorry (user should never see this) + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(displayPath_)), e.toString()); } + + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesRead); //throw X + return bytesRead; //"zero indicates end of file" + } + + std::optional tryGetAttributesFast() override { return {}; }//throw FileError + //although we have an SFTP stream handle, attribute access requires an extra (expensive) round-trip! + //PERF: test case 148 files, 1MB: overall copy time increases by 20% if libssh2_sftp_fstat() gets called per each file + +private: + const std::wstring displayPath_; + LIBSSH2_SFTP_HANDLE* fileHandle_ = nullptr; + std::shared_ptr session_; +}; + +//=========================================================================================================================== + +//libssh2_sftp_open fails with generic LIBSSH2_FX_FAILURE if already existing +struct OutputStreamSftp : public AFS::OutputStreamImpl +{ + OutputStreamSftp(const SftpLogin& login, //throw FileError + const AfsPath& filePath, + std::optional modTime) : + login_(login), + filePath_(filePath), + modTime_(modTime) + { + try + { + session_ = getSharedSftpSession(login); //throw SysError + + session_->executeBlocking("libssh2_sftp_open", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + fileHandle_ = ::libssh2_sftp_open(sd.sftpChannel, getLibssh2Path(filePath), + LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_EXCL, + SFTP_DEFAULT_PERMISSION_FILE); //note: server may also apply umask! (e.g. 0022 for ffs.org) + if (!fileHandle_) + return std::min(::libssh2_session_last_errno(sd.sshSession), LIBSSH2_ERROR_SOCKET_NONE); + return LIBSSH2_ERROR_NONE; + }); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getSftpDisplayPath(login_, filePath_))), e.toString()); } + + //NOTE: fileHandle_ still unowned until end of constructor!!! + + //pre-allocate file space? not supported + } + + ~OutputStreamSftp() + { + if (fileHandle_) //=> cleanup non-finalized output file + { + if (!closeFailed_) //otherwise there's no much point in calling libssh2_sftp_close() a second time => let it leak!? + try { close(); /*throw FileError*/ } + catch (const FileError& e) { logExtraError(e.toString()); } + + session_.reset(); //reset before file deletion to potentially get new session if !SshSession::isHealthy() + + try //see removeFilePlain() + { + runSftpCommand(login_, "libssh2_sftp_unlink", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_unlink(sd.sftpChannel, getLibssh2Path(filePath_)); }); //noexcept! + } + catch (const SysError& e) + { + logExtraError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getSftpDisplayPath(login_, filePath_))) + L"\n\n" + e.toString()); + } + } + } + + size_t getBlockSize() override { return SFTP_OPTIMAL_BLOCK_SIZE_WRITE; } //throw (FileError) + + size_t tryWrite(const void* buffer, size_t bytesToWrite, const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X; may return short! CONTRACT: bytesToWrite > 0 + { + if (bytesToWrite == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + assert(bytesToWrite % getBlockSize() == 0 || bytesToWrite < getBlockSize()); + + ssize_t bytesWritten = 0; + try + { + session_->executeBlocking("libssh2_sftp_write", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + bytesWritten = ::libssh2_sftp_write(fileHandle_, static_cast(buffer), bytesToWrite); + /* "If this function returns zero it should not be considered an error, but simply that there was no error but yet no payload data got sent to the other end." + => sounds like BS, but is it really true!? + From the libssh2_sftp_write code it appears that the function always waits for at least one "ack", unless we give it so much data _libssh2_channel_write() can't sent it all! */ + assert(bytesWritten != 0); + return static_cast(bytesWritten); + }); + + ASSERT_SYSERROR(makeUnsigned(bytesWritten) <= bytesToWrite); //better safe than sorry + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getSftpDisplayPath(login_, filePath_))), e.toString()); } + + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesWritten); //throw X! + + return bytesWritten; + } + + AFS::FinalizeResult finalize(const IoCallback& notifyUnbufferedIO /*throw X*/) override //throw FileError, X + { + close(); //throw FileError + //output finalized => no more exceptions from here on! + //-------------------------------------------------------------------- + + AFS::FinalizeResult result; + //result.filePrint = ... -> not supported by SFTP + try + { + setModTimeIfAvailable(); //throw FileError, follows symlinks + /* is setting modtime after closing the file handle a pessimization? + SFTP: no, needed for functional correctness (synology server), same as for Native */ + } + catch (const FileError& e) { result.errorModTime = e; /*slicing?*/ } + + return result; + } + +private: + void close() //throw FileError + { + if (!fileHandle_) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + try + { + session_->executeBlocking("libssh2_sftp_close", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_close(fileHandle_); }); //noexcept! + + fileHandle_ = nullptr; + } + catch (const SysError& e) + { + closeFailed_ = true; + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getSftpDisplayPath(login_, filePath_))), e.toString()); + } + } + + void setModTimeIfAvailable() const //throw FileError, follows symlinks + { + assert(!fileHandle_); + if (modTime_) + { + LIBSSH2_SFTP_ATTRIBUTES attribNew = {}; + attribNew.flags = LIBSSH2_SFTP_ATTR_ACMODTIME; + attribNew.mtime = static_cast(*modTime_); //32-bit target! loss of data! + attribNew.atime = static_cast(::time(nullptr)); // + + //it seems libssh2_sftp_fsetstat() triggers bugs on synology server => set mtime by path! https://freefilesync.org/forum/viewtopic.php?t=1281 + try + { + session_->executeBlocking("libssh2_sftp_setstat", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_setstat(sd.sftpChannel, getLibssh2Path(filePath_), &attribNew); }); //noexcept! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write modification time of %x."), L"%x", fmtPath(getSftpDisplayPath(login_, filePath_))), e.toString()); } + } + } + + const SftpLogin login_; + const AfsPath filePath_; + const std::optional modTime_; + LIBSSH2_SFTP_HANDLE* fileHandle_ = nullptr; + bool closeFailed_ = false; + std::shared_ptr session_; +}; + +//=========================================================================================================================== + +class SftpFileSystem : public AbstractFileSystem +{ +public: + explicit SftpFileSystem(const SftpLogin& login) : login_(login) {} + + const SftpLogin& getLogin() const { return login_; } + + AfsPath getHomePath() const //throw FileError + { + try + { + //we never ever change the SFTP working directory, right? ...right? + return getServerRealPath("."); //throw SysError + //use "~" instead? NO: libssh2_sftp_realpath() fails with LIBSSH2_FX_NO_SUCH_FILE + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getDisplayPath(AfsPath(Zstr("~"))))), e.toString()); } + } + +private: + Zstring getInitPathPhrase(const AfsPath& itemPath) const override { return concatenateSftpFolderPathPhrase(login_, itemPath); } + + std::vector getPathPhraseAliases(const AfsPath& itemPath) const override + { + std::vector pathAliases; + + if (login_.authType != SftpAuthType::keyFile || login_.privateKeyFilePath.empty()) + pathAliases.push_back(concatenateSftpFolderPathPhrase(login_, itemPath)); + else //why going crazy with key path aliases!? because we can... + for (const Zstring& pathPhrase : ::getPathPhraseAliases(login_.privateKeyFilePath)) + { + auto loginTmp = login_; + loginTmp.privateKeyFilePath = pathPhrase; + + pathAliases.push_back(concatenateSftpFolderPathPhrase(loginTmp, itemPath)); + } + return pathAliases; + } + + std::wstring getDisplayPath(const AfsPath& itemPath) const override { return getSftpDisplayPath(login_, itemPath); } + + bool isNullFileSystem() const override { return login_.server.empty(); } + + std::weak_ordering compareDeviceSameAfsType(const AbstractFileSystem& afsRhs) const override + { + const SftpLogin& lhs = login_; + const SftpLogin& rhs = static_cast(afsRhs).login_; + + return SshDeviceId(lhs) <=> SshDeviceId(rhs); + } + + //---------------------------------------------------------------------------------------------------------------- + ItemType getItemTypeImpl(const AfsPath& itemPath) const //throw SysError, SysErrorSftpProtocol + { + LIBSSH2_SFTP_ATTRIBUTES attr = {}; + runSftpCommand(login_, "libssh2_sftp_lstat", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_lstat(sd.sftpChannel, getLibssh2Path(itemPath), &attr); }); //noexcept! + + if ((attr.flags & LIBSSH2_SFTP_ATTR_PERMISSIONS) == 0) + throw SysError(formatSystemError("libssh2_sftp_lstat", L"", L"File attributes not available.")); + + if (LIBSSH2_SFTP_S_ISLNK(attr.permissions)) + return ItemType::symlink; + if (LIBSSH2_SFTP_S_ISDIR(attr.permissions)) + return ItemType::folder; + return ItemType::file; //LIBSSH2_SFTP_S_ISREG || LIBSSH2_SFTP_S_ISCHR || LIBSSH2_SFTP_S_ISBLK || LIBSSH2_SFTP_S_ISFIFO || LIBSSH2_SFTP_S_ISSOCK + } + + ItemType getItemType(const AfsPath& itemPath) const override //throw FileError + { + try + { + return getItemTypeImpl(itemPath); //throw SysError, SysErrorSftpProtocol + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); + } + } + + std::optional getItemTypeIfExists(const AfsPath& itemPath) const override //throw FileError + { + try + { + try + { + //fast check: 1. perf 2. expected by getFolderStatusNonBlocking() 3. traversing non-existing folder below MIGHT NOT FAIL (e.g. for SFTP on AWS) + return getItemTypeImpl(itemPath); //throw SysError, SysErrorSftpProtocol + } + catch (const SysErrorSftpProtocol& e) + { + const std::optional parentPath = getParentPath(itemPath); + if (!parentPath) //device root => quick access test + throw; + //let's dig deeper, but *only* for SysErrorSftpProtocol, not for general connection issues + //+ check if SFTP error code sounds like "not existing" + if (e.sftpErrorCode == LIBSSH2_FX_NO_SUCH_FILE || + e.sftpErrorCode == LIBSSH2_FX_NO_SUCH_PATH) //-> not seen yet, but sounds reasonable + { + if (const std::optional parentType = getItemTypeIfExists(*parentPath)) //throw FileError + { + if (*parentType == ItemType::file /*obscure, but possible*/) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(*parentPath)))); + + const Zstring itemName = getItemName(itemPath); + assert(!itemName.empty()); + + traverseFolder(*parentPath, //throw FileError + [&](const FileInfo& fi) { if (fi.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }, + [&](const FolderInfo& fi) { if (fi.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }, + [&](const SymlinkInfo& si) { if (si.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }); + //- case-sensitive comparison! itemPath must be normalized! + //- finding the item after getItemType() previously failed is exceptional + } + return std::nullopt; + } + else + throw; + } + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(itemPath))), e.toString()); + } + } + + //---------------------------------------------------------------------------------------------------------------- + //already existing: fail + void createFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try + { + //fails with obscure LIBSSH2_FX_FAILURE if already existing + runSftpCommand(login_, "libssh2_sftp_mkdir", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + return ::libssh2_sftp_mkdir(sd.sftpChannel, getLibssh2Path(folderPath), SFTP_DEFAULT_PERMISSION_FOLDER); + //less explicit variant: return ::libssh2_sftp_mkdir(sd.sftpChannel, getLibssh2Path(folderPath), LIBSSH2_SFTP_DEFAULT_MODE); + }); + } + catch (const SysError& e) //libssh2_sftp_mkdir reports generic LIBSSH2_FX_FAILURE if existing + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); + } + } + + void removeFilePlain(const AfsPath& filePath) const override //throw FileError + { + try + { + runSftpCommand(login_, "libssh2_sftp_unlink", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_unlink(sd.sftpChannel, getLibssh2Path(filePath)); }); //noexcept! + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getDisplayPath(filePath))), e.toString()); + } + } + + void removeSymlinkPlain(const AfsPath& linkPath) const override //throw FileError + { + try + { + runSftpCommand(login_, "libssh2_sftp_unlink", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_unlink(sd.sftpChannel, getLibssh2Path(linkPath)); }); //noexcept! + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete symbolic link %x."), L"%x", fmtPath(getDisplayPath(linkPath))), e.toString()); + } + } + + void removeFolderPlain(const AfsPath& folderPath) const override //throw FileError + { + try + { + //libssh2_sftp_rmdir fails for symlinks! (LIBSSH2_ERROR_SFTP_PROTOCOL: LIBSSH2_FX_NO_SUCH_FILE) + runSftpCommand(login_, "libssh2_sftp_rmdir", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_rmdir(sd.sftpChannel, getLibssh2Path(folderPath)); }); //noexcept! + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(getDisplayPath(folderPath))), e.toString()); + } + } + + void removeFolderIfExistsRecursion(const AfsPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, + const std::function& onBeforeSymlinkDeletion/*throw X*/, + const std::function& onBeforeFolderDeletion /*throw X*/) const override + { + //default implementation: folder traversal + AFS::removeFolderIfExistsRecursion(folderPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion); //throw FileError, X + } + + //---------------------------------------------------------------------------------------------------------------- + AfsPath getServerRealPath(const std::string& sftpPath) const //throw SysError + { + const size_t bufSize = 10000; + std::vector buf(bufSize + 1); //ensure buffer is always null-terminated since we don't evaluate the byte count returned by libssh2_sftp_realpath()! + + int rc = 0; + runSftpCommand(login_, "libssh2_sftp_realpath", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return rc = ::libssh2_sftp_realpath(sd.sftpChannel, sftpPath, buf.data(), bufSize); }); //noexcept! + + const std::string_view sftpPathTrg = makeStringView(buf.data(), rc); + if (!startsWith(sftpPathTrg, '/')) + throw SysError(replaceCpy(L"Invalid path %x.", L"%x", fmtPath(utfTo(sftpPathTrg)))); + + return sanitizeDeviceRelativePath(utfTo(sftpPathTrg)); //code-reuse! but the sanitize part isn't really needed here... + } + + AbstractPath getSymlinkResolvedPath(const AfsPath& linkPath) const override //throw FileError + { + try + { + const AfsPath linkPathTrg = getServerRealPath(getLibssh2Path(linkPath)); //throw SysError + return AbstractPath(makeSharedRef(login_), linkPathTrg); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(getDisplayPath(linkPath))), e.toString()); } + } + + static std::string getSymlinkContentImpl(const SftpFileSystem& sftpFs, const AfsPath& linkPath) //throw SysError + { + std::string buf(10000, '\0'); + int rc = 0; + + runSftpCommand(sftpFs.login_, "libssh2_sftp_readlink", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return rc = ::libssh2_sftp_readlink(sd.sftpChannel, getLibssh2Path(linkPath), buf.data(), buf.size()); }); //noexcept! + + ASSERT_SYSERROR(makeUnsigned(rc) <= buf.size()); //better safe than sorry + + buf.resize(rc); + return buf; + } + + bool equalSymlinkContentForSameAfsType(const AfsPath& linkPathL, const AbstractPath& linkPathR) const override //throw FileError + { + auto getLinkContent = [](const SftpFileSystem& sftpFs, const AfsPath& linkPath) + { + try + { + return getSymlinkContentImpl(sftpFs, linkPath); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(sftpFs.getDisplayPath(linkPath))), e.toString()); } + }; + return getLinkContent(*this, linkPathL) == getLinkContent(static_cast(linkPathR.afsDevice.ref()), linkPathR.afsPath); //throw FileError + } + //---------------------------------------------------------------------------------------------------------------- + + //return value always bound: + std::unique_ptr getInputStream(const AfsPath& filePath) const override //throw FileError, (ErrorFileLocked) + { + return std::make_unique(login_, filePath); //throw FileError + } + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> actual behavior: fail with obscure LIBSSH2_FX_FAILURE error + std::unique_ptr getOutputStream(const AfsPath& filePath, //throw FileError + std::optional streamSize, + std::optional modTime) const override + { + return std::make_unique(login_, filePath, modTime); //throw FileError + } + + //---------------------------------------------------------------------------------------------------------------- + void traverseFolderRecursive(const TraverserWorkload& workload /*throw X*/, size_t parallelOps) const override + { + traverseFolderRecursiveSftp(login_, workload /*throw X*/, parallelOps); //throw X + } + //---------------------------------------------------------------------------------------------------------------- + + //symlink handling: follow + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + FileCopyResult copyFileForSameAfsType(const AfsPath& sourcePath, const StreamAttributes& attrSource, //throw FileError, (ErrorFileLocked), X + const AbstractPath& targetPath, bool copyFilePermissions, const IoCallback& notifyUnbufferedIO /*throw X*/) const override + { + //no native SFTP file copy => use stream-based file copy: + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + return copyFileAsStream(sourcePath, attrSource, targetPath, notifyUnbufferedIO); //throw FileError, (ErrorFileLocked), X + } + + //symlink handling: follow + //already existing: fail + void copyNewFolderForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + //already existing: fail + AFS::createFolderPlain(targetPath); //throw FileError + + if (copyFilePermissions) + throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(AFS::getDisplayPath(targetPath))), _("Operation not supported by device.")); + } + + //already existing: fail (SSH_FX_FAILURE) + void copySymlinkForSameAfsType(const AfsPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions) const override //throw FileError + { + try + { + const std::string buf = getSymlinkContentImpl(*this, sourcePath); //throw SysError + + runSftpCommand(static_cast(targetPath.afsDevice.ref()).login_, "libssh2_sftp_symlink", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + return ::libssh2_sftp_symlink(sd.sftpChannel, getLibssh2Path(targetPath.afsPath), buf); + }); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."), + L"%x", L'\n' + fmtPath(getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(targetPath))), e.toString()); + } + } + + //already existing: undefined behavior! (e.g. fail/overwrite) + //=> actual behavior: fail with obscure LIBSSH2_FX_FAILURE error + void moveAndRenameItemForSameAfsType(const AfsPath& pathFrom, const AbstractPath& pathTo) const override //throw FileError, ErrorMoveUnsupported + { + if (compareDeviceSameAfsType(pathTo.afsDevice.ref()) != std::weak_ordering::equivalent) + throw ErrorMoveUnsupported(generateMoveErrorMsg(pathFrom, pathTo), _("Operation not supported between different devices.")); + + try + { + runSftpCommand(login_, "libssh2_sftp_rename", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) //noexcept! + { + /* LIBSSH2_SFTP_RENAME_NATIVE: "The server is free to do the rename operation in whatever way it chooses. Any other set flags are to be taken as hints to the server." No, thanks! + LIBSSH2_SFTP_RENAME_OVERWRITE: "No overwriting rename in [SFTP] v3/v4" https://www.greenend.org.uk/rjk/sftp/sftpversions.html + + Test: LIBSSH2_SFTP_RENAME_OVERWRITE is not honored on freefilesync.org, no matter if LIBSSH2_SFTP_RENAME_NATIVE is set or not + => makes sense since SFTP v3 does not honor the additional flags that libssh2 sends! + + "... the most widespread SFTP server implementation, the OpenSSH, will fail the SSH_FXP_RENAME request if the target file already exists" + => incidentally this is just the behavior we want! */ + const std::string sftpPathOld = getLibssh2Path(pathFrom); + const std::string sftpPathNew = getLibssh2Path(pathTo.afsPath); + + return ::libssh2_sftp_rename(sd.sftpChannel, sftpPathOld, sftpPathNew, LIBSSH2_SFTP_RENAME_ATOMIC); + }); + } + catch (const SysError& e) //libssh2_sftp_rename_ex reports generic LIBSSH2_FX_FAILURE if target is already existing! + { + throw FileError(generateMoveErrorMsg(pathFrom, pathTo), e.toString()); + } + } + + bool supportsPermissions(const AfsPath& folderPath) const override { return false; } //throw FileError + //wait until there is real demand for copying from and to SFTP with permissions => use stream-based file copy: + + //---------------------------------------------------------------------------------------------------------------- + FileIconHolder getFileIcon (const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + ImageHolder getThumbnailImage(const AfsPath& filePath, int pixelSize) const override { return {}; } //throw FileError; optional return value + + void authenticateAccess(const RequestPasswordFun& requestPassword /*throw X*/) const override //throw FileError, X + { + try + { + const std::shared_ptr mgr = globalSftpSessionManager.get(); + if (!mgr) + throw SysError(formatSystemError("getSessionPassword", L"", L"Function call not allowed during init/shutdown.")); + + mgr->setActiveConfig(login_); + + if (login_.authType == SftpAuthType::password || + login_.authType == SftpAuthType::keyFile) + if (!login_.password) + { + try //1. test for connection error *before* bothering user to enter a password + { + /*auto session =*/ mgr->getSharedSession(login_); //throw SysError, SysErrorPassword + return; //got new SshSession (connected in constructor) or already connected session from cache + } + catch (const SysErrorPassword& e) + { + if (!requestPassword) + throw SysError(e.toString() + L'\n' + _("Password prompt not permitted by current settings.")); + } + + std::wstring lastErrorMsg; + for (;;) + { + //2. request (new) password + std::wstring msg = replaceCpy(_("Please enter your password to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))); + if (lastErrorMsg.empty()) + msg += L"\n" + _("The password will only be remembered until FreeFileSync is closed."); + + const Zstring password = requestPassword(msg, lastErrorMsg); //throw X + mgr->setSessionPassword(login_, password, login_.authType); + + try //3. test access: + { + /*auto session =*/ mgr->getSharedSession(login_); //throw SysError, SysErrorPassword + return; + } + catch (const SysErrorPassword& e) { lastErrorMsg = e.toString(); } + } + } + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(getDisplayPath(AfsPath()))), e.toString()); } + } + + bool hasNativeTransactionalCopy() const override { return false; } + //---------------------------------------------------------------------------------------------------------------- + + int64_t getFreeDiskSpace(const AfsPath& folderPath) const override //throw FileError, returns < 0 if not available + { + //statvfs is an SFTP v3 extension and not supported by all server implementations + //Mikrotik SFTP server fails with LIBSSH2_FX_OP_UNSUPPORTED and corrupts session so that next SFTP call will hang + //(Server sends a duplicate SSH_FX_OP_UNSUPPORTED response with seemingly corrupt body and fails to respond from now on) + //https://freefilesync.org/forum/viewtopic.php?t=618 + //Just discarding the current session is not enough in all cases, e.g. 1. Open SFTP file handle 2. statvfs fails 3. must close file handle + return -1; +#if 0 + const std::string sftpPath = "/"; //::libssh2_sftp_statvfs will fail if path is not yet existing, OTOH root path should work, too? + //NO, for correctness we must check free space for the given folder!! + + //"It is unspecified whether all members of the returned struct have meaningful values on all file systems." + LIBSSH2_SFTP_STATVFS fsStats = {}; + try + { + runSftpCommand(login_, "libssh2_sftp_statvfs", //throw SysError, SysErrorSftpProtocol + [&](const SshSession::Details& sd) { return ::libssh2_sftp_statvfs(sd.sftpChannel, sftpPath.c_str(), sftpPath.size(), &fsStats); }); //noexcept! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine free disk space for %x."), L"%x", fmtPath(getDisplayPath(L"/"))), e.toString()); } + + static_assert(sizeof(fsStats.f_bsize) >= 8); + return fsStats.f_bsize * fsStats.f_bavail; +#endif + } + + std::unique_ptr createRecyclerSession(const AfsPath& folderPath) const override //throw FileError, RecycleBinUnavailable + { + throw RecycleBinUnavailable(replaceCpy(_("The recycle bin is not available for %x."), L"%x", fmtPath(getDisplayPath(folderPath)))); + } + + void moveToRecycleBin(const AfsPath& itemPath) const override //throw FileError, RecycleBinUnavailable + { + throw RecycleBinUnavailable(replaceCpy(_("The recycle bin is not available for %x."), L"%x", fmtPath(getDisplayPath(itemPath)))); + } + + const SftpLogin login_; +}; + +//=========================================================================================================================== + +//expects "clean" login data +Zstring concatenateSftpFolderPathPhrase(const SftpLogin& login, const AfsPath& folderPath) //noexcept +{ + Zstring username; + if (!login.username.empty()) + username = encodeFtpUsername(login.username) + Zstr("@"); + + Zstring server = login.server; + if (parseIpv6Address(server) && login.portCfg > 0) + server = Zstr('[') + server + Zstr(']'); //e.g. [::1]:80 + + Zstring port; + if (login.portCfg > 0) + port = Zstr(':') + numberTo(login.portCfg); + + Zstring relPath = getServerRelPath(folderPath); + if (relPath == Zstr("/")) + relPath.clear(); + + const SftpLogin loginDefault; + + Zstring options; + if (login.timeoutSec != loginDefault.timeoutSec) + options += Zstr("|timeout=") + numberTo(login.timeoutSec); + + if (login.traverserChannelsPerConnection != loginDefault.traverserChannelsPerConnection) + options += Zstr("|chan=") + numberTo(login.traverserChannelsPerConnection); + + if (login.allowZlib) + options += Zstr("|zlib"); + + switch (login.authType) + { + case SftpAuthType::password: + break; + + case SftpAuthType::keyFile: + options += Zstr("|keyfile=") + login.privateKeyFilePath; + break; + + case SftpAuthType::agent: + options += Zstr("|agent"); + break; + } + + if (login.authType != SftpAuthType::agent) + { + if (login.password) + { + if (!login.password->empty()) //password always last => visually truncated by folder input field + options += Zstr("|pass64=") + encodePasswordBase64(*login.password); + } + else + options += Zstr("|pwprompt"); + } + + return Zstring(sftpPrefix) + Zstr("//") + username + server + port + relPath + options; +} +} + + +void fff::sftpInit() +{ + assert(!globalSftpSessionManager.get()); + globalSftpSessionManager.set(std::make_unique()); +} + + +void fff::sftpTeardown() +{ + assert(globalSftpSessionManager.get()); + globalSftpSessionManager.set(nullptr); +} + + +AfsPath fff::getSftpHomePath(const SftpLogin& login) //throw FileError +{ + return SftpFileSystem(login).getHomePath(); //throw FileError +} + + +AfsDevice fff::condenseToSftpDevice(const SftpLogin& login) //noexcept +{ + //clean up input: + SftpLogin loginTmp = login; + trim(loginTmp.server); + trim(loginTmp.username); + trim(loginTmp.privateKeyFilePath); + + loginTmp.timeoutSec = std::max(1, loginTmp.timeoutSec); + loginTmp.traverserChannelsPerConnection = std::max(1, loginTmp.traverserChannelsPerConnection); + + if (startsWithAsciiNoCase(loginTmp.server, "http:" ) || + startsWithAsciiNoCase(loginTmp.server, "https:") || + startsWithAsciiNoCase(loginTmp.server, "ftp:" ) || + startsWithAsciiNoCase(loginTmp.server, "ftps:" ) || + startsWithAsciiNoCase(loginTmp.server, "sftp:" )) + loginTmp.server = afterFirst(loginTmp.server, Zstr(':'), IfNotFoundReturn::none); + trim(loginTmp.server, TrimSide::both, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); }); + + if (std::optional> ip6AndPort = parseIpv6Address(loginTmp.server)) + loginTmp.server = ip6AndPort->first; //remove IPv6 leading/trailing brackets + + return makeSharedRef(loginTmp); +} + + +SftpLogin fff::extractSftpLogin(const AfsDevice& afsDevice) //noexcept +{ + if (const auto sftpDevice = dynamic_cast(&afsDevice.ref())) + return sftpDevice->getLogin(); + + assert(false); + return {}; +} + + +int fff::getServerMaxChannelsPerConnection(const SftpLogin& login) //throw FileError +{ + try + { + const auto timeoutTime = std::chrono::steady_clock::now() + SFTP_CHANNEL_LIMIT_DETECTION_TIME_OUT; + + std::unique_ptr exSession = getExclusiveSftpSession(login); //throw SysError + + ZEN_ON_SCOPE_EXIT(exSession->markAsCorrupted()); //after hitting the server limits, the session might have gone bananas (e.g. server fails on all requests) + + for (;;) + { + try + { + SftpSessionManager::SshSessionExclusive::addSftpChannel({exSession.get()}); //throw SysError + } + catch (SysError&) { if (exSession->getSftpChannelCount() == 0) throw; return static_cast(exSession->getSftpChannelCount()); } + + if (std::chrono::steady_clock::now() > timeoutTime) + throw SysError(_P("Operation timed out after 1 second.", "Operation timed out after %x seconds.", + std::chrono::seconds(SFTP_CHANNEL_LIMIT_DETECTION_TIME_OUT).count()) + L' ' + + replaceCpy(_("Failed to open SFTP channel number %x."), L"%x", formatNumber(exSession->getSftpChannelCount() + 1))); + } + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Unable to connect to %x."), L"%x", fmtPath(login.server)), e.toString()); + } +} + + +bool fff::acceptsItemPathPhraseSftp(const Zstring& itemPathPhrase) //noexcept +{ + Zstring path = expandMacros(itemPathPhrase); //expand before trimming! + trim(path); + return startsWithAsciiNoCase(path, sftpPrefix); //check for explicit SFTP path +} + + +/* syntax: sftp://[[:]@][:port]/[|option_name=value] + + e.g. sftp://user001:secretpassword@private.example.com:222/mydirectory/ + sftp://user001:secretpassword@[::1]:80/ipv6folder/ + sftp://user001:secretpassword@::1/ipv6withoutPort/ + sftp://user001@private.example.com/mydirectory|con=2|cpc=10|keyfile=%AppData%\id_rsa|pass64=c2VjcmV0cGFzc3dvcmQ */ +AbstractPath fff::createItemPathSftp(const Zstring& itemPathPhrase) //noexcept +{ + Zstring pathPhrase = expandMacros(itemPathPhrase); //expand before trimming! + trim(pathPhrase); + + if (startsWithAsciiNoCase(pathPhrase, sftpPrefix)) + pathPhrase = pathPhrase.c_str() + strLength(sftpPrefix); + trim(pathPhrase, TrimSide::left, [](Zchar c) { return c == Zstr('/') || c == Zstr('\\'); }); + + const ZstringView credentials = beforeFirst(pathPhrase, Zstr('@'), IfNotFoundReturn::none); + const ZstringView fullPathOpt = afterFirst(pathPhrase, Zstr('@'), IfNotFoundReturn::all); + + SftpLogin login; + login.username = decodeFtpUsername(Zstring(beforeFirst(credentials, Zstr(':'), IfNotFoundReturn::all))); //support standard FTP syntax, even though + login.password = Zstring( afterFirst(credentials, Zstr(':'), IfNotFoundReturn::none)); //concatenateSftpFolderPathPhrase() uses "pass64" instead + + const ZstringView fullPath = beforeFirst(fullPathOpt, Zstr('|'), IfNotFoundReturn::all); + const ZstringView options = afterFirst(fullPathOpt, Zstr('|'), IfNotFoundReturn::none); + + auto it = std::find_if(fullPath.begin(), fullPath.end(), [](Zchar c) { return c == '/' || c == '\\'; }); + const ZstringView serverPort = makeStringView(fullPath.begin(), it); + const AfsPath serverRelPath = sanitizeDeviceRelativePath({it, fullPath.end()}); + + if (std::optional> ip6AndPort = parseIpv6Address(serverPort)) //e.g. 2001:db8::ff00:42:8329 or [::1]:80 + { + login.server = ip6AndPort->first; + login.portCfg = ip6AndPort->second; //0 if empty + } + else + { + login.server = Zstring(beforeLast(serverPort, Zstr(':'), IfNotFoundReturn::all)); + const ZstringView port = afterLast(serverPort, Zstr(':'), IfNotFoundReturn::none); + login.portCfg = stringTo(port); //0 if empty + } + + assert(login.allowZlib == false); + + split(options, Zstr('|'), [&](ZstringView optPhrase) + { + optPhrase = trimCpy(optPhrase); + if (!optPhrase.empty()) + { + if (startsWith(optPhrase, Zstr("timeout="))) + login.timeoutSec = stringTo(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else if (startsWith(optPhrase, Zstr("chan="))) + login.traverserChannelsPerConnection = stringTo(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else if (startsWith(optPhrase, Zstr("keyfile="))) + { + login.authType = SftpAuthType::keyFile; + login.privateKeyFilePath = getResolvedFilePath(Zstring(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none))); + } + else if (optPhrase == Zstr("agent")) + login.authType = SftpAuthType::agent; + else if (startsWith(optPhrase, Zstr("pass64="))) + login.password = decodePasswordBase64(afterFirst(optPhrase, Zstr('='), IfNotFoundReturn::none)); + else if (optPhrase == Zstr("pwprompt")) + login.password = std::nullopt; + else if (optPhrase == Zstr("zlib")) + login.allowZlib = true; + else + assert(false); + } + }); + return AbstractPath(makeSharedRef(login), serverRelPath); +} diff --git a/FreeFileSync/Source/afs/sftp.h b/FreeFileSync/Source/afs/sftp.h new file mode 100644 index 0000000..8d0c62d --- /dev/null +++ b/FreeFileSync/Source/afs/sftp.h @@ -0,0 +1,55 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SFTP_H_5392187498172458215426 +#define SFTP_H_5392187498172458215426 + +#include "abstract.h" + + +namespace fff +{ +bool acceptsItemPathPhraseSftp(const Zstring& itemPathPhrase); //noexcept +AbstractPath createItemPathSftp(const Zstring& itemPathPhrase); //noexcept + +void sftpInit(); +void sftpTeardown(); + +//------------------------------------------------------- + +enum class SftpAuthType +{ + password, + keyFile, + agent, +}; + +const int DEFAULT_PORT_SFTP = 22; +//SFTP default port: 22, see %WINDIR%\system32\drivers\etc\services +//=> we could use the "ssh" alias, but let's be explicit + +struct SftpLogin +{ + Zstring server; + int portCfg = 0; //use if > 0, DEFAULT_PORT_SFTP otherwise + Zstring username; + SftpAuthType authType = SftpAuthType::password; + std::optional password = Zstr(""); //authType == password or keyFile: none given => prompt during AFS::authenticateAccess() + Zstring privateKeyFilePath; //authType == keyFile: use PEM-encoded private key (protected by password) for authentication + bool allowZlib = false; + //other settings not specific to SFTP session: + int timeoutSec = 10; //valid range: [1, inf) + int traverserChannelsPerConnection = 1; //valid range: [1, inf) +}; +AfsDevice condenseToSftpDevice(const SftpLogin& login); //noexcept; potentially messy user input +SftpLogin extractSftpLogin(const AfsDevice& afsDevice); //noexcept + +int getServerMaxChannelsPerConnection(const SftpLogin& login); //throw FileError + +AfsPath getSftpHomePath(const SftpLogin& login); //throw FileError +} + +#endif //SFTP_H_5392187498172458215426 diff --git a/FreeFileSync/Source/application.cpp b/FreeFileSync/Source/application.cpp new file mode 100644 index 0000000..a868de8 --- /dev/null +++ b/FreeFileSync/Source/application.cpp @@ -0,0 +1,768 @@ +// ***************************************************************************** +// * 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 "application.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "afs/concrete.h" +#include "base/comparison.h" +#include "base/synchronization.h" +#include "ui/batch_status_handler.h" +#include "ui/main_dlg.h" +#include "ui/small_dlgs.h" +#include "base_tools.h" +#include "ffs_paths.h" +#include "return_codes.h" + + #include + +using namespace zen; +using namespace fff; + + +#ifdef __WXGTK3__ + /* Wayland backend used by GTK3 does not allow to move windows! (no such issue on GTK2) + + "I'd really like to know if there is some deep technical reason for it or + if this is really as bloody stupid as it seems?" - vadz https://github.com/wxWidgets/wxWidgets/issues/18733#issuecomment-1011235902 + + Show all available GTK backends: run FreeFileSync with env variable: GDK_BACKEND=help + + => workaround: https://docs.gtk.org/gdk3/func.set_allowed_backends.html */ + GLOBAL_RUN_ONCE(::gdk_set_allowed_backends("x11,*")); //call *before* gtk_init() +#endif + +IMPLEMENT_APP(Application) + + +namespace +{ +std::vector getCommandlineArgs(const wxApp& app) +{ + std::vector args; + for (const wxString& arg : app.argv.GetArguments()) + args.push_back(utfTo(arg)); + //remove first argument which is exe path by convention: https://devblogs.microsoft.com/oldnewthing/20060515-07/?p=31203 + if (!args.empty()) + args.erase(args.begin()); + + return args; +} + + +void showSyntaxHelp() +{ + showNotificationDialog(nullptr, DialogInfoType::info, PopupDialogCfg(). + setTitle(_("Command line")). + setDetailInstructions(_("Syntax:") + L"\n\n" + + L"FreeFileSync" + L'\n' + + TAB_SPACE + L"[" + _("config files:") + L" *.ffs_gui/*.ffs_batch]" + L'\n' + + TAB_SPACE + L"[-DirPair " + _("directory") + L' ' + _("directory") + L"]" L"\n" + + TAB_SPACE + L"[-Edit]" + L'\n' + + TAB_SPACE + L"[" + _("global config file:") + L" GlobalSettings.xml]" + L"\n\n" + + + _("config files:") + L'\n' + + _("Any number of FreeFileSync \"ffs_gui\" and/or \"ffs_batch\" configuration files.") + L"\n\n" + + + L"-DirPair " + _("directory") + L' ' + _("directory") + L'\n' + + _("Any number of alternative directory pairs for at most one config file.") + L"\n\n" + + + L"-Edit" + L'\n' + + _("Open the selected configuration for editing only, without executing it.") + L"\n\n" + + + _("global config file:") + L'\n' + + _("Path to an alternate GlobalSettings.xml file."))); +} + + +void notifyAppError(const std::wstring& msg) +{ + std::cerr << utfTo(_("Error") + L": " + msg) << '\n'; + //alternative0: std::wcerr: cannot display non-ASCII at all, so why does it exist??? + //alternative1: wxSafeShowMessage => NO console output on Debian x86, WTF! + //alternative2: wxMessageBox() => works, but we probably shouldn't block during command line usage +} +} + +//################################################################################################################## + +bool Application::OnInit() +{ + //do not call wxApp::OnInit() to avoid using wxWidgets command line parser + + const auto now = std::chrono::system_clock::now(); //e.g. "ErrorLog 2023-07-05 105207.073.xml" + initExtraLog([logFilePath = appendPath(getConfigDirPath(), Zstr("ErrorLog ") + + formatTime(Zstr("%Y-%m-%d %H%M%S"), getLocalTime(std::chrono::system_clock::to_time_t(now))) + Zstr('.') + + printNumber(Zstr("%03d"), //[ms] should yield a fairly unique name + static_cast(std::chrono::duration_cast(now.time_since_epoch()).count() % 1000)) + + Zstr(".xml"))](const ErrorLog& log) + { + try //don't call functions depending on global state (which might be destroyed already!) + { + saveErrorLog(log, logFilePath); //throw FileError + } + catch (const FileError& e) { assert(false); notifyAppError(e.toString()); } + }); + + //tentatively set program language to OS default until GlobalSettings.xml is read later + try { localizationInit(appendPath(getResourceDirPath(), Zstr("Languages.zip"))); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + + //parallel xBRZ-scaling! => run as early as possible + try { imageResourcesInit(appendPath(getResourceDirPath(), Zstr("Icons.zip"))); } + catch (const FileError& e) { logExtraError(e.toString()); } //not critical in this context + + //GTK should already have been initialized by wxWidgets (see \src\gtk\app.cpp:wxApp::Initialize) +#if GTK_MAJOR_VERSION == 2 + ::gtk_rc_parse(appendPath(getResourceDirPath(), "Gtk2Styles.rc").c_str()); + + //hang on Ubuntu 19.10 (GLib 2.62) caused by ibus initialization: https://freefilesync.org/forum/viewtopic.php?t=6704 + //=> work around 1: bonus: avoid needless DBus calls: https://developer.gnome.org/gio/stable/running-gio-apps.html + // drawback: missing MTP and network links in folder picker: https://freefilesync.org/forum/viewtopic.php?t=6871 + //if (::setenv("GIO_USE_VFS", "local", true /*overwrite*/) != 0) + // std::cerr << utfTo(formatSystemError("setenv(GIO_USE_VFS)", errno)) + '\n'; + // //BUGZ!?: "Modifications of environment variables are not allowed in multi-threaded programs" - https://rachelbythebay.com/w/2017/01/30/env/ + + //=> work around 2: + [[maybe_unused]] GVfs* defaultFs = ::g_vfs_get_default(); //not owned by us! + //no such issue on GTK3! + +#elif GTK_MAJOR_VERSION == 3 + auto loadCSS = [&](const char* fileName) + { + GtkCssProvider* provider = ::gtk_css_provider_new(); + ZEN_ON_SCOPE_EXIT(::g_object_unref(provider)); + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + ::gtk_css_provider_load_from_path(provider, //GtkCssProvider* css_provider + appendPath(getResourceDirPath(), fileName).c_str(), //const gchar* path + &error); //GError** error + if (error) + throw SysError(formatGlibError("gtk_css_provider_load_from_path", error)); + + ::gtk_style_context_add_provider_for_screen(::gdk_screen_get_default(), //GdkScreen* screen + GTK_STYLE_PROVIDER(provider), //GtkStyleProvider* provider + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); //guint priority + }; + try + { + loadCSS("Gtk3Styles.css"); //throw SysError + } + catch (const SysError& e) + { + std::cerr << "[FreeFileSync] " + utfTo(e.toString()) + "\n" "Loading GTK3\'s old CSS format instead..." "\n"; + try + { + loadCSS("Gtk3Styles.old.css"); //throw SysError + } + catch (const SysError& e2) { logExtraError(_("Failed to update the color theme.") + L"\n\n" + e2.toString()); } + } +#else +#error unknown GTK version! +#endif + + /* we're a GUI app: ignore SIGHUP when the parent terminal quits! (or process is killed!) + => the FFS launcher will still be killed => fine + => macOS: apparently not needed! interestingly the FFS launcher does receive SIGHUP and *is* killed */ + if (sighandler_t oldHandler = ::signal(SIGHUP, SIG_IGN); + oldHandler == SIG_ERR) + logExtraError(_("Error during process initialization.") + L"\n\n" + formatSystemError("signal(SIGHUP)", getLastError())); + else assert(!oldHandler); + + + //Windows User Experience Interaction Guidelines: tool tips should have 5s timeout, info tips no timeout => compromise: + wxToolTip::Enable(true); //wxWidgets screw-up: wxToolTip::SetAutoPop is no-op if global tooltip window is not yet constructed: wxToolTip::Enable creates it + wxToolTip::SetAutoPop(15'000); //https://docs.microsoft.com/en-us/windows/win32/uxguide/ctrl-tooltips-and-infotips + + SetAppName(L"FreeFileSync"); //if not set, defaults to executable name + + + initAfs({getResourceDirPath(), getConfigDirPath()}); //bonus: using FTP Gdrive implicitly inits OpenSSL (used in runSanityChecks() on Linux) already during globals init + + + auto onSystemShutdown = [](int /*unused*/ = 0) + { + onSystemShutdownRunTasks(); + + //- it's futile to try and clean up while the process is in full swing (CRASH!) => just terminate! + //- system sends close events to all open dialogs: If one of these calls wxCloseEvent::Veto(), + // e.g. user clicking cancel on save prompt, this would cancel the shutdown + terminateProcess(static_cast(FfsExitCode::cancelled)); + }; + Bind(wxEVT_QUERY_END_SESSION, [onSystemShutdown](wxCloseEvent& event) { onSystemShutdown(); }); //can veto + Bind(wxEVT_END_SESSION, [onSystemShutdown](wxCloseEvent& event) { onSystemShutdown(); }); //can *not* veto + //- log off: Windows/macOS generates wxEVT_QUERY_END_SESSION/wxEVT_END_SESSION + // Linux/macOS generates SIGTERM, which we handle below + //- Windows sends WM_QUERYENDSESSION, WM_ENDSESSION during log off, *not* WM_CLOSE https://devblogs.microsoft.com/oldnewthing/20080421-00/?p=22663 + // => "taskkill sending WM_CLOSE (without /f)" is a misguided app simulating a button-click on X + // -> should send WM_QUERYENDSESSION instead! + if (auto /*sighandler_t n.a. on macOS*/ oldHandler = ::signal(SIGTERM, onSystemShutdown);//"graceful" exit requested, unlike SIGKILL + oldHandler == SIG_ERR) + logExtraError(_("Error during process initialization.") + L"\n\n" + formatSystemError("signal(SIGTERM)", getLastError())); + else assert(!oldHandler); + + //Note: app start is deferred: batch mode requires the wxApp eventhandler to be established for UI update events. This is not the case at the time of OnInit()! + CallAfter([&] { onEnterEventLoop(); }); + + return true; //true: continue processing; false: exit immediately +} + + +int Application::OnExit() +{ + [[maybe_unused]] const bool rv = wxClipboard::Get()->Flush(); //see wx+/context_menu.h + //assert(rv); -> fails if clipboard wasn't used + localizationCleanup(); + imageResourcesCleanup(); + teardownAfs(); + colorThemeCleanup(); + return wxApp::OnExit(); +} + + +wxLayoutDirection Application::GetLayoutDirection() const { return languageLayoutIsRtl() ? wxLayout_RightToLeft : wxLayout_LeftToRight; } + + +int Application::OnRun() +{ +#if wxUSE_EXCEPTIONS +#error why is wxWidgets uncaught exception handling enabled!? +#endif + + //exception => Windows: let it crash and create mini dump!!! Linux/macOS: std::exception::what() logged to console + [[maybe_unused]] const int rc = wxApp::OnRun(); + return static_cast(exitCode_); +} + + + + +void Application::onEnterEventLoop() +{ + const std::vector& commandArgs = getCommandlineArgs(*this); + + //wxWidgets app exit handling is weird... we want to exit only if the logical main window is closed, not just *any* window! + wxTheApp->SetExitOnFrameDelete(false); //prevent popup-windows from becoming temporary top windows leading to program exit after closure + ZEN_ON_SCOPE_EXIT(if (!wxTheApp->GetExitOnFrameDelete()) wxTheApp->ExitMainLoop()); //quit application, if no main window was set (batch silent mode) + + try + { + //parse command line arguments + std::vector> dirPathPhrasePairs; + std::vector cfgFilePaths; + Zstring globalCfgPathAlt; + bool openForEdit = false; + { + const char* optionEdit = "-edit"; + const char* optionDirPair = "-dirpair"; + const char* optionSendTo = "-sendto"; //remaining arguments are unspecified number of folder paths; wonky syntax; let's keep it undocumented + + auto isHelpRequest = [](const Zstring& arg) + { + auto it = std::find_if(arg.begin(), arg.end(), [](Zchar c) { return c != Zstr('/') && c != Zstr('-'); }); + if (it == arg.begin()) return false; //require at least one prefix character + + const Zstring argTmp(it, arg.end()); + return equalAsciiNoCase(argTmp, "help") || + equalAsciiNoCase(argTmp, "h") || + argTmp == Zstr("?"); + }; + + auto isCommandLineOption = [&](const Zstring& arg) + { + return equalAsciiNoCase(arg, optionEdit ) || + equalAsciiNoCase(arg, optionDirPair) || + equalAsciiNoCase(arg, optionSendTo ) || + isHelpRequest(arg); + }; + + for (auto it = commandArgs.begin(); it != commandArgs.end(); ++it) + if (isHelpRequest(*it)) + return showSyntaxHelp(); + else if (equalAsciiNoCase(*it, optionEdit)) + openForEdit = true; + else if (equalAsciiNoCase(*it, optionDirPair)) + { + if (++it == commandArgs.end() || isCommandLineOption(*it)) + throw FileError(replaceCpy(_("A left and a right directory path are expected after %x."), L"%x", utfTo(optionDirPair))); + dirPathPhrasePairs.emplace_back(*it, Zstring()); + + if (++it == commandArgs.end() || isCommandLineOption(*it)) + throw FileError(replaceCpy(_("A left and a right directory path are expected after %x."), L"%x", utfTo(optionDirPair))); + dirPathPhrasePairs.back().second = *it; + } + else if (equalAsciiNoCase(*it, optionSendTo)) + { + for (size_t i = 0; ; ++i) + { + if (++it == commandArgs.end() || isCommandLineOption(*it)) + { + --it; + break; + } + + if (i < 2) //else: -SendTo with more than 2 paths? Doesn't make any sense, does it!? + { + //for -SendTo we expect a list of full native paths, not "phrases" that need to be resolved! + auto getFolderPath = [](Zstring itemPath) + { + try + { + if (getItemType(itemPath) == ItemType::file) //throw FileError + if (const std::optional& parentPath = getParentFolderPath(itemPath)) + return *parentPath; + } + catch (FileError&) {} + + return itemPath; + }; + + if (i % 2 == 0) + dirPathPhrasePairs.emplace_back(getFolderPath(*it), Zstring()); + else + { + const Zstring folderPath = getFolderPath(*it); + if (dirPathPhrasePairs.back().first != folderPath) //else: user accidentally sending to two files, which each time yield the same parent folder + dirPathPhrasePairs.back().second = folderPath; + } + } + } + } + else + { + const Zstring& filePath = getResolvedFilePath(*it); +#if 0 + if (!fileAvailable(filePath)) //...be a little tolerant + for (const Zchar* ext : {Zstr(".ffs_gui"), Zstr(".ffs_batch"), Zstr(".xml")}) + if (fileAvailable(filePath + ext)) + filePath += ext; +#endif + if (endsWithAsciiNoCase(filePath, Zstr(".ffs_gui")) || + endsWithAsciiNoCase(filePath, Zstr(".ffs_batch"))) + cfgFilePaths.push_back(filePath); + else if (endsWithAsciiNoCase(filePath, Zstr(".xml"))) + globalCfgPathAlt = filePath; + else + throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(filePath)), + _("Unexpected file extension:") + L' ' + fmtPath(getFileExtension(filePath)) + L'\n' + + _("Expected:") + L" ffs_gui, ffs_batch, xml"); + } + } + //---------------------------------------------------------------------------------------------------- + + auto hasNonDefaultConfig = [](const LocalPairConfig& lpc) + { + return lpc != LocalPairConfig{lpc.folderPathPhraseLeft, + lpc.folderPathPhraseRight, + std::nullopt, std::nullopt, FilterConfig()}; + }; + + auto replaceDirectories = [&](MainConfiguration& mainCfg) //throw FileError + { + if (!dirPathPhrasePairs.empty()) + { + if (cfgFilePaths.size() > 1) + throw FileError(_("Directories cannot be set for more than one configuration file.")); + + //check if config at folder-pair level is present: this probably doesn't make sense when replacing/adding the user-specified directories + if (hasNonDefaultConfig(mainCfg.firstPair) || std::any_of(mainCfg.additionalPairs.begin(), mainCfg.additionalPairs.end(), hasNonDefaultConfig)) + throw FileError(_("The config file must not contain settings at directory pair level when directories are set via command line.")); + + mainCfg.additionalPairs.clear(); + for (size_t i = 0; i < dirPathPhrasePairs.size(); ++i) + if (i == 0) + { + mainCfg.firstPair.folderPathPhraseLeft = dirPathPhrasePairs[0].first; + mainCfg.firstPair.folderPathPhraseRight = dirPathPhrasePairs[0].second; + } + else + mainCfg.additionalPairs.push_back({dirPathPhrasePairs[i].first, dirPathPhrasePairs[i].second, + std::nullopt, std::nullopt, FilterConfig()}); + } + }; + + const Zstring globalCfgFilePath = !globalCfgPathAlt.empty() ? globalCfgPathAlt : getGlobalConfigDefaultPath(); + + GlobalConfig globalCfg; + try + { + std::wstring warningMsg; + std::tie(globalCfg, warningMsg) = readGlobalConfig(globalCfgFilePath); //throw FileError + assert(warningMsg.empty()); //ignore parsing errors: should be migration problems only *cross-fingers* + } + catch (const FileError& e) + { + try + { + bool cfgFileExists = true; + try { cfgFileExists = itemExists(globalCfgFilePath); /*throw FileError*/ } //=> unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + if (cfgFileExists) + throw; + } + catch (const FileError& e3) { logExtraError(e3.toString()); } + } + + //late GlobalSettings.xml-dependent app initialization: + try { setLanguage(globalCfg.programLanguage); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } + + try { colorThemeInit(*this, globalCfg.appColorTheme); } //throw FileError + catch (const FileError& e) { logExtraError(e.toString()); } //not critical in this context + + + //----------------------------------------------------------- + //distinguish sync scenarios: + //----------------------------------------------------------- + if (cfgFilePaths.empty()) + { + //gui mode: default startup + if (dirPathPhrasePairs.empty()) + MainDialog::create(globalCfg, globalCfgFilePath); + //gui mode: default config with given directories + else + { + FfsGuiConfig guiCfg; + guiCfg.mainCfg.syncCfg.directionCfg = getDefaultSyncCfg(SyncVariant::mirror); + + replaceDirectories(guiCfg.mainCfg); //throw FileError + + MainDialog::create(guiCfg, {} /*cfgFilePaths*/, globalCfg, globalCfgFilePath, !openForEdit /*startComparison*/); + } + } + else if (const Zstring filePath0 = cfgFilePaths[0]; + //batch mode (single config) + cfgFilePaths.size() == 1 && endsWithAsciiNoCase(filePath0, Zstr(".ffs_batch")) && !openForEdit) + { + auto [batchCfg, warningMsg] = readBatchConfig(filePath0); //throw FileError + if (!warningMsg.empty()) + throw FileError(warningMsg); //batch mode: break on errors AND even warnings! + + replaceDirectories(batchCfg.guiCfg.mainCfg); //throw FileError + + runBatchMode(batchCfg, filePath0, globalCfg, globalCfgFilePath); + } + else //GUI mode: (ffs_gui *or* ffs_batch) + { + auto [guiCfg, warningMsg] = readAnyConfig(cfgFilePaths); //throw FileError + if (!warningMsg.empty()) + showNotificationDialog(nullptr, DialogInfoType::warning, PopupDialogCfg().setDetailInstructions(warningMsg)); + //what about simulating changed config on parsing errors? + + replaceDirectories(guiCfg.mainCfg); //throw FileError + //what about simulating changed config due to directory replacement? + //-> propably fine to not show as changed on GUI and not ask user to save on exit! + + MainDialog::create(guiCfg, cfgFilePaths, globalCfg, globalCfgFilePath, !openForEdit /*startComparison*/); + } + } + catch (const FileError& e) + { + raiseExitCode(exitCode_, FfsExitCode::exception); + notifyAppError(e.toString()); + } +} + + +void Application::runBatchMode(const FfsBatchConfig& batchCfg, const Zstring& cfgFilePath, GlobalConfig globalCfg, const Zstring& globalCfgFilePath) +{ + const bool allowUserInteraction = !batchCfg.batchExCfg.autoCloseSummary || + (!batchCfg.guiCfg.mainCfg.ignoreErrors && batchCfg.batchExCfg.batchErrorHandling == BatchErrorHandling::showPopup); + + + /* regular check for software updates -> disabled for batch + if (batchCfg.showProgress && manualProgramUpdateRequired()) + checkForUpdatePeriodically(globalCfg.lastUpdateCheck); + -> WinInet not working when FFS is running as a service!!! https://support.microsoft.com/en-us/help/238425/info-wininet-not-supported-for-use-in-services */ + + + const std::chrono::system_clock::time_point syncStartTime = std::chrono::system_clock::now(); + + const WindowLayout::Dimensions progressDim + { + globalCfg.dpiLayouts[getDpiScalePercent()].progressDlg.size, + std::nullopt /*pos*/, + globalCfg.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized + }; + + //class handling status updates and error messages + BatchStatusHandler statusHandler(!batchCfg.batchExCfg.runMinimized, + extractJobName(cfgFilePath), + syncStartTime, + batchCfg.guiCfg.mainCfg.ignoreErrors, + batchCfg.guiCfg.mainCfg.autoRetryCount, + batchCfg.guiCfg.mainCfg.autoRetryDelay, + globalCfg.soundFileSyncFinished, + globalCfg.soundFileAlertPending, + progressDim, + batchCfg.batchExCfg.autoCloseSummary, + batchCfg.batchExCfg.postBatchAction, + batchCfg.batchExCfg.batchErrorHandling); + + AFS::RequestPasswordFun requestPassword; //throw CancelProcess + if (allowUserInteraction) + requestPassword = [&, password = Zstring()](const std::wstring& msg, const std::wstring& lastErrorMsg) mutable + { + assert(runningOnMainThread()); + if (showPasswordPrompt(statusHandler.getWindowIfVisible(), msg, lastErrorMsg, password) != ConfirmationButton::accept) + statusHandler.cancelProcessNow(CancelReason::user); //throw CancelProcess + + return password; + }; + + try + { + //inform about (important) non-default global settings + logNonDefaultSettings(globalCfg, statusHandler); //throw CancelProcess + + //batch mode: place directory locks on directories during both comparison AND synchronization + std::unique_ptr dirLocks; + + FolderComparison cmpResult = compare(globalCfg.warnDlgs, + globalCfg.fileTimeTolerance, + requestPassword, + globalCfg.runWithBackgroundPriority, + globalCfg.createLockFile, + dirLocks, + extractCompareCfg(batchCfg.guiCfg.mainCfg), + statusHandler); //throw CancelProcess + if (!cmpResult.empty()) + synchronize(syncStartTime, + globalCfg.verifyFileCopy, + globalCfg.copyLockedFiles, + globalCfg.copyFilePermissions, + globalCfg.failSafeFileCopy, + globalCfg.runWithBackgroundPriority, + extractSyncCfg(batchCfg.guiCfg.mainCfg), + cmpResult, + globalCfg.warnDlgs, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + //------------------------------------------------------------------- + BatchStatusHandler::Result r = statusHandler.prepareResult(); + + + AbstractPath logFolderPath = createAbstractPath(batchCfg.guiCfg.mainCfg.altLogFolderPathPhrase); //optional + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(globalCfg.logFolderPhrase); + assert(!AFS::isNullPath(logFolderPath)); //mandatory! but still: let's include fall back + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(getLogFolderDefaultPath()); + + AbstractPath logFilePath = AFS::appendRelPath(logFolderPath, generateLogFileName(globalCfg.logFormat, r.summary)); + //e.g. %AppData%\FreeFileSync\Logs\Backup FreeFileSync 2013-09-15 015052.123 [Error].log + + auto notifyStatusNoThrow = [&](std::wstring&& msg) { try { statusHandler.updateStatus(std::move(msg)); /*throw CancelProcess*/ } catch (CancelProcess&) {} }; + + + if (statusHandler.taskCancelled() && *statusHandler.taskCancelled() == CancelReason::user) + ; /* user cancelled => don't run post sync command + => don't run post sync action + => don't send email notification + => don't play sound notification */ + else + { + //--------------------- post sync command ---------------------- + if (const Zstring cmdLine = trimCpy(expandMacros(batchCfg.guiCfg.mainCfg.postSyncCommand)); + !cmdLine.empty()) + if (batchCfg.guiCfg.mainCfg.postSyncCondition == PostSyncCondition::completion || + (batchCfg.guiCfg.mainCfg.postSyncCondition == PostSyncCondition::errors) == (r.summary.result == TaskResult::cancelled || + r.summary.result == TaskResult::error)) + try + { + //give consoleExecute() some "time to fail", but not too long to hang our process + const int DEFAULT_APP_TIMEOUT_MS = 100; + + if (const auto& [exitCode, output] = consoleExecute(cmdLine, DEFAULT_APP_TIMEOUT_MS); //throw SysError, SysErrorTimeOut + exitCode != 0) + throw SysError(formatSystemError("", replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + + logMsg(r.errorLog.ref(), _("Executing command:") + L' ' + utfTo(cmdLine) + L" [" + replaceCpy(_("Exit code %x"), L"%x", L"0") + L']', MSG_TYPE_INFO); + } + catch (SysErrorTimeOut&) //child process not failed yet => probably fine :> + { + logMsg(r.errorLog.ref(), _("Executing command:") + L' ' + utfTo(cmdLine), MSG_TYPE_INFO); + } + catch (const SysError& e) + { + logMsg(r.errorLog.ref(), replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLine)) + L"\n\n" + e.toString(), MSG_TYPE_ERROR); + } + + //--------------------- email notification ---------------------- + if (const std::string notifyEmail = trimCpy(batchCfg.guiCfg.mainCfg.emailNotifyAddress); + !notifyEmail.empty()) + if (batchCfg.guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::always || + (batchCfg.guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::errorWarning && (r.summary.result == TaskResult::cancelled || + r.summary.result == TaskResult::error || + r.summary.result == TaskResult::warning)) || + (batchCfg.guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::errorOnly && (r.summary.result == TaskResult::cancelled || + r.summary.result == TaskResult::error))) + try + { + logMsg(r.errorLog.ref(), replaceCpy(_("Sending email notification to %x"), L"%x", utfTo(notifyEmail)), MSG_TYPE_INFO); + sendLogAsEmail(notifyEmail, r.summary, r.errorLog.ref(), logFilePath, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e) { logMsg(r.errorLog.ref(), e.toString(), MSG_TYPE_ERROR); } + } + + //--------------------- save log file ---------------------- + std::set logsToKeepPaths; + for (const ConfigFileItem& cfi : globalCfg.mainDlg.config.fileHistory) + if (!equalNativePath(cfi.cfgFilePath, cfgFilePath)) //exception: don't keep old log for the selected cfg file! + logsToKeepPaths.insert(cfi.lastRunStats.logFilePath); + + try //create not before destruction: 1. avoid issues with FFS trying to sync open log file 2. include status in log file name without extra rename + { + //do NOT use tryReportingError()! saving log files should not be cancellable! + saveLogFile(logFilePath, r.summary, r.errorLog.ref(), globalCfg.logfilesMaxAgeDays, globalCfg.logFormat, logsToKeepPaths, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e) + { + try //fallback: log file *must* be saved no matter what! + { + const AbstractPath logFileDefaultPath = AFS::appendRelPath(createAbstractPath(getLogFolderDefaultPath()), generateLogFileName(globalCfg.logFormat, r.summary)); + if (logFilePath == logFileDefaultPath) + throw; + + logMsg(r.errorLog.ref(), e.toString(), MSG_TYPE_ERROR); + + logFilePath = logFileDefaultPath; + saveLogFile(logFileDefaultPath, r.summary, r.errorLog.ref(), globalCfg.logfilesMaxAgeDays, globalCfg.logFormat, logsToKeepPaths, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e2) { logMsg(r.errorLog.ref(), e2.toString(), MSG_TYPE_ERROR); logExtraError(e2.toString()); } //should never happen!!! + } + + //--------- update last sync stats for the selected cfg file --------- + const ErrorLogStats& logStats = getStats(r.errorLog.ref()); + + for (ConfigFileItem& cfi : globalCfg.mainDlg.config.fileHistory) + if (equalNativePath(cfi.cfgFilePath, cfgFilePath)) + { + assert(r.summary.startTime == syncStartTime); + assert(!AFS::isNullPath(logFilePath)); + + cfi.lastRunStats = + { + std::chrono::system_clock::to_time_t(r.summary.startTime), + logFilePath, + r.summary.result, + r.summary.statsProcessed.items, + r.summary.statsProcessed.bytes, + r.summary.totalTime, + logStats.errors, + logStats.warnings, + }; + break; + } + + //--------------------------------------------------------------------------- + const BatchStatusHandler::DlgOptions dlgOpt = statusHandler.showResult(); + + globalCfg.dpiLayouts[getDpiScalePercent()].progressDlg.size = dlgOpt.dim.size; //=> ignore dim.pos + globalCfg.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized = dlgOpt.dim.isMaximized; + + //---------------------------------------------------------------------- + switch (r.summary.result) + { + case TaskResult::success: raiseExitCode(exitCode_, FfsExitCode::success); break; + case TaskResult::warning: raiseExitCode(exitCode_, FfsExitCode::warning); break; + case TaskResult::error: raiseExitCode(exitCode_, FfsExitCode::error ); break; + case TaskResult::cancelled: raiseExitCode(exitCode_, FfsExitCode::cancelled); break; + } + + //email sending, or saving log file failed? at least this should affect the exit code: + if (logStats.errors > 0) + raiseExitCode(exitCode_, FfsExitCode::error); + else if (logStats.warnings > 0) + raiseExitCode(exitCode_, FfsExitCode::warning); + + //--------------------------------------------------------------------------- + //stream sync stats to STDOUT as JSON + JsonValue syncStats(JsonValue::Type::object); + switch (r.summary.result) + { + case TaskResult::success: syncStats.objectVal.set("syncResult", "success"); break; + case TaskResult::warning: syncStats.objectVal.set("syncResult", "warning"); break; + case TaskResult::error: syncStats.objectVal.set("syncResult", "error"); break; + case TaskResult::cancelled: syncStats.objectVal.set("syncResult", "cancelled"); break; + } + + std::string startTimeStr = utfTo(formatTime(Zstr("%Y-%m-%dT%H:%M:%S%z"), getLocalTime(std::chrono::system_clock::to_time_t(r.summary.startTime)))); + syncStats.objectVal.set("startTime", std::move(startTimeStr.insert(startTimeStr.size() - 2, ":"))); //ISO 8601 date/time with offset e.g. 2001-08-23T14:55:02+02:00 + + syncStats.objectVal.set("totalTimeSec", std::chrono::duration_cast(r.summary.totalTime).count()); + + syncStats.objectVal.set("errors", logStats.errors); + syncStats.objectVal.set("warnings", logStats.warnings); + + syncStats.objectVal.set("totalItems", r.summary.statsTotal.items); + syncStats.objectVal.set("totalBytes", r.summary.statsTotal.bytes); + + syncStats.objectVal.set("processedItems", r.summary.statsProcessed.items); + syncStats.objectVal.set("processedBytes", r.summary.statsProcessed.bytes); + + syncStats.objectVal.set("logFile", utfTo(AFS::getDisplayPath(logFilePath))); + + std::cout << serializeJson(syncStats); + + //--------------------------------------------------------------------------- + try //save global settings to XML: e.g. ignored warnings, last sync stats + { + writeConfig(globalCfg, globalCfgFilePath); //FileError + } + catch (const FileError& e) + { + //raiseExitCode(exitCode_, FfsExitCode::error); -> sync successful + if (allowUserInteraction) + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + else + logExtraError(e.toString()); + } + + //--------------------------------------------------------------------------- + //run shutdown *after* saving global config! https://freefilesync.org/forum/viewtopic.php?t=5761 + using FinalRequest = BatchStatusHandler::FinalRequest; + switch (dlgOpt.finalRequest) + { + case FinalRequest::none: + break; + + case FinalRequest::switchGui: //open new top-level window *after* progress dialog is gone => run on main event loop + MainDialog::create(batchCfg.guiCfg, {cfgFilePath}, globalCfg, globalCfgFilePath, true /*startComparison*/); + break; + + case FinalRequest::shutdown: + try + { + shutdownSystem(); //throw FileError + terminateProcess(static_cast(exitCode_)); //better exit in a controlled manner rather than letting the OS kill us any time! + } + catch (const FileError& e) + { + //raiseExitCode(exitCode_, FfsExitCode::error); -> no! sync was successful + if (allowUserInteraction) + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + else + logExtraError(e.toString()); + } + break; + } +} diff --git a/FreeFileSync/Source/application.h b/FreeFileSync/Source/application.h new file mode 100644 index 0000000..f2b0f0d --- /dev/null +++ b/FreeFileSync/Source/application.h @@ -0,0 +1,35 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef APPLICATION_H_081568741942010985702395 +#define APPLICATION_H_081568741942010985702395 + +//#include +#include +#include +#include "config.h" +#include "return_codes.h" + + +namespace fff //avoid name clash with "int ffs()" for fuck's sake! (maxOS, Linux issue only: internally includes , WTF!) +{ +class Application : public wxApp +{ +private: + bool OnInit() override; + int OnRun () override; + int OnExit() override; + wxLayoutDirection GetLayoutDirection() const override; + void onEnterEventLoop(); + + void runBatchMode(const FfsBatchConfig& batchCfg, const Zstring& cfgFilePath, + GlobalConfig globalCfg, const Zstring& globalCfgFilePath); + + FfsExitCode exitCode_ = FfsExitCode::success; +}; +} + +#endif //APPLICATION_H_081568741942010985702395 diff --git a/FreeFileSync/Source/base/algorithm.cpp b/FreeFileSync/Source/base/algorithm.cpp new file mode 100644 index 0000000..a53d0bc --- /dev/null +++ b/FreeFileSync/Source/base/algorithm.cpp @@ -0,0 +1,1886 @@ +// ***************************************************************************** +// * 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 "algorithm.h" +#include +#include +#include //needed for TempFileBuffer only +#include "norm_filter.h" +#include "db_file.h" +#include "cmp_filetime.h" +#include "status_handler_impl.h" +#include "../afs/concrete.h" +#include "../afs/native.h" + +using namespace zen; +using namespace fff; + + +void fff::swapGrids(const MainConfiguration& mainCfg, FolderComparison& folderCmp, + PhaseCallback& callback /*throw X*/) //throw X +{ + for (BaseFolderPair& baseFolder : asRange(folderCmp)) + baseFolder.flip(); + + redetermineSyncDirection(extractDirectionCfg(folderCmp, mainCfg), + callback); //throw FileError +} + +//---------------------------------------------------------------------------------------------- + +namespace +{ +//visitFSObjectRecursively? nope, see premature end of traversal in processFolder() +class SetSyncDirViaDifferences +{ +public: + static void execute(const DirectionByDiff& dirs, ContainerObject& conObj) { SetSyncDirViaDifferences(dirs).recurse(conObj); } + +private: + SetSyncDirViaDifferences(const DirectionByDiff& dirs) : dirs_(dirs) {} + + void recurse(ContainerObject& conObj) const + { + for (FilePair& file : conObj.files()) + processFile(file); + for (SymlinkPair& link : conObj.symlinks()) + processLink(link); + for (FolderPair& folder : conObj.subfolders()) + processFolder(folder); + } + + void processFile(FilePair& file) const + { + const CompareFileResult cat = file.getCategory(); + + //##################### schedule old temporary files for deletion #################### + if (cat == FILE_LEFT_ONLY && endsWith(file.getItemName(), AFS::TEMP_FILE_ENDING)) + return file.setSyncDir(SyncDirection::left); + else if (cat == FILE_RIGHT_ONLY && endsWith(file.getItemName(), AFS::TEMP_FILE_ENDING)) + return file.setSyncDir(SyncDirection::right); + //#################################################################################### + + switch (cat) + { + case FILE_EQUAL: + //file.setSyncDir(SyncDirection::none); + break; + case FILE_RENAMED: + if (dirs_.leftNewer == dirs_.rightNewer) + file.setSyncDir(dirs_.leftNewer); //treat "rename" like a "file update" + else + file.setSyncDirConflict(txtDiffName_); + break; + case FILE_LEFT_ONLY: + file.setSyncDir(dirs_.leftOnly); + break; + case FILE_RIGHT_ONLY: + file.setSyncDir(dirs_.rightOnly); + break; + case FILE_LEFT_NEWER: + file.setSyncDir(dirs_.leftNewer); + break; + case FILE_RIGHT_NEWER: + file.setSyncDir(dirs_.rightNewer); + break; + case FILE_TIME_INVALID: + if (dirs_.leftNewer == dirs_.rightNewer) //e.g. "Mirror" sync variant + file.setSyncDir(dirs_.leftNewer); + else + file.setSyncDirConflict(file.getCategoryCustomDescription()); + break; + case FILE_DIFFERENT_CONTENT: + if (dirs_.leftNewer == dirs_.rightNewer) + file.setSyncDir(dirs_.leftNewer); + else + file.setSyncDirConflict(txtDiffContent_); + break; + case FILE_CONFLICT: + file.setSyncDirConflict(file.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only! + break; + } + } + + void processLink(SymlinkPair& symlink) const + { + switch (symlink.getLinkCategory()) + { + case SYMLINK_EQUAL: + //symlink.setSyncDir(SyncDirection::none); + break; + case SYMLINK_RENAMED: + if (dirs_.leftNewer == dirs_.rightNewer) + symlink.setSyncDir(dirs_.leftNewer); + else + symlink.setSyncDirConflict(txtDiffName_); + break; + case SYMLINK_LEFT_ONLY: + symlink.setSyncDir(dirs_.leftOnly); + break; + case SYMLINK_RIGHT_ONLY: + symlink.setSyncDir(dirs_.rightOnly); + break; + case SYMLINK_LEFT_NEWER: + symlink.setSyncDir(dirs_.leftNewer); + break; + case SYMLINK_RIGHT_NEWER: + symlink.setSyncDir(dirs_.rightNewer); + break; + case SYMLINK_TIME_INVALID: + if (dirs_.leftNewer == dirs_.rightNewer) + symlink.setSyncDir(dirs_.leftNewer); + else + symlink.setSyncDirConflict(symlink.getCategoryCustomDescription()); + break; + case SYMLINK_DIFFERENT_CONTENT: + if (dirs_.leftNewer == dirs_.rightNewer) + symlink.setSyncDir(dirs_.leftNewer); + else + symlink.setSyncDirConflict(txtDiffContent_); + break; + case SYMLINK_CONFLICT: + symlink.setSyncDirConflict(symlink.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only! + break; + } + } + + void processFolder(FolderPair& folder) const + { + const CompareDirResult cat = folder.getDirCategory(); + + //########### schedule abandoned temporary recycle bin directory for deletion ########## + if (cat == DIR_LEFT_ONLY && endsWith(folder.getItemName(), AFS::TEMP_FILE_ENDING)) + return setSyncDirectionRec(SyncDirection::left, folder); // + else if (cat == DIR_RIGHT_ONLY && endsWith(folder.getItemName(), AFS::TEMP_FILE_ENDING)) + return setSyncDirectionRec(SyncDirection::right, folder); //don't recurse below! + //####################################################################################### + + switch (cat) + { + case DIR_EQUAL: + //folder.setSyncDir(SyncDirection::none); + break; + case DIR_RENAMED: + if (dirs_.leftNewer == dirs_.rightNewer) + folder.setSyncDir(dirs_.leftNewer); + else + folder.setSyncDirConflict(txtDiffName_); + break; + case DIR_LEFT_ONLY: + folder.setSyncDir(dirs_.leftOnly); + break; + case DIR_RIGHT_ONLY: + folder.setSyncDir(dirs_.rightOnly); + break; + case DIR_CONFLICT: + folder.setSyncDirConflict(folder.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only! + break; + } + + recurse(folder); + } + + const DirectionByDiff dirs_; + const Zstringc txtDiffName_ = utfTo(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + + _("The items have different names, but it's unknown which side was renamed.")); + const Zstringc txtDiffContent_ = utfTo(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + + _("The items have different content, but it's unknown which side has changed.")); +}; + +//--------------------------------------------------------------------------------------------------------------- + +//test if non-equal items exist in scanned data +bool allItemsCategoryEqual(const ContainerObject& conObj) +{ + for (const FilePair& file : conObj.files()) + if (file.getCategory() != FILE_EQUAL) + return false; + + for (const SymlinkPair& symlink : conObj.symlinks()) + if (symlink.getLinkCategory() != SYMLINK_EQUAL) + return false; + + for (const FolderPair& folder : conObj.subfolders()) + if (folder.getDirCategory() != DIR_EQUAL || !allItemsCategoryEqual(folder)) //short-circuit behavior! + return false; + + return true; +} +} + +bool fff::allElementsEqual(const FolderComparison& folderCmp) +{ + for (const BaseFolderPair& baseFolder : asRange(folderCmp)) + if (!allItemsCategoryEqual(baseFolder)) + return false; + + return true; +} + +//--------------------------------------------------------------------------------------------------------------- + +namespace +{ +template inline +CudAction compareDbEntry(const FilePair& file, const InSyncFile* dbFile, unsigned int fileTimeTolerance, + const std::vector& ignoreTimeShiftMinutes, bool renamedOrMoved) +{ + if (file.isEmpty()) + return dbFile ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange; + else if (!dbFile) + return (renamedOrMoved ? CudAction::update : CudAction::create); + + const InSyncDescrFile& descrDb = selectParam(dbFile->left, dbFile->right); + + return sameFileTime(file.getLastWriteTime(), descrDb.modTime, fileTimeTolerance, ignoreTimeShiftMinutes) && + //- we do *not* consider file ID, but only *user-visual* changes. E.g. user moving data to some other medium should not be considered a change! + file.getFileSize() == dbFile->fileSize ? + CudAction::noChange : CudAction::update; +} + + +//check whether database entry is in sync considering *current* comparison settings +inline +bool stillInSync(const InSyncFile& dbFile, CompareVariant compareVar, unsigned int fileTimeTolerance, const std::vector& ignoreTimeShiftMinutes) +{ + switch (compareVar) + { + case CompareVariant::timeSize: + if (dbFile.cmpVar == CompareVariant::content) return true; //special rule: this is certainly "good enough" for CompareVariant::timeSize! + + //case-sensitive file name match is a database invariant! + return sameFileTime(dbFile.left.modTime, dbFile.right.modTime, fileTimeTolerance, ignoreTimeShiftMinutes); + + case CompareVariant::content: + //case-sensitive file name match is a database invariant! + return dbFile.cmpVar == CompareVariant::content; + //in contrast to comparison, we don't care about modification time here! + + case CompareVariant::size: //file size/case-sensitive file name always matches on both sides for an "in-sync" database entry + return true; + } + assert(false); + return false; +} + +//-------------------------------------------------------------------- + +//check whether database entry and current item match: *irrespective* of current comparison settings +template inline +CudAction compareDbEntry(const SymlinkPair& symlink, const InSyncSymlink* dbSymlink, unsigned int fileTimeTolerance, + const std::vector& ignoreTimeShiftMinutes, bool renamedOrMoved) +{ + if (symlink.isEmpty()) + return dbSymlink ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange; + else if (!dbSymlink) + return (renamedOrMoved ? CudAction::update : CudAction::create); + + const InSyncDescrLink& descrDb = selectParam(dbSymlink->left, dbSymlink->right); + + return sameFileTime(symlink.getLastWriteTime(), descrDb.modTime, fileTimeTolerance, ignoreTimeShiftMinutes) ? + CudAction::noChange : CudAction::update; +} + + +//check whether database entry is in sync considering *current* comparison settings +inline +bool stillInSync(const InSyncSymlink& dbLink, CompareVariant compareVar, unsigned int fileTimeTolerance, const std::vector& ignoreTimeShiftMinutes) +{ + switch (compareVar) + { + case CompareVariant::timeSize: + if (dbLink.cmpVar == CompareVariant::content || dbLink.cmpVar == CompareVariant::size) + return true; //special rule: this is already "good enough" for CompareVariant::timeSize! + + //case-sensitive symlink name match is a database invariant! + return sameFileTime(dbLink.left.modTime, dbLink.right.modTime, fileTimeTolerance, ignoreTimeShiftMinutes); + + case CompareVariant::content: + case CompareVariant::size: //== categorized by content! see comparison.cpp, ComparisonBuffer::compareBySize() + //case-sensitive symlink name match is a database invariant! + return dbLink.cmpVar == CompareVariant::content || dbLink.cmpVar == CompareVariant::size; + } + assert(false); + return false; +} + +//-------------------------------------------------------------------- + +//check whether database entry and current item match: *irrespective* of current comparison settings +template inline +CudAction compareDbEntry(const FolderPair& folder, const InSyncFolder* dbFolder, bool renamedOrMoved) +{ + if (folder.isEmpty()) + return dbFolder ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange; + else if (!dbFolder) + return (renamedOrMoved ? CudAction::update : CudAction::create); + + return CudAction::noChange; +} + + +inline +bool stillInSync(const InSyncFolder& dbFolder) +{ + //case-sensitive folder name match is a database invariant! + return true; +} + +//---------------------------------------------------------------------------------------------- + +class DetectMovedFiles +{ +public: + static void execute(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder) + { + DetectMovedFiles(baseFolder, dbFolder); + baseFolder.removeDoubleEmpty(); //see findAndSetMovePair() + } + +private: + DetectMovedFiles(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder) : + cmpVar_ (baseFolder.getCompVariant()), + fileTimeTolerance_(baseFolder.getFileTimeTolerance()), + ignoreTimeShiftMinutes_(baseFolder.getIgnoredTimeShift()) + { + recurse(baseFolder, &dbFolder, &dbFolder); + + purgeDuplicates(filesL_, exLeftOnlyById_); + purgeDuplicates(filesR_, exRightOnlyById_); + + if ((!exLeftOnlyById_ .empty() || !exLeftOnlyByPath_ .empty()) && + (!exRightOnlyById_.empty() || !exRightOnlyByPath_.empty())) + detectMovePairs(dbFolder); + } + + void recurse(ContainerObject& conObj, const InSyncFolder* dbFolderL, const InSyncFolder* dbFolderR) + { + for (FilePair& file : conObj.files()) + { + file.setMovePair(nullptr); //discard remnants from previous move detection and start fresh (e.g. consider manual folder rename) + + const AFS::FingerPrint filePrintL = file.isEmpty() ? 0 : file.getFilePrint(); + const AFS::FingerPrint filePrintR = file.isEmpty() ? 0 : file.getFilePrint(); + + if (filePrintL != 0) filesL_.push_back(&file); //collect *all* prints for uniqueness check! + if (filePrintR != 0) filesR_.push_back(&file); // + + auto getDbEntry = [](const InSyncFolder* dbFolder, const Zstring& fileName) -> const InSyncFile* + { + if (dbFolder) + if (const auto it = dbFolder->files.find(fileName); + it != dbFolder->files.end()) + return &it->second; + return nullptr; + }; + + if (const CompareFileResult cat = file.getCategory(); + cat == FILE_LEFT_ONLY) + { + if (const InSyncFile* dbEntry = getDbEntry(dbFolderL, file.getItemName())) + exLeftOnlyByPath_.emplace(dbEntry, &file); + } + else if (cat == FILE_RIGHT_ONLY) + { + if (const InSyncFile* dbEntry = getDbEntry(dbFolderR, file.getItemName())) + exRightOnlyByPath_.emplace(dbEntry, &file); + } + } + + for (FolderPair& folder : conObj.subfolders()) + { + auto getDbEntry = [](const InSyncFolder* dbFolder, const ZstringNorm& folderName) -> const InSyncFolder* + { + if (dbFolder) + if (const auto it = dbFolder->folders.find(folderName); + it != dbFolder->folders.end()) + return &it->second; + return nullptr; + }; + const ZstringNorm itemNameL = folder.getItemName(); + const ZstringNorm itemNameR = folder.getItemName(); + + const InSyncFolder* dbEntryL = getDbEntry(dbFolderL, itemNameL); + const InSyncFolder* dbEntryR = dbFolderL == dbFolderR && itemNameL == itemNameR ? + dbEntryL : getDbEntry(dbFolderR, itemNameR); + + recurse(folder, dbEntryL, dbEntryR); + } + } + + template + static void purgeDuplicates(std::vector& files, + std::unordered_map& exOneSideById) + { + if (!files.empty()) + { + std::sort(files.begin(), files.end(), [](const FilePair* lhs, const FilePair* rhs) + { return lhs->getFilePrint() < rhs->getFilePrint(); }); + + AFS::FingerPrint prevPrint = files[0]->getFilePrint(); + + for (auto it = files.begin() + 1; it != files.end(); ++it) + if (const AFS::FingerPrint filePrint = (*it)->getFilePrint(); + prevPrint != filePrint) + prevPrint = filePrint; + else //duplicate file ID! NTFS hard link/symlink? + { + const auto dupFirst = it - 1; + const auto dupLast = std::find_if(it + 1, files.end(), [prevPrint](const FilePair* file) + { return file->getFilePrint() != prevPrint; }); + + //remove from model: do *not* store invalid file prints in sync.ffs_db! + std::for_each(dupFirst, dupLast, [](FilePair* file) { file->clearFilePrint(); }); + it = dupLast - 1; + } + + //collect unique file prints for files existing on one side only: + constexpr CompareFileResult oneSideOnlyTag = side == SelectSide::left ? FILE_LEFT_ONLY : FILE_RIGHT_ONLY; + + for (FilePair* file : files) + if (file->getCategory() == oneSideOnlyTag) + if (const AFS::FingerPrint filePrint = file->getFilePrint(); + filePrint != 0) //skip duplicates marked by clearFilePrint() + exOneSideById.emplace(filePrint, file); + } + } + + void detectMovePairs(const InSyncFolder& container) const + { + for (const auto& [fileName, dbAttrib] : container.files) + findAndSetMovePair(dbAttrib); + + for (const auto& [folderName, subFolder] : container.folders) + detectMovePairs(subFolder); + } + + template + static bool sameSizeAndDate(const FilePair& file, const InSyncFile& dbFile) + { + return file.getFileSize() == dbFile.fileSize && + file.getLastWriteTime() == selectParam(dbFile.left, dbFile.right).modTime; + /* do NOT consider FAT_FILE_TIME_PRECISION_SEC: + 1. if DB contains file metadata collected during folder comparison we can be as precise as we want here + 2. if DB contains file metadata *estimated* directly after file copy: + - most file systems store file times with sub-second precision... + - ...except for FAT, but FAT does not have stable file IDs after file copy anyway (see comment below) + => file time comparison with seconds precision is fine! + + PS: *never* allow a tolerance as container predicate!! + => no strict weak ordering relation! reason: no transitivity of equivalence! */ + } + + template + FilePair* getAssocFilePair(const InSyncFile& dbFile) const + { + const std::unordered_map& exOneSideByPath = selectParam(exLeftOnlyByPath_, exRightOnlyByPath_); + const std::unordered_map& exOneSideById = selectParam(exLeftOnlyById_, exRightOnlyById_); + + if (const auto it = exOneSideByPath.find(&dbFile); + it != exOneSideByPath.end()) + return it->second; //if there is an association by path, don't care if there is also an association by ID, + //even if the association by path doesn't match time and size while the association by ID does! + //there doesn't seem to be (any?) value in allowing this! + + if (const AFS::FingerPrint filePrint = selectParam(dbFile.left, dbFile.right).filePrint; + filePrint != 0) + if (const auto it = exOneSideById.find(filePrint); + it != exOneSideById.end()) + return it->second; + + return nullptr; + } + + void findAndSetMovePair(const InSyncFile& dbFile) const + { + if (stillInSync(dbFile, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_)) + if (FilePair* fileLeftOnly = getAssocFilePair(dbFile)) + if (sameSizeAndDate(*fileLeftOnly, dbFile)) + if (FilePair* fileRightOnly = getAssocFilePair(dbFile)) + if (sameSizeAndDate(*fileRightOnly, dbFile)) + { + if (!fileLeftOnly ->getMovePair() && //needless checks? (file prints are unique in this context) + !fileRightOnly->getMovePair() && // + fileLeftOnly ->getCategory() == FILE_LEFT_ONLY && //is it possible we could get conflicting matches!? + fileRightOnly->getCategory() == FILE_RIGHT_ONLY) //=> likely 'yes', but only in obscure cases + //--------------- found a match --------------- + { + //move pair is just a 'rename' => combine: + if (&fileLeftOnly->parent() == &fileRightOnly->parent()) + { + fileLeftOnly->setSyncedTo(fileLeftOnly->getFileSize(), + fileRightOnly->getLastWriteTime(), //lastWriteTimeTrg + fileLeftOnly ->getLastWriteTime(), //lastWriteTimeSrc + + fileRightOnly->getFilePrint(), //filePrintTrg + fileLeftOnly ->getFilePrint(), //filePrintSrc + + fileRightOnly->isFollowedSymlink(), //isSymlinkTrg + fileLeftOnly ->isFollowedSymlink()); //isSymlinkSrc + + fileLeftOnly->setItemName(fileRightOnly->getItemName()); + + assert(fileLeftOnly->isActive() && fileRightOnly->isActive()); //can this fail? excluded files are not added during comparison... + if (fileLeftOnly->isActive() != fileRightOnly->isActive()) //just in case + fileLeftOnly->setActive(false); + + fileRightOnly->removeItem(); //=> call ContainerObject::removeDoubleEmpty() later! + } + else //regular move pair: mark it! + fileLeftOnly->setMovePair(fileRightOnly); + } + else + assert(fileLeftOnly->getMovePair() == fileRightOnly); + } + } + + const CompareVariant cmpVar_; + const unsigned int fileTimeTolerance_; + const std::vector ignoreTimeShiftMinutes_; + + std::vector filesL_; //collection of *all* file items (with non-null filePrint) + std::vector filesR_; // => detect duplicate file IDs + + std::unordered_map exLeftOnlyById_; + std::unordered_map exRightOnlyById_; + + std::unordered_map exLeftOnlyByPath_; + std::unordered_map exRightOnlyByPath_; + + /* Detect Renamed Files: + + X -> |_| Create right + |_| -> Y Delete right + + resolve as: Move/Rename Y to X on right + + Algorithm: + ---------- + DB-file left <--- (name, size, date) ---> DB-file right + | | + | (file ID, size, date) | (file ID, size, date) + | or | or + | (file path, size, date) | (file path, size, date) + \|/ \|/ + file left only file right only + + FAT caveat: file IDs are generally not stable when file is either moved or renamed! + 1. Move/rename operations on FAT cannot be detected reliably. + 2. database generally contains wrong file ID on FAT after renaming from .ffs_tmp files => correct file IDs in database only after next sync + 3. even exFAT screws up (but less than FAT) and changes IDs after file move. Did they learn nothing from the past? */ +}; + +//---------------------------------------------------------------------------------------------- + +class SetSyncDirViaChanges +{ +public: + static void execute(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder, const DirectionByChange& dirs) + { SetSyncDirViaChanges(baseFolder, dbFolder, dirs); } + +private: + SetSyncDirViaChanges(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder, const DirectionByChange& dirs) : + dirs_(dirs), + cmpVar_ (baseFolder.getCompVariant()), + fileTimeTolerance_ (baseFolder.getFileTimeTolerance()), + ignoreTimeShiftMinutes_(baseFolder.getIgnoredTimeShift()) + { + //-> considering filter not relevant: + // if stricter filter than last time: all ok; + // if less strict filter (if file ex on both sides -> conflict, fine; if file ex. on one side: copy to other side: fine) + recurse(baseFolder, &dbFolder); + } + + void recurse(ContainerObject& conObj, const InSyncFolder* dbFolder) const + { + for (FilePair& file : conObj.files()) + processFile(file, dbFolder); + for (SymlinkPair& symlink : conObj.symlinks()) + processSymlink(symlink, dbFolder); + for (FolderPair& folder : conObj.subfolders()) + processDir(folder, dbFolder); + } + + void processFile(FilePair& file, const InSyncFolder* dbFolder) const + { + const CompareFileResult cat = file.getCategory(); + if (cat == FILE_EQUAL) + return; + else if (cat == FILE_CONFLICT) //take over category conflict: allow *manual* resolution only! + return file.setSyncDirConflict(file.getCategoryCustomDescription()); + + //##################### schedule old temporary files for deletion #################### + if (cat == FILE_LEFT_ONLY && endsWith(file.getItemName(), AFS::TEMP_FILE_ENDING)) + return file.setSyncDir(SyncDirection::left); + else if (cat == FILE_RIGHT_ONLY && endsWith(file.getItemName(), AFS::TEMP_FILE_ENDING)) + return file.setSyncDir(SyncDirection::right); + //#################################################################################### + + //try to find corresponding database entry + auto getDbEntry = [dbFolder](const ZstringNorm& fileName) -> const InSyncFile* + { + if (dbFolder) + if (auto it = dbFolder->files.find(fileName); + it != dbFolder->files.end()) + return &it->second; + return nullptr; + }; + const ZstringNorm itemNameL = file.getItemName(); + const ZstringNorm itemNameR = file.getItemName(); + + const InSyncFile* dbEntryL = getDbEntry(itemNameL); + const InSyncFile* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR); + + if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use? + return file.setSyncDirConflict(txtDbAmbiguous_); + + if (const InSyncFile* dbEntry = dbEntryL ? dbEntryL : dbEntryR; + dbEntry && !stillInSync(*dbEntry, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_)) //check *before* misleadingly reporting txtNoSideChanged_ + return file.setSyncDirConflict(txtDbNotInSync_); + + //consider renamed/moved files as "updated" with regards to "changes"-based sync settings: https://freefilesync.org/forum/viewtopic.php?t=10594 + const bool renamedOrMoved = cat == FILE_RENAMED || file.getMovePair(); + const CudAction changeL = compareDbEntry(file, dbEntryL, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved); + const CudAction changeR = compareDbEntry(file, dbEntryR, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved); + + setSyncDirForChange(file, changeL, changeR); + } + + void processSymlink(SymlinkPair& symlink, const InSyncFolder* dbFolder) const + { + const CompareSymlinkResult cat = symlink.getLinkCategory(); + if (cat == SYMLINK_EQUAL) + return; + else if (cat == SYMLINK_CONFLICT) //take over category conflict: allow *manual* resolution only! + return symlink.setSyncDirConflict(symlink.getCategoryCustomDescription()); + + //try to find corresponding database entry + auto getDbEntry = [dbFolder](const ZstringNorm& linkName) -> const InSyncSymlink* + { + if (dbFolder) + if (auto it = dbFolder->symlinks.find(linkName); + it != dbFolder->symlinks.end()) + return &it->second; + return nullptr; + }; + const ZstringNorm itemNameL = symlink.getItemName(); + const ZstringNorm itemNameR = symlink.getItemName(); + + const InSyncSymlink* dbEntryL = getDbEntry(itemNameL); + const InSyncSymlink* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR); + + if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use? + return symlink.setSyncDirConflict(txtDbAmbiguous_); + + if (const InSyncSymlink* dbEntry = dbEntryL ? dbEntryL : dbEntryR; + dbEntry && !stillInSync(*dbEntry, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_)) + return symlink.setSyncDirConflict(txtDbNotInSync_); + + const bool renamedOrMoved = cat == SYMLINK_RENAMED; + const CudAction changeL = compareDbEntry(symlink, dbEntryL, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved); + const CudAction changeR = compareDbEntry(symlink, dbEntryR, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved); + + setSyncDirForChange(symlink, changeL, changeR); + } + + void processDir(FolderPair& folder, const InSyncFolder* dbFolder) const + { + const CompareDirResult cat = folder.getDirCategory(); + + //########### schedule abandoned temporary recycle bin directory for deletion ########## + if (cat == DIR_LEFT_ONLY && endsWith(folder.getItemName(), AFS::TEMP_FILE_ENDING)) + return setSyncDirectionRec(SyncDirection::left, folder); // + else if (cat == DIR_RIGHT_ONLY && endsWith(folder.getItemName(), AFS::TEMP_FILE_ENDING)) + return setSyncDirectionRec(SyncDirection::right, folder); //don't recurse below! + //####################################################################################### + + //try to find corresponding database entry + auto getDbEntry = [dbFolder](const ZstringNorm& folderName) -> const InSyncFolder* + { + if (dbFolder) + if (auto it = dbFolder->folders.find(folderName); + it != dbFolder->folders.end()) + return &it->second; + return nullptr; + }; + + const ZstringNorm itemNameL = folder.getItemName(); + const ZstringNorm itemNameR = folder.getItemName(); + + const InSyncFolder* dbEntryL = getDbEntry(itemNameL); + const InSyncFolder* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR); + + if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use? + { + auto onFsItem = [&](FileSystemObject& fsObj) + { + if (fsObj.getCategory() != FILE_EQUAL) + fsObj.setSyncDirConflict(txtDbAmbiguous_); + }; + return visitFSObjectRecursively(static_cast(folder), onFsItem, onFsItem, onFsItem); + } + const InSyncFolder* dbEntry = dbEntryL ? dbEntryL : dbEntryR; //exactly one side nullptr? => change in upper/lower case! + + if (cat == DIR_EQUAL) + ; + else if (cat == DIR_CONFLICT) //take over category conflict: allow *manual* resolution only! + folder.setSyncDirConflict(folder.getCategoryCustomDescription()); + else + { + if (dbEntry && !stillInSync(*dbEntry)) + folder.setSyncDirConflict(txtDbNotInSync_); + else + { + const bool renamedOrMoved = cat == DIR_RENAMED; + const CudAction changeL = compareDbEntry(folder, dbEntryL, renamedOrMoved); + const CudAction changeR = compareDbEntry(folder, dbEntryR, renamedOrMoved); + + setSyncDirForChange(folder, changeL, changeR); + } + } + + recurse(folder, dbEntry); + } + + template + SyncDirection getSyncDirForChange(CudAction change) const + { + const auto& changedirs = selectParam(dirs_.left, dirs_.right); + switch (change) + { + case CudAction::noChange: return SyncDirection::none; + case CudAction::create: return changedirs.create; + case CudAction::update: return changedirs.update; + case CudAction::delete_: return changedirs.delete_; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + + void setSyncDirForChange(FileSystemObject& fsObj, CudAction changeL, CudAction changeR) const + { + const SyncDirection dirL = getSyncDirForChange(changeL); + const SyncDirection dirR = getSyncDirForChange(changeR); + if (changeL != CudAction::noChange) + { + if (changeR != CudAction::noChange) //both sides changed + { + if (dirL == dirR) //but luckily agree on direction + fsObj.setSyncDir(dirL); + else + fsObj.setSyncDirConflict(txtBothSidesChanged_); + } + else //change on left + fsObj.setSyncDir(dirL); + } + else + { + if (changeR != CudAction::noChange) //change on right + fsObj.setSyncDir(dirR); + else //no change on either side + fsObj.setSyncDirConflict(txtNoSideChanged_); //obscure, but possible if user widens "fileTimeTolerance" + } + } + + //need ref-counted strings! see FileSystemObject::syncDirectionConflict_ + const Zstringc txtBothSidesChanged_ = utfTo(_("Both sides have changed since last synchronization.")); + const Zstringc txtNoSideChanged_ = utfTo(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("No change since last synchronization.")); + const Zstringc txtDbNotInSync_ = utfTo(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("The database entry is not in sync, considering current settings.")); + const Zstringc txtDbAmbiguous_ = utfTo(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("The database entry is ambiguous.")); + + const DirectionByChange dirs_; + const CompareVariant cmpVar_; + const unsigned int fileTimeTolerance_; + const std::vector ignoreTimeShiftMinutes_; +}; +} + + +std::vector> fff::extractDirectionCfg(FolderComparison& folderCmp, const MainConfiguration& mainCfg) +{ + if (folderCmp.empty()) + return {}; + + //merge first and additional pairs + std::vector allPairs; + allPairs.push_back(mainCfg.firstPair); + allPairs.insert(allPairs.end(), + mainCfg.additionalPairs.begin(), //add additional pairs + mainCfg.additionalPairs.end()); + + if (folderCmp.size() != allPairs.size()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + std::vector> output; + + for (auto it = folderCmp.begin(); it != folderCmp.end(); ++it) + { + BaseFolderPair& baseFolder = it->ref(); + const LocalPairConfig& lpc = allPairs[it - folderCmp.begin()]; + + output.emplace_back(&baseFolder, lpc.localSyncCfg ? lpc.localSyncCfg->directionCfg : mainCfg.syncCfg.directionCfg); + } + return output; +} + + +void fff::redetermineSyncDirection(const std::vector>& directCfgs, + PhaseCallback& callback /*throw X*/) //throw X +{ + if (directCfgs.empty()) + return; + + std::unordered_set pairsToSkip; + std::unordered_map> lastSyncStates; + + //best effort: always set sync directions (even on DB load error and when user cancels during file loading) + ZEN_ON_SCOPE_EXIT + ( + //*INDENT-OFF* + for (const auto& [baseFolder, dirCfg] : directCfgs) + if (!pairsToSkip.contains(baseFolder)) + { + //if only one folder is selected instead of a pair, sync directions don't make sense: (user already received warning during comparison) + if (AFS::isNullPath(baseFolder->getAbstractPath()) || + AFS::isNullPath(baseFolder->getAbstractPath())) + { + SetSyncDirViaDifferences::execute({.leftOnly = SyncDirection::none, + .rightOnly = SyncDirection::none, + .leftNewer = SyncDirection::none, + .rightNewer = SyncDirection::none}, *baseFolder); + } + else if (const DirectionByDiff* diffDirs = std::get_if(&dirCfg.dirs)) + SetSyncDirViaDifferences::execute(*diffDirs, *baseFolder); + else + { + const DirectionByChange& changeDirs = std::get(dirCfg.dirs); + + auto it = lastSyncStates.find(baseFolder); + if (const InSyncFolder* lastSyncState = it != lastSyncStates.end() ? &it->second.ref() : nullptr) + { + //detect moved files (*before* setting sync directions: might combine moved files into single file pairs, which changes category!) + DetectMovedFiles::execute(*baseFolder, *lastSyncState); + + SetSyncDirViaChanges::execute(*baseFolder, *lastSyncState, changeDirs); + } + else //fallback: + { + std::wstring msg = _("Database file is not available: Setting default directions for synchronization."); + if (directCfgs.size() > 1) + msg += SPACED_DASH + getShortDisplayNameForFolderPair(baseFolder->getAbstractPath(), + baseFolder->getAbstractPath()); + try { callback.logMessage(msg, PhaseCallback::MsgType::warning); /*throw X*/} catch (...) {}; + + SetSyncDirViaDifferences::execute(getDiffDirDefault(changeDirs), *baseFolder); + } + } + } + //*INDENT-ON* + ); + + std::vector baseFoldersForDbLoad; + for (const auto& [baseFolder, dirCfg] : directCfgs) + if (std::get_if(&dirCfg.dirs)) + { + if (allItemsCategoryEqual(*baseFolder)) //nothing to do: don't even try to open DB files + pairsToSkip.insert(baseFolder); + else + baseFoldersForDbLoad.push_back(baseFolder); + } + + //(try to) load sync-database files + lastSyncStates = loadLastSynchronousState(baseFoldersForDbLoad, + callback /*throw X*/); //throw X + + callback.updateStatus(_("Calculating sync directions...")); //throw X + callback.requestUiUpdate(true /*force*/); //throw X +} + +//--------------------------------------------------------------------------------------------------------------- + +void fff::setSyncDirectionRec(SyncDirection newDirection, FileSystemObject& fsObj) +{ + auto onFsItem = [newDirection](FileSystemObject& fsObj2) + { + if (fsObj2.getCategory() != FILE_EQUAL) + fsObj2.setSyncDir(newDirection); + }; + visitFSObjectRecursively(fsObj, onFsItem, onFsItem, onFsItem); +} + +//--------------- functions related to filtering ------------------------------------------------------------------------------------ + +void fff::setActiveStatus(bool newStatus, FolderComparison& folderCmp) +{ + auto onFsItem = [newStatus](FileSystemObject& fsObj) { fsObj.setActive(newStatus); }; + + for (BaseFolderPair& baseFolder : asRange(folderCmp)) + visitFSObjectRecursively(baseFolder, onFsItem, onFsItem, onFsItem); +} + + +void fff::setActiveStatus(bool newStatus, FileSystemObject& fsObj) +{ + auto onFsItem = [newStatus](FileSystemObject& fsObj2) { fsObj2.setActive(newStatus); }; + + visitFSObjectRecursively(fsObj, onFsItem, onFsItem, onFsItem); +} + + +namespace +{ +enum FilterStrategy +{ + STRATEGY_SET, + STRATEGY_AND + //STRATEGY_OR -> usage of inOrExcludeAllRows doesn't allow for strategy "or" +}; + +template struct Eval; + +template <> +struct Eval //process all elements +{ + template + static bool process(const T& obj) { return true; } +}; + +template <> +struct Eval +{ + template + static bool process(const T& obj) { return obj.isActive(); } +}; + + +template +class ApplyPathFilter +{ +public: + static void execute(ContainerObject& conObj, const PathFilter& filter) { ApplyPathFilter(conObj, filter); } + +private: + ApplyPathFilter(ContainerObject& conObj, const PathFilter& filter) : filter_(filter) { recurse(conObj); } + + void recurse(ContainerObject& conObj) const + { + for (FilePair& file : conObj.files()) + processFile(file); + for (SymlinkPair& symlink : conObj.symlinks()) + processLink(symlink); + for (FolderPair& folder : conObj.subfolders()) + processDir(folder); + } + + void processFile(FilePair& file) const + { + if (Eval::process(file)) + file.setActive(file.passFileFilter(filter_)); + } + + void processLink(SymlinkPair& symlink) const + { + if (Eval::process(symlink)) + symlink.setActive(symlink.passFileFilter(filter_)); + } + + void processDir(FolderPair& folder) const + { + bool childItemMightMatch = true; + const bool filterPassed = folder.passDirFilter(filter_, &childItemMightMatch); + + if (Eval::process(folder)) + folder.setActive(filterPassed); + + if (!childItemMightMatch) //use same logic like directory traversing: evaluate filter in subdirs only if objects *could* match + { + //exclude all files dirs in subfolders => incompatible with STRATEGY_OR! + auto onFsItem = [](FileSystemObject& fsObj) { fsObj.setActive(false); }; + visitFSObjectRecursively(static_cast(folder), onFsItem, onFsItem, onFsItem); + return; + } + + recurse(folder); + } + + const PathFilter& filter_; +}; + + +template +class ApplySoftFilter //falsify only! -> can run directly after "hard/base filter" +{ +public: + static void execute(ContainerObject& conObj, const SoftFilter& timeSizeFilter) { ApplySoftFilter(conObj, timeSizeFilter); } + +private: + ApplySoftFilter(ContainerObject& conObj, const SoftFilter& timeSizeFilter) : timeSizeFilter_(timeSizeFilter) { recurse(conObj); } + + void recurse(fff::ContainerObject& conObj) const + { + for (FilePair& file : conObj.files()) + processFile(file); + for (SymlinkPair& symlink : conObj.symlinks()) + processLink(symlink); + for (FolderPair& folder : conObj.subfolders()) + processDir(folder); + } + + void processFile(FilePair& file) const + { + if (Eval::process(file)) + { + if (file.isEmpty()) + file.setActive(matchSize(file) && + matchTime(file)); + else if (file.isEmpty()) + file.setActive(matchSize(file) && + matchTime(file)); + else + /* the only case with partially unclear semantics: + file and time filters may match or not match on each side, leaving a total of 16 combinations for both sides! + + ST S T - ST := match size and time + --------- S := match size only + ST |I|I|I|I| T := match time only + ------------ - := no match + S |I|E|?|E| + ------------ I := include row + T |I|?|E|E| E := exclude row + ------------ ? := unclear + - |I|E|E|E| + ------------ + let's set ? := E */ + file.setActive((matchSize(file) && + matchTime(file)) || + (matchSize(file) && + matchTime(file))); + } + } + + void processLink(SymlinkPair& symlink) const + { + if (Eval::process(symlink)) + { + if (symlink.isEmpty()) + symlink.setActive(matchTime(symlink)); + else if (symlink.isEmpty()) + symlink.setActive(matchTime(symlink)); + else + symlink.setActive(matchTime(symlink) || + matchTime (symlink)); + } + } + + void processDir(FolderPair& folder) const + { + if (Eval::process(folder)) + folder.setActive(timeSizeFilter_.matchFolder()); //if date filter is active we deactivate all folders: effectively gets rid of empty folders! + + recurse(folder); + } + + template + bool matchTime(const T& obj) const + { + return timeSizeFilter_.matchTime(obj.template getLastWriteTime()); + } + + template + bool matchSize(const T& obj) const + { + return timeSizeFilter_.matchSize(obj.template getFileSize()); + } + + const SoftFilter timeSizeFilter_; +}; +} + + +void fff::addHardFiltering(BaseFolderPair& baseFolder, const Zstring& excludeFilter) +{ + ApplyPathFilter::execute(baseFolder, NameFilter(FilterConfig().includeFilter, excludeFilter)); +} + + +void fff::addSoftFiltering(BaseFolderPair& baseFolder, const SoftFilter& timeSizeFilter) +{ + if (!timeSizeFilter.isNull()) //since we use STRATEGY_AND, we may skip a "null" filter + ApplySoftFilter::execute(baseFolder, timeSizeFilter); +} + + +void fff::applyFiltering(FolderComparison& folderCmp, const MainConfiguration& mainCfg) +{ + if (folderCmp.empty()) + return; + else if (folderCmp.size() != mainCfg.additionalPairs.size() + 1) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + //merge first and additional pairs + std::vector allPairs; + allPairs.push_back(mainCfg.firstPair); + allPairs.insert(allPairs.end(), + mainCfg.additionalPairs.begin(), //add additional pairs + mainCfg.additionalPairs.end()); + + for (auto it = allPairs.begin(); it != allPairs.end(); ++it) + { + BaseFolderPair& baseFolder = folderCmp[it - allPairs.begin()].ref(); + + const NormalizedFilter normFilter = normalizeFilters(mainCfg.globalFilter, it->localFilter); + + //"set" hard filter + ApplyPathFilter::execute(baseFolder, normFilter.nameFilter.ref()); + + //"and" soft filter + addSoftFiltering(baseFolder, normFilter.timeSizeFilter); + } +} + + +namespace +{ +template inline +bool matchesTime(const T& obj, time_t timeFrom, time_t timeTo) +{ + return timeFrom <= obj.template getLastWriteTime() && + /**/ obj.template getLastWriteTime() <= timeTo; +} +} + + +void fff::applyTimeSpanFilter(FolderComparison& folderCmp, time_t timeFrom, time_t timeTo) +{ + for (BaseFolderPair& baseFolder : asRange(folderCmp)) + { + visitFSObjectRecursively(baseFolder, [](FolderPair& folder) { folder.setActive(false); }, + + [timeFrom, timeTo](FilePair& file) + { + if (file.isEmpty()) + file.setActive(matchesTime(file, timeFrom, timeTo)); + else if (file.isEmpty()) + file.setActive(matchesTime(file, timeFrom, timeTo)); + else + file.setActive(matchesTime(file, timeFrom, timeTo) || + matchesTime(file, timeFrom, timeTo)); + }, + + [timeFrom, timeTo](SymlinkPair& symlink) + { + if (symlink.isEmpty()) + symlink.setActive(matchesTime(symlink, timeFrom, timeTo)); + else if (symlink.isEmpty()) + symlink.setActive(matchesTime(symlink, timeFrom, timeTo)); + else + symlink.setActive(matchesTime(symlink, timeFrom, timeTo) || + matchesTime (symlink, timeFrom, timeTo)); + }); + } +} + + +std::optional fff::getPathDependency(const AbstractPath& itemPathL, const AbstractPath& itemPathR) +{ + if (!AFS::isNullPath(itemPathL) && !AFS::isNullPath(itemPathR)) + { + if (itemPathL.afsDevice == itemPathR.afsDevice) + { + const std::vector relPathL = splitCpy(itemPathL.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip); + const std::vector relPathR = splitCpy(itemPathR.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip); + + const bool leftParent = relPathL.size() <= relPathR.size(); + + const auto& relPathP = leftParent ? relPathL : relPathR; + const auto& relPathC = leftParent ? relPathR : relPathL; + + if (std::equal(relPathP.begin(), relPathP.end(), relPathC.begin(), [](const Zstring& lhs, const Zstring& rhs) { return equalNoCase(lhs, rhs); })) + { + Zstring relDirPath; + std::for_each(relPathC.begin() + relPathP.size(), relPathC.end(), [&](const Zstring& itemName) + { + relDirPath = appendPath(relDirPath, itemName); + }); + + return PathDependency{leftParent ? itemPathL : itemPathR, relDirPath}; + } + } + } + return {}; +} + + +std::optional fff::getFolderPathDependency(const AbstractPath& folderPathL, const PathFilter& filterL, + const AbstractPath& folderPathR, const PathFilter& filterR) +{ + if (std::optional pd = getPathDependency(folderPathL, folderPathR)) + { + const PathFilter& filterP = pd->itemPathParent == folderPathL ? filterL : filterR; + //if there's a dependency, check if the sub directory is (fully) excluded via filter + //=> easy to check but still insufficient in general: + // - one folder may have a *.txt include-filter, the other a *.lng include filter => no dependencies, but "childItemMightMatch = true" below! + // - user may have manually excluded the conflicting items or changed the filter settings without running a re-compare + bool childItemMightMatch = true; + if (pd->relPath.empty() || filterP.passDirFilter(pd->relPath, &childItemMightMatch) || childItemMightMatch) + return pd; + } + return {}; +} + +//############################################################################################################ + +namespace +{ +template +void copyToAlternateFolderFrom(const std::vector& rowsToCopy, + const AbstractPath& targetFolderPath, + bool keepRelPaths, + bool overwriteIfExists, + ProcessCallback& callback /*throw X*/) //throw X +{ + auto reportItemInfo = [&](const std::wstring& msgTemplate, const AbstractPath& itemPath) //throw X + { + reportInfo(replaceCpy(msgTemplate, L"%x", fmtPath(AFS::getDisplayPath(itemPath))), callback); //throw X + }; + const std::wstring txtCreatingFile (_("Creating file %x" )); + const std::wstring txtCreatingFolder(_("Creating folder %x" )); + const std::wstring txtCreatingLink (_("Creating symbolic link %x")); + + auto copyItem = [&](const AbstractPath& targetPath, //throw FileError + const std::function& deleteTargetItem)>& copyItemPlain) //throw FileError + { + //start deleting existing target as required by copyFileTransactional(): + //best amortized performance if "already existing" is the most common case + std::exception_ptr deletionError; + auto tryDeleteTargetItem = [&] + { + if (overwriteIfExists) + try { AFS::removeFilePlain(targetPath); /*throw FileError*/ } + catch (FileError&) { deletionError = std::current_exception(); } //probably "not existing" error, defer evaluation + //else: copyFileTransactional() => undefined behavior! (e.g. fail/overwrite/auto-rename) + }; + + try + { + copyItemPlain(tryDeleteTargetItem); //throw FileError + } + catch (FileError&) + { + bool alreadyExisting = false; + try + { + AFS::getItemType(targetPath); //throw FileError + alreadyExisting = true; + } + catch (FileError&) {} //=> not yet existing (=> fine, no path issue) or access error: + //- let's pretend it doesn't happen :> if it does, worst case: the retry fails with (useless) already existing error + //- itemExists()? too expensive, considering that "already existing" is the most common case + + if (alreadyExisting) + { + if (deletionError) + std::rethrow_exception(deletionError); + throw; + } + + //parent folder missing => create + retry + //parent folder existing (maybe externally created shortly after copy attempt) => retry + if (const std::optional& targetParentPath = AFS::getParentPath(targetPath)) + AFS::createFolderIfMissingRecursion(*targetParentPath); //throw FileError + + //retry: + copyItemPlain(nullptr /*deleteTargetItem*/); //throw FileError + } + }; + + for (const FileSystemObject* fsObj : rowsToCopy) + tryReportingError([&] + { + const Zstring& relPath = keepRelPaths ? fsObj->getRelativePath() : fsObj->getItemName(); + const AbstractPath sourcePath = fsObj->getAbstractPath(); + const AbstractPath targetPath = AFS::appendRelPath(targetFolderPath, relPath); + + visitFSObject(*fsObj, [&](const FolderPair& folder) + { + ItemStatReporter statReporter(1, 0, callback); + reportItemInfo(txtCreatingFolder, targetPath); //throw X + + AFS::createFolderIfMissingRecursion(targetPath); //throw FileError + statReporter.reportDelta(1, 0); + //folder might already exist: see creation of intermediate directories below + }, + + [&](const FilePair& file) + { + ItemStatReporter statReporter(1, file.getFileSize(), callback); + reportItemInfo(txtCreatingFile, targetPath); //throw X + + std::wstring statusMsg = replaceCpy(txtCreatingFile, L"%x", fmtPath(AFS::getDisplayPath(targetPath))); + PercentStatReporter percentReporter(statusMsg, file.getFileSize(), statReporter); + + const FileAttributes attr = file.getAttributes(); + const AFS::StreamAttributes sourceAttr{attr.modTime, attr.fileSize, attr.filePrint}; + + copyItem(targetPath, [&](const std::function& deleteTargetItem) //throw FileError + { + //already existing + !overwriteIfExists: undefined behavior! (e.g. fail/overwrite/auto-rename) + const AFS::FileCopyResult result = AFS::copyFileTransactional(sourcePath, sourceAttr, targetPath, //throw FileError, ErrorFileLocked, X + false /*copyFilePermissions*/, true /*transactionalCopy*/, deleteTargetItem, + [&](int64_t bytesDelta) + { + percentReporter.updateDeltaAndStatus(bytesDelta); //throw X + callback.requestUiUpdate(); //throw X => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! e.g. during first few seconds: STATUS_PERCENT_DELAY! + }); + + if (result.errorModTime) //log only; no popup + callback.logMessage(result.errorModTime->toString(), PhaseCallback::MsgType::warning); + }); + statReporter.reportDelta(1, 0); + }, + + [&](const SymlinkPair& symlink) + { + ItemStatReporter statReporter(1, 0, callback); + reportItemInfo(txtCreatingLink, targetPath); //throw X + + copyItem(targetPath, [&](const std::function& deleteTargetItem) //throw FileError + { + deleteTargetItem(); //throw FileError + AFS::copySymlink(sourcePath, targetPath, false /*copyFilePermissions*/); //throw FileError + }); + statReporter.reportDelta(1, 0); + }); + }, callback); //throw X +} +} + + +void fff::copyToAlternateFolder(const std::vector& selectionL, + const std::vector& selectionR, + const Zstring& targetFolderPathPhrase, + bool keepRelPaths, bool overwriteIfExists, + WarningDialogs& warnings, + ProcessCallback& callback /*throw X*/) //throw X +{ + assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + + const int itemTotal = static_cast(selectionL.size() + selectionR.size()); + int64_t bytesTotal = 0; + + for (const FileSystemObject* fsObj : selectionL) + visitFSObject(*fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) { bytesTotal += static_cast(file.getFileSize()); }, [](const SymlinkPair& symlink) {}); + + for (const FileSystemObject* fsObj : selectionR) + visitFSObject(*fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) { bytesTotal += static_cast(file.getFileSize()); }, [](const SymlinkPair& symlink) {}); + + callback.initNewPhase(itemTotal, bytesTotal, ProcessPhase::none); //throw X + //------------------------------------------------------------------------------ + + const AbstractPath targetFolderPath = createAbstractPath(targetFolderPathPhrase); + + copyToAlternateFolderFrom(selectionL, targetFolderPath, keepRelPaths, overwriteIfExists, callback); + copyToAlternateFolderFrom(selectionR, targetFolderPath, keepRelPaths, overwriteIfExists, callback); +} + +//############################################################################################################ + +namespace +{ +template +void deleteFilesOneSide(const std::vector& rowsToDelete, + bool moveToRecycler, + bool& recyclerMissingReportOnce, + bool& warnRecyclerMissing, //WarningDialogs::warnRecyclerMissing + const std::unordered_map& baseFolderCfgs, + PhaseCallback& callback /*throw X*/) //throw X +{ + const std::wstring txtDelFilePermanent_ = _("Deleting file %x"); + const std::wstring txtDelFileRecycler_ = _("Moving file %x to the recycle bin"); + + const std::wstring txtDelSymlinkPermanent_ = _("Deleting symbolic link %x"); + const std::wstring txtDelSymlinkRecycler_ = _("Moving symbolic link %x to the recycle bin"); + + const std::wstring txtDelFolderPermanent_ = _("Deleting folder %x"); + const std::wstring txtDelFolderRecycler_ = _("Moving folder %x to the recycle bin"); + + auto removeFile = [&](const AbstractPath& filePath, ItemStatReporter& statReporter) + { + if (moveToRecycler) + try + { + reportInfo(replaceCpy(txtDelFileRecycler_, L"%x", fmtPath(AFS::getDisplayPath(filePath))), statReporter); //throw X + AFS::moveToRecycleBinIfExists(filePath); //throw FileError, RecycleBinUnavailable + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce) + { + recyclerMissingReportOnce = true; + callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X + } + callback.logMessage(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(filePath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X + AFS::removeFileIfExists(filePath); //throw FileError + } + else + { + reportInfo(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(filePath))), statReporter); //throw X + AFS::removeFileIfExists(filePath); //throw FileError + } + statReporter.reportDelta(1, 0); + }; + + auto removeSymlink = [&](const AbstractPath& symlinkPath, ItemStatReporter& statReporter) + { + if (moveToRecycler) + try + { + reportInfo(replaceCpy(txtDelSymlinkRecycler_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))), statReporter); //throw X + AFS::moveToRecycleBinIfExists(symlinkPath); //throw FileError, RecycleBinUnavailable + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce) + { + recyclerMissingReportOnce = true; + callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X + } + callback.logMessage(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X + AFS::removeSymlinkIfExists(symlinkPath); //throw FileError + } + else + { + reportInfo(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))), statReporter); //throw X + AFS::removeSymlinkIfExists(symlinkPath); //throw FileError + } + statReporter.reportDelta(1, 0); + }; + + auto removeFolder = [&](const AbstractPath& folderPath, ItemStatReporter& statReporter) + { + auto removeFolderPermanently = [&] + { + auto onBeforeDeletion = [&](const std::wstring& msgTemplate, const std::wstring& displayPath) + { + reportInfo(replaceCpy(msgTemplate, L"%x", fmtPath(displayPath)), statReporter); //throw X + statReporter.reportDelta(1, 0); //it would be more correct to report *after* work was done! + }; + + AFS::removeFolderIfExistsRecursion(folderPath, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFilePermanent_, displayPath); }, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelSymlinkPermanent_, displayPath); }, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFolderPermanent_, displayPath); }); //throw FileError, X + }; + + if (moveToRecycler) + try + { + reportInfo(replaceCpy(txtDelFolderRecycler_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw X + AFS::moveToRecycleBinIfExists(folderPath); //throw FileError, RecycleBinUnavailable + statReporter.reportDelta(1, 0); + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce) + { + recyclerMissingReportOnce = true; + callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X + } + callback.logMessage(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X + removeFolderPermanently(); //throw FileError, X + } + else + { + reportInfo(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw X + removeFolderPermanently(); //throw FileError, X + } + }; + + + for (FileSystemObject* fsObj : rowsToDelete) //all pointers are required(!) to be bound + tryReportingError([&] + { + ItemStatReporter statReporter(1, 0, callback); + + if (!fsObj->isEmpty()) //element may be implicitly deleted, e.g. if parent folder was deleted first + { + visitFSObject(*fsObj, [&](FolderPair& folder) + { + if (folder.isFollowedSymlink()) + removeSymlink(folder.getAbstractPath(), statReporter); //throw FileError, X + else + removeFolder(folder.getAbstractPath(), statReporter); //throw FileError, X + + folder.removeItem(); //removes recursively! + }, + + [&](FilePair& file) + { + if (file.isFollowedSymlink()) + removeSymlink(file.getAbstractPath(), statReporter); //throw FileError, X + else + removeFile(file.getAbstractPath(), statReporter); //throw FileError, X + + file.removeItem(); + }, + + [&](SymlinkPair& symlink) + { + removeSymlink(symlink.getAbstractPath(), statReporter); //throw FileError, X + symlink.removeItem(); + }); + //------- no-throw from here on ------- + const CompareFileResult catOld = fsObj->getCategory(); + + //update sync direction: don't call redetermineSyncDirection() because user may have manually changed directions + if (catOld == CompareFileResult::FILE_EQUAL) + { + const SyncDirection newDir = [&] + { + const SyncDirectionConfig& dirCfg = baseFolderCfgs.find(&fsObj->base())->second; //not found? let it crash! + + if (const DirectionByDiff* diffDirs = std::get_if(&dirCfg.dirs)) + return side == SelectSide::left ? diffDirs->rightOnly : diffDirs->leftOnly; + else + { + const DirectionByChange& changeDirs = std::get(dirCfg.dirs); + return side == SelectSide::left ? changeDirs.left.delete_ : changeDirs.right.delete_; + } + }(); + + setSyncDirectionRec(newDir, *fsObj); //set new direction (recursively) + } + //else: keep old syncDir_ + } + }, callback); //throw X +} +} + + +void fff::deleteFiles(const std::vector& selectionL, + const std::vector& selectionR, + const std::vector>& directCfgs, + bool moveToRecycler, + bool& warnRecyclerMissing, + ProcessCallback& callback /*throw X*/) //throw X +{ + assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + + const int itemCount = static_cast(selectionL.size() + selectionR.size()); + callback.initNewPhase(itemCount, 0, ProcessPhase::none); //throw X + //------------------------------------------------------------------------------ + + ZEN_ON_SCOPE_EXIT + ( + //*INDENT-OFF* + for (const auto& [baseFolder, dirCfg] : directCfgs) + baseFolder->removeDoubleEmpty(); + //*INDENT-ON* + ); + + //build up mapping from base directory to corresponding direction config + std::unordered_map baseFolderCfgs; + for (const auto& [baseFolder, dirCfg] : directCfgs) + baseFolderCfgs[baseFolder] = dirCfg; + + bool recyclerMissingReportOnce = false; + deleteFilesOneSide(selectionL, moveToRecycler, recyclerMissingReportOnce, warnRecyclerMissing, baseFolderCfgs, callback); //throw X + deleteFilesOneSide(selectionR, moveToRecycler, recyclerMissingReportOnce, warnRecyclerMissing, baseFolderCfgs, callback); // +} + +//############################################################################################################ + +namespace +{ +template +void renameItemsOneSide(const std::vector& selection, + const std::span newNames, + const std::unordered_map& baseFolderCfgs, + PhaseCallback& callback /*throw X*/) //throw X +{ + assert(selection.size() == newNames.size()); + + const std::wstring txtRenamingFileXtoY_ {_("Renaming file %x to %y")}; + const std::wstring txtRenamingLinkXtoY_ {_("Renaming symbolic link %x to %y")}; + const std::wstring txtRenamingFolderXtoY_{_("Renaming folder %x to %y")}; + + for (size_t i = 0; i < selection.size(); ++i) + tryReportingError([&] + { + FileSystemObject& fsObj = *selection[i]; + const Zstring& newName = newNames[i]; + + assert(!fsObj.isEmpty()); + + auto haveNameClash = [newNameNorm = getUnicodeNormalForm(newName)](const FileSystemObject& fsObj2) + { + return !fsObj2.isEmpty() && getUnicodeNormalForm(fsObj2.getItemName()) == newNameNorm; + }; + + const bool nameAlreadyExisting = [&] + { + for (const FilePair& file : fsObj.parent().files()) + if (haveNameClash(file)) + return true; + + for (const SymlinkPair& symlink : fsObj.parent().symlinks()) + if (haveNameClash(symlink)) + return true; + + for (const FolderPair& folder : fsObj.parent().subfolders()) + if (haveNameClash(folder)) + return true; + return false; + }(); + + //--------------------------------------------------------------- + ItemStatReporter statReporter(1, 0, callback); + + const std::wstring* txtRenamingXtoY_ = nullptr; + visitFSObject(fsObj, + [&](const FolderPair& folder) { txtRenamingXtoY_ = &txtRenamingFolderXtoY_; }, + [&](const FilePair& file) { txtRenamingXtoY_ = &txtRenamingFileXtoY_; }, + [&](const SymlinkPair& symlink) { txtRenamingXtoY_ = &txtRenamingLinkXtoY_; }); + + reportInfo(replaceCpy(replaceCpy(*txtRenamingXtoY_, L"%x", fmtPath(AFS::getDisplayPath(fsObj.getAbstractPath()))), + L"%y", fmtPath(newName)), statReporter); //throw X + + if (haveNameClash(fsObj)) + return assert(false); //theoretically possible, but practically showRenameDialog() won't return until there is an actual name change + + if (nameAlreadyExisting) //avoid inconsistent file model: expecting moveAndRenameItem() to fail (ERROR_ALREADY_EXISTS) is not good enough + return callback.reportFatalError(replaceCpy(replaceCpy(_("Cannot rename %x to %y."), + L"%x", fmtPath(AFS::getDisplayPath(fsObj.getAbstractPath()))), + L"%y", fmtPath(newName)) + L"\n\n" + + replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(newName))); //throw X + + AFS::moveAndRenameItem(fsObj.getAbstractPath(), + AFS::appendRelPath(fsObj.parent().getAbstractPath(), newName)); //throw FileError, (ErrorMoveUnsupported) + //------- no-throw from here on ------- + statReporter.reportDelta(1, 0); + + const CompareFileResult catOld = fsObj.getCategory(); + + fsObj.setItemName(newName); + +#warning("TODO: some users want to manually fix renamed folders/files: combine them here, don't require a re-compare!") + + //update sync direction: don't call redetermineSyncDirection() because user may have manually changed directions + if (catOld == CompareFileResult::FILE_EQUAL) + { + const SyncDirection newDir = [&] + { + const SyncDirectionConfig& dirCfg = baseFolderCfgs.find(&fsObj.base())->second; //not found? let it crash! + + if (const DirectionByDiff* diffDirs = std::get_if(&dirCfg.dirs)) + return side == SelectSide::left ? diffDirs->leftNewer : diffDirs->rightNewer; + else + { + const DirectionByChange& changeDirs = std::get(dirCfg.dirs); + return side == SelectSide::left ? changeDirs.left.update : changeDirs.right.update; + } + }(); + + fsObj.setSyncDir(newDir); //folder? => do not recurse! + } + //else: keep old syncDir_ + else if (fsObj.getCategory() == FILE_EQUAL) //edge-case, but possible + fsObj.setSyncDir(SyncDirection::none); //shouldn't matter, but avoids hitting some asserts + + }, callback); //throw X +} +} + + +void fff::renameItems(const std::vector& selectionL, + const std::span newNamesL, + const std::vector& selectionR, + const std::span newNamesR, + const std::vector>& directCfgs, + ProcessCallback& callback /*throw X*/) //throw X +{ + assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); })); + + const int itemCount = static_cast(selectionL.size() + selectionR.size()); + callback.initNewPhase(itemCount, 0, ProcessPhase::none); //throw X + //------------------------------------------------------------------------------ + + //build up mapping from base directory to corresponding direction config + std::unordered_map baseFolderCfgs; + for (const auto& [baseFolder, dirCfg] : directCfgs) + baseFolderCfgs[baseFolder] = dirCfg; + + renameItemsOneSide(selectionL, newNamesL, baseFolderCfgs, callback); //throw X + renameItemsOneSide(selectionR, newNamesR, baseFolderCfgs, callback); // +} + +//############################################################################################################ + +void fff::deleteListOfFiles(const std::vector& filesToDeletePaths, + std::vector& deletedPaths, + bool moveToRecycler, + bool& warnRecyclerMissing, + ProcessCallback& cb /*throw X*/) //throw X +{ + assert(deletedPaths.empty()); + + cb.initNewPhase(static_cast(filesToDeletePaths.size()), 0 /*bytesTotal*/, ProcessPhase::none); //throw X + + bool recyclerMissingReportOnce = false; + + for (const Zstring& filePath : filesToDeletePaths) + tryReportingError([&] + { + const AbstractPath cfgPath = createItemPathNative(filePath); + ItemStatReporter statReporter(1, 0, cb); + + if (moveToRecycler) + try + { + reportInfo(replaceCpy(_("Moving file %x to the recycle bin"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))), cb); //throw X + AFS::moveToRecycleBinIfExists(cfgPath); //throw FileError, RecycleBinUnavailable + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce) + { + recyclerMissingReportOnce = true; + cb.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X + } + cb.logMessage(replaceCpy(_("Deleting file %x"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X + AFS::removeFileIfExists(cfgPath); //throw FileError + } + else + { + reportInfo(replaceCpy(_("Deleting file %x"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))), cb); //throw X + AFS::removeFileIfExists(cfgPath); //throw FileError + } + + statReporter.reportDelta(1, 0); + deletedPaths.push_back(filePath); + }, cb); //throw X +} + +//############################################################################################################ + +TempFileBuffer::~TempFileBuffer() +{ + if (!tempFolderPath_.empty()) + try + { + removeDirectoryPlainRecursion(tempFolderPath_); //throw FileError + } + catch (const FileError& e) { logExtraError(e.toString()); } +} + + +void TempFileBuffer::createTempFolderPath() //throw FileError +{ + if (tempFolderPath_.empty()) + { + //generate random temp folder path e.g. C:\Users\Zenju\AppData\Local\Temp\FFS-068b2e88 + const uint32_t shortGuid = getCrc32(generateGUID()); //no need for full-blown (pseudo-)random numbers for this one-time invocation + + const Zstring& tempPathTmp = appendPath(getTempFolderPath(), //throw FileError + Zstr("FFS-") + printNumber(Zstr("%08x"), static_cast(shortGuid))); + + createDirectoryIfMissingRecursion(tempPathTmp); //throw FileError + + tempFolderPath_ = tempPathTmp; + } +} + + +Zstring TempFileBuffer::getAndCreateFolderPath() //throw FileError +{ + createTempFolderPath(); //throw FileError + return tempFolderPath_; +} + + +//returns empty if not available (item not existing, error during copy) +Zstring TempFileBuffer::getTempPath(const FileDescriptor& descr) const +{ + auto it = tempFilePaths_.find(descr); + if (it != tempFilePaths_.end()) + return it->second; + return Zstring(); +} + + +void TempFileBuffer::createTempFiles(const std::set& workLoad, ProcessCallback& callback /*throw X*/) //throw X +{ + const int itemTotal = static_cast(workLoad.size()); + int64_t bytesTotal = 0; + + for (const FileDescriptor& descr : workLoad) + bytesTotal += descr.attr.fileSize; + + callback.initNewPhase(itemTotal, bytesTotal, ProcessPhase::none); //throw X + //------------------------------------------------------------------------------ + + const std::wstring errMsg = tryReportingError([&] + { + createTempFolderPath(); //throw FileError + }, callback); //throw X + if (!errMsg.empty()) return; + + for (const FileDescriptor& descr : workLoad) + { + assert(!tempFilePaths_.contains(descr)); //ensure correct stats, NO overwrite-copy => caller-contract! + + MemoryStreamOut cookie; //create hash to distinguish different versions and file locations + writeNumber (cookie, descr.attr.modTime); + writeNumber (cookie, descr.attr.fileSize); + writeNumber (cookie, descr.attr.filePrint); + writeNumber (cookie, descr.attr.isFollowedSymlink); + writeContainer(cookie, AFS::getInitPathPhrase(descr.path)); + + const uint16_t crc16 = getCrc16(cookie.ref()); + const Zstring descrHash = printNumber(Zstr("%04x"), static_cast(crc16)); + + const Zstring fileName = AFS::getItemName(descr.path); + + auto it = findLast(fileName.begin(), fileName.end(), Zstr('.')); //gracefully handle case of missing "." + const Zstring tempFileName = Zstring(fileName.begin(), it) + Zstr('~') + descrHash + Zstring(it, fileName.end()); + + const Zstring tempFilePath = appendPath(tempFolderPath_, tempFileName); + const AFS::StreamAttributes sourceAttr{descr.attr.modTime, descr.attr.fileSize, descr.attr.filePrint}; + + tryReportingError([&] + { + std::wstring statusMsg = replaceCpy(_("Creating file %x"), L"%x", fmtPath(tempFilePath)); + + ItemStatReporter statReporter(1, descr.attr.fileSize, callback); + PercentStatReporter percentReporter(statusMsg, descr.attr.fileSize, statReporter); + + reportInfo(std::move(statusMsg), callback); //throw X + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + /*const AFS::FileCopyResult result =*/ + AFS::copyFileTransactional(descr.path, sourceAttr, //throw FileError, ErrorFileLocked, X + createItemPathNative(tempFilePath), + false /*copyFilePermissions*/, true /*transactionalCopy*/, nullptr /*onDeleteTargetFile*/, + [&](int64_t bytesDelta) + { + percentReporter.updateDeltaAndStatus(bytesDelta); //throw X + callback.requestUiUpdate(); //throw X => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! e.g. during first few seconds: STATUS_PERCENT_DELAY! + }); + //result.errorModTime? => irrelevant for temp files! + statReporter.reportDelta(1, 0); + + tempFilePaths_[descr] = tempFilePath; + }, callback); //throw X + } +} diff --git a/FreeFileSync/Source/base/algorithm.h b/FreeFileSync/Source/base/algorithm.h new file mode 100644 index 0000000..e091644 --- /dev/null +++ b/FreeFileSync/Source/base/algorithm.h @@ -0,0 +1,111 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ALGORITHM_H_34218518475321452548 +#define ALGORITHM_H_34218518475321452548 + +#include +#include "structures.h" +#include "file_hierarchy.h" +#include "soft_filter.h" +#include "process_callback.h" + + +namespace fff +{ +void swapGrids(const MainConfiguration& mainCfg, FolderComparison& folderCmp, + PhaseCallback& callback /*throw X*/); //throw X + +std::vector> extractDirectionCfg(FolderComparison& folderCmp, const MainConfiguration& mainCfg); + +void redetermineSyncDirection(const std::vector>& directCfgs, + PhaseCallback& callback /*throw X*/); //throw X + +void setSyncDirectionRec(SyncDirection newDirection, FileSystemObject& fsObj); //set new direction (recursively) + +bool allElementsEqual(const FolderComparison& folderCmp); + +//filtering +void applyFiltering (FolderComparison& folderCmp, const MainConfiguration& mainCfg); //full filter apply +void addHardFiltering(BaseFolderPair& baseFolder, const Zstring& excludeFilter); //exclude additional entries only +void addSoftFiltering(BaseFolderPair& baseFolder, const SoftFilter& timeSizeFilter); //exclude additional entries only + +void applyTimeSpanFilter(FolderComparison& folderCmp, time_t timeFrom, time_t timeTo); //overwrite current active/inactive settings + +void setActiveStatus(bool newStatus, FolderComparison& folderCmp); //activate or deactivate all rows +void setActiveStatus(bool newStatus, FileSystemObject& fsObj); //activate or deactivate row: (not recursively anymore) + +struct PathDependency +{ + AbstractPath itemPathParent; + Zstring relPath; //filled if child path is subfolder of parent path; empty if child path == parent path +}; +std::optional getPathDependency(const AbstractPath& itemPathL, const AbstractPath& itemPathR); +std::optional getFolderPathDependency(const AbstractPath& folderPathL, const PathFilter& filterL, + const AbstractPath& folderPathR, const PathFilter& filterR); + +//manual copy to alternate folder: +void copyToAlternateFolder(const std::vector& selectionL, //all pointers need to be bound and !isEmpty! + const std::vector& selectionR, // + const Zstring& targetFolderPathPhrase, + bool keepRelPaths, bool overwriteIfExists, + WarningDialogs& warnings, + ProcessCallback& callback /*throw X*/); //throw X + +//manual deletion of files on main grid +void deleteFiles(const std::vector& selectionL, //all pointers need to be bound and !isEmpty! + const std::vector& selectionR, //refresh GUI grid after deletion to remove invalid rows + const std::vector>& directCfgs, //attention: rows will be physically deleted! + bool moveToRecycler, + bool& warnRecyclerMissing, + ProcessCallback& callback /*throw X*/); //throw X + +void renameItems(const std::vector& selectionL, //all pointers need to be bound and !isEmpty! + const std::span newNamesL, + const std::vector& selectionR, //refresh GUI grid after deletion to remove invalid rows + const std::span newNamesR, + const std::vector>& directCfgs, + ProcessCallback& callback /*throw X*/); //throw X + +void deleteListOfFiles(const std::vector& filesToDeletePaths, + std::vector& deletedPaths, + bool moveToRecycler, + bool& warnRecyclerMissing, + ProcessCallback& callback /*throw X*/); //throw X + +struct FileDescriptor +{ + AbstractPath path; + FileAttributes attr; + + std::weak_ordering operator<=>(const FileDescriptor&) const = default; +}; + +//get native Win32 paths or create temporary copy for SFTP/MTP, etc. +class TempFileBuffer +{ +public: + TempFileBuffer() {} + ~TempFileBuffer(); + + Zstring getAndCreateFolderPath(); //throw FileError + + Zstring getTempPath(const FileDescriptor& descr) const; //returns empty if not in buffer (item not existing, error during copy) + + //contract: only add files not yet in the buffer! + void createTempFiles(const std::set& workLoad, ProcessCallback& callback /*throw X*/); //throw X + +private: + TempFileBuffer (const TempFileBuffer&) = delete; + TempFileBuffer& operator=(const TempFileBuffer&) = delete; + + void createTempFolderPath(); //throw FileError + + std::map tempFilePaths_; + Zstring tempFolderPath_; +}; +} +#endif //ALGORITHM_H_34218518475321452548 diff --git a/FreeFileSync/Source/base/binary.cpp b/FreeFileSync/Source/base/binary.cpp new file mode 100644 index 0000000..a908bfa --- /dev/null +++ b/FreeFileSync/Source/base/binary.cpp @@ -0,0 +1,79 @@ +// ***************************************************************************** +// * 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 "binary.h" + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +bool fff::filesHaveSameContent(const AbstractPath& filePath1, const AbstractPath& filePath2, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + int64_t totalBytesNotified = 0; + IoCallback /*[!] as expected by InputStream::tryRead()*/ notifyIoDiv = IOCallbackDivider(notifyUnbufferedIO, totalBytesNotified); + + const std::unique_ptr stream1 = AFS::getInputStream(filePath1); //throw FileError + const std::unique_ptr stream2 = AFS::getInputStream(filePath2); // + + const size_t blockSize1 = stream1->getBlockSize(); //throw FileError + const size_t blockSize2 = stream2->getBlockSize(); // + + const size_t bufCapacity = blockSize2 - 1 + blockSize1 + blockSize2; + + const std::unique_ptr buf(new std::byte[bufCapacity]); + + std::byte* const buf1 = buf.get() + blockSize2; //capacity: blockSize2 - 1 + blockSize1 + std::byte* const buf2 = buf.get(); //capacity: blockSize2 + + size_t buf1PosEnd = 0; + for (;;) + { + const size_t bytesRead1 = stream1->tryRead(buf1 + buf1PosEnd, blockSize1, notifyIoDiv); //throw FileError, X; may return short; only 0 means EOF + + if (bytesRead1 == 0) //end of file + { + size_t buf1Pos = 0; + while (buf1Pos < buf1PosEnd) + { + const size_t bytesRead2 = stream2->tryRead(buf2, blockSize2, notifyIoDiv); //throw FileError, X; may return short; only 0 means EOF + + if (bytesRead2 == 0 ||//end of file + bytesRead2 > buf1PosEnd - buf1Pos) + return false; + + if (std::memcmp(buf1 + buf1Pos, buf2, bytesRead2) != 0) + return false; + + buf1Pos += bytesRead2; + } + return stream2->tryRead(buf2, blockSize2, notifyIoDiv) == 0; //throw FileError, X; expect EOF + } + else + { + buf1PosEnd += bytesRead1; + + size_t buf1Pos = 0; + while (buf1PosEnd - buf1Pos >= blockSize2) + { + const size_t bytesRead2 = stream2->tryRead(buf2, blockSize2, notifyIoDiv); //throw FileError, X; may return short; only 0 means EOF + + if (bytesRead2 == 0) //end of file + return false; + + if (std::memcmp(buf1 + buf1Pos, buf2, bytesRead2) != 0) + return false; + + buf1Pos += bytesRead2; + } + if (buf1Pos > 0) + { + buf1PosEnd -= buf1Pos; + std::memmove(buf1, buf1 + buf1Pos, buf1PosEnd); + } + } + } +} diff --git a/FreeFileSync/Source/base/binary.h b/FreeFileSync/Source/base/binary.h new file mode 100644 index 0000000..d635b92 --- /dev/null +++ b/FreeFileSync/Source/base/binary.h @@ -0,0 +1,20 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BINARY_H_3941281398513241134 +#define BINARY_H_3941281398513241134 + +#include "../afs/abstract.h" + + +namespace fff +{ +bool filesHaveSameContent(const AbstractPath& filePath1, + const AbstractPath& filePath2, + const zen::IoCallback& notifyUnbufferedIO /*throw X*/); //throw FileError, X +} + +#endif //BINARY_H_3941281398513241134 diff --git a/FreeFileSync/Source/base/cmp_filetime.h b/FreeFileSync/Source/base/cmp_filetime.h new file mode 100644 index 0000000..0061306 --- /dev/null +++ b/FreeFileSync/Source/base/cmp_filetime.h @@ -0,0 +1,93 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CMP_FILETIME_H_032180451675845 +#define CMP_FILETIME_H_032180451675845 + +#include + + +namespace fff +{ +inline +bool sameFileTime(time_t lhs, time_t rhs, /*unsigned*/ int tolerance, const std::vector& ignoreTimeShiftMinutes) +{ + assert(tolerance >= 0); + + if (lhs < rhs) + std::swap(lhs, rhs); + + if (rhs > std::numeric_limits::max() - tolerance) //protect against overflow! + return true; + + if (lhs <= rhs + tolerance) + return true; + + for (const unsigned int minutes : ignoreTimeShiftMinutes) + { + assert(minutes > 0); + const int shiftSec = static_cast(minutes) * 60; + + time_t low = rhs; + time_t high = lhs; + + if (low <= std::numeric_limits::max() - shiftSec) //protect against overflow! + low += shiftSec; + else + high -= shiftSec; + + if (high < low) + std::swap(high, low); + + if (low > std::numeric_limits::max() - tolerance) //protect against overflow! + return true; + + if (high <= low + tolerance) + return true; + } + + return false; +} + +//--------------------------------------------------------------------------------------------------------------- + +enum class TimeResult +{ + equal, + leftNewer, + rightNewer, + leftInvalid, + rightInvalid +}; + + +//number of seconds since Jan 1st 1970 + 1 year (needn't be too precise) +inline const time_t oneYearFromNow = std::time(nullptr) + 365 * 24 * 3600; + + +inline +TimeResult compareFileTime(time_t lhs, time_t rhs, unsigned int tolerance, const std::vector& ignoreTimeShiftMinutes) +{ + assert(oneYearFromNow != 0); + if (sameFileTime(lhs, rhs, tolerance, ignoreTimeShiftMinutes)) //last write time may differ by up to 2 seconds (NTFS vs FAT32) + return TimeResult::equal; + + //check for erroneous dates + if (lhs < 0 || lhs > oneYearFromNow) //earlier than Jan 1st 1970 or more than one year in future + return TimeResult::leftInvalid; + + if (rhs < 0 || rhs > oneYearFromNow) + return TimeResult::rightInvalid; + + //regular time comparison + if (lhs < rhs) + return TimeResult::rightNewer; + else + return TimeResult::leftNewer; +} +} + +#endif //CMP_FILETIME_H_032180451675845 diff --git a/FreeFileSync/Source/base/comparison.cpp b/FreeFileSync/Source/base/comparison.cpp new file mode 100644 index 0000000..31e0d47 --- /dev/null +++ b/FreeFileSync/Source/base/comparison.cpp @@ -0,0 +1,1196 @@ +// ***************************************************************************** +// * 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 "comparison.h" +#include +#include +#include +#include "algorithm.h" +#include "parallel_scan.h" +#include "dir_exist_async.h" +#include "db_file.h" +#include "binary.h" +#include "cmp_filetime.h" +#include "status_handler_impl.h" +#include "../afs/concrete.h" +#include "../afs/native.h" + +using namespace zen; +using namespace fff; + + +std::vector fff::extractCompareCfg(const MainConfiguration& mainCfg) +{ + //merge first and additional pairs + std::vector localCfgs = {mainCfg.firstPair}; + append(localCfgs, mainCfg.additionalPairs); + + std::vector output; + + for (const LocalPairConfig& lpc : localCfgs) + { + const CompConfig cmpCfg = lpc.localCmpCfg ? *lpc.localCmpCfg : mainCfg.cmpCfg; + const SyncConfig syncCfg = lpc.localSyncCfg ? *lpc.localSyncCfg : mainCfg.syncCfg; + NormalizedFilter filter = normalizeFilters(mainCfg.globalFilter, lpc.localFilter); + + //exclude sync.ffs_db and lock files + //=> can't put inside fff::parallelFolderScan() which is also used by versioning + filter.nameFilter = filter.nameFilter.ref().copyFilterAddingExclusion(Zstring(Zstr("*")) + SYNC_DB_FILE_ENDING + Zstr("\n*") + LOCK_FILE_ENDING); + + output.push_back( + { + lpc.folderPathPhraseLeft, lpc.folderPathPhraseRight, + cmpCfg.compareVar, + cmpCfg.handleSymlinks, + cmpCfg.ignoreTimeShiftMinutes, + filter, + syncCfg.directionCfg + }); + } + return output; +} + +//------------------------------------------------------------------------------------------ +namespace +{ +struct ResolvedFolderPair +{ + AbstractPath folderPathLeft; + AbstractPath folderPathRight; +}; + + +struct ResolvedBaseFolders +{ + std::vector resolvedPairs; + FolderStatus baseFolderStatus; +}; + + +ResolvedBaseFolders initializeBaseFolders(const std::vector& fpCfgList, + const AFS::RequestPasswordFun& requestPassword /*throw X*/, + WarningDialogs& warnings, + PhaseCallback& callback /*throw X*/) //throw X +{ + std::vector pathPhrases; + for (const FolderPairCfg& fpCfg : fpCfgList) + { + pathPhrases.push_back(fpCfg.folderPathPhraseLeft_); + pathPhrases.push_back(fpCfg.folderPathPhraseRight_); + } + + ResolvedBaseFolders output; + std::set allFolders; + + tryReportingError([&] + { + //createAbstractPath() -> tryExpandVolumeName() hangs for idle HDD! => run async to make it cancellable + auto protCurrentPhrase = makeSharedRef>(); + + std::future> futFolderPaths = runAsync([pathPhrases, currentPhraseWeak = std::weak_ptr(protCurrentPhrase.ptr())] + { + setCurrentThreadName(Zstr("Normalizing folder paths")); + + std::vector folderPaths; + for (const Zstring& pathPhrase : pathPhrases) + { + if (auto protCurrentPhrase2 = currentPhraseWeak.lock()) //[!] not owned by worker thread! + protCurrentPhrase2->access([&](Zstring& currentPathPhrase) { currentPathPhrase = pathPhrase; }); + else + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Caller context gone!"); + + folderPaths.push_back(createAbstractPath(pathPhrase)); + } + return folderPaths; + }); + + while (futFolderPaths.wait_for(UI_UPDATE_INTERVAL / 2) == std::future_status::timeout) + { + const Zstring pathPhrase = protCurrentPhrase.ref().access([](const Zstring& currentPathPhrase) { return currentPathPhrase; }); + callback.updateStatus(_("Normalizing folder paths...") + L' ' + utfTo(pathPhrase)); //throw X + } + + const std::vector& folderPaths = futFolderPaths.get(); //throw (std::runtime_error) + + //support "retry" for environment variable and variable driver letter resolution! + allFolders.clear(); + allFolders.insert(folderPaths.begin(), folderPaths.end()); + + output.resolvedPairs.clear(); + for (size_t i = 0; i < folderPaths.size(); i += 2) + output.resolvedPairs.push_back({folderPaths[i], folderPaths[i + 1]}); + //--------------------------------------------------------------------------- + + output.baseFolderStatus = getFolderStatusParallel(allFolders, + true /*authenticateAccess*/, requestPassword, callback); //throw X + if (!output.baseFolderStatus.failedChecks.empty()) + { + std::wstring msg = _("Cannot find the following folders:") + L'\n'; + + for (const auto& [folderPath, error] : output.baseFolderStatus.failedChecks) + msg += L'\n' + AFS::getDisplayPath(folderPath); + + msg += L"\n___________________________________________"; + for (const auto& [folderPath, error] : output.baseFolderStatus.failedChecks) + msg += L"\n\n" + replaceCpy(error.toString(), L"\n\n", L'\n'); + + throw FileError(msg); + } + }, callback); //throw X + + + if (!output.baseFolderStatus.notExisting.empty()) + { + std::wstring msg = _("The following folders do not yet exist:") + L'\n'; + + for (const AbstractPath& folderPath : output.baseFolderStatus.notExisting) + msg += L'\n' + AFS::getDisplayPath(folderPath); + + msg += L"\n\n"; + msg += _("The folders are created automatically when needed."); + + callback.reportWarning(msg, warnings.warnFolderNotExisting); //throw X + } + + //--------------------------------------------------------------------------- + std::map, std::set> ciPathAliases; + + for (const AbstractPath& folderPath : allFolders) + ciPathAliases[std::pair(folderPath.afsDevice, folderPath.afsPath.value)].insert(folderPath); + + if (std::any_of(ciPathAliases.begin(), ciPathAliases.end(), [](const auto& item) { return item.second/*aliases*/.size() > 1; })) + { + std::wstring msg = _("The following folder paths differ in case. Please use a single form in order to avoid duplicate accesses."); + for (const auto& [key, aliases] : ciPathAliases) + if (aliases.size() > 1) + { + msg += L'\n'; + for (const AbstractPath& aliasPath : aliases) + msg += L'\n' + AFS::getDisplayPath(aliasPath); + } + + callback.reportWarning(msg, warnings.warnFoldersDifferInCase); //throw X + + //what about /folder and /Folder/subfolder? => yes, inconsistent, but doesn't matter for FFS + } + //--------------------------------------------------------------------------- + + return output; +} + +//############################################################################################################################# + +class ComparisonBuffer +{ +public: + ComparisonBuffer(const FolderStatus& folderStatus, + unsigned int fileTimeTolerance, + ProcessCallback& callback) : + fileTimeTolerance_(fileTimeTolerance), + folderStatus_(folderStatus), + cb_(callback) {} + + FolderComparison execute(const std::vector>& workLoad); + +private: + ComparisonBuffer (const ComparisonBuffer&) = delete; + ComparisonBuffer& operator=(const ComparisonBuffer&) = delete; + + //create comparison result table and fill category except for files existing on both sides: undefinedFiles and undefinedSymlinks are appended! + SharedRef compareByTimeSize(const ResolvedFolderPair& fp, const FolderPairCfg& fpConfig) const; + SharedRef compareBySize (const ResolvedFolderPair& fp, const FolderPairCfg& fpConfig) const; + std::vector> compareByContent(const std::vector>& workLoad) const; + + SharedRef performComparison(const ResolvedFolderPair& fp, + const FolderPairCfg& fpCfg, + std::vector& undefinedFiles, + std::vector& undefinedSymlinks) const; + + BaseFolderStatus getBaseFolderStatus(const AbstractPath& folderPath) const + { + if (folderStatus_.existing.contains(folderPath)) + return BaseFolderStatus::existing; + if (folderStatus_.notExisting.contains(folderPath)) + return BaseFolderStatus::notExisting; + if (folderStatus_.failedChecks.contains(folderPath)) + return BaseFolderStatus::failure; + assert(AFS::isNullPath(folderPath)); + return BaseFolderStatus::notExisting; + }; + + const unsigned int fileTimeTolerance_; + const FolderStatus& folderStatus_; + std::map folderBuffer_; //contains entries for *all* scanned folders! + ProcessCallback& cb_; +}; + + +FolderComparison ComparisonBuffer::execute(const std::vector>& workLoad) +{ + std::set foldersToRead; + for (const auto& [folderPair, fpCfg] : workLoad) + if (getBaseFolderStatus(folderPair.folderPathLeft ) != BaseFolderStatus::failure && //no need to list or display one-sided results if + getBaseFolderStatus(folderPair.folderPathRight) != BaseFolderStatus::failure) //*either* folder existence check fails + { + //+ only traverse *existing* folders + if (getBaseFolderStatus(folderPair.folderPathLeft) == BaseFolderStatus::existing) + foldersToRead.emplace(DirectoryKey{folderPair.folderPathLeft, fpCfg.filter.nameFilter, fpCfg.handleSymlinks}); + if (getBaseFolderStatus(folderPair.folderPathRight) == BaseFolderStatus::existing) + foldersToRead.emplace(DirectoryKey{folderPair.folderPathRight, fpCfg.filter.nameFilter, fpCfg.handleSymlinks}); + } + + //------------------------------------------------------------------ + StopWatch scanTime; + int itemsReported = 0; + + auto onStatusUpdate = [&, textScanning = _("Scanning:") + L' '](const std::wstring& statusLine, int itemsTotal) + { + cb_.updateDataProcessed(itemsTotal - itemsReported, 0); //noexcept + itemsReported = itemsTotal; + + cb_.updateStatus(textScanning + statusLine); //throw X + }; + + folderBuffer_ = parallelFolderScan(foldersToRead, + [&](const PhaseCallback::ErrorInfo& errorInfo) { return cb_.reportError(errorInfo); }, //throw X + onStatusUpdate, //throw X + UI_UPDATE_INTERVAL / 2); //every ~25 ms + + //------------------------------------------------------------------ + const int64_t totalTimeSec = std::chrono::duration_cast(scanTime.elapsed()).count(); + + cb_.logMessage(_P("1 item found", "%x items found", itemsReported) + L" | " + + _("Time elapsed:") + L' ' + utfTo(formatTimeSpan(totalTimeSec)), + PhaseCallback::MsgType::info); //throw X + //caveat: the time while waiting on error dialog or while paused is counted, too :/ OTOH other threads continue working => unclear how to count... + //------------------------------------------------------------------ + + //process binary comparison as one junk + std::vector> workLoadByContent; + for (const auto& [folderPair, fpCfg] : workLoad) + if (fpCfg.compareVar == CompareVariant::content) + workLoadByContent.push_back({folderPair, fpCfg}); + + std::vector> outputByContent = compareByContent(workLoadByContent); + auto itOByC = outputByContent.begin(); + + FolderComparison output; + + //write output in expected order + for (const auto& [folderPair, fpCfg] : workLoad) + switch (fpCfg.compareVar) + { + case CompareVariant::timeSize: + output.push_back(compareByTimeSize(folderPair, fpCfg)); + break; + case CompareVariant::size: + output.push_back(compareBySize(folderPair, fpCfg)); + break; + case CompareVariant::content: + assert(itOByC != outputByContent.end()); + if (itOByC != outputByContent.end()) + output.push_back(*itOByC++); + break; + } + return output; +} + + +//--------------------assemble conflict descriptions--------------------------- + +//const wchar_t arrowLeft [] = L"\u2190"; unicode arrows -> too small +//const wchar_t arrowRight[] = L"\u2192"; +const wchar_t arrowLeft [] = L"<-"; +const wchar_t arrowRight[] = L"->"; + +//NOTE: conflict texts are NOT expected to contain additional path info (already implicit through associated item!) +// => only add path info if information is relevant, e.g. conflict is specific to left/right side only + +template inline +Zstringc getConflictInvalidDate(const FileOrLinkPair& file) +{ + return utfTo(replaceCpy(_("File %x has an invalid date."), L"%x", fmtPath(AFS::getDisplayPath(file.template getAbstractPath()))) + L'\n' + + _("Date:") + L' ' + formatUtcToLocalTime(file.template getLastWriteTime())); +} + + +Zstringc getConflictSameDateDiffSize(const FilePair& file) +{ + return utfTo(_("Files have the same date but a different size.") + L'\n' + + _("Date:") + L' ' + formatUtcToLocalTime(file.getLastWriteTime()) + TAB_SPACE + _("Size:") + L' ' + formatNumber(file.getFileSize()) + L' ' + arrowLeft + L'\n' + + _("Date:") + L' ' + formatUtcToLocalTime(file.getLastWriteTime()) + TAB_SPACE + _("Size:") + L' ' + formatNumber(file.getFileSize()) + L' ' + arrowRight); +} + + +Zstringc getConflictSkippedBinaryComparison() +{ + return utfTo(_("Content comparison was skipped for excluded files.")); +} + + +Zstringc getConflictAmbiguousItemName(const Zstring& itemName) +{ + return utfTo(replaceCpy(_("The name %x is used by more than one item in the folder."), L"%x", fmtPath(itemName))); +} + +//----------------------------------------------------------------------------- + +void categorizeSymlinkByTime(SymlinkPair& symlink) +{ + //categorize symlinks that exist on both sides + switch (compareFileTime(symlink.getLastWriteTime(), + symlink.getLastWriteTime(), symlink.base().getFileTimeTolerance(), symlink.base().getIgnoredTimeShift())) + { + case TimeResult::equal: + symlink.setContentCategory(FileContentCategory::equal); + break; + + case TimeResult::leftNewer: + symlink.setContentCategory(FileContentCategory::leftNewer); + break; + + case TimeResult::rightNewer: + symlink.setContentCategory(FileContentCategory::rightNewer); + break; + + case TimeResult::leftInvalid: + symlink.setCategoryInvalidTime(getConflictInvalidDate(symlink)); + break; + + case TimeResult::rightInvalid: + symlink.setCategoryInvalidTime(getConflictInvalidDate(symlink)); + break; + } +} + + +SharedRef ComparisonBuffer::compareByTimeSize(const ResolvedFolderPair& fp, const FolderPairCfg& fpConfig) const +{ + //do basis scan and retrieve files existing on both sides as "compareCandidates" + std::vector uncategorizedFiles; + std::vector uncategorizedLinks; + SharedRef output = performComparison(fp, fpConfig, uncategorizedFiles, uncategorizedLinks); + + //finish symlink categorization + for (SymlinkPair* symlink : uncategorizedLinks) + categorizeSymlinkByTime(*symlink); + + //categorize files that exist on both sides + for (FilePair* file : uncategorizedFiles) + { + switch (compareFileTime(file->getLastWriteTime(), + file->getLastWriteTime(), fileTimeTolerance_, fpConfig.ignoreTimeShiftMinutes)) + { + case TimeResult::equal: + if (file->getFileSize() == file->getFileSize()) + file->setContentCategory(FileContentCategory::equal); + else + file->setCategoryInvalidTime(getConflictSameDateDiffSize(*file)); + break; + + case TimeResult::leftNewer: + file->setContentCategory(FileContentCategory::leftNewer); + break; + + case TimeResult::rightNewer: + file->setContentCategory(FileContentCategory::rightNewer); + break; + + case TimeResult::leftInvalid: + file->setCategoryInvalidTime(getConflictInvalidDate(*file)); + break; + + case TimeResult::rightInvalid: + file->setCategoryInvalidTime(getConflictInvalidDate(*file)); + break; + } + } + return output; +} + + +namespace +{ +void categorizeSymlinkByContent(SymlinkPair& symlink, PhaseCallback& callback) +{ + //categorize symlinks that exist on both sides + callback.updateStatus(replaceCpy(_("Resolving symbolic link %x"), L"%x", fmtPath(AFS::getDisplayPath(symlink.getAbstractPath())))); //throw X + callback.updateStatus(replaceCpy(_("Resolving symbolic link %x"), L"%x", fmtPath(AFS::getDisplayPath(symlink.getAbstractPath())))); //throw X + + bool equalContent = false; + const std::wstring errMsg = tryReportingError([&] + { + equalContent = AFS::equalSymlinkContent(symlink.getAbstractPath(), + symlink.getAbstractPath()); //throw FileError + }, callback); //throw X + + if (!errMsg.empty()) + symlink.setCategoryConflict(utfTo(errMsg)); + else + symlink.setContentCategory(equalContent ? FileContentCategory::equal : FileContentCategory::different); +} +} + + +SharedRef ComparisonBuffer::compareBySize(const ResolvedFolderPair& fp, const FolderPairCfg& fpConfig) const +{ + //do basis scan and retrieve files existing on both sides as "compareCandidates" + std::vector uncategorizedFiles; + std::vector uncategorizedLinks; + SharedRef output = performComparison(fp, fpConfig, uncategorizedFiles, uncategorizedLinks); + + //finish symlink categorization + for (SymlinkPair* symlink : uncategorizedLinks) + categorizeSymlinkByContent(*symlink, cb_); //"compare by size" has the semantics of a quick content-comparison! + //harmonize with algorithm.cpp, stillInSync()! + + //categorize files that exist on both sides + for (FilePair* file : uncategorizedFiles) + { + //Caveat: + //1. FILE_EQUAL may only be set if file names match in case: InSyncFolder's mapping tables use file name as a key! see db_file.cpp + //2. FILE_EQUAL is expected to mean identical file sizes! See InSyncFile + //3. harmonize with "bool stillInSync()" in algorithm.cpp, FilePair::setSyncedTo() in file_hierarchy.h + if (file->getFileSize() == file->getFileSize()) + file->setContentCategory(FileContentCategory::equal); + else + file->setContentCategory(FileContentCategory::different); + } + return output; +} + + +namespace parallel +{ +//-------------------------------------------------------------- +//ATTENTION CALLBACKS: they also run asynchronously *outside* the singleThread lock! +//-------------------------------------------------------------- +inline +bool filesHaveSameContent(const AbstractPath& filePath1, const AbstractPath& filePath2, //throw FileError, X + const IoCallback& notifyUnbufferedIO /*throw X*/, + std::mutex& singleThread) +{ return parallelScope([=] { return filesHaveSameContent(filePath1, filePath2, notifyUnbufferedIO); /*throw FileError, X*/ }, singleThread); } +} + + +namespace +{ +void categorizeFileByContent(FilePair& file, const std::wstring& txtComparingContentOfFiles, AsyncCallback& acb, std::mutex& singleThread) //throw ThreadStopRequest +{ + bool haveSameContent = false; + const std::wstring errMsg = tryReportingError([&] + { + std::wstring statusMsg = replaceCpy(txtComparingContentOfFiles, L"%x", fmtPath(file.getRelativePath())); + //is it possible that right side has a different relPath? maybe, but who cares, it's a short-lived status message + + ItemStatReporter statReporter(1, file.getFileSize(), acb); + PercentStatReporter percentReporter(statusMsg, file.getFileSize(), statReporter); + + acb.updateStatus(std::move(statusMsg)); //throw ThreadStopRequest + + //callbacks run *outside* singleThread lock! => fine + auto notifyUnbufferedIO = [&percentReporter](int64_t bytesDelta) + { + percentReporter.updateDeltaAndStatus(bytesDelta); //throw ThreadStopRequest + interruptionPoint(); //throw ThreadStopRequest => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! + }; + + haveSameContent = parallel::filesHaveSameContent(file.getAbstractPath(), + file.getAbstractPath(), notifyUnbufferedIO, singleThread); //throw FileError, ThreadStopRequest + statReporter.reportDelta(1, 0); + }, acb); //throw ThreadStopRequest + + if (!errMsg.empty()) + file.setCategoryConflict(utfTo(errMsg)); + else + file.setContentCategory(haveSameContent ? FileContentCategory::equal : FileContentCategory::different); +} +} + + +std::vector> ComparisonBuffer::compareByContent(const std::vector>& workLoad) const +{ + struct ParallelOps + { + size_t current = 0; + }; + std::map parallelOpsStatus; + + struct BinaryWorkload + { + ParallelOps& parallelOpsL; // + ParallelOps& parallelOpsR; //consider aliasing! + RingBuffer filesToCompareBytewise; + }; + std::vector fpWorkload; + + auto addToBinaryWorkload = [&](const AbstractPath& basePathL, const AbstractPath& basePathR, RingBuffer&& filesToCompareBytewise) + { + ParallelOps& posL = parallelOpsStatus[basePathL.afsDevice]; + ParallelOps& posR = parallelOpsStatus[basePathR.afsDevice]; + fpWorkload.push_back({posL, posR, std::move(filesToCompareBytewise)}); + }; + + std::vector> output; + + const Zstringc txtConflictSkippedBinaryComparison = getConflictSkippedBinaryComparison(); //avoid premature pess.: save memory via ref-counted string + + for (const auto& [folderPair, fpCfg] : workLoad) + { + std::vector undefinedFiles; + std::vector uncategorizedLinks; + //run basis scan and retrieve candidates for binary comparison (files existing on both sides) + output.push_back(performComparison(folderPair, fpCfg, undefinedFiles, uncategorizedLinks)); + + RingBuffer filesToCompareBytewise; + //content comparison of file content happens AFTER finding corresponding files and AFTER filtering + //in order to separate into two processes (scanning and comparing) + for (FilePair* file : undefinedFiles) + //pre-check: files have different content if they have a different file size (must not be FILE_EQUAL: see InSyncFile) + if (file->getFileSize() != file->getFileSize()) + file->setContentCategory(FileContentCategory::different); + else + { + //perf: skip binary comparison for excluded rows (e.g. via time span and size filter)! + //both soft and hard filter were already applied in ComparisonBuffer::performComparison()! + assert(file->getContentCategory() == FileContentCategory::unknown); //=default + if (!file->isActive()) + file->setCategoryConflict(txtConflictSkippedBinaryComparison); + else + filesToCompareBytewise.push_back(file); + } + if (!filesToCompareBytewise.empty()) + addToBinaryWorkload(output.back().ref().getAbstractPath(), + output.back().ref().getAbstractPath(), std::move(filesToCompareBytewise)); + + //finish symlink categorization + for (SymlinkPair* symlink : uncategorizedLinks) + categorizeSymlinkByContent(*symlink, cb_); + } + + //finish categorization: compare files (that have same size) bytewise... + if (!fpWorkload.empty()) //run ProcessPhase::binaryCompare only when needed + { + int itemsTotal = 0; + uint64_t bytesTotal = 0; + for (const BinaryWorkload& bwl : fpWorkload) + { + itemsTotal += static_cast(bwl.filesToCompareBytewise.size()); + + for (const FilePair* file : bwl.filesToCompareBytewise) + bytesTotal += file->getFileSize(); //left and right file sizes are equal + } + + cb_.initNewPhase(itemsTotal, bytesTotal, ProcessPhase::binaryCompare); //throw X + StopWatch compareTime; + + std::mutex singleThread; //only a single worker thread may run at a time, except for parallel file I/O + + AsyncCallback acb; // + std::function scheduleMoreTasks; //manage life time: enclose ThreadGroup! + + ThreadGroup> tg(std::numeric_limits::max(), Zstr("Binary Comparison")); + + scheduleMoreTasks = [&, txtComparingContentOfFiles = _("Comparing content of files %x")] + { + bool wereDone = true; + + for (size_t j = 0; j < fpWorkload.size(); ++j) + { + BinaryWorkload& bwl = fpWorkload[j]; + ParallelOps& posL = bwl.parallelOpsL; + ParallelOps& posR = bwl.parallelOpsR; + const size_t newTaskCount = std::min({1 - posL.current, 1 - posR.current, bwl.filesToCompareBytewise.size()}); + if (&posL != &posR) + posL.current += newTaskCount; // + posR.current += newTaskCount; //consider aliasing! + + for (size_t i = 0; i < newTaskCount; ++i) + { + tg.run([&, statusPrio = j, &file = *bwl.filesToCompareBytewise.front()] + { + acb.notifyTaskBegin(statusPrio); //prioritize status messages according to natural order of folder pairs + ZEN_ON_SCOPE_EXIT(acb.notifyTaskEnd()); + + std::lock_guard dummy(singleThread); //protect ALL variable accesses unless explicitly not needed ("parallel" scope)! + //--------------------------------------------------------------------------------------------------- + ZEN_ON_SCOPE_SUCCESS(if (&posL != &posR) --posL.current; + /**/ --posR.current; + scheduleMoreTasks()); + + categorizeFileByContent(file, txtComparingContentOfFiles, acb, singleThread); //throw ThreadStopRequest + }); + + bwl.filesToCompareBytewise.pop_front(); + } + if (posL.current != 0 || posR.current != 0 || !bwl.filesToCompareBytewise.empty()) + wereDone = false; + } + if (wereDone) + acb.notifyAllDone(); + }; + + { + std::lock_guard dummy(singleThread); //[!] potential race with worker threads! + scheduleMoreTasks(); //set initial load + } + + const auto [itemsProcessed, bytesProcessed] = acb.waitUntilDone(UI_UPDATE_INTERVAL / 2 /*every ~25 ms*/, cb_); //throw X + + //--------------------------------------------------------------- + const int64_t totalTimeSec = std::chrono::duration_cast(compareTime.elapsed()).count(); + + cb_.logMessage(_("File contents compared:") + L' ' + formatNumber(itemsProcessed) + L" (" + formatFilesizeShort(bytesProcessed) + L") | " + + _("Time elapsed:") + L' ' + utfTo(formatTimeSpan(totalTimeSec)), + PhaseCallback::MsgType::info); //throw X + } + + return output; +} + +//----------------------------------------------------------------------------------------------- + +class MergeSides +{ +public: + static void execute(const FolderContainer& lhs, const FolderContainer& rhs, + const std::unordered_map& errorsByRelPathL, + const std::unordered_map& errorsByRelPathR, + ContainerObject& output, + std::vector& undefinedFilesOut, + std::vector& undefinedSymlinksOut) + { + MergeSides inst(errorsByRelPathL, errorsByRelPathR, undefinedFilesOut, undefinedSymlinksOut); + + const Zstringc* errorMsg = nullptr; + if (auto it = inst.errorsByRelPathL_.find(Zstring()); //empty path if read-error for whole base directory + it != inst.errorsByRelPathL_.end()) + errorMsg = &it->second; + else if (auto it2 = inst.errorsByRelPathR_.find(Zstring()); + it2 != inst.errorsByRelPathR_.end()) + errorMsg = &it2->second; + + inst.mergeFolders(lhs, rhs, errorMsg, output); + } + +private: + MergeSides(const std::unordered_map& errorsByRelPathL, + const std::unordered_map& errorsByRelPathR, + std::vector& undefinedFilesOut, + std::vector& undefinedSymlinksOut) : + errorsByRelPathL_(errorsByRelPathL), + errorsByRelPathR_(errorsByRelPathR), + undefinedFiles_(undefinedFilesOut), + undefinedSymlinks_(undefinedSymlinksOut) {} + + void mergeFolders(const FolderContainer& lhs, const FolderContainer& rhs, const Zstringc* errorMsg, ContainerObject& output); + + template + void fillOneSide(const FolderContainer& folderCont, const Zstringc* errorMsg, ContainerObject& output); + + template + const Zstringc* checkFailedRead(FileSystemObject& fsObj, const Zstringc* errorMsg); + + const Zstringc* checkFailedRead(FileSystemObject& fsObj, const Zstringc* errorMsg); + + const std::unordered_map& errorsByRelPathL_; //base-relative paths or empty if read-error for whole base directory + const std::unordered_map& errorsByRelPathR_; // + std::vector& undefinedFiles_; + std::vector& undefinedSymlinks_; +}; + + +template inline +const Zstringc* MergeSides::checkFailedRead(FileSystemObject& fsObj, const Zstringc* errorMsg) +{ + if (!errorMsg) + { + const std::unordered_map& errorsByRelPath = selectParam(errorsByRelPathL_, errorsByRelPathR_); + + if (!errorsByRelPath.empty()) //only pay for relPath construction when needed + if (const auto it = errorsByRelPath.find(fsObj.getRelativePath()); + it != errorsByRelPath.end()) + errorMsg = &it->second; + } + + if (errorMsg) //make sure all items are disabled => avoid user panicking: https://freefilesync.org/forum/viewtopic.php?t=7582 + { + fsObj.setActive(false); + fsObj.setCategoryConflict(*errorMsg); //peak memory: Zstringc is ref-counted, unlike std::string! + static_assert(std::is_same_v); + } + return errorMsg; +} + + +const Zstringc* MergeSides::checkFailedRead(FileSystemObject& fsObj, const Zstringc* errorMsg) +{ + if (const Zstringc* errorMsgNew = checkFailedRead(fsObj, errorMsg)) + return errorMsgNew; + + return checkFailedRead(fsObj, errorMsg); +} + + +template +void forEachSorted(const MapType& fileMap, Function fun) +{ + using FileRef = const typename MapType::value_type*; + + std::vector fileList; + fileList.reserve(fileMap.size()); + + for (const auto& item : fileMap) + fileList.push_back(&item); + + //sort for natural default sequence on UI file grid: + std::sort(fileList.begin(), fileList.end(), [](const FileRef& lhs, const FileRef& rhs) { return compareNoCase(lhs->first /*item name*/, rhs->first) < 0; }); + + for (const auto& item : fileList) + fun(item->first, item->second); +} + + +template +void MergeSides::fillOneSide(const FolderContainer& folderCont, const Zstringc* errorMsg, ContainerObject& output) +{ + forEachSorted(folderCont.files, [&](const Zstring& fileName, const FileAttributes& attrib) + { + FilePair& newItem = output.addFile(fileName, attrib); + checkFailedRead(newItem, errorMsg); + }); + + forEachSorted(folderCont.symlinks, [&](const Zstring& linkName, const LinkAttributes& attrib) + { + SymlinkPair& newItem = output.addSymlink(linkName, attrib); + checkFailedRead(newItem, errorMsg); + }); + + forEachSorted(folderCont.folders, [&](const Zstring& folderName, const std::pair& attrib) + { + FolderPair& newFolder = output.addFolder(folderName, attrib.first); + const Zstringc* errorMsgNew = checkFailedRead(newFolder, errorMsg); + fillOneSide(attrib.second, errorMsgNew, newFolder); //recurse + }); +} + + +template inline +void matchFolders(const MapType& mapLeft, const MapType& mapRight, ProcessLeftOnly lo, ProcessRightOnly ro, ProcessBoth bo) +{ + struct FileRef + { + Zstring canonicalName; //perf: buffer instead of compareNoCase()/equalNoCase()? => makes no (significant) difference! + const typename MapType::value_type* ref; + SelectSide side; + }; + std::vector fileList; + fileList.reserve(mapLeft.size() + mapRight.size()); //perf: ~5% shorter runtime + + auto getCanonicalName = [](const Zstring& name) { return trimCpy(getUpperCase(name)); }; + + for (const auto& item : mapLeft ) fileList.push_back({getCanonicalName(item.first), &item, SelectSide::left}); + for (const auto& item : mapRight) fileList.push_back({getCanonicalName(item.first), &item, SelectSide::right}); + + //primary sort: ignore upper/lower case, leading/trailing space, Unicode normal form + //bonus: natural default sequence on UI file grid + std::sort(fileList.begin(), fileList.end(), [](const FileRef& lhs, const FileRef& rhs) { return lhs.canonicalName < rhs.canonicalName; }); + + using ItType = typename std::vector::iterator; + auto tryMatchRange = [&](ItType it, ItType itLast) //auto parameters? compiler error on VS 17.2... + { + const size_t equalCountL = std::count_if(it, itLast, [](const FileRef& fr) { return fr.side == SelectSide::left; }); + const size_t equalCountR = itLast - it - equalCountL; + + if (equalCountL == 1 && equalCountR == 1) //we have a match + { + if (it->side == SelectSide::left) + bo(*it[0].ref, *it[1].ref); + else + bo(*it[1].ref, *it[0].ref); + } + else if (equalCountL == 1 && equalCountR == 0) + lo(*it->ref, nullptr); + else if (equalCountL == 0 && equalCountR == 1) + ro(*it->ref, nullptr); + else //ambiguous (yes, even if one side only, e.g. different Unicode normalization forms) + return false; + return true; + }; + + for (auto it = fileList.begin(); it != fileList.end();) + { + //find equal range: ignore upper/lower case, leading/trailing space, Unicode normal form + auto itEndEq = std::find_if(it + 1, fileList.end(), [&](const FileRef& fr) { return fr.canonicalName != it->canonicalName; }); + if (!tryMatchRange(it, itEndEq)) + { + //secondary sort: respect case, ignore Unicode normal forms + std::sort(it, itEndEq, [](const FileRef& lhs, const FileRef& rhs) { return getUnicodeNormalForm(lhs.ref->first) < getUnicodeNormalForm(rhs.ref->first); }); + + for (auto itCase = it; itCase != itEndEq;) + { + //find equal range: respect case, ignore Unicode normal forms + auto itEndCase = std::find_if(itCase + 1, itEndEq, [&](const FileRef& fr) { return getUnicodeNormalForm(fr.ref->first) != getUnicodeNormalForm(itCase->ref->first); }); + if (!tryMatchRange(itCase, itEndCase)) + { + const Zstringc& conflictMsg = getConflictAmbiguousItemName(itCase->ref->first); + std::for_each(itCase, itEndCase, [&](const FileRef& fr) + { + if (fr.side == SelectSide::left) + lo(*fr.ref, &conflictMsg); + else + ro(*fr.ref, &conflictMsg); + }); + } + itCase = itEndCase; + } + } + it = itEndEq; + } +} + + +void MergeSides::mergeFolders(const FolderContainer& lhs, const FolderContainer& rhs, const Zstringc* errorMsg, ContainerObject& output) +{ + using FileData = FolderContainer::FileList::value_type; + + matchFolders(lhs.files, rhs.files, [&](const FileData& fileLeft, const Zstringc* conflictMsg) + { + FilePair& newItem = output.addFile(fileLeft.first, fileLeft.second); + checkFailedRead(newItem, conflictMsg ? conflictMsg : errorMsg); + }, + [&](const FileData& fileRight, const Zstringc* conflictMsg) + { + FilePair& newItem = output.addFile(fileRight.first, fileRight.second); + checkFailedRead(newItem, conflictMsg ? conflictMsg : errorMsg); + }, + [&](const FileData& fileLeft, const FileData& fileRight) + { + FilePair& newItem = output.addFile(fileLeft.first, + fileLeft.second, + fileRight.first, + fileRight.second); + if (!checkFailedRead(newItem, errorMsg)) + undefinedFiles_.push_back(&newItem); + static_assert(std::is_same_v>); + //ContainerObject::addFile() must NOT invalidate references used in "undefinedFiles"! + }); + + //----------------------------------------------------------------------------------------------- + using SymlinkData = FolderContainer::SymlinkList::value_type; + + matchFolders(lhs.symlinks, rhs.symlinks, [&](const SymlinkData& symlinkLeft, const Zstringc* conflictMsg) + { + SymlinkPair& newItem = output.addSymlink(symlinkLeft.first, symlinkLeft.second); + checkFailedRead(newItem, conflictMsg ? conflictMsg : errorMsg); + }, + [&](const SymlinkData& symlinkRight, const Zstringc* conflictMsg) + { + SymlinkPair& newItem = output.addSymlink(symlinkRight.first, symlinkRight.second); + checkFailedRead(newItem, conflictMsg ? conflictMsg : errorMsg); + }, + [&](const SymlinkData& symlinkLeft, const SymlinkData& symlinkRight) //both sides + { + SymlinkPair& newItem = output.addSymlink(symlinkLeft.first, + symlinkLeft.second, + symlinkRight.first, + symlinkRight.second); + if (!checkFailedRead(newItem, errorMsg)) + undefinedSymlinks_.push_back(&newItem); + }); + + //----------------------------------------------------------------------------------------------- + using FolderData = FolderContainer::FolderList::value_type; + + matchFolders(lhs.folders, rhs.folders, [&](const FolderData& dirLeft, const Zstringc* conflictMsg) + { + FolderPair& newFolder = output.addFolder(dirLeft.first, dirLeft.second.first); + const Zstringc* errorMsgNew = checkFailedRead(newFolder, conflictMsg ? conflictMsg : errorMsg); + this->fillOneSide(dirLeft.second.second, errorMsgNew, newFolder); //recurse + }, + [&](const FolderData& dirRight, const Zstringc* conflictMsg) + { + FolderPair& newFolder = output.addFolder(dirRight.first, dirRight.second.first); + const Zstringc* errorMsgNew = checkFailedRead(newFolder, conflictMsg ? conflictMsg : errorMsg); + this->fillOneSide(dirRight.second.second, errorMsgNew, newFolder); //recurse + }, + [&](const FolderData& dirLeft, const FolderData& dirRight) + { + FolderPair& newFolder = output.addFolder(dirLeft.first, dirLeft.second.first, dirRight.first, dirRight.second.first); + const Zstringc* errorMsgNew = checkFailedRead(newFolder, errorMsg); + mergeFolders(dirLeft.second.second, dirRight.second.second, errorMsgNew, newFolder); //recurse + }); +} + +//----------------------------------------------------------------------------------------------- + +//uncheck excluded directories (see parallelFolderScan()) + remove superfluous excluded subdirectories +void stripExcludedDirectories(ContainerObject& conObj, const PathFilter& filter) +{ + for (FolderPair& folder : conObj.subfolders()) + stripExcludedDirectories(folder, filter); + + /* remove superfluous directories: + this does not invalidate "std::vector& undefinedFiles", since we delete folders only + and there is no side-effect for memory positions of FilePair and SymlinkPair thanks to SharedRef! */ + static_assert(std::is_same_v>); + + conObj.foldersRemoveIf([&](FolderPair& folder) + { + const bool included = folder.passDirFilter(filter, nullptr /*childItemMightMatch*/); //child items were already excluded during scanning + + if (!included) //falsify only! (e.g. might already be inactive due to read error!) + folder.setActive(false); + + return !included && //don't check active status, but eval filter directly! + folder.subfolders().empty() && + folder.symlinks ().empty() && + folder.files ().empty(); + }); +} + + +//create comparison result table and fill category except for files existing on both sides: undefinedFiles and undefinedSymlinks are appended! +SharedRef ComparisonBuffer::performComparison(const ResolvedFolderPair& fp, + const FolderPairCfg& fpCfg, + std::vector& undefinedFiles, + std::vector& undefinedSymlinks) const +{ + cb_.updateStatus(_("Generating file list...")); //throw X + cb_.requestUiUpdate(true /*force*/); //throw X + + const BaseFolderStatus folderStatusL = getBaseFolderStatus(fp.folderPathLeft); + const BaseFolderStatus folderStatusR = getBaseFolderStatus(fp.folderPathRight); + + + std::unordered_map failedReadsL; //base-relative paths or empty if read-error for whole base directory + std::unordered_map failedReadsR; // + const FolderContainer* folderContL = nullptr; + const FolderContainer* folderContR = nullptr; + + + const FolderContainer empty; + if (folderStatusL == BaseFolderStatus::failure || + folderStatusR == BaseFolderStatus::failure) + { + auto it = folderStatus_.failedChecks.find(fp.folderPathLeft); + if (it == folderStatus_.failedChecks.end()) + it = folderStatus_.failedChecks.find(fp.folderPathRight); + + failedReadsL[Zstring() /*empty string for root*/] = failedReadsR[Zstring()] = utfTo(it->second.toString()); + + folderContL = ∅ //no need to list or display one-sided results if + folderContR = ∅ //*any* folder existence check failed (even if other side exists in folderBuffer_!) + } + else + { + auto evalBuffer = [&](const AbstractPath& folderPath, const FolderContainer*& folderCont, std::unordered_map& failedReads) + { + auto it = folderBuffer_.find({folderPath, fpCfg.filter.nameFilter, fpCfg.handleSymlinks}); + if (it != folderBuffer_.end()) + { + const DirectoryValue& dirVal = it->second; + + //mix failedFolderReads with failedItemReads: + //associate folder traversing errors with folder (instead of child items only) to show on GUI! See "MergeSides" + //=> minor pessimization for "excludeFilterFailedRead" which needlessly excludes parent folders, too + failedReads = dirVal.failedFolderReads; //failedReads.insert(dirVal.failedFolderReads.begin(), dirVal.failedFolderReads.end()); + failedReads.insert(dirVal.failedItemReads.begin(), dirVal.failedItemReads.end()); + + assert(getBaseFolderStatus(folderPath) == BaseFolderStatus::existing); + folderCont = &dirVal.folderCont; + } + else + { + assert(getBaseFolderStatus(folderPath) == BaseFolderStatus::notExisting); //including AFS::isNullPath() + folderCont = ∅ + } + }; + evalBuffer(fp.folderPathLeft, folderContL, failedReadsL); + evalBuffer(fp.folderPathRight, folderContR, failedReadsR); + } + + + Zstring excludeFilterFailedRead; + if (failedReadsL.contains(Zstring()) || + failedReadsR.contains(Zstring())) //empty path if read-error for whole base directory + excludeFilterFailedRead += Zstr("*\n"); + else + { + for (const auto& [relPath, errorMsg] : failedReadsL) + excludeFilterFailedRead += relPath + Zstr('\n'); //exclude item AND (potential) child items! + + for (const auto& [relPath, errorMsg] : failedReadsR) + excludeFilterFailedRead += relPath + Zstr('\n'); + } + + //somewhat obscure, but it's possible on Linux file systems to have a backslash as part of a file name + //=> avoid misinterpretation when parsing the filter phrase in PathFilter (see path_filter.cpp::parseFilterPhrase()) + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) replace(excludeFilterFailedRead, Zstr('/'), Zstr('?')); + if constexpr (FILE_NAME_SEPARATOR != Zstr('\\')) replace(excludeFilterFailedRead, Zstr('\\'), Zstr('?')); + + + SharedRef output = makeSharedRef(fp.folderPathLeft, + folderStatusL, //check folder existence only once! + fp.folderPathRight, + folderStatusR, // + fpCfg.filter.nameFilter.ref().copyFilterAddingExclusion(excludeFilterFailedRead), + fpCfg.compareVar, + fileTimeTolerance_, + fpCfg.ignoreTimeShiftMinutes); + //PERF_START; + MergeSides::execute(*folderContL, *folderContR, failedReadsL, failedReadsR, + output.ref(), undefinedFiles, undefinedSymlinks); + //PERF_STOP; + + //##################### in/exclude rows according to filtering ##################### + //NOTE: we need to finish de-activating rows BEFORE binary comparison is run so that it can skip them! + + //attention: some excluded directories are still in the comparison result! (see include filter handling!) + if (!fpCfg.filter.nameFilter.ref().isNull()) + stripExcludedDirectories(output.ref(), fpCfg.filter.nameFilter.ref()); //mark excluded directories (see parallelFolderScan()) + remove superfluous excluded subdirectories + + //apply soft filtering (hard filter already applied during traversal!) + addSoftFiltering(output.ref(), fpCfg.filter.timeSizeFilter); + + //################################################################################## + return output; +} +} + + +FolderComparison fff::compare(WarningDialogs& warnings, + unsigned int fileTimeTolerance, + const AFS::RequestPasswordFun& requestPassword /*throw X*/, + bool runWithBackgroundPriority, + bool createDirLocks, + std::unique_ptr& dirLocks, + const std::vector& fpCfgList, + ProcessCallback& callback /*throw X*/) //throw X +{ + //indicator at the very beginning of the log to make sense of "total time" + //init process: keep at beginning so that all GUI elements are initialized properly + callback.initNewPhase(-1, -1, ProcessPhase::scan); //throw X; it's unknown how many files will be scanned => -1 objects + //callback.logInfo(Comparison started")); -> still useful? + + //------------------------------------------------------------------------------- + + //prevent operating system going into sleep state + std::optional noStandby; + try + { + noStandby.emplace(runWithBackgroundPriority ? ProcessPriority::background : ProcessPriority::normal); //throw FileError + } + catch (const FileError& e) //failure is not critical => log only + { + callback.logMessage(e.toString(), PhaseCallback::MsgType::warning); //throw X + } + + const ResolvedBaseFolders& resInfo = initializeBaseFolders(fpCfgList, + requestPassword, warnings, callback); //throw X + //directory existence only checked *once* to avoid race conditions! + if (resInfo.resolvedPairs.size() != fpCfgList.size()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + std::vector> workLoad; + for (size_t i = 0; i < fpCfgList.size(); ++i) + workLoad.emplace_back(resInfo.resolvedPairs[i], fpCfgList[i]); + + //-----------execute basic checks all at once before starting comparison---------- + + //check for incomplete input + { + bool haveFullPair = false; + std::wstring partialPairList; + + for (const ResolvedFolderPair& fp : resInfo.resolvedPairs) + if (AFS::isNullPath(fp.folderPathLeft) != AFS::isNullPath(fp.folderPathRight)) + { + partialPairList += L"\n" + + (AFS::isNullPath(fp.folderPathLeft ) ? L"<" + _("empty") + L">" : AFS::getDisplayPath(fp.folderPathLeft)) + L" | " + + (AFS::isNullPath(fp.folderPathRight) ? L"<" + _("empty") + L">" : AFS::getDisplayPath(fp.folderPathRight)); + } + else if (!AFS::isNullPath(fp.folderPathLeft)) + haveFullPair = true; + + //error if: all empty or exist both full and partial pairs -> support single-folder comparison scenario + if (!partialPairList.empty() == haveFullPair) + callback.reportFatalError(trimCpy(_("A folder input field is empty.") + L" \n\n" + + _("Please select both left and right folders for synchronization.") + L"\n" + partialPairList)); //throw X + else if (!partialPairList.empty()) //partial pairs only => maybe deliberate, maybe accidental, so give some hint + callback.logMessage(_("A folder input field is empty.") + L"\n" + partialPairList, PhaseCallback::MsgType::warning); //throw X + } + + //Check whether one side is a sub directory of the other side (folder-pair-wise!) + //The similar check (warnDependentBaseFolders) if one directory is read/written by multiple pairs not before beginning of synchronization + { + std::wstring msg; + bool shouldExclude = false; + + for (const auto& [folderPair, fpCfg] : workLoad) + if (std::optional pd = getFolderPathDependency(folderPair.folderPathLeft, fpCfg.filter.nameFilter.ref(), + folderPair.folderPathRight, fpCfg.filter.nameFilter.ref())) + { + msg += L"\n\n" + + AFS::getDisplayPath(folderPair.folderPathLeft) + L" <-> " + L'\n' + + AFS::getDisplayPath(folderPair.folderPathRight); + if (!pd->relPath.empty()) + { + shouldExclude = true; + msg += std::wstring() + L'\n' + L"⇒ " + + _("Exclude:") + L' ' + utfTo(FILE_NAME_SEPARATOR + pd->relPath + FILE_NAME_SEPARATOR); + } + } + + if (!msg.empty()) + callback.reportWarning(_("One folder of the folder pair is a subfolder of the other.") + + (shouldExclude ? L'\n' + _("The folder should be excluded via filter.") : L"") + + msg, warnings.warnDependentFolderPair); //throw X + } + //-------------------end of basic checks------------------------------------------ + + //lock (existing) directories before comparison + if (createDirLocks) + { + std::set folderPathsToLock; + for (const AbstractPath& folderPath : resInfo.baseFolderStatus.existing) + if (const Zstring& nativePath = getNativeItemPath(folderPath); //restrict directory locking to native paths until further + !nativePath.empty()) + folderPathsToLock.insert(nativePath); + + dirLocks = std::make_unique(folderPathsToLock, warnings.warnDirectoryLockFailed, callback); + } + + try + { + FolderComparison output; + //reduce peak memory by restricting lifetime of ComparisonBuffer to have ended when loading potentially huge InSyncFolder instance in redetermineSyncDirection() + { + //------------------- fill directory buffer: traverse/read folders -------------------------- + ComparisonBuffer cmpBuf(resInfo.baseFolderStatus, + fileTimeTolerance, callback); + //PERF_START; + output = cmpBuf.execute(workLoad); + //PERF_STOP; + } + assert(output.size() == fpCfgList.size()); + + //--------- set initial sync-direction -------------------------------------------------- + std::vector> directCfgs; + for (auto it = output.begin(); it != output.end(); ++it) + directCfgs.emplace_back(&it->ref(), fpCfgList[it - output.begin()].directionCfg); + + redetermineSyncDirection(directCfgs, + callback); //throw X + + return output; + } + catch (const std::bad_alloc& e) + { + callback.reportFatalError(_("Out of memory.") + L' ' + utfTo(e.what())); + return {}; + } +} diff --git a/FreeFileSync/Source/base/comparison.h b/FreeFileSync/Source/base/comparison.h new file mode 100644 index 0000000..903a827 --- /dev/null +++ b/FreeFileSync/Source/base/comparison.h @@ -0,0 +1,60 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef COMPARISON_H_8032178534545426 +#define COMPARISON_H_8032178534545426 + +#include "file_hierarchy.h" +#include "process_callback.h" +#include "norm_filter.h" +#include "lock_holder.h" + + +namespace fff +{ +struct FolderPairCfg +{ + FolderPairCfg(const Zstring& folderPathPhraseLeft, + const Zstring& folderPathPhraseRight, + CompareVariant cmpVar, + SymLinkHandling handleSymlinksIn, + const std::vector& ignoreTimeShiftMinutesIn, + const NormalizedFilter& filterIn, + const SyncDirectionConfig& directCfg) : + folderPathPhraseLeft_ (folderPathPhraseLeft), + folderPathPhraseRight_(folderPathPhraseRight), + compareVar(cmpVar), + handleSymlinks(handleSymlinksIn), + ignoreTimeShiftMinutes(ignoreTimeShiftMinutesIn), + filter(filterIn), + directionCfg(directCfg) {} + + Zstring folderPathPhraseLeft_; //unresolved directory names as entered by user! + Zstring folderPathPhraseRight_; // + + CompareVariant compareVar; + SymLinkHandling handleSymlinks; + std::vector ignoreTimeShiftMinutes; + + NormalizedFilter filter; + + SyncDirectionConfig directionCfg; +}; + +std::vector extractCompareCfg(const MainConfiguration& mainCfg); //fill FolderPairCfg and resolve folder pairs + +//FFS core routine: output.size() == fpCfgList.size() or 0 on fatal error +FolderComparison compare(WarningDialogs& warnings, + unsigned int fileTimeTolerance, + const AFS::RequestPasswordFun& requestPassword /*throw X*/, + bool runWithBackgroundPriority, + bool createDirLocks, + std::unique_ptr& dirLocks, //out + const std::vector& fpCfgList, + ProcessCallback& callback /*throw X*/); //throw X +} + +#endif //COMPARISON_H_8032178534545426 diff --git a/FreeFileSync/Source/base/db_file.cpp b/FreeFileSync/Source/base/db_file.cpp new file mode 100644 index 0000000..34061e3 --- /dev/null +++ b/FreeFileSync/Source/base/db_file.cpp @@ -0,0 +1,1047 @@ +// ***************************************************************************** +// * 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 "db_file.h" +#include //std::endian +#include +#include +#include +#include "../afs/native.h" +#include "status_handler_impl.h" + +using namespace zen; +using namespace fff; + + +namespace +{ +//------------------------------------------------------------------------------------------------------------------------------- +const char DB_FILE_DESCR[] = "FreeFileSync"; +const int DB_FILE_VERSION = 11; //2020-02-07 +const int DB_STREAM_VERSION = 5; //2023-07-29 +//------------------------------------------------------------------------------------------------------------------------------- + +struct SessionData +{ + bool isLeadStream = false; + std::string rawStream; + + bool operator==(const SessionData&) const = default; +}; + + +using UniqueId = std::string; +using DbStreams = std::unordered_map; //list of streams by session GUID + +/*------------------------------------------------------------------------------ + | ensure 32/64 bit portability: use fixed size data types only e.g. uint32_t | + ------------------------------------------------------------------------------*/ + +template inline +AbstractPath getDatabaseFilePath(const BaseFolderPair& baseFolder) +{ + static_assert(std::endian::native == std::endian::little); + /* Windows, Linux, macOS considerations for uniform database format: + - different file IDs: no, but the volume IDs are different! + - problem with case sensitivity: no + - are UTC file times identical: yes (at least with 1 sec precision) + - endianess: FFS currently not running on any big-endian platform + - precomposed/decomposed UTF: differences already ignored + - 32 vs 64-bit: already handled + + => give DB files different names: */ + const Zstring dbName = Zstr(".sync"); //files beginning with dots are usually hidden + return AFS::appendRelPath(baseFolder.getAbstractPath(), dbName + SYNC_DB_FILE_ENDING); +} + +//####################################################################################################################################### + +void saveStreams(const DbStreams& streamList, const AbstractPath& dbPath, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + MemoryStreamOut memStreamOut; + + //write FreeFileSync file identifier + writeArray(memStreamOut, DB_FILE_DESCR, sizeof(DB_FILE_DESCR)); + + //save file format version + writeNumber(memStreamOut, DB_FILE_VERSION); + + //write stream list + writeNumber(memStreamOut, static_cast(streamList.size())); + + for (const auto& [sessionID, sessionData] : streamList) + { + writeContainer(memStreamOut, sessionID); + + writeNumber(memStreamOut, sessionData.isLeadStream); + writeContainer (memStreamOut, sessionData.rawStream); + } + + writeNumber(memStreamOut, getCrc32(memStreamOut.ref())); + //------------------------------------------------------------------------------------------------------------------------ + + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + const std::unique_ptr byteStreamOut = AFS::getOutputStream(dbPath, + memStreamOut.ref().size(), + std::nullopt /*modTime*/); //throw FileError + + unbufferedSave(memStreamOut.ref(), [&](const void* buffer, size_t bytesToWrite) + { + return byteStreamOut->tryWrite(buffer, bytesToWrite, notifyUnbufferedIO); //throw FileError, X + }, + byteStreamOut->getBlockSize()); //throw FileError, X + + byteStreamOut->finalize(notifyUnbufferedIO); //throw FileError, X + +} + + +DEFINE_NEW_FILE_ERROR(FileErrorDatabaseNotExisting) +DEFINE_NEW_FILE_ERROR(FileErrorDatabaseCorrupted) + +DbStreams loadStreams(const AbstractPath& dbPath, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, FileErrorDatabaseNotExisting, FileErrorDatabaseCorrupted, X +{ + std::string byteStream; + try + { + const std::unique_ptr fileIn = AFS::getInputStream(dbPath); //throw FileError, ErrorFileLocked + + byteStream = unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + return fileIn->tryRead(buffer, bytesToRead, notifyUnbufferedIO); //throw FileError, ErrorFileLocked, X; may return short, only 0 means EOF! + }, + fileIn->getBlockSize()); //throw FileError, X + } + catch (const FileError& e) + { + bool dbNotYetExisting = false; + try { dbNotYetExisting = !AFS::itemExists(dbPath); /*throw FileError*/ } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + //caveat: merging FileError might create redundant error message: https://freefilesync.org/forum/viewtopic.php?t=9377 + + if (dbNotYetExisting) //throw FileError + throw FileErrorDatabaseNotExisting(replaceCpy(_("Database file %x does not yet exist."), L"%x", fmtPath(AFS::getDisplayPath(dbPath)))); + else + throw; + } + //------------------------------------------------------------------------------------------------------------------------ + try + { + MemoryStreamIn memStreamIn(byteStream); + + char formatDescr[sizeof(DB_FILE_DESCR)] = {}; + readArray(memStreamIn, formatDescr, sizeof(formatDescr)); //throw SysErrorUnexpectedEos + + if (!std::equal(DB_FILE_DESCR, DB_FILE_DESCR + sizeof(DB_FILE_DESCR), formatDescr)) + throw SysError(_("File content is corrupted.") + L" (invalid header)"); + + const int version = readNumber(memStreamIn); //throw SysErrorUnexpectedEos + if (version == 9 || //TODO: remove migration code at some time! v9 used until 2017-02-01 + version == 10) //TODO: remove migration code at some time! v10 used until 2020-02-07 + ; + else if (version == DB_FILE_VERSION) //catch data corruption ASAP + don't rely on std::bad_alloc for consistency checking + // => only "partially" useful for container/stream metadata since the streams data is zlib-compressed + { + assert(byteStream.size() >= sizeof(uint32_t)); //obviously in this context! + MemoryStreamOut crcStreamOut; + writeNumber(crcStreamOut, getCrc32(byteStream.begin(), byteStream.end() - sizeof(uint32_t))); + + if (!endsWith(byteStream, crcStreamOut.ref())) + throw SysError(_("File content is corrupted.") + L" (invalid checksum)"); + } + else + throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo(version))); + + DbStreams output; + + //read stream list + size_t streamCount = readNumber(memStreamIn); //throw SysErrorUnexpectedEos + while (streamCount-- != 0) + { + std::string sessionID = readContainer(memStreamIn); //throw SysErrorUnexpectedEos + + SessionData sessionData = {}; + + if (version == 9) //TODO: remove migration code at some time! v9 used until 2017-02-01 + { + sessionData.rawStream = readContainer(memStreamIn); //throw SysErrorUnexpectedEos + + MemoryStreamIn streamIn(sessionData.rawStream); + const int streamVersion = readNumber(streamIn); //throw SysErrorUnexpectedEos + if (streamVersion != 2) //don't throw here due to old stream formats + continue; + sessionData.isLeadStream = readNumber(streamIn) != 0; //throw SysErrorUnexpectedEos + } + else + { + sessionData.isLeadStream = readNumber (memStreamIn) != 0; //throw SysErrorUnexpectedEos + sessionData.rawStream = readContainer(memStreamIn); // + } + + output[sessionID] = std::move(sessionData); + } + return output; + } + catch (const SysError& e) + { + throw FileErrorDatabaseCorrupted(replaceCpy(_("Cannot read database file %x."), L"%x", fmtPath(AFS::getDisplayPath(dbPath))), e.toString()); + } +} + +//####################################################################################################################################### + +class StreamGenerator +{ +public: + static void execute(const InSyncFolder& dbFolder, //throw FileError + const std::wstring& displayFilePathL, //used for diagnostics only + const std::wstring& displayFilePathR, + std::string& streamL, + std::string& streamR) + { + MemoryStreamOut outL; + MemoryStreamOut outR; + //save format version + writeNumber(outL, DB_STREAM_VERSION); + writeNumber(outR, DB_STREAM_VERSION); + + auto compStream = [&](const std::string& stream) //throw FileError + { + try + { + /* Zlib: optimal level - test case 1 million files + level|size [MB]|time [ms] + 0 49.54 272 (uncompressed) + 1 14.53 1013 + 2 14.13 1106 + 3 13.76 1288 - best compromise between speed and compression + 4 13.20 1526 + 5 12.73 1916 + 6 12.58 2765 + 7 12.54 3633 + 8 12.51 9032 + 9 12.50 19698 (maximal compression) */ + return compress(stream, 3 /*level*/); //throw SysError + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(displayFilePathL + L"/" + displayFilePathR)), e.toString()); + } + }; + + StreamGenerator generator; + //PERF_START + generator.recurse(dbFolder); + //PERF_STOP + + const std::string bufText = compStream(generator.streamOutText_ .ref()); + const std::string bufSmallNum = compStream(generator.streamOutSmallNum_.ref()); + const std::string bufBigNum = compStream(generator.streamOutBigNum_ .ref()); + + MemoryStreamOut streamOut; + writeContainer(streamOut, bufText); + writeContainer(streamOut, bufSmallNum); + writeContainer(streamOut, bufBigNum); + + const std::string& buf = streamOut.ref(); + + //distribute "outputBoth" over left and right streams: + const size_t size1stPart = buf.size() / 2; + const size_t size2ndPart = buf.size() - size1stPart; + + writeNumber(outL, size1stPart); + writeNumber(outR, size2ndPart); + + if (size1stPart > 0) writeArray(outL, buf.c_str(), size1stPart); + if (size2ndPart > 0) writeArray(outR, buf.c_str() + size1stPart, size2ndPart); + + streamL = std::move(outL.ref()); + streamR = std::move(outR.ref()); + } + +private: + void recurse(const InSyncFolder& container) + { + writeNumber(streamOutSmallNum_, static_cast(container.files.size())); + for (const auto& [itemName, inSyncData] : container.files) + { + writeItemName(itemName.normStr); + writeNumber(streamOutSmallNum_, static_cast(inSyncData.cmpVar)); + writeNumber(streamOutSmallNum_, inSyncData.fileSize); + + writeFileDescr(inSyncData.left); + writeFileDescr(inSyncData.right); + } + + writeNumber(streamOutSmallNum_, static_cast(container.symlinks.size())); + for (const auto& [itemName, inSyncData] : container.symlinks) + { + writeItemName(itemName.normStr); + writeNumber(streamOutSmallNum_, static_cast(inSyncData.cmpVar)); + + writeNumber(streamOutBigNum_, inSyncData.left .modTime); + writeNumber(streamOutBigNum_, inSyncData.right.modTime); + } + + writeNumber(streamOutSmallNum_, static_cast(container.folders.size())); + for (const auto& [itemName, inSyncData] : container.folders) + { + writeItemName(itemName.normStr); + + recurse(inSyncData); + } + } + + void writeItemName(const Zstring& str) { writeContainer(streamOutText_, utfTo(str)); } + + void writeFileDescr(const InSyncDescrFile& descr) + { + writeNumber(streamOutBigNum_, descr.modTime); + writeNumber(streamOutBigNum_, descr.filePrint); + static_assert(sizeof(descr.modTime) <= sizeof(int64_t)); //ensure cross-platform compatibility! + } + + /* maximize zlib compression by grouping similar data (=> 20% size reduction!) + -> further ~5% reduction possible by having one container per data type + + other ideas: - avoid left/right side interleaving in writeFileDescr() => pessimization! + - convert CompareVariant/InSyncStatus to "enum : unsigned char" => only 0,4% size reduction! + - split up writeItemName() to use streamOutSmallNum_ + streamOutText_ => pessimization! + - use null-termination in writeItemName() => 5% size reduction (embedded zeros impossible?) + - use empty item name as sentinel => only 0,17% size reduction! + - save fileSize using instreamOutBigNum_ => pessimization! */ + MemoryStreamOut streamOutText_; // + MemoryStreamOut streamOutSmallNum_; //data with bias to lead side (= always left in this context) + MemoryStreamOut streamOutBigNum_; // +}; + + +class StreamParser +{ +public: + static SharedRef execute(bool leadStreamLeft, //throw FileError + const std::string& streamL, + const std::string& streamR, + const std::wstring& displayFilePathL, //for diagnostics only + const std::wstring& displayFilePathR) + { + try + { + MemoryStreamIn streamInL(streamL); + MemoryStreamIn streamInR(streamR); + + const int streamVersion = readNumber(streamInL); //throw SysErrorUnexpectedEos + const int streamVersionR = readNumber(streamInR); // + + if (streamVersion != streamVersionR) + throw SysError(_("File content is corrupted.") + L" (different stream formats)"); + + //TODO: remove migration code at some time! 2017-02-01 + if (streamVersion == 2) + { + const bool has1stPartL = readNumber(streamInL) != 0; //throw SysErrorUnexpectedEos + const bool has1stPartR = readNumber(streamInR) != 0; // + + if (has1stPartL == has1stPartR) + throw SysError(_("File content is corrupted.") + L" (second stream part missing)"); + if (has1stPartL != leadStreamLeft) + throw SysError(_("File content is corrupted.") + L" (has1stPartL != leadStreamLeft)"); + + MemoryStreamIn& in1stPart = leadStreamLeft ? streamInL : streamInR; + MemoryStreamIn& in2ndPart = leadStreamLeft ? streamInR : streamInL; + + const size_t size1stPart = static_cast(readNumber(in1stPart)); + const size_t size2ndPart = static_cast(readNumber(in2ndPart)); + + std::string tmpB(size1stPart + size2ndPart, '\0'); //throw std::bad_alloc + readArray(in1stPart, tmpB.data(), size1stPart); //stream always non-empty + readArray(in2ndPart, tmpB.data() + size1stPart, size2ndPart); //throw SysErrorUnexpectedEos + + const std::string tmpL = readContainer(streamInL); + const std::string tmpR = readContainer(streamInR); + + auto output = makeSharedRef(); + StreamParserV2 parser(decompress(tmpL), // + decompress(tmpR), //throw SysError + decompress(tmpB)); // + parser.recurse(output.ref()); //throw SysError + return output; + } + else if (streamVersion == 3 || //TODO: remove migration code at some time! 2021-02-14 + streamVersion == 4 || //TODO: remove migration code at some time! 2023-07-29 + streamVersion == DB_STREAM_VERSION) + { + MemoryStreamIn& streamInPart1 = leadStreamLeft ? streamInL : streamInR; + MemoryStreamIn& streamInPart2 = leadStreamLeft ? streamInR : streamInL; + + const size_t sizePart1 = static_cast(readNumber(streamInPart1)); + const size_t sizePart2 = static_cast(readNumber(streamInPart2)); + + std::string buf(sizePart1 + sizePart2, '\0'); + if (sizePart1 > 0) readArray(streamInPart1, buf.data(), sizePart1); //throw SysErrorUnexpectedEos + if (sizePart2 > 0) readArray(streamInPart2, buf.data() + sizePart1, sizePart2); // + + MemoryStreamIn streamIn(buf); + const std::string bufText = readContainer(streamIn); // + const std::string bufSmallNum = readContainer(streamIn); //throw SysErrorUnexpectedEos + const std::string bufBigNum = readContainer(streamIn); // + + auto output = makeSharedRef(); + StreamParser parser(streamVersion, + decompress(bufText), // + decompress(bufSmallNum), //throw SysError + decompress(bufBigNum)); // + if (leadStreamLeft) + parser.recurse(output.ref()); //throw SysError + else + parser.recurse(output.ref()); //throw SysError + return output; + } + else + throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo(streamVersion))); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read database file %x."), L"%x", fmtPath(displayFilePathL) + L", " + fmtPath(displayFilePathR)), e.toString()); + } + } + +private: + StreamParser(int streamVersion, + std::string&& bufText, + std::string&& bufSmallNumbers, + std::string&& bufBigNumbers) : + streamVersion_(streamVersion), + bufText_ (std::move(bufText)), + bufSmallNumbers_(std::move(bufSmallNumbers)), + bufBigNumbers_ (std::move(bufBigNumbers)) {} + + template + void recurse(InSyncFolder& container) //throw SysError + { + size_t fileCount = readNumber(streamInSmallNum_); //throw SysErrorUnexpectedEos + while (fileCount-- != 0) + { + const Zstring itemName = readItemName(); // + const auto cmpVar = static_cast(readNumber(streamInSmallNum_)); // + const uint64_t fileSize = readNumber(streamInSmallNum_); // + + const InSyncDescrFile descrL = readFileDescr(); //throw SysErrorUnexpectedEos + const InSyncDescrFile descrT = readFileDescr(); // + + container.addFile(itemName, + selectParam(descrL, descrT), + selectParam(descrT, descrL), cmpVar, fileSize); + } + + size_t linkCount = readNumber(streamInSmallNum_); + while (linkCount-- != 0) + { + const Zstring itemName = readItemName(); // + const auto cmpVar = static_cast(readNumber(streamInSmallNum_)); // + + const InSyncDescrLink descrL{static_cast(readNumber(streamInBigNum_))}; //throw SysErrorUnexpectedEos + const InSyncDescrLink descrT{static_cast(readNumber(streamInBigNum_))}; // + + container.addSymlink(itemName, + selectParam(descrL, descrT), + selectParam(descrT, descrL), cmpVar); + } + + size_t dirCount = readNumber(streamInSmallNum_); // + while (dirCount-- != 0) + { + const Zstring itemName = readItemName(); // + + if (streamVersion_ <= 4) //TODO: remove migration code at some time! 2023-07-29 + /*const auto status = static_cast(*/ readNumber(streamInSmallNum_); + + InSyncFolder& dbFolder = container.addFolder(itemName); + recurse(dbFolder); + } + } + + Zstring readItemName() { return utfTo(readContainer(streamInText_)); } //throw SysErrorUnexpectedEos + + InSyncDescrFile readFileDescr() //throw SysErrorUnexpectedEos + { + const auto modTime = static_cast(readNumber(streamInBigNum_)); //throw SysErrorUnexpectedEos + + AFS::FingerPrint filePrint = 0; + if (streamVersion_ == 3) //TODO: remove migration code at some time! 2021-02-14 + { + const auto& devFileId = readContainer(streamInBigNum_); //throw SysErrorUnexpectedEos + ino_t fileIndex = 0; + if (devFileId.size() == sizeof(dev_t) + sizeof(fileIndex)) + { + std::memcpy(&fileIndex, &devFileId[devFileId.size() - sizeof(fileIndex)], sizeof(fileIndex)); + filePrint = fileIndex; + } + else assert(devFileId.empty()); + } + else + filePrint = readNumber(streamInBigNum_); //throw SysErrorUnexpectedEos + + return {modTime, filePrint}; + } + + //TODO: remove migration code at some time! 2017-02-01 + class StreamParserV2 + { + public: + StreamParserV2(std::string&& bufferL, + std::string&& bufferR, + std::string&& bufferB) : + bufL_(std::move(bufferL)), + bufR_(std::move(bufferR)), + bufB_(std::move(bufferB)) {} + + void recurse(InSyncFolder& container) //throw SysError + { + size_t fileCount = readNumber(inputBoth_); + while (fileCount-- != 0) + { + const Zstring itemName = utfTo(readContainer(inputBoth_)); + const auto cmpVar = static_cast(readNumber(inputBoth_)); + const uint64_t fileSize = readNumber(inputBoth_); + const auto modTimeL = static_cast(readNumber(inputLeft_)); + /*const auto fileIdL =*/ readContainer(inputLeft_); + const auto modTimeR = static_cast(readNumber(inputRight_)); + /*const auto fileIdR =*/ readContainer(inputRight_); + container.addFile(itemName, InSyncDescrFile{modTimeL, AFS::FingerPrint()}, InSyncDescrFile{modTimeR, AFS::FingerPrint()}, cmpVar, fileSize); + } + + size_t linkCount = readNumber(inputBoth_); + while (linkCount-- != 0) + { + const Zstring itemName = utfTo(readContainer(inputBoth_)); + const auto cmpVar = static_cast(readNumber(inputBoth_)); + const auto modTimeL = static_cast(readNumber(inputLeft_)); + const auto modTimeR = static_cast(readNumber(inputRight_)); + container.addSymlink(itemName, InSyncDescrLink{modTimeL}, InSyncDescrLink{modTimeR}, cmpVar); + } + + size_t dirCount = readNumber(inputBoth_); + while (dirCount-- != 0) + { + const Zstring itemName = utfTo(readContainer(inputBoth_)); + /*const auto status = static_cast(*/ readNumber(inputBoth_); + + InSyncFolder& dbFolder = container.addFolder(itemName); + recurse(dbFolder); + } + } + + private: + const std::string bufL_; + const std::string bufR_; + const std::string bufB_; + MemoryStreamIn inputLeft_ {bufL_}; //data related to one side only + MemoryStreamIn inputRight_{bufR_}; // + MemoryStreamIn inputBoth_ {bufB_}; //data concerning both sides + }; + + const int streamVersion_; + const std::string bufText_; + const std::string bufSmallNumbers_; + const std::string bufBigNumbers_ ; + MemoryStreamIn streamInText_ {bufText_}; // + MemoryStreamIn streamInSmallNum_{bufSmallNumbers_}; //data with bias to lead side + MemoryStreamIn streamInBigNum_ {bufBigNumbers_}; // +}; + +//####################################################################################################################################### + +class LastSynchronousStateUpdater +{ + /* 1. filter by file name does *not* create a new hierarchy, but merely gives a different *view* on the existing file hierarchy + => only update database entries matching this view! + 2. Symlink handling *does* create a new (asymmetric) hierarchy during comparison + => update all database entries! */ +public: + static void execute(const BaseFolderPair& baseFolder, InSyncFolder& dbFolder) + { + LastSynchronousStateUpdater updater(baseFolder.getCompVariant(), baseFolder.getFilter()); + updater.recurse(baseFolder, Zstring(), dbFolder); + } + +private: + LastSynchronousStateUpdater(CompareVariant activeCmpVar, const PathFilter& filter) : + filter_(filter), + activeCmpVar_(activeCmpVar) {} + + void recurse(const ContainerObject& conObj, const Zstring& relPath, InSyncFolder& dbFolder) + { + processFiles (conObj, relPath, dbFolder.files); + processLinks (conObj, relPath, dbFolder.symlinks); + processFolders(conObj, relPath, dbFolder.folders); + } + + void processFiles(const ContainerObject& conObj, const Zstring& parentRelPath, InSyncFolder::FileList& dbFiles) + { + std::unordered_set toPreserve; + + for (const FilePair& file : conObj.files()) + if (!file.isPairEmpty()) + { + if (file.getCategory() == FILE_EQUAL) //data in sync: write current state + { + //Caveat: If FILE_EQUAL, we *implicitly* assume equal left and right file names matching case: InSyncFolder's mapping tables use file name as a key! + //This makes us silently dependent from code in algorithm.h!!! + assert(file.hasEquivalentItemNames()); + const Zstring& fileName = file.getItemName(); + assert(file.getFileSize() == file.getFileSize()); + + //create or update new "in-sync" state + dbFiles.insert_or_assign(fileName, InSyncFile + { + .left = InSyncDescrFile{file.getLastWriteTime(), file.getFilePrint()}, + .right = InSyncDescrFile{file.getLastWriteTime(), file.getFilePrint()}, + .cmpVar = activeCmpVar_, + .fileSize = file.getFileSize(), + }); + toPreserve.insert(fileName); + } + else //not in sync: preserve last synchronous state + { + toPreserve.insert(file.getItemName()); //left/right may differ in case! + toPreserve.insert(file.getItemName()); // + } + } + + //delete removed items (= "in-sync") from database + std::erase_if(dbFiles, [&](const InSyncFolder::FileList::value_type& v) + { + if (toPreserve.contains(v.first)) + return false; + //all items not existing in "currentFiles" have either been deleted meanwhile or been excluded via filter: + const Zstring& itemRelPath = appendPath(parentRelPath, v.first.normStr); + return filter_.passFileFilter(itemRelPath); + //note: items subject to traveral errors are also excluded by this file filter here! see comparison.cpp, modified file filter for read errors + }); + } + + void processLinks(const ContainerObject& conObj, const Zstring& parentRelPath, InSyncFolder::SymlinkList& dbSymlinks) + { + std::unordered_set toPreserve; + + for (const SymlinkPair& symlink : conObj.symlinks()) + if (!symlink.isPairEmpty()) + { + if (symlink.getLinkCategory() == SYMLINK_EQUAL) //data in sync: write current state + { + assert(symlink.hasEquivalentItemNames()); + const Zstring& linkName = symlink.getItemName(); + + //create or update new "in-sync" state + dbSymlinks.insert_or_assign(linkName, InSyncSymlink + { + .left = InSyncDescrLink{symlink.getLastWriteTime()}, + .right = InSyncDescrLink{symlink.getLastWriteTime()}, + .cmpVar = activeCmpVar_, + }); + toPreserve.insert(linkName); + } + else //not in sync: preserve last synchronous state + { + toPreserve.insert(symlink.getItemName()); //left/right may differ in case! + toPreserve.insert(symlink.getItemName()); // + } + } + + //delete removed items (= "in-sync") from database + std::erase_if(dbSymlinks, [&](const InSyncFolder::SymlinkList::value_type& v) + { + if (toPreserve.contains(v.first)) + return false; + //all items not existing in "currentSymlinks" have either been deleted meanwhile or been excluded via filter: + const Zstring& itemRelPath = appendPath(parentRelPath, v.first.normStr); + return filter_.passFileFilter(itemRelPath); + }); + } + + void processFolders(const ContainerObject& conObj, const Zstring& parentRelPath, InSyncFolder::FolderList& dbFolders) + { + std::unordered_map toPreserve; + + for (const FolderPair& folder : conObj.subfolders()) + if (!folder.isPairEmpty()) + { + if (folder.getDirCategory() == DIR_EQUAL) + { + assert(folder.hasEquivalentItemNames()); + const Zstring& folderName = folder.getItemName(); + + //create directory entry if not existing (but do *not touch* existing child elements!!!) + dbFolders.try_emplace(folderName); + + toPreserve.emplace(folderName, &folder); + } + else //not in sync: preserve last synchronous state + { + toPreserve.emplace(folder.getItemName(), &folder); //names differing (in case)? => treat like any other folder rename + toPreserve.emplace(folder.getItemName(), &folder); //=> no *new* database entries even if child items are in sync + //BUT: update existing one: there should be only *one* DB entry after a folder rename (matching either folder name on left or right) + } + } + + //delete removed items (= "in-sync") from database + eraseIf(dbFolders, [&](InSyncFolder::FolderList::value_type& v) + { + const Zstring& itemRelPath = appendPath(parentRelPath, v.first.normStr); + + if (auto it = toPreserve.find(v.first); it != toPreserve.end()) + { + recurse(*(it->second), itemRelPath, v.second); //required even if e.g. DIR_LEFT_ONLY: + //existing child-items may not be in sync, but items deleted on both sides *are* in-sync!!! + return false; + } + + //if folder is not included in "current folders", it is either not existing anymore, in which case it should be deleted from database + //or it was excluded via filter and the database entry should be preserved + bool childItemMightMatch = true; + const bool passFilter = filter_.passDirFilter(itemRelPath, &childItemMightMatch); + if (!passFilter && childItemMightMatch) + dbSetEmptyState(v.second, appendSeparator(itemRelPath)); //child items might match, e.g. *.txt include filter! + return passFilter; + }); + } + + //delete all entries for removed folder (= "in-sync") from database + void dbSetEmptyState(InSyncFolder& dbFolder, const Zstring& parentRelPathPf) + { + std::erase_if(dbFolder.files, [&](const InSyncFolder::FileList ::value_type& v) { return filter_.passFileFilter(parentRelPathPf + v.first.normStr); }); + std::erase_if(dbFolder.symlinks, [&](const InSyncFolder::SymlinkList::value_type& v) { return filter_.passFileFilter(parentRelPathPf + v.first.normStr); }); + + eraseIf(dbFolder.folders, [&](InSyncFolder::FolderList::value_type& v) + { + const Zstring& itemRelPath = parentRelPathPf + v.first.normStr; + + bool childItemMightMatch = true; + const bool passFilter = filter_.passDirFilter(itemRelPath, &childItemMightMatch); + if (!passFilter && childItemMightMatch) + dbSetEmptyState(v.second, appendSeparator(itemRelPath)); + return passFilter; + }); + } + + const PathFilter& filter_; //filter used while scanning directory: generates view on actual files! + const CompareVariant activeCmpVar_; +}; + + +struct StreamStatusNotifier +{ + StreamStatusNotifier(const std::wstring& statusMsg, AsyncCallback& acb /*throw ThreadStopRequest*/) : + msgPrefix_(statusMsg + L' '), acb_(acb) {} + + void operator()(int64_t bytesDelta) //throw ThreadStopRequest + { + bytesTotal_ += bytesDelta; + + const auto now = std::chrono::steady_clock::now(); + if (now >= lastUpdate_ + UI_UPDATE_INTERVAL / 2) //every ~25 ms + { + lastUpdate_ = now; + acb_.updateStatus(msgPrefix_ + formatFilesizeShort(bytesTotal_)); //throw ThreadStopRequest + } + } + +private: + const std::wstring msgPrefix_; + int64_t bytesTotal_ = 0; + AsyncCallback& acb_; + std::chrono::steady_clock::time_point lastUpdate_; +}; + + +std::pair findCommonSession(const DbStreams& streamsLeft, const DbStreams& streamsRight, //throw FileError + const std::wstring& displayFilePathL, //used for diagnostics only + const std::wstring& displayFilePathR) +{ + auto itCommonL = streamsLeft .end(); + auto itCommonR = streamsRight.end(); + + for (auto itL = streamsLeft.begin(); itL != streamsLeft.end(); ++itL) + { + auto itR = streamsRight.find(itL->first); + if (itR != streamsRight.end()) + /* handle case when db file is loaded together with a (former) copy of itself: + - some streams may have been updated in the meantime => must not discard either db file! + - since db file was copied, multiple streams may have matching sessionID + => IGNORE all of them: one of them may be used later against other sync targets! */ + if (itL->second.isLeadStream != itR->second.isLeadStream) + { + if (itCommonL != streamsLeft.end()) //should not be possible! + throw FileError(replaceCpy(_("Cannot read database file %x."), L"%x", fmtPath(displayFilePathL) + L", " + fmtPath(displayFilePathR)), + _("File content is corrupted.") + L" (multiple common sessions found)"); + itCommonL = itL; + itCommonR = itR; + } + } + + return {itCommonL, itCommonR}; +} +} + +//####################################################################################################################################### + +std::unordered_map> fff::loadLastSynchronousState(const std::vector& baseFolders, + PhaseCallback& callback /*throw X*/) //throw X +{ + std::set dbFilePaths; + + for (const BaseFolderPair* baseFolder : baseFolders) + //avoid race condition with directory existence check: reading sync.ffs_db may succeed although first dir check had failed => conflicts! + if (baseFolder->getFolderStatus() == BaseFolderStatus::existing && + baseFolder->getFolderStatus() == BaseFolderStatus::existing) + { + dbFilePaths.insert(getDatabaseFilePath(*baseFolder)); + dbFilePaths.insert(getDatabaseFilePath(*baseFolder)); + } + //else: ignore; there's no value in reporting it other than to confuse users + + std::map dbStreamsByPath; + //------------ (try to) load DB files in parallel ------------------------- + { + Protected&> protDbStreamsByPath(dbStreamsByPath); + std::vector> parallelWorkload; + + for (const AbstractPath& dbPath : dbFilePaths) + parallelWorkload.emplace_back(dbPath, [&protDbStreamsByPath](ParallelContext& ctx) //throw ThreadStopRequest + { + tryReportingError([&] //throw ThreadStopRequest + { + StreamStatusNotifier notifyLoad(replaceCpy(_("Loading file %x..."), L"%x", fmtPath(AFS::getDisplayPath(ctx.itemPath))), ctx.acb); + try + { + DbStreams dbStreams = ::loadStreams(ctx.itemPath, notifyLoad); //throw FileError, FileErrorDatabaseNotExisting, FileErrorDatabaseCorrupted, ThreadStopRequest + + protDbStreamsByPath.access([&](auto& dbStreamsByPath2) { dbStreamsByPath2.emplace(ctx.itemPath, std::move(dbStreams)); }); + } + catch (FileErrorDatabaseNotExisting&) {} //redundant info => no reportInfo() + }, ctx.acb); + }); + + massParallelExecute(parallelWorkload, + Zstr("Load sync.ffs_db"), callback /*throw X*/); //throw X + } + //---------------------------------------------------------------- + + std::unordered_map> output; + + for (const BaseFolderPair* baseFolder : baseFolders) + if (baseFolder->getFolderStatus() == BaseFolderStatus::existing && + baseFolder->getFolderStatus() == BaseFolderStatus::existing) + { + const AbstractPath dbPathL = getDatabaseFilePath(*baseFolder); + const AbstractPath dbPathR = getDatabaseFilePath(*baseFolder); + + auto itL = dbStreamsByPath.find(dbPathL); + auto itR = dbStreamsByPath.find(dbPathR); + + if (itL != dbStreamsByPath.end() && + itR != dbStreamsByPath.end()) + try + { + const DbStreams& streamsL = itL->second; + const DbStreams& streamsR = itR->second; + + //find associated session: there can be at most one session within intersection of left and right IDs + const auto [itStreamL, itStreamR] = findCommonSession(streamsL, streamsR, + AFS::getDisplayPath(dbPathL), + AFS::getDisplayPath(dbPathR)); //throw FileError + if (itStreamL != streamsL.end()) + { + assert(itStreamL->second.isLeadStream != itStreamR->second.isLeadStream); + SharedRef lastSyncState = StreamParser::execute(itStreamL->second.isLeadStream, + itStreamL->second.rawStream, + itStreamR->second.rawStream, + AFS::getDisplayPath(dbPathL), + AFS::getDisplayPath(dbPathR)); //throw FileError + output.emplace(baseFolder, lastSyncState); + } + } + catch (const FileError& e) { callback.reportFatalError(e.toString()); } //throw X + } + + return output; +} + + +void fff::saveLastSynchronousState(const BaseFolderPair& baseFolder, bool transactionalCopy, + PhaseCallback& callback /*throw X*/) //throw X +{ + const AbstractPath dbPathL = getDatabaseFilePath(baseFolder); + const AbstractPath dbPathR = getDatabaseFilePath(baseFolder); + + //------------ (try to) load DB files in parallel ------------------------- + DbStreams streamsL; //list of session ID + DirInfo-stream + DbStreams streamsR; // + { + bool loadSuccessL = false; + bool loadSuccessR = false; + std::vector> parallelWorkload; + + for (const auto& [dbPath, streamsOut, loadSuccess] : + { + std::tuple(dbPathL, &streamsL, &loadSuccessL), + std::tuple(dbPathR, &streamsR, &loadSuccessR) + }) + parallelWorkload.emplace_back(dbPath, [&streamsOut = *streamsOut, &loadSuccess = *loadSuccess](ParallelContext& ctx) //throw ThreadStopRequest + { + const std::wstring errMsg = tryReportingError([&] //throw ThreadStopRequest + { + StreamStatusNotifier notifyLoad(replaceCpy(_("Loading file %x..."), L"%x", fmtPath(AFS::getDisplayPath(ctx.itemPath))), ctx.acb); + + try { streamsOut = ::loadStreams(ctx.itemPath, notifyLoad); } //throw FileError, FileErrorDatabaseNotExisting, FileErrorDatabaseCorrupted, ThreadStopRequest + catch (FileErrorDatabaseNotExisting&) {} + catch (FileErrorDatabaseCorrupted&) {} //=> just overwrite corrupted DB file: error already reported by loadLastSynchronousState() + }, ctx.acb); + + loadSuccess = errMsg.empty(); + }); + + massParallelExecute(parallelWorkload, + Zstr("Load sync.ffs_db"), callback /*throw X*/); //throw X + + if (!loadSuccessL || !loadSuccessR) + return; /* don't continue when one of the two files failed to load (e.g. network drop): + no common session would be found, (although it may exist!) => + a) if file also fails to save: new orphan session in the other file created + b) if file saves successfully: previous stream sessions lost + old session in other file not cleaned up (orphan) */ + } + //---------------------------------------------------------------- + + //load last synchrounous state + auto itStreamOldL = streamsL.cend(); + auto itStreamOldR = streamsR.cend(); + InSyncFolder lastSyncState; + try + { + //find associated session: there can be at most one session within intersection of left and right IDs + std::tie(itStreamOldL, itStreamOldR) = findCommonSession(streamsL, streamsR, + AFS::getDisplayPath(dbPathL), + AFS::getDisplayPath(dbPathR)); //throw FileError + if (itStreamOldL != streamsL.end()) + lastSyncState = std::move(StreamParser::execute(itStreamOldL->second.isLeadStream /*leadStreamLeft*/, + itStreamOldL->second.rawStream, + itStreamOldR->second.rawStream, + AFS::getDisplayPath(dbPathL), + AFS::getDisplayPath(dbPathR)).ref()); //throw FileError + } + catch (const FileError& e) { callback.reportFatalError(e.toString()); } //throw X + //if database files are corrupted: just overwrite! User is already informed about errors right after comparing! + + //update last synchrounous state + LastSynchronousStateUpdater::execute(baseFolder, lastSyncState); + + //serialize again + SessionData sessionDataL = {}; + SessionData sessionDataR = {}; + sessionDataL.isLeadStream = true; + sessionDataR.isLeadStream = false; + + if (const std::wstring errMsg = tryReportingError([&] //throw X +{ + StreamGenerator::execute(lastSyncState, //throw FileError + AFS::getDisplayPath(dbPathL), + AFS::getDisplayPath(dbPathR), + sessionDataL.rawStream, + sessionDataR.rawStream); + }, callback /*throw X*/); !errMsg.empty()) + return; + + //check if there is some work to do at all + if (itStreamOldL != streamsL.end() && itStreamOldL->second == sessionDataL && + itStreamOldR != streamsR.end() && itStreamOldR->second == sessionDataR) + return; //some users monitor the *.ffs_db file with RTS => don't touch the file if it isnt't strictly needed + + //erase old session data + if (itStreamOldL != streamsL.end()) + streamsL.erase(itStreamOldL); + if (itStreamOldR != streamsR.end()) + streamsR.erase(itStreamOldR); + + //create new session data + const std::string sessionID = generateGUID(); + + streamsL[sessionID] = std::move(sessionDataL); + streamsR[sessionID] = std::move(sessionDataR); + + //------------ save DB files in parallel ------------------------- + //1. create *both* ffs_tmp files first (caveat: *not* necessarily in parallel, depending on deviceParallelOps!) + //2. if successful, rename both files (almost) transactionally! + bool saveSuccessL = false; + bool saveSuccessR = false; + std::optional dbPathTmpL; + std::optional dbPathTmpR; + ZEN_ON_SCOPE_EXIT + ( + //*INDENT-OFF* + if (dbPathTmpL) try { AFS::removeFilePlain(*dbPathTmpL); } catch (const FileError& e) { logExtraError(e.toString()); } + if (dbPathTmpR) try { AFS::removeFilePlain(*dbPathTmpR); } catch (const FileError& e) { logExtraError(e.toString()); } + //*INDENT-ON* + ) + + std::vector> parallelWorkloadSave, parallelWorkloadMove; + + for (const auto& [dbPath, streams, saveSuccess, dbPathTmp] : + { + std::tuple(dbPathL, &streamsL, &saveSuccessL, &dbPathTmpL), + std::tuple(dbPathR, &streamsR, &saveSuccessR, &dbPathTmpR) + }) + { + parallelWorkloadSave.emplace_back(dbPath, [&streams = *streams, + &saveSuccess = *saveSuccess, + &dbPathTmp = *dbPathTmp, + transactionalCopy](ParallelContext& ctx) //throw ThreadStopRequest + { + const std::wstring errMsg = tryReportingError([&] //throw ThreadStopRequest + { + StreamStatusNotifier notifySave(replaceCpy(_("Saving file %x..."), L"%x", fmtPath(AFS::getDisplayPath(ctx.itemPath))), ctx.acb); + + if (transactionalCopy && !AFS::hasNativeTransactionalCopy(ctx.itemPath)) //=> write (both?) DB files as a transaction + { + const Zstring shortGuid = printNumber(Zstr("%04x"), static_cast(getCrc16(generateGUID()))); + const AbstractPath tmpPath = AFS::appendRelPath(*AFS::getParentPath(ctx.itemPath), AFS::getItemName(ctx.itemPath) + Zstr('.') + shortGuid + AFS::TEMP_FILE_ENDING); + + saveStreams(streams, tmpPath, notifySave); //throw FileError, ThreadStopRequest + dbPathTmp = tmpPath; //pass file ownership + } + else //some MTP devices don't even allow renaming files: https://freefilesync.org/forum/viewtopic.php?t=6531 + { + AFS::removeFileIfExists(ctx.itemPath); //throw FileError + saveStreams(streams, ctx.itemPath, notifySave); //throw FileError, ThreadStopRequest + } + }, ctx.acb); + + saveSuccess = errMsg.empty(); + }); + //---------------------------------------------------------------------------- + if (transactionalCopy && !AFS::hasNativeTransactionalCopy(dbPath)) + parallelWorkloadMove.emplace_back(dbPath, [&dbPathTmp = *dbPathTmp](ParallelContext& ctx) //throw ThreadStopRequest + { + tryReportingError([&] //throw ThreadStopRequest + { + //rename temp file (almost) transactionally: without write access, file creation would have failed + AFS::removeFileIfExists(ctx.itemPath); //throw FileError + AFS::moveAndRenameItem(*dbPathTmp, ctx.itemPath); //throw FileError, (ErrorMoveUnsupported) + + dbPathTmp = std::nullopt; //basically a "ScopeGuard::dismiss()" + }, ctx.acb); + }); + } + + massParallelExecute(parallelWorkloadSave, + Zstr("Save sync.ffs_db"), callback /*throw X*/); //throw X + //---------------------------------------------------------------- + if (saveSuccessL && saveSuccessR) + massParallelExecute(parallelWorkloadMove, + Zstr("Move sync.ffs_db"), callback /*throw X*/); //throw X +} diff --git a/FreeFileSync/Source/base/db_file.h b/FreeFileSync/Source/base/db_file.h new file mode 100644 index 0000000..04e7b1f --- /dev/null +++ b/FreeFileSync/Source/base/db_file.h @@ -0,0 +1,89 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DB_FILE_H_834275398588021574 +#define DB_FILE_H_834275398588021574 + +#include +#include +#include "file_hierarchy.h" +#include "process_callback.h" + + +namespace fff +{ +constexpr ZstringView SYNC_DB_FILE_ENDING = Zstr(".ffs_db"); //don't use Zstring as global constant: avoid static initialization order problem in global namespace! + +struct InSyncDescrFile //subset of FileAttributes +{ + time_t modTime = 0; + AFS::FingerPrint filePrint = 0; //optional! +}; + +struct InSyncDescrLink +{ + time_t modTime = 0; +}; + + +//artificial hierarchy of last synchronous state: +struct InSyncFile +{ + InSyncDescrFile left; //support flip()! + InSyncDescrFile right; // + CompareVariant cmpVar = CompareVariant::timeSize; //the one active while finding "file in sync" + uint64_t fileSize = 0; //file size must be identical on both sides! +}; + +struct InSyncSymlink +{ + InSyncDescrLink left; + InSyncDescrLink right; + CompareVariant cmpVar = CompareVariant::timeSize; +}; + +struct InSyncFolder +{ + //------------------------------------------------------------------ + using FolderList = std::unordered_map; // + using FileList = std::unordered_map; // key: file name (ignoring Unicode normal forms) + using SymlinkList = std::unordered_map; // + //------------------------------------------------------------------ + + FolderList folders; + FileList files; + SymlinkList symlinks; //non-followed symlinks + + //convenience + InSyncFolder& addFolder(const Zstring& folderName) + { + const auto [it, inserted] = folders.try_emplace(folderName); + assert(inserted); + return it->second; + } + + void addFile(const Zstring& fileName, const InSyncDescrFile& descrL, const InSyncDescrFile& descrR, CompareVariant cmpVar, uint64_t fileSize) + { + files.emplace(fileName, InSyncFile {descrL, descrR, cmpVar, fileSize}); + assert(inserted); + } + + void addSymlink(const Zstring& linkName, const InSyncDescrLink& descrL, const InSyncDescrLink& descrR, CompareVariant cmpVar) + { + symlinks.emplace(linkName, InSyncSymlink {descrL, descrR, cmpVar}); + assert(inserted); + } +}; + + +std::unordered_map> loadLastSynchronousState(const std::vector& baseFolders, + PhaseCallback& callback /*throw X*/); //throw X + +void saveLastSynchronousState(const BaseFolderPair& baseFolder, bool transactionalCopy, //throw X + PhaseCallback& callback /*throw X*/); +} + +#endif //DB_FILE_H_834275398588021574 diff --git a/FreeFileSync/Source/base/dir_exist_async.h b/FreeFileSync/Source/base/dir_exist_async.h new file mode 100644 index 0000000..640479b --- /dev/null +++ b/FreeFileSync/Source/base/dir_exist_async.h @@ -0,0 +1,159 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DIR_EXIST_ASYNC_H_0817328167343215806734213 +#define DIR_EXIST_ASYNC_H_0817328167343215806734213 + +#include +#include "process_callback.h" +#include "../afs/abstract.h" + + +namespace fff +{ +namespace +{ +struct FolderStatus +{ + std::set existing; + std::set notExisting; + std::map failedChecks; +}; +//- directory existence checking may hang for non-existent network drives => run asynchronously and update UI! +//- check existence of all directories in parallel! (avoid adding up search times if multiple network drives are not reachable) +//- authenticateAccess() better be integrated into folder existence check: if fails, why bother to go on with the folder!? +//- probably don't need timeout: https://freefilesync.org/forum/viewtopic.php?t=7350#p36817 +// => benefit: waits until user login completed in AFS::authenticateAccess() +FolderStatus getFolderStatusParallel(const std::set& folderPaths, + bool authenticateAccess, const AFS::RequestPasswordFun& requestPassword /*throw X*/, + PhaseCallback& cb /*throw X*/) //throw X +{ + using namespace zen; + + //aggregate folder paths that are on the same root device: see parallel_scan.h + std::map> perDevicePaths; + + for (const AbstractPath& folderPath : folderPaths) + if (!AFS::isNullPath(folderPath)) //skip empty folders + perDevicePaths[folderPath.afsDevice].insert(folderPath); + //---------------------------------------------------------------------- + + std::vector>> futFoldersExist; + + struct AsyncPrompt + { + std::wstring msg; + std::wstring lastErrorMsg; + std::promise promPassword; + }; + auto protPromptsPending = authenticateAccess && requestPassword ? std::make_shared>>() : nullptr; + + //---------------------------------------------------------------------- + std::vector>> deviceThreadGroups; + //---------------------------------------------------------------------- + + for (const auto& [device, deviceFolderPaths] : perDevicePaths) + { + deviceThreadGroups.emplace_back(1, Zstr("DirExist: ") + utfTo(AFS::getDisplayPath(AbstractPath(device, AfsPath())))); + deviceThreadGroups.back().detach(); //don't wait on hanging threads if user cancels + + //1. login to network share, connect with Google Drive, etc. + std::shared_future futAuth; + if (authenticateAccess) + { + AFS::RequestPasswordFun threadRequestPassword; //throw std::future_error + if (requestPassword) + threadRequestPassword = [promptsPendingWeak = std::weak_ptr(protPromptsPending)](const std::wstring& msg, const std::wstring& lastErrorMsg) + { + std::future futPassword; + if (auto protPromptsPending2 = promptsPendingWeak.lock()) //[!] not owned by worker thread! + protPromptsPending2->access([&](RingBuffer& promptsPending) + { + promptsPending.push_back(AsyncPrompt{msg, lastErrorMsg, {}}); + futPassword = promptsPending.back().promPassword.get_future(); + }); + return futPassword.get(); //throw std::future_error -> if std::promise destroyed before password was set + }; + + futAuth = runAsync([device /*clang bug*/= device, threadRequestPassword] + { + setCurrentThreadName(Zstr("Auth: ") + utfTo(AFS::getDisplayPath(AbstractPath(device, AfsPath())))); + AFS::authenticateAccess(device, threadRequestPassword); //throw FileError, std::future_error + }); + } + + for (const AbstractPath& folderPath : deviceFolderPaths) + { + std::packaged_task pt([folderPath, futAuth] + { + if (futAuth.valid()) + futAuth.get(); //throw FileError, std::future_error + + /* 2. check dir existence: + + CAVEAT: the case-sensitive semantics of AFS::itemExists() do not fit here! + BUT: its implementation happens to be okay for our use: + Assume we have a case-insensitive path match: + => AFS::itemExists() first checks AFS::getItemType() + => either succeeds (fine) or fails because of 1. not existing or 2. access error + => if the subsequent case-sensitive folder search also doesn't find the folder: only a problem in case 2 + => FFS tries to create the folder during sync and fails with I. access error (fine) or II. already existing (obscures the previous "access error") */ + return AFS::itemExists(folderPath); //throw FileError; return "false" IFF nothing (of any type) exists + + //check for ItemType::file? too pedantic? + // throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getDisplayPath(folderPath))), + // replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(folderPath)))); + }); + auto futIsExisting = pt.get_future(); + deviceThreadGroups.back().run(std::move(pt)); + + futFoldersExist.emplace_back(folderPath, std::move(futIsExisting)); + } + } + //---------------------------------------------------------------------- + + FolderStatus output; + + for (auto& [folderPath, futFolderExists] : futFoldersExist) + { + cb.updateStatus(replaceCpy(_("Searching for folder %x..."), L"%x", fmtPath(AFS::getDisplayPath(folderPath)))); //throw X + + while (futFolderExists.wait_for(UI_UPDATE_INTERVAL / 2) == std::future_status::timeout) + { + cb.requestUiUpdate(); //throw X + + //marshal password prompt callback from current thread (probably main) to worker threads + //=> polling delay doesn't matter because user interaction is required + if (protPromptsPending) + protPromptsPending->access([&](RingBuffer& promptsPending) + { + //call back while holding Protected<> lock!? device authentication threads blocking doesn't matter because prompts are serialized to GUI anyway + if (!promptsPending.empty()) + { + assert(requestPassword); //... in this context + const Zstring password = requestPassword(promptsPending.front().msg, promptsPending.front().lastErrorMsg); //throw X + promptsPending.front().promPassword.set_value(password); + promptsPending.pop_front(); + } + }); + } + + try + { + //call future::get() only *once*! otherwise: undefined behavior! + if (futFolderExists.get()) //throw FileError, (std::future_error) + output.existing.insert(folderPath); + else + output.notExisting.insert(folderPath); + } + catch (const FileError& e) { output.failedChecks.emplace(folderPath, e); } + } + return output; +} +} +} + +#endif //DIR_EXIST_ASYNC_H_0817328167343215806734213 diff --git a/FreeFileSync/Source/base/dir_lock.cpp b/FreeFileSync/Source/base/dir_lock.cpp new file mode 100644 index 0000000..9162551 --- /dev/null +++ b/FreeFileSync/Source/base/dir_lock.cpp @@ -0,0 +1,560 @@ +// ***************************************************************************** +// * 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 "dir_lock.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + #include //std::cerr + #include //open() + #include //close() + #include //kill() + +using namespace zen; +using namespace fff; + + +namespace +{ +constexpr std::chrono::seconds EMIT_LIFE_SIGN_INTERVAL (5); //show life sign; +constexpr std::chrono::seconds POLL_LIFE_SIGN_INTERVAL (4); //poll for life sign; +constexpr std::chrono::seconds DETECT_ABANDONED_INTERVAL(30); //assume abandoned lock; + +const char LOCK_FILE_DESCR[] = "FreeFileSync"; +const int LOCK_FILE_VERSION = 3; //2020-02-07 +const int ABANDONED_LOCK_LEVEL_MAX = 10; +} + + +Zstring fff::impl::getAbandonedLockFileName(const Zstring& lockFileName) //throw SysError +{ + Zstring fileName = lockFileName; + int level = 0; + + //recursive abandoned locks!? (almost) impossible, except for file system bugs: https://freefilesync.org/forum/viewtopic.php?t=6568 + const Zstring tmp = afterFirst(fileName, Zstr("Delete."), IfNotFoundReturn::none); //e.g. Delete.1.sync.ffs_lock + if (!tmp.empty()) + { + const Zstring levelStr = beforeFirst(tmp, Zstr('.'), IfNotFoundReturn::none); + if (!levelStr.empty() && std::all_of(levelStr.begin(), levelStr.end(), [](Zchar c) { return zen::isDigit(c); })) + { + fileName = afterFirst(tmp, Zstr('.'), IfNotFoundReturn::none); + level = stringTo(levelStr) + 1; + + if (level >= ABANDONED_LOCK_LEVEL_MAX) + throw SysError(L"Endless recursion."); + } + } + + return Zstr("Delete.") + numberTo(level) + Zstr(".") + fileName; //preserve lock file extension! +} + + +namespace +{ +//worker thread +class LifeSigns +{ +public: + LifeSigns(const Zstring& lockFilePath) : lockFilePath_(lockFilePath) + { + } + + void operator()() const //throw ThreadStopRequest + { + const std::optional parentDirPath = getParentFolderPath(lockFilePath_); + setCurrentThreadName(Zstr("DirLock: ") + (parentDirPath ? *parentDirPath : Zstr(""))); + + for (;;) + { + interruptibleSleep(EMIT_LIFE_SIGN_INTERVAL); //throw ThreadStopRequest + emitLifeSign(); //noexcept + } + } + +private: + //try to append one byte... + void emitLifeSign() const //noexcept + { + try + { +#if 1 + const int fdLockFile = ::open(lockFilePath_.c_str(), O_WRONLY | O_APPEND | O_CLOEXEC); + if (fdLockFile == -1) + THROW_LAST_SYS_ERROR("open"); + ZEN_ON_SCOPE_EXIT(::close(fdLockFile)); + +#else //alternative using lseek => no apparent benefit https://freefilesync.org/forum/viewtopic.php?t=7553#p25505 + const int fdLockFile = ::open(lockFilePath_.c_str(), O_WRONLY | O_CLOEXEC); + if (fdLockFile == -1) + THROW_LAST_SYS_ERROR("open"); + ZEN_ON_SCOPE_EXIT(::close(fdLockFile)); + + if (const off_t offset = ::lseek(fdLockFile, 0, SEEK_END); + offset == -1) + THROW_LAST_SYS_ERROR("lseek"); +#endif + const ssize_t bytesWritten = ::write(fdLockFile, " ", 1); //writes *up to* count bytes + if (bytesWritten <= 0) + { + if (bytesWritten == 0) //comment in safe-read.c suggests to treat this as an error due to buggy drivers + errno = ENOSPC; + THROW_LAST_SYS_ERROR("write"); + } + ASSERT_SYSERROR(bytesWritten == 1); //better safe than sorry + } + catch (const SysError& e) + { + logExtraError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(lockFilePath_)) + L"\n\n" + e.toString()); + } + } + + const Zstring lockFilePath_; //thread-local! +}; + + + using ProcessId = pid_t; + using SessionId = pid_t; + +//return ppid on Windows, sid on Linux/Mac, "no value" if process corresponding to "processId" is not existing +std::optional getSessionId(ProcessId processId) //throw FileError +{ + if (::kill(processId, 0) != 0) //sig == 0: no signal sent, just existence check + return {}; + + const pid_t procSid = ::getsid(processId); //NOT to be confused with "login session", e.g. not stable on OS X!!! + if (procSid < 0) //pids are never negative, empiric proof: https://linux.die.net/man/2/wait + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "getsid"); + + return procSid; +} + + +struct LockInformation //throw FileError +{ + std::string lockId; //16 byte GUID - a universal identifier for this lock (no matter what the path is, considering symlinks, distributed network, etc.) + + //identify local computer + std::string computerName; //format: HostName.DomainName + std::string userId; + + //identify running process + SessionId sessionId = 0; //Windows: parent process id; Linux/macOS: session of the process, NOT the user + ProcessId processId = 0; +}; + + +LockInformation getLockInfoFromCurrentProcess() //throw FileError +{ + LockInformation lockInfo = + { + .lockId = generateGUID(), + .userId = utfTo(getLoginUser()), //throw FileError + }; + + const std::string osName = "Linux"; + + //wxGetFullHostName() is a performance killer and can hang for some users, so don't touch! + std::vector buf(10000); + if (::gethostname(buf.data(), buf.size()) != 0) + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "gethostname"); + lockInfo.computerName = osName + ' ' + buf.data() + '.'; + + if (::getdomainname(buf.data(), buf.size()) != 0) + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "getdomainname"); + lockInfo.computerName += buf.data(); //can be "(none)"! + + lockInfo.processId = ::getpid(); //never fails + + std::optional sessionIdTmp = getSessionId(lockInfo.processId); //throw FileError + if (!sessionIdTmp) + throw FileError(_("Cannot get process information."), L"no session id found"); //should not happen? + lockInfo.sessionId = *sessionIdTmp; + + return lockInfo; +} + + +std::string serialize(const LockInformation& lockInfo) +{ + MemoryStreamOut streamOut; + writeArray(streamOut, LOCK_FILE_DESCR, sizeof(LOCK_FILE_DESCR)); + writeNumber(streamOut, LOCK_FILE_VERSION); + + static_assert(sizeof(lockInfo.processId) <= sizeof(uint64_t)); //ensure cross-platform compatibility! + static_assert(sizeof(lockInfo.sessionId) <= sizeof(uint64_t)); // + + writeContainer(streamOut, lockInfo.lockId); + writeContainer(streamOut, lockInfo.computerName); + writeContainer(streamOut, lockInfo.userId); + writeNumber(streamOut, lockInfo.sessionId); + writeNumber(streamOut, lockInfo.processId); + + writeNumber(streamOut, getCrc32(streamOut.ref())); + writeArray(streamOut, "x", 1); //sentinel: mark logical end with a non-space character + return streamOut.ref(); +} + + +LockInformation unserialize(const std::string& byteStream) //throw SysError +{ + MemoryStreamIn streamIn(byteStream); + + char formatDescr[sizeof(LOCK_FILE_DESCR)] = {}; + readArray(streamIn, &formatDescr, sizeof(formatDescr)); //throw SysErrorUnexpectedEos + + if (!std::equal(std::begin(formatDescr), std::end(formatDescr), std::begin(LOCK_FILE_DESCR))) + throw SysError(_("File content is corrupted.") + L" (invalid header)"); + + const int version = readNumber(streamIn); //throw SysErrorUnexpectedEos + if (version != LOCK_FILE_VERSION) + throw SysError(_("Unsupported data format.") + L' ' + replaceCpy(_("Version: %x"), L"%x", numberTo(version))); + + //-------------------------------------------------------------------- + //catch data corruption ASAP + don't rely on std::bad_alloc for consistency checking + const size_t posEnd = byteStream.rfind('x'); //skip blanks (+ unrelated corrupted data e.g. nulls!) + if (posEnd == std::string::npos) + throw SysErrorUnexpectedEos(); + + const std::string_view byteStreamTrm = makeStringView(byteStream.begin(), posEnd); + + MemoryStreamOut crcStreamOut; + writeNumber(crcStreamOut, getCrc32(byteStreamTrm.begin(), byteStreamTrm.end() - sizeof(uint32_t))); + + if (!endsWith(byteStreamTrm, crcStreamOut.ref())) + throw SysError(_("File content is corrupted.") + L" (invalid checksum)"); + //-------------------------------------------------------------------- + + LockInformation lockInfo = {}; + lockInfo.lockId = readContainer(streamIn); // + lockInfo.computerName = readContainer(streamIn); //SysErrorUnexpectedEos + lockInfo.userId = readContainer(streamIn); // + lockInfo.sessionId = static_cast(readNumber(streamIn)); //[!] conversion + lockInfo.processId = static_cast(readNumber(streamIn)); //[!] conversion + return lockInfo; +} + + +LockInformation retrieveLockInfo(const Zstring& lockFilePath) //throw FileError +{ + const std::string byteStream = getFileContent(lockFilePath, nullptr /*notifyUnbufferedIO*/); //throw FileError + try + { + return unserialize(byteStream); //throw SysError + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(lockFilePath)), e.toString()); + } +} + + +inline +std::string retrieveLockId(const Zstring& lockFilePath) //throw FileError +{ + return retrieveLockInfo(lockFilePath).lockId; //throw FileError +} + + +enum class ProcessStatus +{ + notRunning, + running, + itsUs, + noIdea, +}; + +ProcessStatus getProcessStatus(const LockInformation& lockInfo) //throw FileError +{ + const LockInformation localInfo = getLockInfoFromCurrentProcess(); //throw FileError + + if (lockInfo.computerName != localInfo.computerName || + lockInfo.userId != localInfo.userId) //another user may run a session right now! + return ProcessStatus::noIdea; //lock owned by different computer in this network + + if (lockInfo.sessionId == localInfo.sessionId && + lockInfo.processId == localInfo.processId) //obscure, but possible: deletion failed or a lock file is "stolen" and put back while the program is running + return ProcessStatus::itsUs; + + if (std::optional sessionId = getSessionId(lockInfo.processId)) //throw FileError + return *sessionId == lockInfo.sessionId ? ProcessStatus::running : ProcessStatus::notRunning; + return ProcessStatus::notRunning; +} + + +DEFINE_NEW_FILE_ERROR(ErrorFileNotExisting) +uint64_t getLockFileSize(const Zstring& filePath) //throw FileError, ErrorFileNotExisting +{ + struct stat fileInfo = {}; + if (::stat(filePath.c_str(), &fileInfo) == 0) + return fileInfo.st_size; + + if (errno == ENOENT) + throw ErrorFileNotExisting(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(filePath)), formatSystemError("stat", errno)); + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(filePath)), "stat"); +} + + +void waitOnDirLock(const Zstring& lockFilePath, const DirLockCallback& notifyStatus /*throw X*/, std::chrono::milliseconds cbInterval) //throw FileError +{ + std::wstring infoMsg = _("Waiting while directory is in use:") + L' ' + fmtPath(lockFilePath); + + if (notifyStatus) notifyStatus(std::wstring(infoMsg)); //throw X + + //convenience optimization only: if we know the owning process crashed, we needn't wait DETECT_ABANDONED_INTERVAL sec + bool lockOwnderDead = false; + std::string originalLockId; //empty if it cannot be retrieved + try + { + const LockInformation& lockInfo = retrieveLockInfo(lockFilePath); //throw FileError + + infoMsg += SPACED_DASH + _("Username:") + L' ' + utfTo(lockInfo.userId); + + originalLockId = lockInfo.lockId; + switch (getProcessStatus(lockInfo)) //throw FileError + { + case ProcessStatus::itsUs: //since we've already passed LockAdmin, the lock file seems abandoned ("stolen"?) although it's from this process + case ProcessStatus::notRunning: + lockOwnderDead = true; + break; + case ProcessStatus::running: + case ProcessStatus::noIdea: + break; + } + } + catch (FileError&) {} //logfile may be only partly written -> this is no error! + //------------------------------------------------------------------------------ + + uint64_t fileSizeOld = 0; + auto lastLifeSign = std::chrono::steady_clock::now(); + + for (;;) + { + uint64_t fileSizeNew = 0; + try + { + fileSizeNew = getLockFileSize(lockFilePath); //throw FileError, ErrorFileNotExisting + } + catch (ErrorFileNotExisting&) { return; } //what we are waiting for... + + const auto lastCheckTime = std::chrono::steady_clock::now(); + + if (fileSizeNew != fileSizeOld) //received life sign from lock + { + fileSizeOld = fileSizeNew; + lastLifeSign = lastCheckTime; + } + + if (lockOwnderDead || //no need to wait any longer... + lastCheckTime >= lastLifeSign + DETECT_ABANDONED_INTERVAL) + { + const Zstring lockFileName = [&] + { + try + { + return fff::impl::getAbandonedLockFileName(getItemName(lockFilePath)); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(lockFilePath)), e.toString()); } + }(); + + DirLock guardDeletion(*getParentFolderPath(lockFilePath), lockFileName, notifyStatus, cbInterval); //throw FileError + + //now that the lock is in place check existence again: meanwhile another process may have deleted and created a new lock! + std::string currentLockId; + try { currentLockId = retrieveLockId(lockFilePath); /*throw FileError*/ } + catch (FileError&) {} + + if (currentLockId != originalLockId) + return; //another process has placed a new lock, leave scope: the wait for the old lock is technically over... + + try + { + if (getLockFileSize(lockFilePath) != fileSizeOld) //throw FileError, ErrorFileNotExisting + return; //late life sign (or maybe even a different lock if retrieveLockId() failed!) + } + catch (ErrorFileNotExisting&) { return; } //what we are waiting for anyway... + + removeFilePlain(lockFilePath); //throw FileError + return; + } + + //wait some time... + const auto delayUntil = std::chrono::steady_clock::now() + POLL_LIFE_SIGN_INTERVAL; + for (auto now = std::chrono::steady_clock::now(); now < delayUntil; now = std::chrono::steady_clock::now()) + { + if (notifyStatus) + { + //one signal missed: it's likely this is an abandoned lock => show countdown + if (lastCheckTime >= lastLifeSign + EMIT_LIFE_SIGN_INTERVAL + std::chrono::seconds(1)) + { + const int remainingSeconds = std::max(0, static_cast(std::chrono::duration_cast(DETECT_ABANDONED_INTERVAL - (now - lastLifeSign)).count())); + notifyStatus(infoMsg + SPACED_DASH + _("Lock file apparently abandoned...") + L' ' + _P("1 sec", "%x sec", remainingSeconds)); //throw X + } + else + notifyStatus(std::wstring(infoMsg)); //throw X; emit a message in any case (might clear other one) + } + std::this_thread::sleep_for(cbInterval); + } + } +} + + +void releaseLock(const Zstring& lockFilePath) { removeFilePlain(lockFilePath); } //throw FileError + + +bool tryLock(const Zstring& lockFilePath) //throw FileError +{ + //important: we want the lock file to have exactly the permissions specified + //=> yes, disabling umask() is messy (per-process!), but fchmod() may not be supported: https://freefilesync.org/forum/viewtopic.php?t=8096 + const mode_t oldMask = ::umask(0); //always succeeds + ZEN_ON_SCOPE_EXIT(::umask(oldMask)); + + const mode_t lockFileMode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH; //0666 + + //O_EXCL contains a race condition on NFS file systems: https://linux.die.net/man/2/open + const int hFile = ::open(lockFilePath.c_str(), //const char* pathname + O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, //int flags + lockFileMode); //mode_t mode + if (hFile == -1) + { + if (errno == EEXIST) + return false; + + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(lockFilePath)), "open"); + } + FileOutputPlain fileOut(hFile, lockFilePath); //pass handle ownership + + //write housekeeping info: user, process info, lock GUID + const std::string byteStream = serialize(getLockInfoFromCurrentProcess()); //throw FileError + + unbufferedSave(byteStream, [&](const void* buffer, size_t bytesToWrite) + { + return fileOut.tryWrite(buffer, bytesToWrite); //throw FileError; may return short! CONTRACT: bytesToWrite > 0 + }, + fileOut.getBlockSize()); + + fileOut.close(); //throw FileError + return true; +} +} + + +class DirLock::SharedDirLock +{ +public: + SharedDirLock(const Zstring& lockFilePath, const DirLockCallback& notifyStatus, std::chrono::milliseconds cbInterval) : //throw FileError + lockFilePath_(lockFilePath) + { + if (notifyStatus) notifyStatus(replaceCpy(_("Creating file %x"), L"%x", fmtPath(lockFilePath))); //throw X + + while (!::tryLock(lockFilePath)) //throw FileError + { + ::waitOnDirLock(lockFilePath, notifyStatus, cbInterval); //throw FileError + } + + lifeSignthread_ = InterruptibleThread(LifeSigns(lockFilePath)); + } + + ~SharedDirLock() + { + lifeSignthread_.requestStop(); //thread lifetime is subset of this instances's life + lifeSignthread_.join(); + + try + { + ::releaseLock(lockFilePath_); //throw FileError + } + catch (const FileError& e) { logExtraError(e.toString()); } //inform user about remnant lock files *somehow*! + } + +private: + SharedDirLock (const DirLock&) = delete; + SharedDirLock& operator=(const DirLock&) = delete; + + const Zstring lockFilePath_; + InterruptibleThread lifeSignthread_; +}; + + +class DirLock::LockAdmin //administrate all locks held by this process to avoid deadlock by recursion +{ +public: + static LockAdmin& instance() + { + static LockAdmin inst; + return inst; + } + + //create or retrieve a SharedDirLock + std::shared_ptr retrieve(const Zstring& lockFilePath, const DirLockCallback& notifyStatus, std::chrono::milliseconds cbInterval) //throw FileError + { + assert(runningOnMainThread()); //function is not thread-safe! + + tidyUp(); + + //optimization: check if we already own a lock for this path + if (auto itGuid = guidByPath_.find(lockFilePath); + itGuid != guidByPath_.end()) + if (const std::shared_ptr& activeLock = getActiveLock(itGuid->second)) //returns null-lock if not found + return activeLock; //SharedDirLock is still active -> enlarge circle of shared ownership + + try //check based on lock GUID, deadlock prevention: "lockFilePath" may be an alternative name for a lock already owned by this process + { + const std::string lockId = retrieveLockId(lockFilePath); //throw FileError + if (const std::shared_ptr& activeLock = getActiveLock(lockId)) //returns null-lock if not found + { + guidByPath_[lockFilePath] = lockId; //found an alias for one of our active locks + return activeLock; + } + } + catch (FileError&) {} //catch everything, let SharedDirLock constructor deal with errors, e.g. 0-sized/corrupted lock files + + //lock not owned by us => create a new one + auto newLock = std::make_shared(lockFilePath, notifyStatus, cbInterval); //throw FileError + const std::string& newLockGuid = retrieveLockId(lockFilePath); //throw FileError + + guidByPath_[lockFilePath] = newLockGuid; //update registry + locksByGuid_[newLockGuid] = newLock; // + + return newLock; + } + +private: + LockAdmin() {} + LockAdmin (const LockAdmin&) = delete; + LockAdmin& operator=(const LockAdmin&) = delete; + + using UniqueId = std::string; + + std::shared_ptr getActiveLock(const UniqueId& lockId) //returns null if none found + { + auto it = locksByGuid_.find(lockId); + return it != locksByGuid_.end() ? it->second.lock() : nullptr; //try to get shared_ptr; throw() + } + + void tidyUp() //remove obsolete entries + { + std::erase_if(locksByGuid_, [](const auto& v) { return v.second.expired(); }); + std::erase_if(guidByPath_, [&](const auto& v) { return !locksByGuid_.contains(v.second); }); + } + + std::unordered_map guidByPath_; //lockFilePath |-> GUID; n:1; locks can be referenced by a lockFilePath or alternatively a GUID + std::unordered_map> locksByGuid_; //GUID |-> "shared lock ownership"; 1:1 +}; + + +DirLock::DirLock(const Zstring& folderPath, const Zstring& fileName, const DirLockCallback& notifyStatus, std::chrono::milliseconds cbInterval) //throw FileError +{ + sharedLock_ = LockAdmin::instance().retrieve(appendPath(folderPath, fileName), notifyStatus, cbInterval); //throw FileError +} diff --git a/FreeFileSync/Source/base/dir_lock.h b/FreeFileSync/Source/base/dir_lock.h new file mode 100644 index 0000000..31da3db --- /dev/null +++ b/FreeFileSync/Source/base/dir_lock.h @@ -0,0 +1,59 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DIR_LOCK_H_81740832174954356 +#define DIR_LOCK_H_81740832174954356 + +#include +#include +#include +#include + + +namespace fff +{ +/* RAII structure to place a directory lock against other FFS processes: + - recursive locking supported, even with alternate lockfile names, e.g. via symlinks, network mounts, case-differences etc. + - ownership shared between all object instances refering to a specific lock location(= GUID) + - can be copied safely and efficiently! (ref-counting) + - detects and resolves abandoned locks (instantly if lock is associated with local pc, else after 30 seconds) + - temporary locks created during abandoned lock resolution keep "lockFilePath"'s extension + - race-free (Windows, almost on Linux(NFS)) + - NOT thread-safe! (1. global LockAdmin 2. locks for directory aliases should be created sequentially to detect duplicate locks!) */ + +//intermediate locks created by DirLock use this extension, too: +constexpr ZstringView LOCK_FILE_ENDING = Zstr(".ffs_lock"); //don't use Zstring as global constant: avoid static initialization order problem in global namespace! + +//while waiting for the lock +using DirLockCallback = std::function; //throw X + +class DirLock +{ +public: + DirLock(const Zstring& folderPath, + const DirLockCallback& notifyStatus, //callback only used during construction + std::chrono::milliseconds cbInterval) : + DirLock(folderPath, Zstring(Zstr("sync")) + LOCK_FILE_ENDING, notifyStatus, cbInterval) {} //throw FileError + + DirLock(const Zstring& folderPath, + const Zstring& fileName, + const DirLockCallback& notifyStatus, + std::chrono::milliseconds cbInterval); //throw FileError + +private: + class LockAdmin; + class SharedDirLock; + std::shared_ptr sharedLock_; +}; + + +namespace impl //declare for unit tests: +{ +Zstring getAbandonedLockFileName(const Zstring& lockFilePath); //throw FileError +} +} + +#endif //DIR_LOCK_H_81740832174954356 diff --git a/FreeFileSync/Source/base/file_hierarchy.cpp b/FreeFileSync/Source/base/file_hierarchy.cpp new file mode 100644 index 0000000..89b9d90 --- /dev/null +++ b/FreeFileSync/Source/base/file_hierarchy.cpp @@ -0,0 +1,575 @@ +// ***************************************************************************** +// * 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 "file_hierarchy.h" +#include +#include +#include + +using namespace zen; +using namespace fff; + + +std::wstring fff::getShortDisplayNameForFolderPair(const AbstractPath& itemPathL, const AbstractPath& itemPathR) +{ + Zstring commonTrail; + AbstractPath tmpPathL = itemPathL; + AbstractPath tmpPathR = itemPathR; + for (;;) + { + const std::optional parentPathL = AFS::getParentPath(tmpPathL); + const std::optional parentPathR = AFS::getParentPath(tmpPathR); + if (!parentPathL || !parentPathR) + break; + + const Zstring itemNameL = AFS::getItemName(tmpPathL); + const Zstring itemNameR = AFS::getItemName(tmpPathR); + if (!equalNoCase(itemNameL, itemNameR)) //let's compare case-insensitively (even on Linux!) + break; + + tmpPathL = *parentPathL; + tmpPathR = *parentPathR; + + commonTrail = appendPath(itemNameL, commonTrail); + } + if (!commonTrail.empty()) + return utfTo(commonTrail); + + auto getLastComponent = [](const AbstractPath& itemPath) + { + if (!AFS::getParentPath(itemPath)) //= device root + return AFS::getDisplayPath(itemPath); + return utfTo(AFS::getItemName(itemPath)); + }; + + if (AFS::isNullPath(itemPathL)) + return getLastComponent(itemPathR); + else if (AFS::isNullPath(itemPathR)) + return getLastComponent(itemPathL); + else + return getLastComponent(itemPathL) + L" | " + + getLastComponent(itemPathR); +} + + +void ContainerObject::removeDoubleEmpty() +{ + eraseIf(files_, [](const auto& fsObj) { return fsObj.ref().isPairEmpty(); }); + eraseIf(symlinks_, [](const auto& fsObj) { return fsObj.ref().isPairEmpty(); }); + eraseIf(subfolders_, [](const auto& fsObj) { return fsObj.ref().isPairEmpty(); }); + + for (FolderPair& folder : subfolders()) + folder.removeDoubleEmpty(); +} + + +namespace +{ +SyncOperation getIsolatedSyncOperation(const FileSystemObject& fsObj, + bool selectedForSync, + SyncDirection syncDir, + bool hasDirectionConflict) +{ + assert(!hasDirectionConflict || syncDir == SyncDirection::none); + + if (fsObj.isEmpty() || fsObj.isEmpty()) + { + if (!selectedForSync) + return SO_DO_NOTHING; + + if (hasDirectionConflict) + return SO_UNRESOLVED_CONFLICT; + + if (fsObj.isEmpty()) + { + if (fsObj.isEmpty()) //both sides empty: should only occur temporarily, if ever + return SO_EQUAL; + else //right-only + switch (syncDir) + { + case SyncDirection::left: return SO_CREATE_LEFT; + case SyncDirection::right: return SO_DELETE_RIGHT; + case SyncDirection::none: return SO_DO_NOTHING; + } + } + else //left-only + switch (syncDir) + { + case SyncDirection::left: return SO_DELETE_LEFT; + case SyncDirection::right: return SO_CREATE_RIGHT; + case SyncDirection::none: return SO_DO_NOTHING; + } + } + //-------------------------------------------------------------- + std::optional result; + + visitFSObject(fsObj, + [&](const FolderPair& folder) //see FolderPair::getCategory() + { + if (folder.hasEquivalentItemNames()) //a.k.a. DIR_EQUAL + { + assert(syncDir == SyncDirection::none); + return result = SO_EQUAL; //no matter if "conflict" (e.g. traversal error) or "not selected" + } + + if (!selectedForSync) + return result = SO_DO_NOTHING; + + if (hasDirectionConflict) + return result = SO_UNRESOLVED_CONFLICT; + + switch (syncDir) + { + case SyncDirection::left: return result = SO_RENAME_LEFT; + case SyncDirection::right: return result = SO_RENAME_RIGHT; + case SyncDirection::none: return result = SO_DO_NOTHING; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + }, + //-------------------------------------------------------------- + [&](const FilePair& file) //see FilePair::getCategory() + { + if (file.getContentCategory() == FileContentCategory::equal && file.hasEquivalentItemNames()) //a.k.a. FILE_EQUAL + { + assert(syncDir == SyncDirection::none); + return result = SO_EQUAL; //no matter if "conflict" (e.g. traversal error) or "not selected" + } + + if (!selectedForSync) + return result = SO_DO_NOTHING; + + if (hasDirectionConflict) + return result = SO_UNRESOLVED_CONFLICT; + + switch (file.getContentCategory()) + { + case FileContentCategory::unknown: + case FileContentCategory::leftNewer: + case FileContentCategory::rightNewer: + case FileContentCategory::invalidTime: + case FileContentCategory::different: + case FileContentCategory::conflict: + switch (syncDir) + { + case SyncDirection::left: return result = SO_OVERWRITE_LEFT; + case SyncDirection::right: return result = SO_OVERWRITE_RIGHT; + case SyncDirection::none: return result = SO_DO_NOTHING; + } + break; + + case FileContentCategory::equal: + switch (syncDir) + { + case SyncDirection::left: return result = SO_RENAME_LEFT; + case SyncDirection::right: return result = SO_RENAME_RIGHT; + case SyncDirection::none: return result = SO_DO_NOTHING; + } + break; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + }, + //-------------------------------------------------------------- + [&](const SymlinkPair& symlink) //see SymlinkPair::getCategory() + { + if (symlink.getContentCategory() == FileContentCategory::equal && symlink.hasEquivalentItemNames()) //a.k.a. SYMLINK_EQUAL + { + assert(syncDir == SyncDirection::none); + return result = SO_EQUAL; //no matter if "conflict" (e.g. traversal error) or "not selected" + } + + if (!selectedForSync) + return result = SO_DO_NOTHING; + + if (hasDirectionConflict) + return result = SO_UNRESOLVED_CONFLICT; + + switch (symlink.getContentCategory()) + { + case FileContentCategory::unknown: + case FileContentCategory::leftNewer: + case FileContentCategory::rightNewer: + case FileContentCategory::invalidTime: + case FileContentCategory::different: + case FileContentCategory::conflict: + switch (syncDir) + { + case SyncDirection::left: return result = SO_OVERWRITE_LEFT; + case SyncDirection::right: return result = SO_OVERWRITE_RIGHT; + case SyncDirection::none: return result = SO_DO_NOTHING; + } + break; + + case FileContentCategory::equal: + switch (syncDir) + { + case SyncDirection::left: return result = SO_RENAME_LEFT; + case SyncDirection::right: return result = SO_RENAME_RIGHT; + case SyncDirection::none: return result = SO_DO_NOTHING; + } + break; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + }); + return *result; +} + + +template inline +bool hasDirectChild(const ContainerObject& conObj, Predicate p) +{ + return std::any_of(conObj.files ().begin(), conObj.files ().end(), p) || + std::any_of(conObj.symlinks ().begin(), conObj.symlinks ().end(), p) || + std::any_of(conObj.subfolders().begin(), conObj.subfolders().end(), p); +} +} + + +SyncOperation FileSystemObject::testSyncOperation(SyncDirection testSyncDir) const //semantics: "what if"! assumes "active, no conflict, no recursion (directory)! +{ + return getIsolatedSyncOperation(*this, true, testSyncDir, false); +} + +//SyncOperation FolderPair::testSyncOperation() const -> no recursion: we do NOT consider child elements when testing! + + +SyncOperation FileSystemObject::getSyncOperation() const +{ + return getIsolatedSyncOperation(*this, selectedForSync_, syncDir_, !syncDirectionConflict_.empty()); + //do *not* make a virtual call to testSyncOperation()! See FilePair::testSyncOperation()! <- better not implement one in terms of the other!!! +} + + +SyncOperation FolderPair::getSyncOperation() const +{ + if (!syncOpBuffered_) //redetermine... + { + //suggested operation *not* considering child elements + syncOpBuffered_ = FileSystemObject::getSyncOperation(); + + //action for child elements may occassionally have to overwrite parent task: + switch (*syncOpBuffered_) + { + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + assert(false); + [[fallthrough]]; + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_EQUAL: + break; //take over suggestion, no problem for child-elements + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_DO_NOTHING: + case SO_UNRESOLVED_CONFLICT: + if (isEmpty()) + { + //1. if at least one child-element is to be created, make sure parent folder is created also + //note: this automatically fulfills "create parent folders even if excluded" + if (hasDirectChild(*this, [](const FileSystemObject& fsObj) + { + assert(!fsObj.isPairEmpty() || fsObj.getSyncOperation() == SO_DO_NOTHING); + const SyncOperation op = fsObj.getSyncOperation(); + return op == SO_CREATE_LEFT || + op == SO_MOVE_LEFT_TO; + })) + syncOpBuffered_ = SO_CREATE_LEFT; + //2. cancel parent deletion if only a single child is not also scheduled for deletion + else if (*syncOpBuffered_ == SO_DELETE_RIGHT && + hasDirectChild(*this, [](const FileSystemObject& fsObj) + { + if (fsObj.isPairEmpty()) + return false; //fsObj may already be empty because it once contained a "move source" + const SyncOperation op = fsObj.getSyncOperation(); + return op != SO_DELETE_RIGHT && + op != SO_MOVE_RIGHT_FROM; + })) + syncOpBuffered_ = SO_DO_NOTHING; + } + else if (isEmpty()) + { + if (hasDirectChild(*this, [](const FileSystemObject& fsObj) + { + assert(!fsObj.isPairEmpty() || fsObj.getSyncOperation() == SO_DO_NOTHING); + const SyncOperation op = fsObj.getSyncOperation(); + return op == SO_CREATE_RIGHT || + op == SO_MOVE_RIGHT_TO; + })) + syncOpBuffered_ = SO_CREATE_RIGHT; + else if (*syncOpBuffered_ == SO_DELETE_LEFT && + hasDirectChild(*this, [](const FileSystemObject& fsObj) + { + if (fsObj.isPairEmpty()) + return false; + const SyncOperation op = fsObj.getSyncOperation(); + return op != SO_DELETE_LEFT && + op != SO_MOVE_LEFT_FROM; + })) + syncOpBuffered_ = SO_DO_NOTHING; + } + break; + } + } + return *syncOpBuffered_; +} + + +inline //called by private only! +SyncOperation FilePair::applyMoveOptimization(SyncOperation op) const +{ + /* check whether we can optimize "create + delete" via "move": + note: as long as we consider "create + delete" cases only, detection of renamed files, should be fine even for "binary" comparison variant! */ + if (FilePair* refFile = getMovePair()) + { + const SyncOperation opRef = refFile->FileSystemObject::getSyncOperation(); //do *not* make a virtual call! + if (op == SO_CREATE_LEFT && + opRef == SO_DELETE_LEFT) + op = SO_MOVE_LEFT_TO; + else if (op == SO_DELETE_LEFT && + opRef == SO_CREATE_LEFT) + op = SO_MOVE_LEFT_FROM; + else if (op == SO_CREATE_RIGHT && + opRef == SO_DELETE_RIGHT) + op = SO_MOVE_RIGHT_TO; + else if (op == SO_DELETE_RIGHT && + opRef == SO_CREATE_RIGHT) + op = SO_MOVE_RIGHT_FROM; + } + + return op; +} + + +SyncOperation FilePair::testSyncOperation(SyncDirection testSyncDir) const +{ + return applyMoveOptimization(FileSystemObject::testSyncOperation(testSyncDir)); +} + + +SyncOperation FilePair::getSyncOperation() const +{ + return applyMoveOptimization(FileSystemObject::getSyncOperation()); +} + + +std::wstring fff::getCategoryDescription(CompareFileResult cmpRes) +{ + switch (cmpRes) + { + case FILE_EQUAL: + return _("Both sides are equal"); + case FILE_RENAMED: + return _("Items differ in name only"); + case FILE_LEFT_ONLY: + return _("Item exists on left side only"); + case FILE_RIGHT_ONLY: + return _("Item exists on right side only"); + case FILE_LEFT_NEWER: + return _("Left side is newer"); + case FILE_RIGHT_NEWER: + return _("Right side is newer"); + case FILE_DIFFERENT_CONTENT: + return _("Items have different content"); + case FILE_TIME_INVALID: + case FILE_CONFLICT: + return _("Conflict/item cannot be categorized"); + } + assert(false); + return std::wstring(); +} + + +namespace +{ +const wchar_t arrowLeft [] = L"<-"; +const wchar_t arrowRight[] = L"->"; +//const wchar_t arrowRight[] = L"\u2192"; unicode arrows -> too small +} + + +std::wstring fff::getCategoryDescription(const FileSystemObject& fsObj) +{ + const std::wstring footer = [&] + { + if (fsObj.hasEquivalentItemNames()) + return L'\n' + fmtPath(fsObj.getItemName()); + else + return std::wstring(L"\n") + + fmtPath(fsObj.getItemName()) + L' ' + arrowLeft + L'\n' + + fmtPath(fsObj.getItemName()) + L' ' + arrowRight; + }(); + + if (const Zstringc descr = fsObj.getCategoryCustomDescription(); + !descr.empty()) + return utfTo(descr) + footer; + + const CompareFileResult cmpRes = fsObj.getCategory(); + switch (cmpRes) + { + case FILE_EQUAL: + case FILE_RENAMED: + case FILE_LEFT_ONLY: + case FILE_RIGHT_ONLY: + case FILE_DIFFERENT_CONTENT: + return getCategoryDescription(cmpRes) + footer; //use generic description + + case FILE_LEFT_NEWER: + case FILE_RIGHT_NEWER: + { + std::wstring descr = getCategoryDescription(cmpRes); + + visitFSObject(fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) + { + descr += std::wstring(L"\n") + + formatUtcToLocalTime(file.getLastWriteTime()) + L' ' + arrowLeft + L'\n' + + formatUtcToLocalTime(file.getLastWriteTime()) + L' ' + arrowRight ; + }, + [&](const SymlinkPair& symlink) + { + descr += std::wstring(L"\n") + + formatUtcToLocalTime(symlink.getLastWriteTime()) + L' ' + arrowLeft + L'\n' + + formatUtcToLocalTime(symlink.getLastWriteTime()) + L' ' + arrowRight ; + }); + return descr + footer; + } + + case FILE_TIME_INVALID: + case FILE_CONFLICT: + assert(false); //should have getCategoryDescription()! + return _("Error") + footer; + } + assert(false); + return std::wstring(); +} + + +std::wstring fff::getSyncOpDescription(SyncOperation op) +{ + switch (op) + { + case SO_CREATE_LEFT: + return _("Copy new item to left"); + case SO_CREATE_RIGHT: + return _("Copy new item to right"); + case SO_DELETE_LEFT: + return _("Delete left item"); + case SO_DELETE_RIGHT: + return _("Delete right item"); + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + return _("Move left file"); //move only supported for files + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + return _("Move right file"); + case SO_OVERWRITE_LEFT: + return _("Update left item"); + case SO_OVERWRITE_RIGHT: + return _("Update right item"); + case SO_DO_NOTHING: + return _("Do nothing"); + case SO_EQUAL: + return _("Both sides are equal"); + case SO_RENAME_LEFT: + return _("Rename left item"); + case SO_RENAME_RIGHT: + return _("Rename right item"); + case SO_UNRESOLVED_CONFLICT: //not used on GUI, but in .csv + return _("Conflict/item cannot be categorized"); + } + assert(false); + return std::wstring(); +} + + +std::wstring fff::getSyncOpDescription(const FileSystemObject& fsObj) +{ + const SyncOperation op = fsObj.getSyncOperation(); + + const std::wstring rightArrowDown = languageLayoutIsRtl() ? + std::wstring() + RTL_MARK + LEFT_ARROW_ANTICLOCK : + std::wstring() + LTR_MARK + RIGHT_ARROW_CURV_DOWN; + //Windows bug: RIGHT_ARROW_CURV_DOWN rendering and extent calculation is buggy (see wx+\tooltip.cpp) => need LTR mark! + + auto generateFooter = [&] + { + if (fsObj.hasEquivalentItemNames()) + return L'\n' + fmtPath(fsObj.getItemName()); + + Zstring itemNameNew = fsObj.getItemName(); + Zstring itemNameOld = fsObj.getItemName(); + + if (const SyncDirection dir = getEffectiveSyncDir(op); + dir != SyncDirection::none) + { + if (dir == SyncDirection::left) + std::swap(itemNameNew, itemNameOld); + + return L'\n' + fmtPath(itemNameOld) + L' ' + rightArrowDown + L'\n' + fmtPath(itemNameNew); + } + else + return L'\n' + + fmtPath(itemNameNew) + L' ' + arrowLeft + L'\n' + + fmtPath(itemNameOld) + L' ' + arrowRight; + }; + + switch (op) + { + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + return getSyncOpDescription(op) + generateFooter(); + + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + if (auto fileFrom = dynamic_cast(&fsObj)) + if (const FilePair* fileTo = fileFrom->getMovePair()) + { + const bool onLeft = op == SO_MOVE_LEFT_FROM || op == SO_MOVE_LEFT_TO; + const bool isMoveSource = op == SO_MOVE_LEFT_FROM || op == SO_MOVE_RIGHT_FROM; + + if (!isMoveSource) + std::swap(fileFrom, fileTo); + + auto getRelPath = [&](const FileSystemObject& fso) { return onLeft ? fso.getRelativePath() : fso.getRelativePath(); }; + + const Zstring relPathFrom = getRelPath(*fileFrom); + const Zstring relPathTo = getRelPath(*fileTo); + + //attention: ::SetWindowText() doesn't handle tab characters correctly in combination with certain file names, so don't use + return getSyncOpDescription(op) + L'\n' + + (beforeLast(relPathFrom, FILE_NAME_SEPARATOR, IfNotFoundReturn::none) == + beforeLast(relPathTo, FILE_NAME_SEPARATOR, IfNotFoundReturn::none) ? + //detected pure "rename" + fmtPath(getItemName(relPathFrom)) + L' ' + rightArrowDown + L'\n' + //show file name only + fmtPath(getItemName(relPathTo)) : + //"move" or "move + rename" + fmtPath(relPathFrom) + L' ' + rightArrowDown + L'\n' + + fmtPath(relPathTo)); + } + break; + + case SO_UNRESOLVED_CONFLICT: + return fsObj.getSyncOpConflict() + generateFooter(); + } + + assert(false); + return std::wstring(); +} diff --git a/FreeFileSync/Source/base/file_hierarchy.h b/FreeFileSync/Source/base/file_hierarchy.h new file mode 100644 index 0000000..1089520 --- /dev/null +++ b/FreeFileSync/Source/base/file_hierarchy.h @@ -0,0 +1,1493 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_HIERARCHY_H_257235289645296 +#define FILE_HIERARCHY_H_257235289645296 + +#include +#include +#include "structures.h" +#include "path_filter.h" +#include "../afs/abstract.h" + + +namespace fff +{ +struct FileAttributes +{ + time_t modTime = 0; //number of seconds since Jan. 1st 1970 GMT + uint64_t fileSize = 0; + AFS::FingerPrint filePrint = 0; //optional + bool isFollowedSymlink = false; + + static_assert(std::is_signed_v, "we need time_t to be signed"); + + std::strong_ordering operator<=>(const FileAttributes&) const = default; +}; + + +struct LinkAttributes +{ + time_t modTime = 0; //number of seconds since Jan. 1st 1970 GMT +}; + + +struct FolderAttributes +{ + bool isFollowedSymlink = false; +}; + + +struct FolderContainer +{ + //------------------------------------------------------------------ + //key: raw file name, without any (Unicode) normalization, preserving original upper-/lower-case + //"Changing data [...] to NFC would cause interoperability problems. Always leave data as it is." + using FolderList = std::unordered_map>; + using FileList = std::unordered_map; + using SymlinkList = std::unordered_map; + //------------------------------------------------------------------ + + FolderContainer() = default; + FolderContainer (const FolderContainer&) = delete; //catch accidental (and unnecessary) copying + FolderContainer& operator=(const FolderContainer&) = delete; // + + FileList files; + SymlinkList symlinks; //non-followed symlinks + FolderList folders; + + void addFile(const Zstring& itemName, const FileAttributes& attr) + { + files.insert_or_assign(itemName, attr); //update entry if already existing (e.g. during folder traverser "retry") + } + + void addSymlink(const Zstring& itemName, const LinkAttributes& attr) + { + symlinks.insert_or_assign(itemName, attr); + } + + FolderContainer& addFolder(const Zstring& itemName, const FolderAttributes& attr) + { + auto& p = folders[itemName]; //value default-construction is okay here + p.first = attr; + return p.second; + } +}; + +//------------------------------------------------------------------ + +enum class SelectSide +{ + left, + right +}; + + +template +constexpr SelectSide getOtherSide = side == SelectSide::left ? SelectSide::right : SelectSide::left; + + +template inline +T& selectParam(T& left, T& right) +{ + if constexpr (side == SelectSide::left) + return left; + else + return right; +} + + +enum class FileContentCategory : unsigned char +{ + unknown, + equal, + leftNewer, + rightNewer, + invalidTime, + different, + conflict, +}; + + +inline +SyncDirection getEffectiveSyncDir(SyncOperation syncOp) +{ + switch (syncOp) + { + case SO_CREATE_LEFT: + case SO_DELETE_LEFT: + case SO_OVERWRITE_LEFT: + case SO_RENAME_LEFT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + return SyncDirection::left; + + case SO_CREATE_RIGHT: + case SO_DELETE_RIGHT: + case SO_OVERWRITE_RIGHT: + case SO_RENAME_RIGHT: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + return SyncDirection::right; + + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + break; //nothing to do + } + return SyncDirection::none; +} + + +std::wstring getShortDisplayNameForFolderPair(const AbstractPath& itemPathL, const AbstractPath& itemPathR); + + +class FileSystemObject; +class SymlinkPair; +class FilePair; +class FolderPair; +class BaseFolderPair; + +/*------------------------------------------------------------------ + inheritance diagram: + +std::enable_shared_from_this PathInformation + /|\ /|\ + |____________ _________|_________ + | | | + FileSystemObject ContainerObject + /|\ /|\ + ___________|___________ ______|______ + | | | | | + SymlinkPair FilePair FolderPair BaseFolderPair + +------------------------------------------------------------------*/ + +struct PathInformation //diamond-shaped inheritance! +{ + virtual ~PathInformation() {} + + template AbstractPath getAbstractPath() const; + template Zstring getRelativePath() const; //get path relative to base sync dir (without leading/trailing FILE_NAME_SEPARATOR) + +private: + virtual AbstractPath getAbstractPathL() const = 0; //implemented by FileSystemObject + BaseFolderPair + virtual AbstractPath getAbstractPathR() const = 0; // + + virtual Zstring getRelativePathL() const = 0; //implemented by SymlinkPair/FilePair + ContainerObject + virtual Zstring getRelativePathR() const = 0; // +}; + +template <> inline AbstractPath PathInformation::getAbstractPath() const { return getAbstractPathL(); } +template <> inline AbstractPath PathInformation::getAbstractPath() const { return getAbstractPathR(); } + +template <> inline Zstring PathInformation::getRelativePath() const { return getRelativePathL(); } +template <> inline Zstring PathInformation::getRelativePath() const { return getRelativePathR(); } + +//------------------------------------------------------------------ + +class ContainerObject : public virtual PathInformation +{ + friend class FileSystemObject; //access to updateRelPathsRecursion() + +public: + using FileList = std::vector>; //MergeSides::execute() requires a structure that doesn't invalidate pointers after push_back() + using SymlinkList = std::vector>; // + using FolderList = std::vector>; + + FolderPair& addFolder(const Zstring& itemNameL, const FolderAttributes& attribL, + const Zstring& itemNameR, const FolderAttributes& attribR); //exists on both sides + + template + FolderPair& addFolder(const Zstring& itemName, const FolderAttributes& attr); //exists on one side only + + FilePair& addFile(const Zstring& itemNameL, const FileAttributes& attribL, + const Zstring& itemNameR, const FileAttributes& attribR); //exists on both sides + + template + FilePair& addFile(const Zstring& itemName, const FileAttributes& attr); //exists on one side only + + SymlinkPair& addSymlink(const Zstring& itemNameL, const LinkAttributes& attribL, + const Zstring& itemNameR, const LinkAttributes& attribR); //exists on both sides + + template + SymlinkPair& addSymlink(const Zstring& itemName, const LinkAttributes& attr); //exists on one side only + + zen::Range> files() const { return {files_.begin(), files_.end()}; } + zen::Range> files() { return {files_.begin(), files_.end()}; } + + zen::Range> symlinks() const { return {symlinks_.begin(), symlinks_.end()}; } + zen::Range> symlinks() { return {symlinks_.begin(), symlinks_.end()}; } + + zen::Range> subfolders() const { return {subfolders_.begin(), subfolders_.end()}; } + zen::Range> subfolders() { return {subfolders_.begin(), subfolders_.end()}; } + + void clearFiles () { files_ .clear(); } + void clearSymlinks () { symlinks_ .clear(); } + void clearSubfolders() { subfolders_.clear(); } + + template + void foldersRemoveIf(Function fun) { zen::eraseIf(subfolders_, [fun](auto& fsObj) { return fun(fsObj.ref()); }); } + + const BaseFolderPair& getBase() const { return base_; } + /**/ BaseFolderPair& getBase() { return base_; } + + void removeDoubleEmpty(); //remove all invalid entries (where both sides are empty) recursively + + virtual void flip(); + +protected: + explicit ContainerObject(BaseFolderPair& baseFolder) : //used during BaseFolderPair constructor + base_(baseFolder) //take reference only: baseFolder *not yet* fully constructed at this point! + { assert(relPathL_.c_str() == relPathR_.c_str()); } //expected by the following contructor! + + explicit ContainerObject(const FileSystemObject& fsAlias); //used during FolderPair constructor + + virtual ~ContainerObject() //don't need polymorphic deletion, but we have a vtable anyway + { assert(relPathL_.c_str() == relPathR_.c_str() || relPathL_ != relPathR_); } + + template + void updateRelPathsRecursion(const FileSystemObject& fsAlias); + +private: + ContainerObject (const ContainerObject&) = delete; //this class is referenced by its child elements => make it non-copyable/movable! + ContainerObject& operator=(const ContainerObject&) = delete; + + Zstring getRelativePathL() const override { return relPathL_; } + Zstring getRelativePathR() const override { return relPathR_; } + + FileList files_; + SymlinkList symlinks_; + FolderList subfolders_; + + Zstring relPathL_; //path relative to base sync dir (without leading/trailing FILE_NAME_SEPARATOR) + Zstring relPathR_; //class invariant: shared Zstring iff equal! + + BaseFolderPair& base_; +}; + +//------------------------------------------------------------------ + +enum class BaseFolderStatus +{ + existing, + notExisting, + failure, +}; + +class BaseFolderPair : public ContainerObject +{ +public: + BaseFolderPair(const AbstractPath& folderPathLeft, + BaseFolderStatus folderStatusLeft, + const AbstractPath& folderPathRight, + BaseFolderStatus folderStatusRight, + const FilterRef& filter, + CompareVariant cmpVar, + unsigned int fileTimeTolerance, + const std::vector& ignoreTimeShiftMinutes) : + ContainerObject(*this), //trust that ContainerObject knows that *this is not yet fully constructed! + filter_(filter), cmpVar_(cmpVar), fileTimeTolerance_(fileTimeTolerance), ignoreTimeShiftMinutes_(ignoreTimeShiftMinutes), + folderStatusLeft_ (folderStatusLeft), + folderStatusRight_(folderStatusRight), + folderPathLeft_(folderPathLeft), + folderPathRight_(folderPathRight) {} + + template BaseFolderStatus getFolderStatus() const; //base folder status at the time of comparison! + template void setFolderStatus(BaseFolderStatus value); //update after creating the directory in FFS + + //get settings which were used while creating BaseFolderPair: + const PathFilter& getFilter() const { return filter_.ref(); } + CompareVariant getCompVariant() const { return cmpVar_; } + unsigned int getFileTimeTolerance() const { return fileTimeTolerance_; } + const std::vector& getIgnoredTimeShift() const { return ignoreTimeShiftMinutes_; } + + void flip() override; + +private: + AbstractPath getAbstractPathL() const override { return folderPathLeft_; } + AbstractPath getAbstractPathR() const override { return folderPathRight_; } + + const FilterRef filter_; //filter used while scanning directory: represents sub-view of actual files! + const CompareVariant cmpVar_; + const unsigned int fileTimeTolerance_; + const std::vector ignoreTimeShiftMinutes_; + + BaseFolderStatus folderStatusLeft_; + BaseFolderStatus folderStatusRight_; + + AbstractPath folderPathLeft_; + AbstractPath folderPathRight_; +}; + + +using FolderComparison = std::vector>; //make sure pointers to sub-elements remain valid +//don't change this back to std::vector inconsiderately: comparison uses push_back to add entries which may result in a full copy! + +zen::Range> inline asRange( FolderComparison& vect) { return {vect.begin(), vect.end()}; } +zen::Range> inline asRange(const FolderComparison& vect) { return {vect.begin(), vect.end()}; } + +//------------------------------------------------------------------ +struct FSObjectVisitor +{ + virtual ~FSObjectVisitor() {} + virtual void visit(const FilePair& file ) = 0; + virtual void visit(const SymlinkPair& symlink) = 0; + virtual void visit(const FolderPair& folder ) = 0; +}; + +//------------------------------------------------------------------ + +class FileSystemObject : public std::enable_shared_from_this, public virtual PathInformation +{ +public: + virtual void accept(FSObjectVisitor& visitor) const = 0; + + bool isPairEmpty() const; //true, if both sides are empty + template bool isEmpty() const; + + //path getters always return valid values, even if isEmpty()! + template Zstring getItemName() const; //case sensitive! + + bool hasEquivalentItemNames() const; //*quick* check if left/right names are equivalent when ignoring Unicode normalization forms + + //for use during compare() only: + virtual void setCategoryConflict(const Zstringc& description) = 0; + + //comparison result + virtual CompareFileResult getCategory() const = 0; + virtual Zstringc getCategoryCustomDescription() const = 0; //optional + + //sync settings + void setSyncDir(SyncDirection newDir); + void setSyncDirConflict(const Zstringc& description); //set syncDir = SyncDirection::none + fill conflict description + + bool isActive() const { return selectedForSync_; } + void setActive(bool active); + + //sync operation + virtual SyncOperation testSyncOperation(SyncDirection testSyncDir) const; //"what if" semantics! assumes "active, no conflict, no recursion (directory)! + virtual SyncOperation getSyncOperation() const; + std::wstring getSyncOpConflict() const; //return conflict when determining sync direction or (still unresolved) conflict during categorization + + const ContainerObject& parent() const { return parent_; } + /**/ ContainerObject& parent() { return parent_; } + const BaseFolderPair& base() const { return parent_.getBase(); } + /**/ BaseFolderPair& base() { return parent_.getBase(); } + + bool passFileFilter(const PathFilter& filter) const; //optimized for perf! + + virtual void flip(); + + template + void setItemName(const Zstring& itemName); + +protected: + FileSystemObject(const Zstring& itemNameL, + const Zstring& itemNameR, + ContainerObject& parentObj) : + itemNameL_(itemNameL), + itemNameR_(itemNameL == itemNameR ? itemNameL : itemNameR), //perf: no measurable speed drawback; -3% peak memory => further needed by ContainerObject construction! + parent_(parentObj) + { + assert(itemNameL_.c_str() == itemNameR_.c_str() || itemNameL_ != itemNameR_); //also checks ref-counted string precondition + FileSystemObject::notifySyncCfgChanged(); //non-virtual call! (=> anyway in a constructor!) + } + + virtual ~FileSystemObject() //don't need polymorphic deletion, but we have a vtable anyway + { assert(itemNameL_.c_str() == itemNameR_.c_str() || itemNameL_ != itemNameR_); } + + virtual void notifySyncCfgChanged() + { + if (auto fsParent = dynamic_cast(&parent_)) + fsParent->notifySyncCfgChanged(); //propagate! + } + + template void removeFsObject(); + +private: + FileSystemObject (const FileSystemObject&) = delete; + FileSystemObject& operator=(const FileSystemObject&) = delete; + + AbstractPath getAbstractPathL() const override { return AFS::appendRelPath(base().getAbstractPath(), getRelativePath()); } + AbstractPath getAbstractPathR() const override { return AFS::appendRelPath(base().getAbstractPath(), getRelativePath()); } + + template + void propagateChangedItemName(); //required after any itemName changes + + bool selectedForSync_ = true; + + SyncDirection syncDir_ = SyncDirection::none; + Zstringc syncDirectionConflict_; //non-empty if we have a conflict setting sync-direction + //conserve memory (avoid std::string SSO overhead + allow ref-counting!) + + Zstring itemNameL_; //use as indicator: empty means "not existing on this side" + Zstring itemNameR_; //class invariant: same Zstring.c_str() pointer iff equal! + + ContainerObject& parent_; +}; + +//------------------------------------------------------------------ + + +class FolderPair : public FileSystemObject, public ContainerObject +{ +public: + void accept(FSObjectVisitor& visitor) const override; + + CompareFileResult getCategory() const override; + CompareDirResult getDirCategory() const { return static_cast(getCategory()); } + + FolderPair(const Zstring& itemNameL, const FolderAttributes& attrL, //use empty itemName if "not existing" + const Zstring& itemNameR, const FolderAttributes& attrR, + ContainerObject& parentObj) : + FileSystemObject(itemNameL, itemNameR, parentObj), + ContainerObject(static_cast(*this)), //FileSystemObject fully constructed at this point! + attrL_(attrL), + attrR_(attrR) {} + + template bool isFollowedSymlink() const; + + SyncOperation getSyncOperation() const override; + + template + void setSyncedTo(bool isSymlinkTrg, bool isSymlinkSrc); //call after successful sync + + bool passDirFilter(const PathFilter& filter, bool* childItemMightMatch) const; //optimized for perf! + + void flip() override; + + void setCategoryConflict(const Zstringc& description) override; + Zstringc getCategoryCustomDescription() const override; //optional + + template void removeItem(); + +private: + void notifySyncCfgChanged() override { syncOpBuffered_ = {}; FileSystemObject::notifySyncCfgChanged(); } + + mutable std::optional syncOpBuffered_; //determining sync-op for directory may be expensive as it depends on child-objects => buffer + + FolderAttributes attrL_; + FolderAttributes attrR_; + + Zstringc categoryConflict_; //conserve memory (avoid std::string SSO overhead + allow ref-counting! +}; + + +//------------------------------------------------------------------ + +class FilePair : public FileSystemObject +{ +public: + void accept(FSObjectVisitor& visitor) const override; + + FilePair(const Zstring& itemNameL, //use empty string if "not existing" + const FileAttributes& attrL, + const Zstring& itemNameR, // + const FileAttributes& attrR, + ContainerObject& parentObj) : + FileSystemObject(itemNameL, itemNameR, parentObj), + attrL_(attrL), + attrR_(attrR) {} + + CompareFileResult getCategory() const override; + + template time_t getLastWriteTime() const; + template uint64_t getFileSize() const; + template bool isFollowedSymlink() const; + template FileAttributes getAttributes() const; + template AFS::FingerPrint getFilePrint() const; + template void clearFilePrint(); + + + void setMovePair(FilePair* ref); //reference to corresponding moved/renamed file + FilePair* getMovePair() const; //may be nullptr + + SyncOperation testSyncOperation(SyncDirection testSyncDir) const override; //semantics: "what if"! assumes "active, no conflict, no recursion (directory)! + SyncOperation getSyncOperation() const override; + + template + void setSyncedTo(uint64_t fileSize, + time_t lastWriteTimeTrg, + time_t lastWriteTimeSrc, + AFS::FingerPrint filePrintTrg, + AFS::FingerPrint filePrintSrc, + bool isSymlinkTrg, + bool isSymlinkSrc); //call after successful sync + + void flip() override; + + void setCategoryConflict(const Zstringc& description) override; + void setCategoryInvalidTime(const Zstringc& description); + Zstringc getCategoryCustomDescription() const override; //optional + + void setContentCategory(FileContentCategory category); + FileContentCategory getContentCategory() const; + + template void removeItem(); + +private: + Zstring getRelativePathL() const override { return appendPath(parent().getRelativePath(), getItemName()); } + Zstring getRelativePathR() const override { return appendPath(parent().getRelativePath(), getItemName()); } + + SyncOperation applyMoveOptimization(SyncOperation op) const; + + FileAttributes attrL_; + FileAttributes attrR_; + + std::weak_ptr moveFileRef_; //optional, filled by DetectMovedFiles::findAndSetMovePair() + + FileContentCategory contentCategory_ = FileContentCategory::unknown; + Zstringc categoryDescr_; //optional: custom category description (e.g. FileContentCategory::conflict or invalidTime) +}; + +//------------------------------------------------------------------ + +class SymlinkPair : public FileSystemObject //models an unresolved symbolic link: followed-links should go in FilePair/FolderPair +{ +public: + void accept(FSObjectVisitor& visitor) const override; + + SymlinkPair(const Zstring& itemNameL, //use empty string if "not existing" + const LinkAttributes& attrL, + const Zstring& itemNameR, //use empty string if "not existing" + const LinkAttributes& attrR, + ContainerObject& parentObj) : + FileSystemObject(itemNameL, itemNameR, parentObj), + attrL_(attrL), + attrR_(attrR) {} + + CompareFileResult getCategory() const override; + CompareSymlinkResult getLinkCategory() const { return static_cast(getCategory()); } + + template time_t getLastWriteTime() const; //write time of the link, NOT target! + + template + void setSyncedTo(time_t lastWriteTimeTrg, + time_t lastWriteTimeSrc); //call after successful sync + + void flip() override; + + void setCategoryConflict(const Zstringc& description) override; + void setCategoryInvalidTime(const Zstringc& description); + Zstringc getCategoryCustomDescription() const override; //optional + + void setContentCategory(FileContentCategory category); + FileContentCategory getContentCategory() const; + + template void removeItem(); + +private: + Zstring getRelativePathL() const override { return appendPath(parent().getRelativePath(), getItemName()); } + Zstring getRelativePathR() const override { return appendPath(parent().getRelativePath(), getItemName()); } + + LinkAttributes attrL_; + LinkAttributes attrR_; + + FileContentCategory contentCategory_ = FileContentCategory::unknown; + Zstringc categoryDescr_; //optional: custom category description (e.g. FileContentCategory::conflict or invalidTime) +}; + +//------------------------------------------------------------------ + +//generic descriptions (usecase CSV legend, sync config) +std::wstring getCategoryDescription(CompareFileResult cmpRes); +std::wstring getSyncOpDescription(SyncOperation op); + +//item-specific descriptions +std::wstring getCategoryDescription(const FileSystemObject& fsObj); +std::wstring getSyncOpDescription(const FileSystemObject& fsObj); + +//------------------------------------------------------------------ + +namespace impl +{ +template +struct FSObjectLambdaVisitor : public FSObjectVisitor +{ + FSObjectLambdaVisitor(Function1 onFolder, + Function2 onFile, + Function3 onSymlink) : //unifying assignment + onFolder_(std::move(onFolder)), onFile_(std::move(onFile)), onSymlink_(std::move(onSymlink)) {} +private: + void visit(const FolderPair& folder ) override { onFolder_ (folder); } + void visit(const FilePair& file ) override { onFile_ (file); } + void visit(const SymlinkPair& symlink) override { onSymlink_(symlink); } + + const Function1 onFolder_; + const Function2 onFile_; + const Function3 onSymlink_; +}; +} + +template inline +void visitFSObject(const FileSystemObject& fsObj, Function1 onFolder, Function2 onFile, Function3 onSymlink) +{ + impl::FSObjectLambdaVisitor visitor(onFolder, onFile, onSymlink); + fsObj.accept(visitor); +} + + +template inline +void visitFSObject(FileSystemObject& fsObj, Function1 onFolder, Function2 onFile, Function3 onSymlink) +{ + visitFSObject(static_cast(fsObj), + [onFolder ](const FolderPair& folder) { onFolder (const_cast(folder)); }, // + [onFile ](const FilePair& file) { onFile (const_cast(file )); }, //physical object is not const anyway + [onSymlink](const SymlinkPair& symlink) { onSymlink(const_cast(symlink)); }); // +} + +//------------------------------------------------------------------ + +namespace impl +{ +template +class RecursiveObjectVisitor +{ +public: + RecursiveObjectVisitor(Function1 onFolder, + Function2 onFile, + Function3 onSymlink) : //unifying assignment + onFolder_ (std::move(onFolder)), + onFile_ (std::move(onFile)), + onSymlink_(std::move(onSymlink)) {} + + void execute(ContainerObject& conObj) + { + for (FilePair& file : conObj.files()) + onFile_(file); + for (SymlinkPair& symlink : conObj.symlinks()) + onSymlink_(symlink); + for (FolderPair& subFolder : conObj.subfolders()) + { + onFolder_(subFolder); + execute(subFolder); + } + } + +private: + RecursiveObjectVisitor (const RecursiveObjectVisitor&) = delete; + RecursiveObjectVisitor& operator=(const RecursiveObjectVisitor&) = delete; + + const Function1 onFolder_; + const Function2 onFile_; + const Function3 onSymlink_; +}; +} + +template inline +void visitFSObjectRecursively(ContainerObject& conObj, //consider contained items only + Function1 onFolder, + Function2 onFile, + Function3 onSymlink) +{ + impl::RecursiveObjectVisitor(onFolder, onFile, onSymlink).execute(conObj); +} + +template inline +void visitFSObjectRecursively(FileSystemObject& fsObj, //consider item and contained items (if folder) + Function1 onFolder, + Function2 onFile, + Function3 onSymlink) +{ + visitFSObject(fsObj, [onFolder, onFile, onSymlink](FolderPair& folder) + { + onFolder(folder); + impl::RecursiveObjectVisitor(onFolder, onFile, onSymlink).execute(const_cast(folder)); + }, onFile, onSymlink); +} + + + + + + + + + + + + + + + + + + +//--------------------- implementation ------------------------------------------ + +//inline virtual... admittedly its use may be limited +inline void FolderPair ::accept(FSObjectVisitor& visitor) const { visitor.visit(*this); } +inline void FilePair ::accept(FSObjectVisitor& visitor) const { visitor.visit(*this); } +inline void SymlinkPair::accept(FSObjectVisitor& visitor) const { visitor.visit(*this); } + + +inline +void FileSystemObject::setSyncDir(SyncDirection newDir) +{ + syncDir_ = newDir; + syncDirectionConflict_.clear(); + + notifySyncCfgChanged(); +} + + +inline +void FileSystemObject::setSyncDirConflict(const Zstringc& description) +{ + assert(!description.empty()); + syncDir_ = SyncDirection::none; + syncDirectionConflict_ = description; + + notifySyncCfgChanged(); +} + + +inline +std::wstring FileSystemObject::getSyncOpConflict() const +{ + assert(getSyncOperation() == SO_UNRESOLVED_CONFLICT); + return zen::utfTo(syncDirectionConflict_); +} + + +inline +void FileSystemObject::setActive(bool active) +{ + selectedForSync_ = active; + notifySyncCfgChanged(); +} + + +template inline +bool FileSystemObject::isEmpty() const +{ + return selectParam(itemNameL_, itemNameR_).empty(); +} + + +inline +bool FileSystemObject::isPairEmpty() const +{ + return isEmpty() && isEmpty(); +} + + +template inline +Zstring FileSystemObject::getItemName() const +{ + //assert(!itemNameL_.empty() || !itemNameR_.empty()); //-> file pair might be temporarily empty (until removed after sync) + + const Zstring& itemName = selectParam(itemNameL_, itemNameR_); //empty if not existing + if (!itemName.empty()) //avoid ternary-WTF! (implicit copy-constructor call!!!!!!) + return itemName; + return selectParam>(itemNameL_, itemNameR_); +} + + +inline +bool FileSystemObject::hasEquivalentItemNames() const +{ + if (itemNameL_.c_str() == itemNameR_.c_str() || //most likely case + itemNameL_.empty() || itemNameR_.empty()) // + return true; + + assert(itemNameL_ != itemNameR_); //class invariant + return getUnicodeNormalForm(itemNameL_) == getUnicodeNormalForm(itemNameR_); +} + + +template inline +void FileSystemObject::removeFsObject() +{ + if (isEmpty>()) + { + selectParam(itemNameL_, itemNameR_) = selectParam>(itemNameL_, itemNameR_); //ensure (c_str) class invariant! + setSyncDir(SyncDirection::none); //calls notifySyncCfgChanged() + } + else + { + selectParam(itemNameL_, itemNameR_).clear(); + //keep current syncDir_ + notifySyncCfgChanged(); //needed!? + } + + propagateChangedItemName(); +} + + +template inline +void FilePair::removeItem() +{ + if (isEmpty>()) + setMovePair(nullptr); //cut ties between "move" pairs + + selectParam(attrL_, attrR_) = FileAttributes(); + contentCategory_ = FileContentCategory::unknown; + removeFsObject(); +} + + +template inline +void SymlinkPair::removeItem() +{ + selectParam(attrL_, attrR_) = LinkAttributes(); + contentCategory_ = FileContentCategory::unknown; + removeFsObject(); +} + + +template inline +void FolderPair::removeItem() +{ + for (FilePair& file : files()) + file.removeItem(); + for (SymlinkPair& symlink : symlinks()) + symlink.removeItem(); + for (FolderPair& folder : subfolders()) + folder.removeItem(); + + selectParam(attrL_, attrR_) = FolderAttributes(); + removeFsObject(); +} + + +template inline +void FileSystemObject::setItemName(const Zstring& itemName) +{ + assert(!itemName.empty()); + assert(!isPairEmpty()); + + selectParam(itemNameL_, itemNameR_) = itemName; + + if (itemNameL_.c_str() != itemNameR_.c_str() && + itemNameL_ == itemNameR_) + itemNameL_ = itemNameR_; //preserve class invariant + assert(itemNameL_.c_str() == itemNameR_.c_str() || itemNameL_ != itemNameR_); + + propagateChangedItemName(); +} + + +template inline +void FileSystemObject::propagateChangedItemName() +{ + if (itemNameL_.empty() && itemNameR_.empty()) return; //both sides might just have been deleted by removeItem<> + + if (auto conObj = dynamic_cast(this)) + { + const Zstring& itemNameOld = zen::getItemName(conObj->getRelativePath()); + if (itemNameOld != getItemName()) //perf: premature optimization? + conObj->updateRelPathsRecursion(*this); + } +} + + +template inline +void ContainerObject::updateRelPathsRecursion(const FileSystemObject& fsAlias) +{ + //perf: only call if actual item name changed: + assert(selectParam(relPathL_, relPathR_) != appendPath(fsAlias.parent().getRelativePath(), fsAlias.getItemName())); + + constexpr SelectSide otherSide = getOtherSide; + + if (fsAlias.isEmpty()) //=> 1. other side's relPath also needs updating! 2. both sides have same name + selectParam(relPathL_, relPathR_) = appendPath(selectParam(fsAlias.parent().relPathL_, + fsAlias.parent().relPathR_), fsAlias.getItemName()); + else //assume relPath on other side is up to date! + assert(selectParam(relPathL_, relPathR_) == appendPath(fsAlias.parent().getRelativePath(), fsAlias.getItemName())); + + if (fsAlias.parent().relPathL_.c_str() == // + fsAlias.parent().relPathR_.c_str() && //see ContainerObject constructor and setItemName() + fsAlias.getItemName().c_str() == // + fsAlias.getItemName().c_str()) // + selectParam(relPathL_, relPathR_) = selectParam(relPathL_, relPathR_); + else + selectParam(relPathL_, relPathR_) = appendPath(selectParam(fsAlias.parent().relPathL_, + fsAlias.parent().relPathR_), fsAlias.getItemName()); + assert(relPathL_.c_str() == relPathR_.c_str() || relPathL_ != relPathR_); + + for (FolderPair& folder : subfolders()) + folder.updateRelPathsRecursion(folder); +} + + +inline +ContainerObject::ContainerObject(const FileSystemObject& fsAlias) : + relPathL_(appendPath(fsAlias.parent().relPathL_, fsAlias.getItemName())), + relPathR_(fsAlias.parent().relPathL_.c_str() == // + fsAlias.parent().relPathR_.c_str() && //take advantage of FileSystemObject's Zstring reuse: + fsAlias.getItemName().c_str() == //=> perf: 12% faster merge phase; -4% peak memory + fsAlias.getItemName().c_str() ? // + relPathL_ : //ternary-WTF! (implicit copy-constructor call!!) => no big deal for a Zstring + appendPath(fsAlias.parent().relPathR_, fsAlias.getItemName())), + base_(fsAlias.parent().base_) +{ + assert(relPathL_.c_str() == relPathR_.c_str() || relPathL_ != relPathR_); +} + + +inline +FolderPair& ContainerObject::addFolder(const Zstring& itemNameL, const FolderAttributes& attribL, + const Zstring& itemNameR, const FolderAttributes& attribR) +{ + subfolders_.push_back(makeSharedRef(itemNameL, attribL, itemNameR, attribR, *this)); + return subfolders_.back().ref(); +} + + +template <> inline +FolderPair& ContainerObject::addFolder(const Zstring& itemName, const FolderAttributes& attr) +{ + return addFolder(itemName, attr, Zstring(), FolderAttributes()); +} + + +template <> inline +FolderPair& ContainerObject::addFolder(const Zstring& itemName, const FolderAttributes& attr) +{ + return addFolder(Zstring(), FolderAttributes(), itemName, attr); +} + + +inline +FilePair& ContainerObject::addFile(const Zstring& itemNameL, const FileAttributes& attribL, + const Zstring& itemNameR, const FileAttributes& attribR) +{ + files_.push_back(makeSharedRef(itemNameL, attribL, itemNameR, attribR, *this)); + return files_.back().ref(); +} + + +template <> inline +FilePair& ContainerObject::addFile(const Zstring& itemName, const FileAttributes& attr) +{ + return addFile(itemName, attr, Zstring(), FileAttributes()); +} + + +template <> inline +FilePair& ContainerObject::addFile(const Zstring& itemName, const FileAttributes& attr) +{ + return addFile(Zstring(), FileAttributes(), itemName, attr); +} + + +inline +SymlinkPair& ContainerObject::addSymlink(const Zstring& itemNameL, const LinkAttributes& attribL, + const Zstring& itemNameR, const LinkAttributes& attribR) +{ + symlinks_.push_back(makeSharedRef(itemNameL, attribL, itemNameR, attribR, *this)); + return symlinks_.back().ref(); +} + + +template <> inline +SymlinkPair& ContainerObject::addSymlink(const Zstring& itemName, const LinkAttributes& attr) +{ + return addSymlink(itemName, attr, Zstring(), LinkAttributes()); +} + + +template <> inline +SymlinkPair& ContainerObject::addSymlink(const Zstring& itemName, const LinkAttributes& attr) +{ + return addSymlink(Zstring(), LinkAttributes(), itemName, attr); +} + + +inline +void FileSystemObject::flip() +{ + std::swap(itemNameL_, itemNameR_); + notifySyncCfgChanged(); +} + + +inline +void ContainerObject::flip() +{ + for (FilePair& file : files()) + file.flip(); + for (SymlinkPair& symlink : symlinks()) + symlink.flip(); + for (FolderPair& folder : subfolders()) + folder.flip(); + + std::swap(relPathL_, relPathR_); +} + + +inline +void BaseFolderPair::flip() +{ + ContainerObject::flip(); + std::swap(folderStatusLeft_, folderStatusRight_); + std::swap(folderPathLeft_, folderPathRight_); +} + + +inline +void FolderPair::flip() //this overrides both ContainerObject/FileSystemObject::flip! +{ + ContainerObject ::flip(); //call base class versions + FileSystemObject::flip(); // + std::swap(attrL_, attrR_); +} + + +inline +void FilePair::flip() +{ + FileSystemObject::flip(); //call base class version + std::swap(attrL_, attrR_); + + switch (contentCategory_) + { + case FileContentCategory::unknown: + case FileContentCategory::equal: + case FileContentCategory::invalidTime: + case FileContentCategory::different: + case FileContentCategory::conflict: break; + case FileContentCategory::leftNewer: contentCategory_ = FileContentCategory::rightNewer; break; + case FileContentCategory::rightNewer: contentCategory_ = FileContentCategory::leftNewer; break; + } +} + + +inline +void SymlinkPair::flip() +{ + FileSystemObject::flip(); //call base class versions + std::swap(attrL_, attrR_); + + switch (contentCategory_) + { + case FileContentCategory::unknown: + case FileContentCategory::equal: + case FileContentCategory::invalidTime: + case FileContentCategory::different: + case FileContentCategory::conflict: break; + case FileContentCategory::leftNewer: contentCategory_ = FileContentCategory::rightNewer; break; + case FileContentCategory::rightNewer: contentCategory_ = FileContentCategory::leftNewer; break; + } +} + + +template inline +BaseFolderStatus BaseFolderPair::getFolderStatus() const +{ + return selectParam(folderStatusLeft_, folderStatusRight_); +} + + +template inline +void BaseFolderPair::setFolderStatus(BaseFolderStatus value) +{ + selectParam(folderStatusLeft_, folderStatusRight_) = value; +} + + +inline +void FolderPair::setCategoryConflict(const Zstringc& description) +{ + assert(!description.empty()); + categoryConflict_ = description; +} + + +inline +void FilePair::setCategoryConflict(const Zstringc& description) +{ + assert(!description.empty()); + categoryDescr_ = description; + contentCategory_ = FileContentCategory::conflict; +} + + +inline +void SymlinkPair::setCategoryConflict(const Zstringc& description) +{ + assert(!description.empty()); + categoryDescr_ = description; + contentCategory_ = FileContentCategory::conflict; +} + + +inline +void FilePair::setCategoryInvalidTime(const Zstringc& description) +{ + assert(!description.empty()); + categoryDescr_ = description; + contentCategory_ = FileContentCategory::invalidTime; +} + + +inline +void SymlinkPair::setCategoryInvalidTime(const Zstringc& description) +{ + assert(!description.empty()); + categoryDescr_ = description; + contentCategory_ = FileContentCategory::invalidTime; +} + + +inline Zstringc FolderPair ::getCategoryCustomDescription() const { return categoryConflict_; } +inline Zstringc FilePair ::getCategoryCustomDescription() const { return categoryDescr_; } +inline Zstringc SymlinkPair::getCategoryCustomDescription() const { return categoryDescr_; } + + +inline +void FilePair::setContentCategory(FileContentCategory category) +{ + assert(!isEmpty() &&!isEmpty()); + assert(category != FileContentCategory::unknown); + contentCategory_ = category; +} + + +inline +void SymlinkPair::setContentCategory(FileContentCategory category) +{ + assert(!isEmpty() &&!isEmpty()); + assert(category != FileContentCategory::unknown); + contentCategory_ = category; +} + + +inline +FileContentCategory FilePair::getContentCategory() const +{ + assert(!isEmpty() &&!isEmpty()); + return contentCategory_; +} + + +inline +FileContentCategory SymlinkPair::getContentCategory() const +{ + assert(!isEmpty() &&!isEmpty()); + return contentCategory_; +} + + +inline +CompareFileResult FolderPair::getCategory() const +{ + if (!categoryConflict_.empty()) + return FILE_CONFLICT; + + if (isEmpty()) + { + if (isEmpty()) + return FILE_EQUAL; + else + return FILE_RIGHT_ONLY; + } + else + { + if (isEmpty()) + return FILE_LEFT_ONLY; + else + return hasEquivalentItemNames() ? FILE_EQUAL : FILE_RENAMED; + } +} + + +inline +CompareFileResult FilePair::getCategory() const +{ + assert(contentCategory_ == FileContentCategory::conflict || + (isEmpty() || isEmpty()) == (contentCategory_ == FileContentCategory::unknown)); + assert(contentCategory_ != FileContentCategory::conflict || !categoryDescr_.empty()); + + if (contentCategory_ == FileContentCategory::conflict) + { + assert(!categoryDescr_.empty()); + return FILE_CONFLICT; + } + + if (isEmpty()) + { + if (isEmpty()) + return FILE_EQUAL; + else + return FILE_RIGHT_ONLY; + } + else + { + if (isEmpty()) + return FILE_LEFT_ONLY; + else + //Caveat: + //1. FILE_EQUAL may only be set if names match in case: InSyncFolder's mapping tables use file name as a key! see db_file.cpp + //2. harmonize with "bool stillInSync()" in algorithm.cpp, FilePair::setSyncedTo() in file_hierarchy.h + //3. FILE_EQUAL is expected to mean identical file sizes! See InSyncFile + switch (contentCategory_) + { + case FileContentCategory::unknown: + case FileContentCategory::conflict: assert(false); return FILE_CONFLICT; + case FileContentCategory::equal: return hasEquivalentItemNames() ? FILE_EQUAL : FILE_RENAMED; + case FileContentCategory::leftNewer: return FILE_LEFT_NEWER; + case FileContentCategory::rightNewer: return FILE_RIGHT_NEWER; + case FileContentCategory::invalidTime: return FILE_TIME_INVALID; + case FileContentCategory::different: return FILE_DIFFERENT_CONTENT; + } + } + throw std::logic_error(std::string(__FILE__) + '[' + zen::numberTo(__LINE__) + "] Contract violation!"); +} + + +inline +CompareFileResult SymlinkPair::getCategory() const +{ + assert(contentCategory_ == FileContentCategory::conflict || + (isEmpty() || isEmpty()) == (contentCategory_ == FileContentCategory::unknown)); + assert(contentCategory_ != FileContentCategory::conflict || !categoryDescr_.empty()); + + if (contentCategory_ == FileContentCategory::conflict) + { + assert(!categoryDescr_.empty()); + return FILE_CONFLICT; + } + + if (isEmpty()) + { + if (isEmpty()) + return FILE_EQUAL; + else + return FILE_RIGHT_ONLY; + } + else + { + if (isEmpty()) + return FILE_LEFT_ONLY; + else + //Caveat: + //1. SYMLINK_EQUAL may only be set if names match in case: InSyncFolder's mapping tables use link name as a key! see db_file.cpp + //2. harmonize with "bool stillInSync()" in algorithm.cpp, FilePair::setSyncedTo() in file_hierarchy.h + switch (contentCategory_) + { + case FileContentCategory::unknown: + case FileContentCategory::conflict: assert(false); return FILE_CONFLICT; + case FileContentCategory::equal: return hasEquivalentItemNames() ? FILE_EQUAL : FILE_RENAMED; + case FileContentCategory::leftNewer: return FILE_LEFT_NEWER; + case FileContentCategory::rightNewer: return FILE_RIGHT_NEWER; + case FileContentCategory::invalidTime: return FILE_TIME_INVALID; + case FileContentCategory::different: return FILE_DIFFERENT_CONTENT; + } + } + throw std::logic_error(std::string(__FILE__) + '[' + zen::numberTo(__LINE__) + "] Contract violation!"); +} + + +template inline +FileAttributes FilePair::getAttributes() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_); +} + + +template inline +time_t FilePair::getLastWriteTime() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_).modTime; +} + + +template inline +uint64_t FilePair::getFileSize() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_).fileSize; +} + + +template inline +bool FolderPair::isFollowedSymlink() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_).isFollowedSymlink; +} + + +template inline +bool FilePair::isFollowedSymlink() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_).isFollowedSymlink; +} + + +template inline +AFS::FingerPrint FilePair::getFilePrint() const +{ + assert(!isEmpty()); + return selectParam(attrL_, attrR_).filePrint; +} + + +template inline +void FilePair::clearFilePrint() +{ + selectParam(attrL_, attrR_).filePrint = 0; +} + + +inline +void FilePair::setMovePair(FilePair* ref) +{ + FilePair* refOld = getMovePair(); + if (ref != refOld) + { + if (refOld) + refOld->moveFileRef_.reset(); + + if (ref) + { + FilePair* refOld2 = ref->getMovePair(); + assert(!refOld2); //destroying already exising pair!? why? + if (refOld2) + refOld2 ->moveFileRef_.reset(); + + /**/ moveFileRef_ = std::static_pointer_cast(ref->shared_from_this()); + ref->moveFileRef_ = std::static_pointer_cast( shared_from_this()); + } + else + moveFileRef_.reset(); + } + else + assert(!ref); //are we called needlessly!? +} + + +inline +FilePair* FilePair::getMovePair() const +{ + if (moveFileRef_.expired()) //skip std::shared_ptr construction => premature optimization? + return nullptr; + + FilePair* ref = moveFileRef_.lock().get(); + assert(!ref || (isEmpty() != isEmpty())); + assert(!ref || ref->moveFileRef_.lock().get() == this); //both ends should agree + return ref; +} + + +template inline +void FolderPair::setSyncedTo(bool isSymlinkTrg, + bool isSymlinkSrc) +{ + selectParam< sideTrg >(attrL_, attrR_) = {.isFollowedSymlink = isSymlinkTrg}; + selectParam>(attrL_, attrR_) = {.isFollowedSymlink = isSymlinkSrc}; + + setItemName(getItemName>()); + + categoryConflict_.clear(); + setSyncDir(SyncDirection::none); +} + + +template inline +void FilePair::setSyncedTo(uint64_t fileSize, + time_t lastWriteTimeTrg, + time_t lastWriteTimeSrc, + AFS::FingerPrint filePrintTrg, + AFS::FingerPrint filePrintSrc, + bool isSymlinkTrg, + bool isSymlinkSrc) +{ + setMovePair(nullptr); //cut ties between "move" pairs + + selectParam< sideTrg >(attrL_, attrR_) = {lastWriteTimeTrg, fileSize, filePrintTrg, isSymlinkTrg}; + selectParam>(attrL_, attrR_) = {lastWriteTimeSrc, fileSize, filePrintSrc, isSymlinkSrc}; + + setItemName(getItemName>()); + + contentCategory_ = FileContentCategory::equal; + categoryDescr_.clear(); + setSyncDir(SyncDirection::none); +} + + +template inline +void SymlinkPair::setSyncedTo(time_t lastWriteTimeTrg, + time_t lastWriteTimeSrc) +{ + selectParam< sideTrg >(attrL_, attrR_) = {.modTime = lastWriteTimeTrg}; + selectParam>(attrL_, attrR_) = {.modTime = lastWriteTimeSrc}; + + setItemName(getItemName>()); + + contentCategory_ = FileContentCategory::equal; + categoryDescr_.clear(); + setSyncDir(SyncDirection::none); +} + + +inline +bool FolderPair::passDirFilter(const PathFilter& filter, bool* childItemMightMatch) const +{ + const Zstring& relPathL = getRelativePath(); + const Zstring& relPathR = getRelativePath(); + assert(relPathL.c_str() == relPathR.c_str() || relPathL!= relPathR); + + if (filter.passDirFilter(relPathL, childItemMightMatch)) + return relPathL.c_str() == relPathR.c_str() /*perf!*/ || equalNoCase(relPathL, relPathR) || + filter.passDirFilter(relPathR, childItemMightMatch); + else + { + if (childItemMightMatch && *childItemMightMatch && + relPathL.c_str() != relPathR.c_str() /*perf!*/ && !equalNoCase(relPathL, relPathR)) + filter.passDirFilter(relPathR, childItemMightMatch); + return false; + } +} + + +inline +bool FileSystemObject::passFileFilter(const PathFilter& filter) const +{ + assert(!dynamic_cast(this)); + assert(parent().getRelativePath().c_str() == + parent().getRelativePath().c_str() || + parent().getRelativePath()!= + parent().getRelativePath()); + assert(getItemName().c_str() == + getItemName().c_str() || + getItemName() != + getItemName()); + + const Zstring relPathL = getRelativePath(); + + if (!filter.passFileFilter(relPathL)) + return false; + + if (parent().getRelativePath().c_str() == // + parent().getRelativePath().c_str() && //perf! see ContainerObject constructor + getItemName().c_str() == // + getItemName().c_str()) // + return true; + + const Zstring relPathR = getRelativePath(); + + if (equalNoCase(relPathL, relPathR)) + return true; + + return filter.passFileFilter(relPathR); +} + + +template inline +time_t SymlinkPair::getLastWriteTime() const +{ + return selectParam(attrL_, attrR_).modTime; +} +} + +#endif //FILE_HIERARCHY_H_257235289645296 diff --git a/FreeFileSync/Source/base/icon_loader.cpp b/FreeFileSync/Source/base/icon_loader.cpp new file mode 100644 index 0000000..ee94da0 --- /dev/null +++ b/FreeFileSync/Source/base/icon_loader.cpp @@ -0,0 +1,315 @@ +// ***************************************************************************** +// * 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 "icon_loader.h" +#include //includes + + #include + #include + #include + #include + #include + + +using namespace zen; +using namespace fff; + + +namespace +{ +ImageHolder copyToImageHolder(const GdkPixbuf& pixBuf, int maxSize) //throw SysError +{ + //see: https://developer.gnome.org/gdk-pixbuf/stable/gdk-pixbuf-The-GdkPixbuf-Structure.html + if (const GdkColorspace cs = ::gdk_pixbuf_get_colorspace(&pixBuf); + cs != GDK_COLORSPACE_RGB) + throw SysError(formatSystemError("gdk_pixbuf_get_colorspace", L"", L"Unexpected color space: " + numberTo(static_cast(cs)))); + + if (const int bitCount = ::gdk_pixbuf_get_bits_per_sample(&pixBuf); + bitCount != 8) + throw SysError(formatSystemError("gdk_pixbuf_get_bits_per_sample", L"", L"Unexpected bits per sample: " + numberTo(bitCount))); + + const int channels = ::gdk_pixbuf_get_n_channels(&pixBuf); + if (channels != 3 && channels != 4) + throw SysError(formatSystemError("gdk_pixbuf_get_n_channels", L"", L"Unexpected number of channels: " + numberTo(channels))); + + assert(::gdk_pixbuf_get_has_alpha(&pixBuf) == (channels == 4)); + + const unsigned char* srcBytes = ::gdk_pixbuf_read_pixels(&pixBuf); + const int srcWidth = ::gdk_pixbuf_get_width (&pixBuf); + const int srcHeight = ::gdk_pixbuf_get_height(&pixBuf); + const int srcStride = ::gdk_pixbuf_get_rowstride(&pixBuf); + + //don't stretch small images, shrink large ones only! + int targetWidth = srcWidth; + int targetHeight = srcHeight; + + const int maxExtent = std::max(targetWidth, targetHeight); + if (maxSize < maxExtent) + { + targetWidth = numeric::intDivRound(targetWidth * maxSize, maxExtent); + targetHeight = numeric::intDivRound(targetHeight * maxSize, maxExtent); + } + ImageHolder imgOut(targetWidth, targetHeight, true /*withAlpha*/); + unsigned char* rgbOut = imgOut.getRgb(); + unsigned char* alphaOut = imgOut.getAlpha(); + + if (srcWidth != targetWidth || + srcHeight != targetHeight) + { + const auto pixRead = [srcBytes, srcStride, channels](int x, int y) + { + const unsigned char* const ptr = srcBytes + y * srcStride + channels * x; //RGB(A) byte order + + const int a = channels == 4 ? ptr[3] : 255; + + return [a, ptr](int channel) + { + if (channel == 3) + return a; + + return ptr[channel] * a; + //Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html + }; + }; + + const auto pixWrite = [rgbOut, alphaOut](const auto& interpolate) mutable + { + const double a = interpolate(3); + if (a <= 0.0) + { + *alphaOut++ = 0; + rgbOut += 3; //don't care about color + } + else + { + *alphaOut++ = xbrz::byteRound(a); + *rgbOut++ = xbrz::byteRound(interpolate(0) / a); //r + *rgbOut++ = xbrz::byteRound(interpolate(1) / a); //g + *rgbOut++ = xbrz::byteRound(interpolate(2) / a); //b + } + }; + xbrz::bilinearScale(pixRead, //PixReader pixRead + srcWidth, //int srcWidth + srcHeight, //int srcHeight + pixWrite, //PixWriter pixWrite + targetWidth, //int trgWidth + targetHeight, //int trgHeight + 0, //int yFirst + targetHeight); //int yLast +#if 0 //alternative: but does it support alpha-channel? + GdkPixbuf* pixBufShrinked = ::gdk_pixbuf_scale_simple(pixBuf, //const GdkPixbuf* src + targetWidth, //int dest_width + targetHeight, //int dest_height + GDK_INTERP_BILINEAR); //GdkInterpType interp_type + if (!pixBufShrinked) + throw SysError(formatSystemError("gdk_pixbuf_scale_simple", L"", L"Not enough memory.")); + ZEN_ON_SCOPE_EXIT(::g_object_unref(pixBufShrinked)); +#endif + } + else //perf: going overboard? + for (int y = 0; y < srcHeight; ++y) + for (int x = 0; x < srcWidth; ++x) + { + const unsigned char* const ptr = srcBytes + y * srcStride + channels * x; //RGB(A) byte order + + *alphaOut++ = channels == 4 ? ptr[3] : 255; + *rgbOut++ = ptr[0]; + *rgbOut++ = ptr[1]; + *rgbOut++ = ptr[2]; + } + + return imgOut; +} + + +ImageHolder imageHolderFromGicon(GIcon& gicon, int maxSize) //throw SysError +{ + assert(runningOnMainThread()); //GTK is NOT thread safe!!! + assert(!G_IS_FILE_ICON(&gicon) && !G_IS_LOADABLE_ICON(&gicon)); //see comment in image_holder.h => icon loading must not block main thread + + GtkIconTheme* const defaultTheme = ::gtk_icon_theme_get_default(); //not owned! + ASSERT_SYSERROR(defaultTheme); //no more error details + + GtkIconInfo* const iconInfo = ::gtk_icon_theme_lookup_by_gicon(defaultTheme, //GtkIconTheme* icon_theme + &gicon, //GIcon* icon + maxSize, //gint size + GTK_ICON_LOOKUP_USE_BUILTIN); //GtkIconLookupFlags flags + if (!iconInfo) + throw SysError(formatSystemError("gtk_icon_theme_lookup_by_gicon", L"", L"Icon not available.")); +#if GTK_MAJOR_VERSION == 2 + ZEN_ON_SCOPE_EXIT(::gtk_icon_info_free(iconInfo)); +#elif GTK_MAJOR_VERSION == 3 + ZEN_ON_SCOPE_EXIT(::g_object_unref(iconInfo)); +#else +#error unknown GTK version! +#endif + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + GdkPixbuf* const pixBuf = ::gtk_icon_info_load_icon(iconInfo, &error); + if (!pixBuf) + throw SysError(formatGlibError("gtk_icon_info_load_icon", error)); + ZEN_ON_SCOPE_EXIT(::g_object_unref(pixBuf)); + + //we may have to shrink (e.g. GTK3, openSUSE): "an icon theme may have icons that differ slightly from their nominal sizes" + return copyToImageHolder(*pixBuf, maxSize); //throw SysError +} +} + + +FileIconHolder fff::getIconByTemplatePath(const Zstring& templatePath, int maxSize) //throw SysError +{ + //uses full file name, e.g. "AUTHORS" has own mime type on Linux: + gchar* const contentType = ::g_content_type_guess(templatePath.c_str(), //const gchar* filename + nullptr, //const guchar* data + 0, //gsize data_size + nullptr); //gboolean* result_uncertain + if (!contentType) + throw SysError(formatSystemError("g_content_type_guess(" + copyStringTo(templatePath) + ')', L"", L"Unknown content type.")); + ZEN_ON_SCOPE_EXIT(::g_free(contentType)); + + GIcon* const fileIcon = ::g_content_type_get_icon(contentType); + if (!fileIcon) + throw SysError(formatSystemError("g_content_type_get_icon(" + std::string(contentType) + ')', L"", L"Icon not available.")); + + return FileIconHolder(fileIcon /*pass ownership*/, maxSize); + +} + + +FileIconHolder fff::genericFileIcon(int maxSize) //throw SysError +{ + //we're called by getDisplayIcon()! -> avoid endless recursion! + GIcon* const fileIcon = ::g_content_type_get_icon("text/plain"); + if (!fileIcon) + throw SysError(formatSystemError("g_content_type_get_icon(text/plain)", L"", L"Icon not available.")); + + return FileIconHolder(fileIcon /*pass ownership*/, maxSize); + +} + + +FileIconHolder fff::genericDirIcon(int maxSize) //throw SysError +{ + GIcon* const dirIcon = ::g_content_type_get_icon("inode/directory"); //should contain fallback to GTK_STOCK_DIRECTORY ("gtk-directory") + if (!dirIcon) + throw SysError(formatSystemError("g_content_type_get_icon(inode/directory)", L"", L"Icon not available.")); + + return FileIconHolder(dirIcon /*pass ownership*/, maxSize); + +} + + +FileIconHolder fff::getTrashIcon(int maxSize) //throw SysError +{ + GIcon* const trashIcon = ::g_themed_icon_new("user-trash-full"); //empty: "user-trash" + if (!trashIcon) + throw SysError(formatSystemError("g_themed_icon_new(user-trash-full)", L"", L"Icon not available.")); + + return FileIconHolder(trashIcon /*pass ownership*/, maxSize); + +} + + +FileIconHolder fff::getFileManagerIcon(int maxSize) //throw SysError +{ + GIcon* const trashIcon = ::g_themed_icon_new("system-file-manager"); + if (!trashIcon) + throw SysError(formatSystemError("g_themed_icon_new(system-file-manager)", L"", L"Icon not available.")); + + return FileIconHolder(trashIcon /*pass ownership*/, maxSize); + +} + + +FileIconHolder fff::getFileIcon(const Zstring& filePath, int maxSize) //throw SysError +{ + GFile* file = ::g_file_new_for_path(filePath.c_str()); //documented to "never fail" + ZEN_ON_SCOPE_EXIT(::g_object_unref(file)); + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + GFileInfo* const fileInfo = ::g_file_query_info(file, G_FILE_ATTRIBUTE_STANDARD_ICON, G_FILE_QUERY_INFO_NONE, nullptr /*cancellable*/, &error); + if (!fileInfo) + throw SysError(formatGlibError("g_file_query_info", error)); + ZEN_ON_SCOPE_EXIT(::g_object_unref(fileInfo)); + + GIcon* const gicon = ::g_file_info_get_icon(fileInfo); //no ownership transfer! + if (!gicon) + throw SysError(formatSystemError("g_file_info_get_icon", L"", L"Icon not available.")); + + //https://github.com/GNOME/gtk/blob/master/gtk/gtkicontheme.c#L4082 + if (G_IS_FILE_ICON(gicon) || G_IS_LOADABLE_ICON(gicon)) //see comment in image_holder.h + throw SysError(L"Icon loading might block main thread."); + //shouldn't be a problem for native file systems -> G_IS_THEMED_ICON(gicon) + + //the remaining icon types won't block! + assert(GDK_IS_PIXBUF(gicon) || G_IS_THEMED_ICON(gicon) || G_IS_EMBLEMED_ICON(gicon)); + + g_object_ref(gicon); /*macro!*/ //pass ownership + return FileIconHolder(gicon, maxSize); // + +} + + +ImageHolder fff::getThumbnailImage(const Zstring& filePath, int maxSize) //throw SysError +{ + struct stat fileInfo = {}; + if (::stat(filePath.c_str(), &fileInfo) != 0) + THROW_LAST_SYS_ERROR("stat"); + + if (!S_ISREG(fileInfo.st_mode)) //skip blocking file types, e.g. named pipes, see file_io.cpp + throw SysError(_("Unsupported item type.") + L" [" + printNumber(L"0%06o", fileInfo.st_mode & S_IFMT) + L']'); + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + GdkPixbuf* const pixBuf = ::gdk_pixbuf_new_from_file(filePath.c_str(), &error); + if (!pixBuf) + throw SysError(formatGlibError("gdk_pixbuf_new_from_file", error)); + ZEN_ON_SCOPE_EXIT(::g_object_unref(pixBuf)); + + return copyToImageHolder(*pixBuf, maxSize); //throw SysError + +} + + +wxImage fff::extractWxImage(ImageHolder&& ih) +{ + assert(runningOnMainThread()); + if (!ih.getRgb()) + return wxNullImage; + + wxImage img(ih.getWidth(), ih.getHeight(), ih.releaseRgb(), false /*static_data*/); //pass ownership + if (ih.getAlpha()) + img.SetAlpha(ih.releaseAlpha(), false /*static_data*/); + else + { + assert(false); + img.SetAlpha(); + ::memset(img.GetAlpha(), wxIMAGE_ALPHA_OPAQUE, ih.getWidth() * ih.getHeight()); + } + return img; +} + + +wxImage fff::extractWxImage(zen::FileIconHolder&& fih) +{ + assert(runningOnMainThread()); + + wxImage img; + if (GIcon* gicon = fih.gicon.get()) + try + { + img = extractWxImage(imageHolderFromGicon(*gicon, fih.maxSize)); //throw SysError + } + catch (SysError&) {} //might fail if icon theme is missing a MIME type! + + fih.gicon.reset(); + return img; + +} diff --git a/FreeFileSync/Source/base/icon_loader.h b/FreeFileSync/Source/base/icon_loader.h new file mode 100644 index 0000000..c3dc06f --- /dev/null +++ b/FreeFileSync/Source/base/icon_loader.h @@ -0,0 +1,34 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ICON_LOADER_H_1348701985713445 +#define ICON_LOADER_H_1348701985713445 + +#include +#include +#include + + +namespace fff +{ +//=> all functions are safe to call from multiple threads! +//COM needs to be initialized before calling any of these functions! CoInitializeEx/CoUninitialize +//=> don't call from WM_PAINT handler! https://docs.microsoft.com/en-us/archive/blogs/yvesdolc/do-you-receive-wm_paint-when-waiting-for-a-com-call-to-return + +zen::FileIconHolder getIconByTemplatePath(const Zstring& templatePath, int maxSize); //throw SysError +zen::FileIconHolder genericFileIcon(int maxSize); //throw SysError +zen::FileIconHolder genericDirIcon (int maxSize); //throw SysError +zen::FileIconHolder getTrashIcon (int maxSize); //throw SysError +zen::FileIconHolder getFileManagerIcon(int maxSize); //throw SysError +zen::FileIconHolder getFileIcon(const Zstring& filePath, int maxSize); //throw SysError +zen::ImageHolder getThumbnailImage(const Zstring& filePath, int maxSize); //throw SysError + +//invalidates image holder! call from GUI thread only! +wxImage extractWxImage(zen::ImageHolder&& ih); +wxImage extractWxImage(zen::FileIconHolder&& fih); //might fail if icon theme is missing a MIME type! +} + +#endif //ICON_LOADER_H_1348701985713445 diff --git a/FreeFileSync/Source/base/lock_holder.h b/FreeFileSync/Source/base/lock_holder.h new file mode 100644 index 0000000..127d337 --- /dev/null +++ b/FreeFileSync/Source/base/lock_holder.h @@ -0,0 +1,54 @@ +#ifndef LOCK_HOLDER_H_489572039485723453425 +#define LOCK_HOLDER_H_489572039485723453425 + +#include "dir_lock.h" +#include "process_callback.h" + + +namespace fff +{ + +//Attention: 1. call after having checked directory existence! +// 2. perf: remove folder aliases (e.g. case differences) *before* calling this function!!! + +//hold locks for a number of directories without blocking during lock creation +class LockHolder +{ +public: + LockHolder(const std::set& folderPaths, bool& warnDirectoryLockFailed, PhaseCallback& pcb /*throw X*/) + { + using namespace zen; + + std::vector> failedLocks; + + for (const Zstring& folderPath : folderPaths) + try + { + //lock file creation is synchronous and may block noticeably for slow devices (USB sticks, mapped cloud storage) + lockHolder_.emplace_back(folderPath, + [&](std::wstring&& msg) { pcb.updateStatus(std::move(msg)); /*throw X*/ }, + UI_UPDATE_INTERVAL / 2); //throw FileError + } + catch (const FileError& e) { failedLocks.emplace_back(folderPath, e); } + + if (!failedLocks.empty()) + { + std::wstring msg = _("Cannot set directory locks for the following folders:"); + + for (const auto& [folderPath, error] : failedLocks) + { + msg += L"\n\n"; + //msg += fmtPath(folderPath) + L'\n' -> seems redundant + msg += replaceCpy(error.toString(), L"\n\n", L'\n'); + } + + pcb.reportWarning(msg, warnDirectoryLockFailed); //throw X + } + } + +private: + std::vector lockHolder_; +}; +} + +#endif //LOCK_HOLDER_H_489572039485723453425 diff --git a/FreeFileSync/Source/base/multi_rename.cpp b/FreeFileSync/Source/base/multi_rename.cpp new file mode 100644 index 0000000..cd1dd86 --- /dev/null +++ b/FreeFileSync/Source/base/multi_rename.cpp @@ -0,0 +1,198 @@ +// ***************************************************************************** +// * 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 "multi_rename.h" +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +std::wstring_view findLongestSubstring(const std::vector& strings) +{ + if (strings.empty()) + return {}; + + const std::wstring_view strMin = *std::min_element(strings.begin(), strings.end(), + /**/[](const std::wstring_view lhs, const std::wstring_view rhs) { return lhs.size() < rhs.size(); }); + + for (size_t sz = strMin.size(); sz > 0; --sz) //iterate over size, descending + for (size_t i = 0; i + sz <= strMin.size(); ++i) + { + const std::wstring_view substr(strMin.data() + i, sz); + //perf: duplicate substrings, especially für size = 1? + + const bool isCommon = [&] + { + for (const std::wstring_view str : strings) + if (str.data() != strMin.data()) //sufficient check: an extension of strMin necessarily contains "substr" anyway + if (!contains(str, substr)) + return false; + return true; + }(); + + if (isCommon) + return substr; //*first* occuring substring of maximum size + } + + return {}; +} + + +struct StringPart +{ + std::vector diff; //may be empty, but only at beginning (or between filename/extension parts) + std::wstring_view common; //may be empty, but only at end (dito) +}; + +std::vector getStringParts(std::vector&& strings) +{ + std::wstring_view substr = findLongestSubstring(strings); + if (!substr.empty()) + { + std::vector head; + std::vector tail; + + for (const std::wstring_view str : strings) + { + head.push_back(beforeFirst(str, substr, IfNotFoundReturn::none)); + tail.push_back(afterFirst (str, substr, IfNotFoundReturn::none)); + } + + std::vector np = getStringParts(std::move(head)); + assert(np.empty() || np.back().common.empty()); //otherwise we could construct an even longer substring! + + if (np.empty()) + np.push_back({{}, substr}); + else + np.back().common = substr; + + const std::vector npTail = getStringParts(std::move(tail)); + assert(npTail.empty() || !npTail.front().diff.empty()); //otherwise we could construct an even longer substring! + + append(np, npTail); + return np; + } + else + { + if (std::all_of(strings.begin(), strings.end(), [](const std::wstring_view str) { return str.empty(); })) + /**/return {}; + + return {{std::move(strings), {}}}; + } +} + + +constexpr wchar_t placeholders[] = //http://xahlee.info/comp/unicode_circled_numbers.html +{ + //L'\u24FF', //⓿ <- rendered bigger than the rest (same for ⓫) on Centos Linux + L'\u2776', //❶ + L'\u2777', //❷ + L'\u2778', //❸ + L'\u2779', //❹ + L'\u277A', //❺ + L'\u277B', //❻ + L'\u277C', //❼ + L'\u277D', //❽ + L'\u277E', //❾ + L'\u277F', //❿ -> last one is special: represents "all the rest" +}; + + +inline +size_t getPlaceholderIndex(wchar_t c) +{ + static_assert(std::size(placeholders) == 10); + if (placeholders[0] <= c && c <= placeholders[9]) + return static_cast(c - placeholders[0]); + + return static_cast(-1); +} +} + + +bool fff::isRenamePlaceholderChar(wchar_t c) { return getPlaceholderIndex(c) < std::size(placeholders); } + + +struct fff::RenameBuf +{ + explicit RenameBuf(const std::vector& s) : strings(s) + { + //file extensions deserve special treatment: https://freefilesync.org/forum/viewtopic.php?t=11943#p46453 + std::vector names; + std::vector extensions; //including "." + for (const std::wstring& fileName : strings) + { + auto it = findLast(fileName.begin(), fileName.end(), L'.'); + names. push_back(makeStringView(fileName.begin(), it)); + extensions.push_back(makeStringView(it, fileName.end())); + } + + parts = getStringParts(std::move(names)); + append(parts, getStringParts(std::move(extensions))); + } + + std::vector strings; + std::vector parts; +}; + + +//e.g. "Season ❶, Episode ❷ - ❸.avi" +std::pair> fff::getPlaceholderPhrase(const std::vector& strings) +{ + auto renameBuf = makeSharedRef(strings); + + std::wstring phrase; + size_t placeIdx = 0; + + for (const StringPart& p : renameBuf.ref().parts) + { + if (!p.diff.empty()) + { + phrase += placeholders[placeIdx++]; + + if (placeIdx >= std::size(placeholders)) + break; //represent "all the rest" with last placeholder + } + phrase += p.common; //TODO: what if common part incidentally contains placeholder character!? + } + return {phrase, renameBuf}; +} + + +const std::vector fff::resolvePlaceholderPhrase(const std::wstring_view phrase, const RenameBuf& buf) +{ + std::vector*> diffByIdx; + + for (const StringPart& p : buf.parts) + if (!p.diff.empty()) + diffByIdx.push_back(&p.diff), assert(p.diff.size() == buf.strings.size()); + + std::vector output; + + for (size_t i = 0; i < buf.strings.size(); ++i) + { + std::wstring resolved; + + for (const wchar_t c : phrase) + if (const size_t placeIdx = getPlaceholderIndex(c); + placeIdx < diffByIdx.size()) + { + if (placeIdx == std::size(placeholders) - 1) //last placeholder represents "all the rest" + resolved.append((*diffByIdx[placeIdx])[i].data(), buf.strings[i].data() + buf.strings[i].size()); + else + resolved += (*diffByIdx[placeIdx])[i]; + } + else + resolved += c; + + output.push_back(std::move(resolved)); + } + + return output; +} diff --git a/FreeFileSync/Source/base/multi_rename.h b/FreeFileSync/Source/base/multi_rename.h new file mode 100644 index 0000000..ce44ddb --- /dev/null +++ b/FreeFileSync/Source/base/multi_rename.h @@ -0,0 +1,23 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef MULTI_RENAME_H_489572039485723453425 +#define MULTI_RENAME_H_489572039485723453425 + +#include +#include + +namespace fff +{ +struct RenameBuf; + +std::pair> getPlaceholderPhrase(const std::vector& strings); +const std::vector resolvePlaceholderPhrase(const std::wstring_view phrase, const RenameBuf& buf); + +bool isRenamePlaceholderChar(wchar_t c); +} + +#endif //MULTI_RENAME_H_489572039485723453425 diff --git a/FreeFileSync/Source/base/norm_filter.h b/FreeFileSync/Source/base/norm_filter.h new file mode 100644 index 0000000..f96a0ae --- /dev/null +++ b/FreeFileSync/Source/base/norm_filter.h @@ -0,0 +1,67 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef NORM_FILTER_H_974896787346251 +#define NORM_FILTER_H_974896787346251 + +#include "path_filter.h" +#include "soft_filter.h" + + +namespace fff +{ +struct NormalizedFilter //grade-a filter: global/local filter settings combined, units resolved, ready for use +{ + NormalizedFilter(const FilterRef& hf, const SoftFilter& sf) : nameFilter(hf), timeSizeFilter(sf) {} + + //"hard" filter: relevant during comparison, physically skips files + FilterRef nameFilter; + //"soft" filter: relevant after comparison; equivalent to user selection + SoftFilter timeSizeFilter; +}; + + +//combine global and local filters via "logical and" +NormalizedFilter normalizeFilters(const FilterConfig& global, const FilterConfig& local); + +inline +bool isNullFilter(const FilterConfig& filterCfg) +{ + return NameFilter::isNull(filterCfg.includeFilter, filterCfg.excludeFilter) && + SoftFilter(filterCfg.timeSpan, filterCfg.unitTimeSpan, + filterCfg.sizeMin, filterCfg.unitSizeMin, + filterCfg.sizeMax, filterCfg.unitSizeMax).isNull(); +} + + + + + + + + + + +// ----------------------- implementation ----------------------- +inline +NormalizedFilter normalizeFilters(const FilterConfig& global, const FilterConfig& local) +{ + SoftFilter globalTimeSize(global.timeSpan, global.unitTimeSpan, + global.sizeMin, global.unitSizeMin, + global.sizeMax, global.unitSizeMax); + + SoftFilter localTimeSize(local.timeSpan, local.unitTimeSpan, + local.sizeMin, local.unitSizeMin, + local.sizeMax, local.unitSizeMax); + + + return NormalizedFilter(constructFilter(global.includeFilter, global.excludeFilter, + local .includeFilter, local .excludeFilter), + combineFilters(globalTimeSize, localTimeSize)); +} +} + +#endif //NORM_FILTER_H_974896787346251 diff --git a/FreeFileSync/Source/base/parallel_scan.cpp b/FreeFileSync/Source/base/parallel_scan.cpp new file mode 100644 index 0000000..fd9dd70 --- /dev/null +++ b/FreeFileSync/Source/base/parallel_scan.cpp @@ -0,0 +1,465 @@ +// ***************************************************************************** +// * 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 "parallel_scan.h" +#include +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +const int FOLDER_TRAVERSAL_LEVEL_MAX = 100; + +/* PERF NOTE + + --------------------------------------------- + |Test case: Reading from two different disks| + --------------------------------------------- + Windows 7: + 1st(unbuffered) |2nd (OS buffered) + ---------------------------------- + 1 Thread: 57s | 8s + 2 Threads: 39s | 7s + + --------------------------------------------------- + |Test case: Reading two directories from same disk| + --------------------------------------------------- + Windows 7: Windows XP: + 1st(unbuffered) |2nd (OS buffered) 1st(unbuffered) |2nd (OS buffered) + ---------------------------------- ---------------------------------- + 1 Thread: 41s | 13s 1 Thread: 45s | 13s + 2 Threads: 42s | 11s 2 Threads: 38s | 8s + + => Traversing does not take any advantage of file locality so that multiple threads operating on the same disk impose no performance overhead! (even faster on XP) */ + +class AsyncCallback +{ +public: + AsyncCallback(size_t threadsToFinish, std::chrono::milliseconds cbInterval) : threadsToFinish_(threadsToFinish), cbInterval_(cbInterval) {} + + //blocking call: context of worker thread + AFS::TraverserCallback::HandleError reportError(const AFS::TraverserCallback::ErrorInfo& errorInfo) //throw ThreadStopRequest + { + assert(!runningOnMainThread()); + std::unique_lock dummy(lockRequest_); + interruptibleWait(conditionReadyForNewRequest_, dummy, [this] { return !errorRequest_ && !errorResponse_; }); //throw ThreadStopRequest + + errorRequest_ = errorInfo; + conditionNewRequest.notify_all(); + + interruptibleWait(conditionHaveResponse_, dummy, [this] { return static_cast(errorResponse_); }); //throw ThreadStopRequest + + AFS::TraverserCallback::HandleError rv = *errorResponse_; + + errorRequest_ = std::nullopt; + errorResponse_ = std::nullopt; + + dummy.unlock(); //optimization for condition_variable::notify_all() + conditionReadyForNewRequest_.notify_all(); //instead of notify_one(); work around bug: https://svn.boost.org/trac/boost/ticket/7796 + + return rv; + } + + //context of main thread + void waitUntilDone(const TravErrorCb& onError, const TravStatusCb& onStatusUpdate) //throw X + { + assert(runningOnMainThread()); + for (;;) + { + const std::chrono::steady_clock::time_point callbackTime = std::chrono::steady_clock::now() + cbInterval_; + + for (std::unique_lock dummy(lockRequest_) ;;) //process all errors without delay + { + const bool rv = conditionNewRequest.wait_until(dummy, callbackTime, [this] { return (errorRequest_ && !errorResponse_) || (threadsToFinish_ == 0); }); + if (!rv) //time-out + condition not met + break; + + if (errorRequest_ && !errorResponse_) + { + assert(threadsToFinish_ != 0); + switch (onError({errorRequest_->msg, errorRequest_->failTime, errorRequest_->retryNumber})) //throw X + { + case PhaseCallback::ignore: + errorResponse_ = AFS::TraverserCallback::HandleError::ignore; + break; + + case PhaseCallback::retry: + errorResponse_ = AFS::TraverserCallback::HandleError::retry; + break; + } + conditionHaveResponse_.notify_all(); //instead of notify_one(); work around bug: https://svn.boost.org/trac/boost/ticket/7796 + } + if (threadsToFinish_ == 0) + { + dummy.unlock(); + onStatusUpdate(getStatusLine(), itemsScanned_); //throw X; one last call for accurate stat-reporting! + return; + } + } + + //call member functions outside of mutex scope: + onStatusUpdate(getStatusLine(), itemsScanned_); //throw X + } + } + + //perf optimization: comparison phase is 7% faster by avoiding needless std::wstring construction for reportCurrentFile() + bool mayReportCurrentFile(int threadIdx, std::chrono::steady_clock::time_point& lastReportTime) const + { + if (threadIdx != notifyingThreadIdx_) //only one thread at a time may report status: the first in sequential order + return false; + + const auto now = std::chrono::steady_clock::now(); + if (now > lastReportTime + cbInterval_) //perform ui updates not more often than necessary + { + lastReportTime = now; //keep "lastReportTime" at worker thread level to avoid locking! + return true; + } + return false; + } + + void reportCurrentFile(const std::wstring& filePath) //context of worker thread + { + assert(!runningOnMainThread()); + std::lock_guard dummy(lockCurrentStatus_); + currentFile_ = filePath; + } + + void incItemsScanned() { ++itemsScanned_; } + //perf: scanning is almost entirely file I/O bound, not CPU bound! => no prob having multiple threads poking at the same variable! + + void notifyTaskBegin(int threadIdx, size_t parallelOps) + { + assert(!zen::runningOnMainThread()); + std::lock_guard dummy(lockCurrentStatus_); + + [[maybe_unused]] const auto [it, inserted] = activeThreadIdxs_.emplace(threadIdx, parallelOps); + assert(inserted); + + notifyingThreadIdx_ = activeThreadIdxs_.begin()->first; + } + + void notifyTaskEnd(int threadIdx) + { + assert(!zen::runningOnMainThread()); + { + std::lock_guard dummy(lockCurrentStatus_); + + [[maybe_unused]] const size_t no = activeThreadIdxs_.erase(threadIdx); + assert(no == 1); + + notifyingThreadIdx_ = activeThreadIdxs_.empty() ? 0 : activeThreadIdxs_.begin()->first; + } + { + std::lock_guard dummy(lockRequest_); + assert(threadsToFinish_ > 0); + if (--threadsToFinish_ == 0) + conditionNewRequest.notify_all(); //perf: should unlock mutex before notify!? (insignificant) + } + } + +private: + std::wstring getStatusLine() //context of main thread, call repreatedly + { + assert(runningOnMainThread()); + + size_t parallelOpsTotal = 0; + std::wstring filePath; + { + std::lock_guard dummy(lockCurrentStatus_); + parallelOpsTotal = activeThreadIdxs_.size(); + filePath = currentFile_; + } + if (parallelOpsTotal >= 2) + return L'[' + _P("1 thread", "%x threads", parallelOpsTotal) + L"] " + filePath; + else + return filePath; + } + + //---- main <-> worker communication channel ---- + std::mutex lockRequest_; + std::condition_variable conditionReadyForNewRequest_; + std::condition_variable conditionNewRequest; + std::condition_variable conditionHaveResponse_; + std::optional errorRequest_; + std::optional errorResponse_; + size_t threadsToFinish_; //can't use activeThreadIdxs_.size() which is locked by different mutex! + //also note: activeThreadIdxs_.size() may be 0 during worker thread construction! + + //---- status updates ---- + std::mutex lockCurrentStatus_; //different lock for status updates so that we're not blocked by other threads reporting errors + std::wstring currentFile_; + std::map activeThreadIdxs_; + + std::atomic notifyingThreadIdx_{0}; //CAVEAT: do NOT use boost::thread::id: https://svn.boost.org/trac/boost/ticket/5754 + const std::chrono::milliseconds cbInterval_; + + //---- status updates II (lock-free) ---- + std::atomic itemsScanned_{0}; //std:atomic is uninitialized by default! +}; + +//------------------------------------------------------------------------------------------------- + +struct TraverserConfig +{ + const AbstractPath baseFolderPath; //thread-safe like an int! :) + const FilterRef filter; + const SymLinkHandling handleSymlinks; + + std::unordered_map& failedDirReads; + std::unordered_map& failedItemReads; + + AsyncCallback& acb; + const int threadIdx; + std::chrono::steady_clock::time_point& lastReportTime; //thread-level +}; + + +class DirCallback : public AFS::TraverserCallback +{ +public: + DirCallback(TraverserConfig& cfg, + Zstring&& parentRelPathPf, //postfixed with FILE_NAME_SEPARATOR (or empty!) + FolderContainer& output, + int level) : + cfg_(cfg), + parentRelPathPf_(std::move(parentRelPathPf)), + output_(output), + level_(level) {} //MUST NOT use cfg_ during construction! see BaseDirCallback() + + virtual void onFile (const AFS::FileInfo& fi) override; // + virtual std::shared_ptr onFolder (const AFS::FolderInfo& fi) override; //throw ThreadStopRequest + virtual HandleLink onSymlink(const AFS::SymlinkInfo& li) override; // + + HandleError reportDirError (const ErrorInfo& errorInfo) override { return reportError(errorInfo, Zstring()); } //throw ThreadStopRequest + HandleError reportItemError(const ErrorInfo& errorInfo, const Zstring& itemName) override { return reportError(errorInfo, itemName); } // + +private: + HandleError reportError(const ErrorInfo& errorInfo, const Zstring& itemName /*optional*/); //throw ThreadStopRequest + + TraverserConfig& cfg_; + const Zstring parentRelPathPf_; + FolderContainer& output_; + const int level_; +}; + + +class BaseDirCallback : public DirCallback +{ +public: + BaseDirCallback(const DirectoryKey& baseFolderKey, DirectoryValue& output, + AsyncCallback& acb, int threadIdx, std::chrono::steady_clock::time_point& lastReportTime) : + DirCallback(travCfg_ /*not yet constructed!!!*/, Zstring(), output.folderCont, 0 /*level*/), + travCfg_ + { + baseFolderKey.folderPath, + baseFolderKey.filter, + baseFolderKey.handleSymlinks, + output.failedFolderReads, + output.failedItemReads, + acb, + threadIdx, + lastReportTime, + } + { + if (acb.mayReportCurrentFile(threadIdx, lastReportTime)) + acb.reportCurrentFile(AFS::getDisplayPath(baseFolderKey.folderPath)); //just in case first directory access is blocking + } + +private: + TraverserConfig travCfg_; +}; + + +void DirCallback::onFile(const AFS::FileInfo& fi) //throw ThreadStopRequest +{ + interruptionPoint(); //throw ThreadStopRequest + + const Zstring& relPath = parentRelPathPf_ + fi.itemName; + + //update status information no matter if item is excluded or not! + if (cfg_.acb.mayReportCurrentFile(cfg_.threadIdx, cfg_.lastReportTime)) + cfg_.acb.reportCurrentFile(AFS::getDisplayPath(AFS::appendRelPath(cfg_.baseFolderPath, relPath))); + + //------------------------------------------------------------------------------------ + //apply filter before processing (use relative name!) + if (!cfg_.filter.ref().passFileFilter(relPath)) + return; + //note: sync.ffs_db database and lock files are excluded via path filter! + + output_.addFile(fi.itemName, + { + .modTime = fi.modTime, + .fileSize = fi.fileSize, + .filePrint = fi.filePrint, + .isFollowedSymlink = fi.isFollowedSymlink, + }); + + cfg_.acb.incItemsScanned(); //add 1 element to the progress indicator +} + + +std::shared_ptr DirCallback::onFolder(const AFS::FolderInfo& fi) //throw ThreadStopRequest +{ + interruptionPoint(); //throw ThreadStopRequest + + Zstring relPath = parentRelPathPf_ + fi.itemName; + + //update status information no matter if item is excluded or not! + if (cfg_.acb.mayReportCurrentFile(cfg_.threadIdx, cfg_.lastReportTime)) + cfg_.acb.reportCurrentFile(AFS::getDisplayPath(AFS::appendRelPath(cfg_.baseFolderPath, relPath))); + + //------------------------------------------------------------------------------------ + //apply filter before processing (use relative name!) + bool childItemMightMatch = true; + const bool passFilter = cfg_.filter.ref().passDirFilter(relPath, &childItemMightMatch); + if (!passFilter && !childItemMightMatch) + return nullptr; //do NOT traverse subdirs + //else: ensure directory filtering is applied later to exclude actually filtered directories!!! + + FolderContainer& subFolder = output_.addFolder(fi.itemName, {.isFollowedSymlink = fi.isFollowedSymlink}); + if (passFilter) + cfg_.acb.incItemsScanned(); //add 1 element to the progress indicator + + //------------------------------------------------------------------------------------ + if (level_ > FOLDER_TRAVERSAL_LEVEL_MAX) //Win32 traverser: stack overflow approximately at level 1000 + //check after FolderContainer::addFolder() + for (size_t retryNumber = 0;; ++retryNumber) + switch (reportItemError({replaceCpy(_("Cannot read directory %x."), L"%x", AFS::getDisplayPath(AFS::appendRelPath(cfg_.baseFolderPath, relPath))) + + L"\n\n" L"Endless recursion.", std::chrono::steady_clock::now(), retryNumber}, fi.itemName)) //throw ThreadStopRequest + { + case AFS::TraverserCallback::HandleError::retry: + break; + case AFS::TraverserCallback::HandleError::ignore: + return nullptr; + } + + return std::make_shared(cfg_, std::move(relPath += FILE_NAME_SEPARATOR), subFolder, level_ + 1); +} + + +DirCallback::HandleLink DirCallback::onSymlink(const AFS::SymlinkInfo& si) //throw ThreadStopRequest +{ + interruptionPoint(); //throw ThreadStopRequest + + const Zstring& relPath = parentRelPathPf_ + si.itemName; + + //update status information no matter if item is excluded or not! + if (cfg_.acb.mayReportCurrentFile(cfg_.threadIdx, cfg_.lastReportTime)) + cfg_.acb.reportCurrentFile(AFS::getDisplayPath(AFS::appendRelPath(cfg_.baseFolderPath, relPath))); + + switch (cfg_.handleSymlinks) + { + case SymLinkHandling::exclude: + return HandleLink::skip; + + case SymLinkHandling::asLink: + if (cfg_.filter.ref().passFileFilter(relPath)) //always use file filter: Link type may not be "stable" on Linux! + { + output_.addSymlink(si.itemName, {.modTime = si.modTime}); + cfg_.acb.incItemsScanned(); //add 1 element to the progress indicator + } + return HandleLink::skip; + + case SymLinkHandling::follow: + //filter symlinks before trying to follow them: handle user-excluded broken symlinks! + //since we don't know yet what type the symlink will resolve to, only do this when both filter variants agree: + if (!cfg_.filter.ref().passFileFilter(relPath)) + { + bool childItemMightMatch = true; + if (!cfg_.filter.ref().passDirFilter(relPath, &childItemMightMatch)) + if (!childItemMightMatch) + return HandleLink::skip; + } + return HandleLink::follow; + } + + assert(false); + return HandleLink::skip; +} + + +DirCallback::HandleError DirCallback::reportError(const ErrorInfo& errorInfo, const Zstring& itemName /*optional*/) //throw ThreadStopRequest +{ + const HandleError handleErr = cfg_.acb.reportError(errorInfo); //throw ThreadStopRequest + switch (handleErr) + { + case HandleError::ignore: + if (itemName.empty()) + cfg_.failedDirReads.emplace(beforeLast(parentRelPathPf_, FILE_NAME_SEPARATOR, IfNotFoundReturn::none), utfTo(errorInfo.msg)); + else + cfg_.failedItemReads.emplace(parentRelPathPf_ + itemName, utfTo(errorInfo.msg)); + break; + + case HandleError::retry: + break; + } + return handleErr; +} +} + + +std::map fff::parallelFolderScan(const std::set& foldersToRead, + const TravErrorCb& onError, const TravStatusCb& onStatusUpdate, + std::chrono::milliseconds cbInterval) +{ + std::map output; + + //aggregate folder paths that are on the same root device: + // => one worker thread *per device*: avoid excessive parallelism + // => parallel folder traversal considers "parallel file operations" as specified by user + // => (S)FTP: avoid hitting connection limits inadvertently + std::map> perDeviceFolders; + + for (const DirectoryKey& key : foldersToRead) + perDeviceFolders[key.folderPath.afsDevice].insert(key); + + //communication channel used by threads + AsyncCallback acb(perDeviceFolders.size() /*threadsToFinish*/, cbInterval); //manage life time: enclose InterruptibleThread's!!! + + std::vector worker; + ZEN_ON_SCOPE_SUCCESS( for (InterruptibleThread& wt : worker) wt.join(); ); //no stop needed in success case => preempt ~InterruptibleThread() + ZEN_ON_SCOPE_FAIL( for (InterruptibleThread& wt : worker) wt.requestStop(); ); //stop *all* at the same time before join! + + //init worker threads + for (const auto& [afsDevice, dirKeys] : perDeviceFolders) + { + const int threadIdx = static_cast(worker.size()); + Zstring threadName = Zstr("Compare[") + numberTo(threadIdx + 1) + Zstr('/') + numberTo(perDeviceFolders.size()) + Zstr("] ") + + utfTo(AFS::getDisplayPath({afsDevice, AfsPath()})); + + const size_t parallelOps = 1; + std::map workload; + + for (const DirectoryKey& key : dirKeys) + workload.emplace(key, &output[key]); //=> DirectoryValue* unshared for lock-free worker-thread access + + worker.emplace_back([afsDevice /*clang bug*/= afsDevice, workload, threadIdx, &acb, parallelOps, threadName = std::move(threadName)]() mutable + { + setCurrentThreadName(threadName); + + acb.notifyTaskBegin(threadIdx, parallelOps); + ZEN_ON_SCOPE_EXIT(acb.notifyTaskEnd(threadIdx)); + + std::chrono::steady_clock::time_point lastReportTime; //keep thread-local! + + AFS::TraverserWorkload travWorkload; + + for (auto& [folderKey, folderVal] : workload) + { + assert(folderKey.folderPath.afsDevice == afsDevice); + travWorkload.emplace_back(folderKey.folderPath.afsPath, std::make_shared(folderKey, *folderVal, acb, threadIdx, lastReportTime)); + } + AFS::traverseFolderRecursive(afsDevice, travWorkload, parallelOps); //throw ThreadStopRequest + }); + } + acb.waitUntilDone(onError, onStatusUpdate); //throw X + + return output; +} diff --git a/FreeFileSync/Source/base/parallel_scan.h b/FreeFileSync/Source/base/parallel_scan.h new file mode 100644 index 0000000..8148998 --- /dev/null +++ b/FreeFileSync/Source/base/parallel_scan.h @@ -0,0 +1,54 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PARALLEL_SCAN_H_924588904275284572857 +#define PARALLEL_SCAN_H_924588904275284572857 + +#include +#include +#include +#include "path_filter.h" +#include "structures.h" +#include "file_hierarchy.h" +#include "process_callback.h" + + +namespace fff +{ +struct DirectoryKey +{ + AbstractPath folderPath; + FilterRef filter; + SymLinkHandling handleSymlinks = SymLinkHandling::exclude; + + std::weak_ordering operator<=>(const DirectoryKey&) const = default; +}; + + +struct DirectoryValue +{ + FolderContainer folderCont; + + //relative paths (or empty string for root) for directories that could not be read (completely), e.g. access denied, or temporary network drop + std::unordered_map failedFolderReads; + + //relative paths (never empty) for failure to read single file/dir/symlink + std::unordered_map failedItemReads; +}; + + +//Attention: 1. ensure directory filtering is applied later to exclude filtered folders which have been kept as parent folders +// 2. remove folder aliases (e.g. case differences) *before* calling this function!!! + +using TravErrorCb = std::function; +using TravStatusCb = std::function; + +std::map parallelFolderScan(const std::set& foldersToRead, + const TravErrorCb& onError, const TravStatusCb& onStatusUpdate, //NOT optional + std::chrono::milliseconds cbInterval); +} + +#endif //PARALLEL_SCAN_H_924588904275284572857 diff --git a/FreeFileSync/Source/base/path_filter.cpp b/FreeFileSync/Source/base/path_filter.cpp new file mode 100644 index 0000000..16a206e --- /dev/null +++ b/FreeFileSync/Source/base/path_filter.cpp @@ -0,0 +1,318 @@ +// ***************************************************************************** +// * 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 "path_filter.h" +#include +#include + +using namespace zen; +using namespace fff; + + +std::strong_ordering fff::operator<=>(const FilterRef& lhs, const FilterRef& rhs) +{ + //caveat: typeid returns static type for pointers, dynamic type for references!!! + if (const std::strong_ordering cmp = std::type_index(typeid(lhs.ref())) <=> + std::type_index(typeid(rhs.ref())); + cmp != std::strong_ordering::equal) + return cmp; + + return lhs.ref().compareSameType(rhs.ref()); +} + + +void NameFilter::parseFilterPhrase(const Zstring& filterPhrase, FilterSet& filter) +{ + //normalize filter: 1. ignore Unicode normalization form 2. ignore case + Zstring filterPhraseNorm = getUpperCase(filterPhrase); + //3. fix path separator + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) replace(filterPhraseNorm, Zstr('/'), FILE_NAME_SEPARATOR); + if constexpr (FILE_NAME_SEPARATOR != Zstr('\\')) replace(filterPhraseNorm, Zstr('\\'), FILE_NAME_SEPARATOR); + + static_assert(FILE_NAME_SEPARATOR == '/'); + const Zstring sepAsterisk = Zstr("/*"); + const Zstring asteriskSep = Zstr("*/"); + + auto processTail = [&](const ZstringView phrase) + { + if (endsWith(phrase, Zstr(':'))) //file-only tag + filter.fileMasks.insert({phrase.begin(), phrase.end() - 1}); + else if (endsWith(phrase, FILE_NAME_SEPARATOR) || //folder-only tag + endsWith(phrase, sepAsterisk)) // abc\* + filter.folderMasks.insert(Zstring(beforeLast(phrase, FILE_NAME_SEPARATOR, IfNotFoundReturn::none))); + else + { + filter.fileMasks .insert(Zstring(phrase)); + filter.folderMasks.insert(Zstring(phrase)); + } + }; + + split2(filterPhraseNorm, [](Zchar c) { return c == FILTER_ITEM_SEPARATOR || c == Zstr('\n'); }, //delimiters + [&](ZstringView itemPhrase) + { + itemPhrase = trimCpy(itemPhrase); + if (!itemPhrase.empty()) + { + /* phrase | action + +---------+-------- + | \blah | remove \ + | \*blah | remove \ + | \*\blah | remove \ + | \*\* | remove \ + +---------+-------- + | *blah | + | *\blah | -> add blah + | *\*blah | -> add *blah + +---------+-------- + | blah: | remove : (file only) + | blah\*: | remove : (file only) + +---------+-------- + | blah\ | remove \ (folder only) + | blah*\ | remove \ (folder only) + | blah\*\ | remove \ (folder only) + +---------+-------- + | blah* | + | blah\* | remove \* (folder only) + | blah*\* | remove \* (folder only) + +---------+-------- */ + if (startsWith(itemPhrase, FILE_NAME_SEPARATOR)) // \abc + processTail(afterFirst(itemPhrase, FILE_NAME_SEPARATOR, IfNotFoundReturn::none)); + else + { + processTail(itemPhrase); + if (startsWith(itemPhrase, asteriskSep)) // *\abc + processTail(afterFirst(itemPhrase, asteriskSep, IfNotFoundReturn::none)); + } + } + }); +} + + +void NameFilter::MaskMatcher::insert(const Zstring& mask) +{ + assert(mask == getUpperCase(mask)); + if (mask.empty()) + return; + + if (contains(mask, Zstr('?')) || + contains(mask, Zstr('*'))) + realMasks_.insert(mask); + else + { + relPaths_ .insert(mask); + relPathsCmp_.insert(mask); //little memory wasted thanks to COW string! + } +} + + +namespace +{ +//"true" if path or any parent path matches the mask +bool matchesMask(const Zchar* path, const Zchar* const pathEnd, const Zchar* mask /*0-terminated*/) +{ + for (;; ++mask, ++path) + { + Zchar m = *mask; + switch (m) + { + case 0: + return path == pathEnd || *path == FILE_NAME_SEPARATOR; //"full" or parent path match + + case Zstr('?'): //should not match FILE_NAME_SEPARATOR + if (path == pathEnd || *path == FILE_NAME_SEPARATOR) + return false; + break; + + case Zstr('*'): + do //advance mask to next non-* char + { + m = *++mask; + } + while (m == Zstr('*')); + + if (m == 0) //mask ends with '*': + return true; + + ++mask; + if (m == Zstr('?')) //*? pattern + { + while (path != pathEnd) + if (*path++ != FILE_NAME_SEPARATOR) + if (matchesMask(path, pathEnd, mask)) + return true; + } + else //*[letter or /] pattern + while (path != pathEnd) + if (*path++ == m) + if (matchesMask(path, pathEnd, mask)) + return true; + return false; + + default: + if (path == pathEnd || *path != m) + return false; + } + } +} + + +//"true" if path matches (only!) the beginning of mask +template bool matchesMaskBegin(const ZstringView relPath, const Zstring& mask); + +template <> inline +bool matchesMaskBegin(const ZstringView relPath, const Zstring& mask) +{ + auto itP = relPath.begin(); + for (auto itM = mask.begin(); itM != mask.end(); ++itM, ++itP) + { + const Zchar m = *itM; + switch (m) + { + case Zstr('?'): + if (itP == relPath.end() || *itP == FILE_NAME_SEPARATOR) + return false; + break; + + case Zstr('*'): + return true; + + default: + if (itP == relPath.end()) + return m == FILE_NAME_SEPARATOR && mask.end() - itM > 1; //require strict sub match + + if (*itP != m) + return false; + } + } + return false; //not a strict sub match +} + +template <> inline //perf: going overboard? remaining fruits are hanging higher and higher... +bool matchesMaskBegin(const ZstringView relPath, const Zstring& mask) +{ + return mask.size() > relPath.size() + 1 && //room for FILE_NAME_SEPARATOR *and* at least one more char + mask[relPath.size()] == FILE_NAME_SEPARATOR && + startsWith(mask, relPath); +} +} + + +bool NameFilter::MaskMatcher::matches(const ZstringView relPath) const +{ + assert(!relPath.empty()); + + if (std::any_of(realMasks_.begin(), realMasks_.end(), [&](const Zstring& mask) { return matchesMask(relPath.data(), relPath.data() + relPath.size(), mask.c_str()); })) + /**/return true; + + //perf: for relPaths_ we can go from linear to *constant* time!!! => annihilates https://freefilesync.org/forum/viewtopic.php?t=7768#p26519 + + ZstringView parentPath = relPath; + for (;;) //check all parent paths! + { + if (relPaths_.contains(parentPath)) //heterogenous lookup! + return true; + + parentPath = beforeLast(parentPath, FILE_NAME_SEPARATOR, IfNotFoundReturn::none); + if (parentPath.empty()) + return false; + } +} + + +bool NameFilter::MaskMatcher::matchesBegin(const ZstringView relPath) const +{ + return std::any_of(realMasks_.begin(), realMasks_.end(), [&](const Zstring& mask) { return matchesMaskBegin(relPath, mask); }) || + /**/ std::any_of(relPaths_ .begin(), relPaths_ .end(), [&](const Zstring& mask) { return matchesMaskBegin(relPath, mask); }); +} + +//################################################################################################# + +NameFilter::NameFilter(const Zstring& includePhrase, const Zstring& excludePhrase) +{ + parseFilterPhrase(includePhrase, includeFilter); + parseFilterPhrase(excludePhrase, excludeFilter); +} + + +void NameFilter::addExclusion(const Zstring& excludePhrase) +{ + parseFilterPhrase(excludePhrase, excludeFilter); +} + + +bool NameFilter::passFileFilter(const Zstring& relFilePath) const +{ + assert(!startsWith(relFilePath, FILE_NAME_SEPARATOR)); + + //normalize input: 1. ignore Unicode normalization form 2. ignore case + const Zstring& pathFmt = getUpperCase(relFilePath); + + const ZstringView parentPath = beforeLast(pathFmt, FILE_NAME_SEPARATOR, IfNotFoundReturn::none); + + if (excludeFilter.fileMasks.matches(pathFmt) || //either match on file or any parent folder + (!parentPath.empty() && excludeFilter.folderMasks.matches(parentPath))) //match on any parent folder only + return false; + + return includeFilter.fileMasks.matches(pathFmt) || + (!parentPath.empty() && includeFilter.folderMasks.matches(parentPath)); +} + + +bool NameFilter::passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const +{ + assert(!startsWith(relDirPath, FILE_NAME_SEPARATOR)); + assert(!childItemMightMatch || *childItemMightMatch); //check correct usage + + //normalize input: 1. ignore Unicode normalization form 2. ignore case + const Zstring& pathFmt = getUpperCase(relDirPath); + + if (excludeFilter.folderMasks.matches(pathFmt)) + { + if (childItemMightMatch) + *childItemMightMatch = false; //perf: no need to traverse deeper; subfolders/subfiles would be excluded by filter anyway! + + /* Attention: If *childItemMightMatch == false, then any direct filter evaluation for a child item must also return "false"! + + This is not a problem for folder traversal which stops at the first *childItemMightMatch == false anyway, but other code continues recursing further, + e.g. the database update code in db_file.cpp recurses unconditionally without *childItemMightMatch check! */ + return false; + } + + if (includeFilter.folderMasks.matches(pathFmt)) + return true; + + if (childItemMightMatch) + *childItemMightMatch = includeFilter.fileMasks .matchesBegin(pathFmt) || //might match a file or folder in subdirectory + includeFilter.folderMasks.matchesBegin(pathFmt); // + return false; +} + + +bool NameFilter::isNull(const Zstring& includePhrase, const Zstring& excludePhrase) +{ + return trimCpy(includePhrase) == Zstr("*") && //harmonize with ui/folder_pair.cpp tooltip + trimCpy(excludePhrase).empty(); + //return NameFilter(includePhrase, excludePhrase).isNull(); -> very expensive for huge lists +} + + +bool NameFilter::isNull() const +{ + return compareSameType(NameFilter(Zstr("*"), Zstr(""))) == std::strong_ordering::equal; + //avoid static non-POD null-NameFilter instance +} + + +std::strong_ordering NameFilter::compareSameType(const PathFilter& other) const +{ + assert(typeid(*this) == typeid(other)); //always given in this context! + + const NameFilter& lhs = *this; + const NameFilter& rhs = static_cast(other); + + return std::tie(lhs.includeFilter, lhs.excludeFilter) <=> + std::tie(rhs.includeFilter, rhs.excludeFilter); +} diff --git a/FreeFileSync/Source/base/path_filter.h b/FreeFileSync/Source/base/path_filter.h new file mode 100644 index 0000000..0ec02e7 --- /dev/null +++ b/FreeFileSync/Source/base/path_filter.h @@ -0,0 +1,258 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef HARD_FILTER_H_825780275842758345 +#define HARD_FILTER_H_825780275842758345 + +#include +#include + + +namespace fff +{ +/* Semantics of PathFilter: + 1. using it creates a NEW folder hierarchy! -> must be considered by variant! + 2. it applies equally to both sides => it always matches either both sides or none! => can be used while traversing a single folder! + + PathFilter (interface) + /|\ + ____________|_____________ + | | | + NullFilter NameFilter CombinedFilter */ + +class PathFilter; +using FilterRef = zen::SharedRef; + +std::strong_ordering operator<=>(const FilterRef& lhs, const FilterRef& rhs); //fix GCC warning: "... has not been declared within ?fff + +const Zchar FILTER_ITEM_SEPARATOR = Zstr('|'); + +class PathFilter +{ +public: + virtual ~PathFilter() {} + + virtual bool passFileFilter(const Zstring& relFilePath) const = 0; + virtual bool passDirFilter (const Zstring& relDirPath, bool* childItemMightMatch) const = 0; + //childItemMightMatch: file/dir in subdirectories could(!) match + //note: this hint is only set if passDirFilter returns false! + + virtual bool isNull() const = 0; //filter is equivalent to NullFilter + + virtual FilterRef copyFilterAddingExclusion(const Zstring& excludePhrase) const = 0; + +private: + friend std::strong_ordering operator<=>(const FilterRef& lhs, const FilterRef& rhs); + + virtual std::strong_ordering compareSameType(const PathFilter& other) const = 0; //assumes typeid(*this) == typeid(other)! +}; + + +//small helper method: merge two hard filters (thereby remove Null-filters) +FilterRef combineFilters(const FilterRef& first, const FilterRef& second); + + +class NullFilter : public PathFilter //no filtering at all +{ +public: + bool passFileFilter(const Zstring& relFilePath) const override { return true; } + bool passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const override; + bool isNull() const override { return true; } + FilterRef copyFilterAddingExclusion(const Zstring& excludePhrase) const override; + +private: + std::strong_ordering compareSameType(const PathFilter& other) const override { assert(typeid(*this) == typeid(other)); return std::strong_ordering::equal; } +}; + + +class NameFilter : public PathFilter //filter by base-relative file path +{ +public: + NameFilter(const Zstring& includePhrase, const Zstring& excludePhrase); + + void addExclusion(const Zstring& excludePhrase); + + bool passFileFilter(const Zstring& relFilePath) const override; + bool passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const override; + + bool isNull() const override; + static bool isNull(const Zstring& includePhrase, const Zstring& excludePhrase); //*fast* check without expensive NameFilter construction! + FilterRef copyFilterAddingExclusion(const Zstring& excludePhrase) const override; + +private: + friend class CombinedFilter; + std::strong_ordering compareSameType(const PathFilter& other) const override; + + class MaskMatcher + { + public: + void insert(const Zstring& mask); //expected: upper-case + Unicode-normalized! + bool matches(const ZstringView relPath) const; + bool matchesBegin(const ZstringView relPath) const; + + inline friend std::strong_ordering operator<=>(const MaskMatcher& lhs, const MaskMatcher& rhs) + { + return std::tie(lhs.realMasks_, lhs.relPathsCmp_) <=> + std::tie(rhs.realMasks_, rhs.relPathsCmp_); + } + //can't "= default" because std::unordered_set doesn't support <=>! + //CAVEAT: when operator<=> is not "default" we also don't get operator== for free! declare manually: + bool operator==(const MaskMatcher&) const; + //why declare, but not define? if undeclared, "std::tie <=> std::tie" incorrectly deduces std::weak_ordering + //=> bug? no, looks like "C++ standard nonsense": https://cplusplus.github.io/LWG/issue3431 + //std::three_way_comparable requires __WeaklyEqualityComparableWith!! this is stupid on first sight. And on second. And on third. + + private: + std::set realMasks_; //always containing ? or * (use std::set<> to scrap duplicates!) + std::unordered_set relPaths_; //never containing ? or * + std::set relPathsCmp_; //req. for operator<=> only :( + }; + + struct FilterSet + { + MaskMatcher fileMasks; + MaskMatcher folderMasks; + + std::strong_ordering operator<=>(const FilterSet&) const = default; + }; + + static void parseFilterPhrase(const Zstring& filterPhrase, FilterSet& filter); + + FilterSet includeFilter; + FilterSet excludeFilter; +}; + + +class CombinedFilter : public PathFilter //combine two filters to match if and only if both match +{ +public: + CombinedFilter(const NameFilter& first, const NameFilter& second) : first_(first), second_(second) { assert(!first.isNull() && !second.isNull()); } //if either is null, then wy use CombinedFilter? + + bool passFileFilter(const Zstring& relFilePath) const override; + bool passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const override; + bool isNull() const override; + FilterRef copyFilterAddingExclusion(const Zstring& excludePhrase) const override; + +private: + std::strong_ordering compareSameType(const PathFilter& other) const override; + + const NameFilter first_; + const NameFilter second_; +}; + + + + + + +//--------------- inline implementation --------------------------------------- +inline +bool NullFilter::passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const +{ + assert(!childItemMightMatch || *childItemMightMatch); //check correct usage + return true; +} + + +inline +FilterRef NullFilter::copyFilterAddingExclusion(const Zstring& excludePhrase) const +{ + auto filter = zen::makeSharedRef(Zstr("*"), excludePhrase); + if (filter.ref().isNull()) + return zen::makeSharedRef(); + return filter; +} + + +inline +FilterRef NameFilter::copyFilterAddingExclusion(const Zstring& excludePhrase) const +{ + auto tmp = zen::makeSharedRef(*this); + tmp.ref().addExclusion(excludePhrase); + return tmp; +} + + +inline +bool CombinedFilter::passFileFilter(const Zstring& relFilePath) const +{ + return first_ .passFileFilter(relFilePath) && //short-circuit behavior + second_.passFileFilter(relFilePath); +} + + +inline +bool CombinedFilter::passDirFilter(const Zstring& relDirPath, bool* childItemMightMatch) const +{ + if (first_.passDirFilter(relDirPath, childItemMightMatch)) + return second_.passDirFilter(relDirPath, childItemMightMatch); + else + { + if (childItemMightMatch && *childItemMightMatch) + second_.passDirFilter(relDirPath, childItemMightMatch); + return false; + } +} + + +inline +bool CombinedFilter::isNull() const +{ + return first_.isNull() && second_.isNull(); +} + + +inline +FilterRef CombinedFilter::copyFilterAddingExclusion(const Zstring& excludePhrase) const +{ + NameFilter tmp(first_); + tmp.addExclusion(excludePhrase); + + return zen::makeSharedRef(tmp, second_); +} + + +inline +std::strong_ordering CombinedFilter::compareSameType(const PathFilter& other) const +{ + assert(typeid(*this) == typeid(other)); //always given in this context! + + const CombinedFilter& lhs = *this; + const CombinedFilter& rhs = static_cast(other); + + if (const std::strong_ordering cmp = lhs.first_.compareSameType(rhs.first_); + cmp != std::strong_ordering::equal) + return cmp; + + return lhs.second_.compareSameType(rhs.second_); +} + + +inline +FilterRef constructFilter(const Zstring& includePhrase, + const Zstring& excludePhrase, + const Zstring& includePhrase2, + const Zstring& excludePhrase2) +{ + if (NameFilter::isNull(includePhrase, Zstring())) + { + auto filterTmp = zen::makeSharedRef(includePhrase2, excludePhrase + Zstr('\n') + excludePhrase2); + if (filterTmp.ref().isNull()) + return zen::makeSharedRef(); + + return filterTmp; + } + else + { + if (NameFilter::isNull(includePhrase2, Zstring())) + return zen::makeSharedRef(includePhrase, excludePhrase + Zstr('\n') + excludePhrase2); + else + return zen::makeSharedRef(NameFilter(includePhrase, excludePhrase + Zstr('\n') + excludePhrase2), NameFilter(includePhrase2, Zstring())); + } +} +} + +#endif //HARD_FILTER_H_825780275842758345 diff --git a/FreeFileSync/Source/base/process_callback.h b/FreeFileSync/Source/base/process_callback.h new file mode 100644 index 0000000..8803c04 --- /dev/null +++ b/FreeFileSync/Source/base/process_callback.h @@ -0,0 +1,91 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PROCESS_CALLBACK_H_48257827842345454545 +#define PROCESS_CALLBACK_H_48257827842345454545 + +#include +#include +#include + + +namespace fff +{ +struct PhaseCallback +{ + virtual ~PhaseCallback() {} + + //note: this one must NOT throw in order to properly allow undoing setting of statistics! + //it is in general paired with a call to requestUiUpdate() to compensate! + virtual void updateDataProcessed(int itemsDelta, int64_t bytesDelta) = 0; //noexcept! + virtual void updateDataTotal (int itemsDelta, int64_t bytesDelta) = 0; // + /* the estimated and actual total workload may change *during* sync: + 1. file cannot be moved -> fallback to copy + delete + 2. file copy, actual size changed after comparison + 3. file contains significant ADS data, is sparse or compressed + 4. file/directory already deleted externally: nothing to do, 0 logical operations and data + 5. auto-resolution for failed create operations due to missing source + 6. directory deletion: may contain more items than scanned by FFS (excluded by filter) or less (contains followed symlinks) + 7. delete directory to recycler: no matter how many child-elements exist, this is only 1 item to process! + 8. user-defined deletion directory on different volume: full file copy required (instead of move) + 9. Binary file comparison: short-circuit behavior after first difference is found + 10. Error during file copy, retry: bytes were copied => increases total workload! */ + + //opportunity to abort must be implemented in a frequently-executed method like requestUiUpdate() + virtual void requestUiUpdate(bool force = false) = 0; //throw X + + //UI info only, should *not* be logged: called periodically after data was processed: expected(!) to request GUI update + virtual void updateStatus(std::wstring&& msg) = 0; //throw X + + enum class MsgType + { + info, + warning, + error, + }; + //log only; must *not* call updateStatus()! + virtual void logMessage(const std::wstring& msg, MsgType type) = 0; //throw X + + virtual void reportWarning(const std::wstring& msg, bool& warningActive) = 0; //throw X + + struct ErrorInfo + { + std::wstring msg; + std::chrono::steady_clock::time_point failTime; + size_t retryNumber = 0; + }; + enum Response + { + ignore, + retry + }; + virtual Response reportError(const ErrorInfo& errorInfo) = 0; //throw X; recoverable error + + virtual void reportFatalError(const std::wstring& msg) = 0; //throw X; non-recoverable error +}; + +//perform ui updates not more often than necessary: +constexpr std::chrono::milliseconds UI_UPDATE_INTERVAL(50); //20 FPS +//- Win 7 copy progress bar uses 100 ms +//- Windows 10: not seeing CPU impact in Process Explorer when going as low as 2ms => too good to be true? + +enum class ProcessPhase +{ + none, //initial status + scan, + binaryCompare, + sync +}; + +//interface for comparison and synchronization process status updates (used by GUI and Batch mode) +struct ProcessCallback : public PhaseCallback +{ + //informs about the estimated amount of data that will be processed in the next synchronization phase + virtual void initNewPhase(int itemsTotal, int64_t bytesTotal, ProcessPhase phaseId) = 0; //throw X +}; +} + +#endif //PROCESS_CALLBACK_H_48257827842345454545 diff --git a/FreeFileSync/Source/base/soft_filter.h b/FreeFileSync/Source/base/soft_filter.h new file mode 100644 index 0000000..170e355 --- /dev/null +++ b/FreeFileSync/Source/base/soft_filter.h @@ -0,0 +1,111 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SOFT_FILTER_H_810457108534657 +#define SOFT_FILTER_H_810457108534657 + +#include +#include "structures.h" + + +namespace fff +{ +/* +Semantics of SoftFilter: +1. It potentially may match only one side => it MUST NOT be applied while traversing a single folder to avoid mismatches +2. => it is applied after traversing and just marks rows, (NO deletions after comparison are allowed) +3. => equivalent to a user temporarily (de-)selecting rows => not relevant for -mode! +*/ + +class SoftFilter +{ +public: + SoftFilter(size_t timeSpan, UnitTime unitTimeSpan, + uint64_t sizeMin, UnitSize unitSizeMin, + uint64_t sizeMax, UnitSize unitSizeMax); + + bool matchTime(time_t writeTime) const { return timeFrom_ <= writeTime; } + bool matchSize(uint64_t fileSize) const { return sizeMin_ <= fileSize && fileSize <= sizeMax_; } + bool matchFolder() const { return matchesFolder_; } + bool isNull() const; //filter is equivalent to NullFilter, but may be technically slower + + //small helper method: merge two soft filters + friend SoftFilter combineFilters(const SoftFilter& first, const SoftFilter& second); + +private: + SoftFilter(time_t timeFrom, + uint64_t sizeMin, + uint64_t sizeMax, + bool matchesFolder); + + time_t timeFrom_ = 0; //unit: UTC, seconds + uint64_t sizeMin_ = 0; //unit: bytes + uint64_t sizeMax_ = 0; //unit: bytes + const bool matchesFolder_; +}; + + + + + + + + + + + + + + +// ----------------------- implementation ----------------------- +inline +SoftFilter::SoftFilter(size_t timeSpan, UnitTime unitTimeSpan, + uint64_t sizeMin, UnitSize unitSizeMin, + uint64_t sizeMax, UnitSize unitSizeMax) : + matchesFolder_(unitTimeSpan == UnitTime::none && + unitSizeMin == UnitSize::none && + unitSizeMax == UnitSize::none) //exclude folders if size or date filter is active: avoids creating empty folders if not needed! +{ + resolveUnits(timeSpan, unitTimeSpan, + sizeMin, unitSizeMin, + sizeMax, unitSizeMax, + timeFrom_, + sizeMin_, + sizeMax_); +} + +inline +SoftFilter::SoftFilter(time_t timeFrom, + uint64_t sizeMin, + uint64_t sizeMax, + bool matchesFolder) : + timeFrom_(timeFrom), + sizeMin_ (sizeMin), + sizeMax_ (sizeMax), + matchesFolder_(matchesFolder) {} + + +inline +SoftFilter combineFilters(const SoftFilter& lhs, const SoftFilter& rhs) +{ + return SoftFilter(std::max(lhs.timeFrom_, rhs.timeFrom_), + std::max(lhs.sizeMin_, rhs.sizeMin_), + std::min(lhs.sizeMax_, rhs.sizeMax_), + lhs.matchesFolder_ && rhs.matchesFolder_); +} + + +inline +bool SoftFilter::isNull() const //filter is equivalent to NullFilter, but may be technically slower +{ + return timeFrom_ == std::numeric_limits::min() && + sizeMin_ == 0U && + sizeMax_ == std::numeric_limits::max() && + matchesFolder_; +} +} + +#endif //SOFT_FILTER_H_810457108534657 diff --git a/FreeFileSync/Source/base/speed_test.cpp b/FreeFileSync/Source/base/speed_test.cpp new file mode 100644 index 0000000..3480208 --- /dev/null +++ b/FreeFileSync/Source/base/speed_test.cpp @@ -0,0 +1,226 @@ +// ***************************************************************************** +// * 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 "speed_test.h" +#include +#include +#include + +using namespace zen; +using namespace fff; + + +void SpeedTest::addSample(std::chrono::nanoseconds timeElapsed, int itemsCurrent, int64_t bytesCurrent) +{ + //time expected to be monotonously ascending + assert(samples_.empty() || samples_.back().timeElapsed <= timeElapsed); + + samples_.push_back(Sample{timeElapsed, itemsCurrent, bytesCurrent}); + + //remove old records outside of "window" + std::optional lastPop; + while (!samples_.empty() && samples_.front().timeElapsed <= timeElapsed - windowSize_) + { + lastPop = samples_.front(); + samples_.pop_front(); + } + if (lastPop) //keep one point before new start to handle gaps + samples_.push_front(*lastPop); +} + + +std::optional SpeedTest::getRemainingSec(int /*itemsRemaining*/, int64_t bytesRemaining) const +{ + if (!samples_.empty()) + { + const double timeDelta = std::chrono::duration(samples_.back().timeElapsed - samples_.front().timeElapsed).count(); + const int64_t bytesDelta = samples_.back().bytes - samples_.front().bytes; + + //"items" counts logical operations *NOT* disk accesses, so we better play safe and use "bytes" only! + + if (bytesDelta != 0) //sign(dataRemaining) != sign(bytesDelta) usually an error, so show it! + return bytesRemaining * timeDelta / bytesDelta; + } + return std::nullopt; +} + + +std::optional SpeedTest::getBytesPerSec() const +{ + if (!samples_.empty()) + { + const double timeDelta = std::chrono::duration(samples_.back().timeElapsed - samples_.front().timeElapsed).count(); + const int64_t bytesDelta = samples_.back().bytes - samples_.front().bytes; + + if (!numeric::isNull(timeDelta)) + return bytesDelta / timeDelta; + } + return std::nullopt; +} + + +std::optional SpeedTest::getItemsPerSec() const +{ + if (!samples_.empty()) + { + const double timeDelta = std::chrono::duration(samples_.back().timeElapsed - samples_.front().timeElapsed).count(); + const int itemsDelta = samples_.back().items - samples_.front().items; + + if (!numeric::isNull(timeDelta)) + return itemsDelta / timeDelta; + } + return std::nullopt; +} + + +std::wstring SpeedTest::getBytesPerSecFmt() const +{ + if (const std::optional bps = getBytesPerSec()) + return replaceCpy(_("%x/sec"), L"%x", formatFilesizeShort(std::llround(*bps))); + return {}; +} + + +std::wstring SpeedTest::getItemsPerSecFmt() const +{ + if (const std::optional ips = getItemsPerSec()) + return replaceCpy(_("%x/sec"), L"%x", replaceCpy(_("%x items"), L"%x", formatTwoDigitPrecision(*ips))); + return {}; +} + + +/* +class for calculation of remaining time: +---------------------------------------- +"filesize |-> time" is an affine linear function f(x) = z_1 + z_2 x + +For given n measurements, sizes x_0, ..., x_n and times f_0, ..., f_n, the function f (as a polynom of degree 1) can be lineary approximated by + +z_1 = (r - s * q / p) / ((n + 1) - s * s / p) +z_2 = (q - s * z_1) / p = (r - (n + 1) z_1) / s + +with +p := x_0^2 + ... + x_n^2 +q := f_0 x_0 + ... + f_n x_n +r := f_0 + ... + f_n +s := x_0 + ... + x_n + +=> the time to process N files with amount of data D is: N * z_1 + D * z_2 + +Problem: +-------- +Times f_0, ..., f_n can be very small so that precision of the PC clock is poor. +=> Times have to be accumulated to enhance precision: +Copying of m files with sizes x_i and times f_i (i = 1, ..., m) takes sum_i f(x_i) := m * z_1 + z_2 * sum x_i = sum f_i +With X defined as the accumulated sizes and F the accumulated times this gives: (in theory...) +m * z_1 + z_2 * X = F <=> +z_1 + z_2 * X / m = F / m + +=> we obtain a new (artificial) measurement with size X / m and time F / m to be used in the linear approximation above + + +Statistics::Statistics(int totalObjectCount, double totalDataAmount, unsigned recordCount) : + itemsTotal(totalObjectCount), + bytesTotal(totalDataAmount), + recordsMax(recordCount), + objectsLast(0), + dataLast(0), + timeLast(wxGetLocalTimeMillis()), + z1_current(0), + z2_current(0), + dummyRecordPresent(false) {} + + +wxString Statistics::getRemainingTime(int objectsCurrent, double dataCurrent) +{ + //add new measurement point + const int m = objectsCurrent - objectsLast; + if (m != 0) + { + objectsLast = objectsCurrent; + + const double X = dataCurrent - dataLast; + dataLast = dataCurrent; + + const int64_t timeCurrent = wxGetLocalTimeMillis(); + const double F = (timeCurrent - timeLast).ToDouble(); + timeLast = timeCurrent; + + record newEntry; + newEntry.x_i = X / m; + newEntry.f_i = F / m; + + //remove dummy record + if (dummyRecordPresent) + { + measurements.pop_back(); + dummyRecordPresent = false; + } + + //insert new record + measurements.push_back(newEntry); + if (measurements.size() > recordsMax) + measurements.pop_front(); + } + else //dataCurrent increased without processing new objects: + { //modify last measurement until m != 0 + const double X = dataCurrent - dataLast; //do not set dataLast, timeLast variables here, but write dummy record instead + if (!isNull(X)) + { + const int64_t timeCurrent = wxGetLocalTimeMillis(); + const double F = (timeCurrent - timeLast).ToDouble(); + + record modifyEntry; + modifyEntry.x_i = X; + modifyEntry.f_i = F; + + //insert dummy record + if (!dummyRecordPresent) + { + measurements.push_back(modifyEntry); + if (measurements.size() > recordsMax) + measurements.pop_front(); + dummyRecordPresent = true; + } + else //modify dummy record + measurements.back() = modifyEntry; + } + } + + //calculate remaining time based on stored measurement points + double p = 0; + double q = 0; + double r = 0; + double s = 0; + for (const record& rec : measurements) + { + const double x_i = rec.x_i; + const double f_i = rec.f_i; + p += x_i * x_i; + q += f_i * x_i; + r += f_i; + s += x_i; + } + + if (!isNull(p)) + { + const double n = measurements.size(); + const double tmp = (n - s * s / p); + + if (!isNull(tmp) && !isNull(s)) + { + const double z1 = (r - s * q / p) / tmp; + const double z2 = (r - n * z1) / s; //not (n + 1) here, since n already is the number of measurements + + //refresh current values for z1, z2 + z1_current = z1; + z2_current = z2; + } + } + + return formatRemainingTime((itemsTotal - objectsCurrent) * z1_current + (bytesTotal - dataCurrent) * z2_current); +} +*/ diff --git a/FreeFileSync/Source/base/speed_test.h b/FreeFileSync/Source/base/speed_test.h new file mode 100644 index 0000000..dad2f12 --- /dev/null +++ b/FreeFileSync/Source/base/speed_test.h @@ -0,0 +1,47 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PERF_CHECK_H_87804217589312454 +#define PERF_CHECK_H_87804217589312454 + +#include +#include +#include +#include + + +namespace fff +{ +class SpeedTest +{ +public: + explicit SpeedTest(std::chrono::milliseconds windowSize) : windowSize_(windowSize) {} + + void addSample(std::chrono::nanoseconds timeElapsed, int itemsCurrent, int64_t bytesCurrent); + + std::optional getRemainingSec(int itemsRemaining, int64_t bytesRemaining) const; + std::optional getBytesPerSec() const; + std::optional getItemsPerSec() const; + + std::wstring getBytesPerSecFmt() const; //empty if not (yet) available + std::wstring getItemsPerSecFmt() const; // + + void clear() { samples_.clear(); } + +private: + struct Sample + { + std::chrono::nanoseconds timeElapsed{}; //std::chrono::duration is uninitialized by default! WTF + int items = 0; + int64_t bytes = 0; + }; + + const std::chrono::milliseconds windowSize_; + zen::RingBuffer samples_; +}; +} + +#endif //PERF_CHECK_H_87804217589312454 diff --git a/FreeFileSync/Source/base/status_handler_impl.h b/FreeFileSync/Source/base/status_handler_impl.h new file mode 100644 index 0000000..7f67042 --- /dev/null +++ b/FreeFileSync/Source/base/status_handler_impl.h @@ -0,0 +1,559 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STATUS_HANDLER_IMPL_H_07682758976 +#define STATUS_HANDLER_IMPL_H_07682758976 + +#include +#include +#include +#include "process_callback.h" +#include "speed_test.h" + + +namespace fff +{ +class AsyncCallback +{ +public: + AsyncCallback() {} + + //non-blocking: context of worker thread (and main thread, see reportStats()) + void updateDataProcessed(int itemsDelta, int64_t bytesDelta) //noexcept! + { + itemsDeltaProcessed_ += itemsDelta; + bytesDeltaProcessed_ += bytesDelta; + } + void updateDataTotal(int itemsDelta, int64_t bytesDelta) //noexcept! + { + itemsDeltaTotal_ += itemsDelta; + bytesDeltaTotal_ += bytesDelta; + } + + //context of worker thread + void updateStatus(std::wstring&& msg) //throw ThreadStopRequest + { + assert(!zen::runningOnMainThread()); + { + std::lock_guard dummy(lockCurrentStatus_); + if (ThreadStatus* ts = getThreadStatus()) //call while holding "lockCurrentStatus_" lock!! + ts->statusMsg = std::move(msg); + else assert(false); + } + zen::interruptionPoint(); //throw ThreadStopRequest + } + + //blocking call: context of worker thread + //=> indirect support for "pause": logInfo() is called under singleThread lock, + // so all other worker threads will wait when coming out of parallel I/O (trying to lock singleThread) + void logMessage(const std::wstring& msg, PhaseCallback::MsgType type) //throw ThreadStopRequest + { + assert(!zen::runningOnMainThread()); + { + std::unique_lock dummy(lockRequest_); + zen::interruptibleWait(conditionReadyForNewRequest_, dummy, [this] { return !logMsgRequest_; }); //throw ThreadStopRequest + + logMsgRequest_ = LogMsgRequest{msg, type}; + } + conditionNewRequest.notify_all(); + } + + //blocking call: context of worker thread + PhaseCallback::Response reportError(const PhaseCallback::ErrorInfo& errorInfo) //throw ThreadStopRequest + { + assert(!zen::runningOnMainThread()); + std::unique_lock dummy(lockRequest_); + zen::interruptibleWait(conditionReadyForNewRequest_, dummy, [this] { return !errorRequest_ && !errorResponse_; }); //throw ThreadStopRequest + + errorRequest_ = errorInfo; + conditionNewRequest.notify_all(); + + zen::interruptibleWait(conditionHaveResponse_, dummy, [this] { return static_cast(errorResponse_); }); //throw ThreadStopRequest + + PhaseCallback::Response rv = *errorResponse_; + + errorRequest_ = std::nullopt; + errorResponse_ = std::nullopt; + + dummy.unlock(); //optimization for condition_variable::notify_all() + conditionReadyForNewRequest_.notify_all(); //=> spurious wake-up for AsyncCallback::logInfo() + return rv; + } + + //blocking call: context of worker thread + void reportWarning(const std::wstring& msg, bool& warningActive) //throw ThreadStopRequest + { + assert(!zen::runningOnMainThread()); + { + std::unique_lock dummy(lockRequest_); + zen::interruptibleWait(conditionReadyForNewRequest_, dummy, [this] { return !warningRequest_ && !warningResponse_; }); //throw ThreadStopRequest + + warningRequest_ = WarningRequest{msg, warningActive}; + conditionNewRequest.notify_all(); + + zen::interruptibleWait(conditionHaveResponse_, dummy, [this] { return static_cast(warningResponse_); }); //throw ThreadStopRequest + + warningActive = warningResponse_->warningActive; + + warningRequest_ = std::nullopt; + warningResponse_ = std::nullopt; + } + conditionReadyForNewRequest_.notify_all(); //=> spurious wake-up for AsyncCallback::logInfo() + } + + //context of main thread + std::pair waitUntilDone(std::chrono::milliseconds cbInterval, PhaseCallback& cb) //throw X + { + assert(zen::runningOnMainThread()); + for (;;) + { + const std::chrono::steady_clock::time_point callbackTime = std::chrono::steady_clock::now() + cbInterval; + + for (std::unique_lock dummy(lockRequest_);;) //process all errors without delay + { + const bool rv = conditionNewRequest.wait_until(dummy, callbackTime, [this] + { + return logMsgRequest_ || (errorRequest_ && !errorResponse_) || (warningRequest_ && !warningResponse_) || finishNowRequest_; + }); + if (!rv) //time-out + condition not met + break; + + if (logMsgRequest_) + { + cb.logMessage(logMsgRequest_->msg, logMsgRequest_->type); //throw X + logMsgRequest_ = {}; + conditionReadyForNewRequest_.notify_all(); //=> spurious wake-up for AsyncCallback::reportError() + } + if (errorRequest_ && !errorResponse_) + { + assert(!finishNowRequest_); + errorResponse_ = cb.reportError(*errorRequest_); //throw X + conditionHaveResponse_.notify_all(); //instead of notify_one(); work around bug: https://svn.boost.org/trac/boost/ticket/7796 + } + if (warningRequest_ && !warningResponse_) + { + assert(!finishNowRequest_); + bool warningActive = warningRequest_->warningActive; + cb.reportWarning(warningRequest_->msg, warningActive); //throw X + warningResponse_ = WarningResponse{warningActive}; + conditionHaveResponse_.notify_all(); + } + if (finishNowRequest_) + { + dummy.unlock(); //call member functions outside of mutex scope: + reportStats(cb); //one last call for accurate stat-reporting! + return std::make_pair(itemsProcessed_, bytesProcessed_); + } + } + + //call back outside of mutex scope: + cb.updateStatus(getStatusMsg()); //throw X + reportStats(cb); + } + } + + void notifyTaskBegin(size_t prio) //noexcept + { + assert(!zen::runningOnMainThread()); + const std::thread::id threadId = std::this_thread::get_id(); + std::lock_guard dummy(lockCurrentStatus_); + assert(!getThreadStatus()); + + if (statusByPriority_.size() < prio + 1) + statusByPriority_.resize(prio + 1); + + statusByPriority_[prio].push_back({threadId, std::wstring()}); + } + + void notifyTaskEnd() //noexcept + { + assert(!zen::runningOnMainThread()); + const std::thread::id threadId = std::this_thread::get_id(); + std::lock_guard dummy(lockCurrentStatus_); + + for (std::vector& sbp : statusByPriority_) + for (ThreadStatus& ts : sbp) + if (ts.threadId == threadId) + { + std::swap(ts, sbp.back()); + sbp.pop_back(); + return; + } + assert(false); + } + + void notifyAllDone() //noexcept + { + { + std::lock_guard dummy(lockRequest_); + assert(!finishNowRequest_); + finishNowRequest_ = true; + } + conditionNewRequest.notify_all(); + } + +private: + AsyncCallback (const AsyncCallback&) = delete; + AsyncCallback& operator=(const AsyncCallback&) = delete; + + struct ThreadStatus + { + std::thread::id threadId; + std::wstring statusMsg; + }; + + ThreadStatus* getThreadStatus() //call while holding "lockCurrentStatus_" lock!! + { + assert(!zen::runningOnMainThread()); + const std::thread::id threadId = std::this_thread::get_id(); + + for (std::vector& sbp : statusByPriority_) + for (ThreadStatus& ts : sbp) //thread count is (hopefully) small enough so that linear search won't hurt perf + if (ts.threadId == threadId) + return &ts; + return nullptr; + } + + //context of main thread + void reportStats(PhaseCallback& cb) + { + assert(zen::runningOnMainThread()); + const int itemsDeltaProcessed = itemsDeltaProcessed_; //get value snapshot from atomics + const int64_t bytesDeltaProcessed = bytesDeltaProcessed_; // + if (itemsDeltaProcessed != 0 || bytesDeltaProcessed != 0) + { + updateDataProcessed (-itemsDeltaProcessed, -bytesDeltaProcessed); //careful with these atomics: don't just set to 0 + cb.updateDataProcessed( itemsDeltaProcessed, bytesDeltaProcessed); //noexcept! + + itemsProcessed_ += itemsDeltaProcessed; + bytesProcessed_ += bytesDeltaProcessed; + } + + const int itemsDeltaTotal = itemsDeltaTotal_; + const int64_t bytesDeltaTotal = bytesDeltaTotal_; + if (itemsDeltaTotal != 0 || bytesDeltaTotal != 0) + { + updateDataTotal (-itemsDeltaTotal, -bytesDeltaTotal); + cb.updateDataTotal( itemsDeltaTotal, bytesDeltaTotal); //noexcept! + } + } + + //context of main thread, call repreatedly + std::wstring getStatusMsg() + { + assert(zen::runningOnMainThread()); + + size_t parallelOpsTotal = 0; + std::wstring statusMsg; + { + std::lock_guard dummy(lockCurrentStatus_); + + for (const auto& sbp : statusByPriority_) + parallelOpsTotal += sbp.size(); + + statusMsg = [&] + { + for (const std::vector& sbp : statusByPriority_) + for (const ThreadStatus& ts : sbp) + if (!ts.statusMsg.empty()) + return ts.statusMsg; + return std::wstring(); + }(); + } + if (parallelOpsTotal >= 2) + return L'[' + _P("1 thread", "%x threads", parallelOpsTotal) + L"] " + statusMsg; + else + return statusMsg; + } + + struct LogMsgRequest + { + std::wstring msg; + PhaseCallback::MsgType type = PhaseCallback::MsgType::error; + }; + struct WarningRequest + { + std::wstring msg; + bool warningActive = false; + }; + struct WarningResponse { bool warningActive = false; }; + + //---- main <-> worker communication channel ---- + std::mutex lockRequest_; + std::condition_variable conditionReadyForNewRequest_; + std::condition_variable conditionNewRequest; + std::condition_variable conditionHaveResponse_; + std::optional logMsgRequest_; + std::optional errorRequest_; + std::optional errorResponse_; + std::optional warningRequest_; + std::optional warningResponse_; + bool finishNowRequest_ = false; + + //---- status updates ---- + std::mutex lockCurrentStatus_; //different lock for status updates so that we're not blocked by other threads reporting errors + std::vector> statusByPriority_; + //give status messages priority according to their folder pair (e.g. first folder pair has prio 0) => visualize (somewhat) natural processing order + + //---- status updates II (lock-free) ---- + std::atomic itemsDeltaProcessed_{0}; // + std::atomic bytesDeltaProcessed_{0}; //std:atomic is uninitialized by default! + std::atomic itemsDeltaTotal_ {0}; // + std::atomic bytesDeltaTotal_ {0}; // + + //---- aggregated numbers; accessed by main thread only ---- + int itemsProcessed_ = 0; + int64_t bytesProcessed_ = 0; +}; + + +//manage statistics reporting for a single item of work +template +class ItemStatReporter +{ +public: + ItemStatReporter(int itemsExpected, int64_t bytesExpected, Callback& cb) : + itemsExpected_(itemsExpected), + bytesExpected_(bytesExpected), + cb_(cb) {} + + ~ItemStatReporter() + { + const bool scopeFail = std::uncaught_exceptions() > exeptionCount_; + if (scopeFail) + cb_.updateDataTotal(itemsReported_, bytesReported_); //=> unexpected increase of total workload + else + //update statistics to consider the real amount of data, e.g. CopyFileEx: more than the "file size" for ADS streams, + //less for sparse and compressed files, or file changed in the meantime! + cb_.updateDataTotal(itemsReported_ - itemsExpected_, bytesReported_ - bytesExpected_); //noexcept! + } + + void updateStatus(std::wstring&& msg) { cb_.updateStatus(std::move(msg)); } //throw X + + void logMessage(const std::wstring& msg, PhaseCallback::MsgType type) { cb_.logMessage(msg, type); } //throw X + + void reportWarning(const std::wstring& msg, bool& warningActive) { cb_.reportWarning(msg, warningActive); }//throw X + + void reportDelta(int itemsDelta, int64_t bytesDelta) //noexcept! + { + cb_.updateDataProcessed(itemsDelta, bytesDelta); //noexcept! + itemsReported_ += itemsDelta; + bytesReported_ += bytesDelta; + + //special rule: avoid temporary statistics mess up, even though they are corrected anyway below: + if (itemsReported_ > itemsExpected_) + { + cb_.updateDataTotal(itemsReported_ - itemsExpected_, 0); //noexcept! + itemsReported_ = itemsExpected_; + } + if (bytesReported_ > bytesExpected_) + { + cb_.updateDataTotal(0, bytesReported_ - bytesExpected_); //=> everything above "bytesExpected" adds to both "processed" and "total" data + bytesReported_ = bytesExpected_; + } + } + +private: + int itemsReported_ = 0; + int64_t bytesReported_ = 0; + const int itemsExpected_; + const int64_t bytesExpected_; + Callback& cb_; + const int exeptionCount_ = std::uncaught_exceptions(); +}; + +using AsyncItemStatReporter = ItemStatReporter; + +//===================================================================================================================== + +constexpr std::chrono::seconds STATUS_PERCENT_DELAY(2); +constexpr std::chrono::seconds STATUS_PERCENT_MIN_DURATION(3); +const int STATUS_PERCENT_MIN_CHANGES_PER_SEC = 2; +constexpr std::chrono::seconds STATUS_PERCENT_SPEED_WINDOW(10); + +template +struct PercentStatReporter +{ + PercentStatReporter(const std::wstring& statusMsg, int64_t bytesExpected, ItemStatReporter& statReporter) : + msgPrefix_(statusMsg + L"... "), + bytesExpected_(bytesExpected), + statReporter_(statReporter) {} + //[!] no "updateStatus() /*throw X*/" in constructor! let caller decide + + void updateDeltaAndStatus(int64_t bytesDelta) //throw X + { + statReporter_.reportDelta(0 /*itemsDelta*/, bytesDelta); + bytesCopied_ += bytesDelta; + + const auto now = std::chrono::steady_clock::now(); + if (now >= lastUpdate_ + UI_UPDATE_INTERVAL / 2) //every ~25 ms + { + lastUpdate_ = now; + + if (!showPercent_ && bytesCopied_ > 0) + { + if (startTime_ == std::chrono::steady_clock::time_point()) + { + startTime_ = now; //get higher-quality perf stats when starting timing here rather than constructor!? + speedTest_.addSample(std::chrono::seconds(0), 0 /*itemsCurrent*/, bytesCopied_); + } + else if (const std::chrono::nanoseconds elapsed = now - startTime_; + elapsed >= STATUS_PERCENT_DELAY) + { + speedTest_.addSample(elapsed, 0 /*itemsCurrent*/, bytesCopied_); + + if (const std::optional remSecs = speedTest_.getRemainingSec(0 /*itemsRemaining*/, bytesExpected_ - bytesCopied_)) + if (*remSecs > std::chrono::duration(STATUS_PERCENT_MIN_DURATION).count()) + { + showPercent_ = true; + speedTest_.clear(); //discard (probably messy) numbers + } + } + } + if (showPercent_) + { + speedTest_.addSample(now - startTime_, 0 /*itemsCurrent*/, bytesCopied_); + const std::optional bps = speedTest_.getBytesPerSec(); + + statReporter_.updateStatus(msgPrefix_ + formatPercent(std::min(static_cast(bytesCopied_) / bytesExpected_, 1.0), //> 100% possible! see process_callback.h notes + bps ? *bps : 0, bytesExpected_)); //throw X + } + } + } + +private: + static std::wstring formatPercent(double fraction, double bytesPerSec, int64_t bytesTotal) + { + const double totalSecs = numeric::isNull(bytesPerSec) ? 0 : bytesTotal / bytesPerSec; + const double expectedSteps = totalSecs * STATUS_PERCENT_MIN_CHANGES_PER_SEC; + + const int decPlaces = [&] //TODO? protect against format flickering!? + { + if (expectedSteps <= 100) return 0; + if (expectedSteps <= 1000) return 1; + if (expectedSteps <= 10000) return 2; + if (expectedSteps <= 100000) return 3; + //return static_cast(std::ceil(std::log10(expectedSteps))) - 2; -> overkill! + /**/ return 4; + }(); + return zen::formatProgressPercent(fraction, decPlaces); + } + + bool showPercent_ = false; + const std::wstring msgPrefix_; + const int64_t bytesExpected_; + int64_t bytesCopied_ = 0; + std::chrono::steady_clock::time_point startTime_; + std::chrono::steady_clock::time_point lastUpdate_; + SpeedTest speedTest_{STATUS_PERCENT_SPEED_WINDOW}; + ItemStatReporter& statReporter_; +}; + +//===================================================================================================================== + +template inline +void reportInfo(std::wstring&& msg, Callback& cb /*throw X*/) //throw X +{ + cb.logMessage(msg, PhaseCallback::MsgType::info); //throw X + cb.updateStatus(std::move(msg)); // +} + + +template inline //return ignored error message if available +std::wstring tryReportingError(Function cmd /*throw FileError*/, Callback& cb /*throw X*/) //throw X +{ + for (size_t retryNumber = 0;; ++retryNumber) + try + { + cmd(); //throw FileError + return std::wstring(); + } + catch (const zen::FileError& e) + { + assert(!e.toString().empty()); + switch (cb.reportError({e.toString(), std::chrono::steady_clock::now(), retryNumber})) //throw X + { + case PhaseCallback::ignore: + return e.toString(); + case PhaseCallback::retry: + break; //continue with loop + } + } +} + +//===================================================================================================================== +struct ParallelContext +{ + const AbstractPath& itemPath; + AsyncCallback& acb; +}; +using ParallelWorkItem = std::function /*throw ThreadStopRequest*/; + + +namespace +{ +void massParallelExecute(const std::vector>& workload, + const Zstring& threadGroupName, + PhaseCallback& callback /*throw X*/) //throw X +{ + using namespace zen; + + std::map*>> perDeviceWorkload; + for (const auto& item : workload) + perDeviceWorkload[item.first.afsDevice].push_back(&item); + + if (perDeviceWorkload.empty()) + return; //[!] otherwise AsyncCallback::notifyAllDone() is never called! + + AsyncCallback acb; //manage life time: enclose ThreadGroup's!!! + std::atomic activeDeviceCount(perDeviceWorkload.size()); // + + //--------------------------------------------------------------------------------------------------------- + std::vector>> deviceThreadGroups; //worker threads live here... + //--------------------------------------------------------------------------------------------------------- + + for (const auto& [afsDevice, wl] : perDeviceWorkload) + { + const size_t statusPrio = deviceThreadGroups.size(); + + const Zstring& deviceGroupName = threadGroupName + Zstr(' ') + utfTo(AFS::getDisplayPath(AbstractPath(afsDevice, AfsPath()))); + deviceThreadGroups.emplace_back(1, deviceGroupName); + auto& threadGroup = deviceThreadGroups.back(); + + for (const std::pair* item : wl) + threadGroup.run([&acb, statusPrio, &itemPath = item->first, &task = item->second] + { + acb.notifyTaskBegin(statusPrio); + ZEN_ON_SCOPE_EXIT(acb.notifyTaskEnd()); + + ParallelContext pctx{itemPath, acb}; + task(pctx); //throw ThreadStopRequest + }); + + threadGroup.notifyWhenDone([&acb, &activeDeviceCount] /*noexcept! runs on worker thread!*/ + { + if (--activeDeviceCount == 0) + acb.notifyAllDone(); //noexcept + }); + } + + acb.waitUntilDone(UI_UPDATE_INTERVAL / 2 /*every ~25 ms*/, callback); //throw X +} +} + +//===================================================================================================================== + +template inline +auto parallelScope(Function&& fun, std::mutex& singleThread) //throw X +{ + singleThread.unlock(); + ZEN_ON_SCOPE_EXIT(singleThread.lock()); + + return fun(); //throw X +} +} + +#endif //STATUS_HANDLER_IMPL_H_07682758976 diff --git a/FreeFileSync/Source/base/structures.cpp b/FreeFileSync/Source/base/structures.cpp new file mode 100644 index 0000000..19554b0 --- /dev/null +++ b/FreeFileSync/Source/base/structures.cpp @@ -0,0 +1,380 @@ +// ***************************************************************************** +// * 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 "structures.h" +#include +#include +#include "../afs/concrete.h" + +using namespace zen; +using namespace fff; + + +std::wstring fff::getVariantName(std::optional var) +{ + if (!var) + return _("Multiple..."); + + switch (*var) + { + case CompareVariant::timeSize: return _("File time and size"); + case CompareVariant::content: return _("File content"); + case CompareVariant::size: return _("File size"); + } + assert(false); + return _("Error"); +} + + +std::wstring fff::getVariantName(std::optional var) +{ + if (!var) + return _("Multiple..."); + + switch (*var) + { + case SyncVariant::twoWay: return _("Two way"); + case SyncVariant::mirror: return _("Mirror"); + case SyncVariant::update: return _("Update"); + case SyncVariant::custom: return _("Custom"); + } + assert(false); + return _("Error"); +} + + +//use in sync log files where users expect ANSI: https://freefilesync.org/forum/viewtopic.php?t=4647 +std::wstring fff::getVariantNameWithSymbol(SyncVariant var) +{ + switch (var) + { + case SyncVariant::twoWay: return _("Two way") + L" <->"; + case SyncVariant::mirror: return _("Mirror") + L" ->"; + case SyncVariant::update: return _("Update") + L" >"; + case SyncVariant::custom: return _("Custom") + L" <>"; + } + assert(false); + return _("Error"); +} + + +DirectionByDiff fff::getDiffDirDefault(const DirectionByChange& changeDirs) +{ + return + { + .leftOnly = changeDirs.left.create, + .rightOnly = changeDirs.right.create, + .leftNewer = changeDirs.left.update, + .rightNewer = changeDirs.right.update, + }; +} + + +DirectionByChange fff::getChangesDirDefault(const DirectionByDiff& diffDirs) +{ + return + { + .left + { + .create = diffDirs.leftOnly, + .update = diffDirs.leftNewer, + .delete_ = diffDirs.rightOnly, + }, + .right + { + .create = diffDirs.rightOnly, + .update = diffDirs.rightNewer, + .delete_ = diffDirs.leftOnly, + } + }; +} + + +namespace +{ +DirectionByChange getTwoWayDirSet() +{ + return + { + .left + { + .create = SyncDirection::right, + .update = SyncDirection::right, + .delete_ = SyncDirection::right, + }, + .right + { + .create = SyncDirection::left, + .update = SyncDirection::left, + .delete_ = SyncDirection::left, + } + }; +} + + +DirectionByDiff getMirrorDirSet() +{ + return + { + .leftOnly = SyncDirection::right, + .rightOnly = SyncDirection::right, + .leftNewer = SyncDirection::right, + .rightNewer = SyncDirection::right, + }; +} + + +DirectionByChange getUpdateDirSet() +{ + return + { + .left + { + .create = SyncDirection::right, + .update = SyncDirection::right, + .delete_ = SyncDirection::none, + }, + .right + { + .create = SyncDirection::none, + .update = SyncDirection::none, + .delete_ = SyncDirection::none, + } + }; +} +} + + +SyncVariant fff::getSyncVariant(const SyncDirectionConfig& cfg) +{ + if (const DirectionByDiff* diffDirs = std::get_if(&cfg.dirs)) + { + if (*diffDirs == getMirrorDirSet()) + return SyncVariant::mirror; + if (*diffDirs == getDiffDirDefault(getUpdateDirSet())) //poor man's "update", still deserves name on GUI + return SyncVariant::update; + } + else + { + const DirectionByChange& changeDirs = std::get(cfg.dirs); + if (changeDirs == getTwoWayDirSet()) + return SyncVariant::twoWay; + if (changeDirs == getChangesDirDefault(getMirrorDirSet())) //equivalent: "mirror" defined in terms of "changes" + return SyncVariant::mirror; + if (changeDirs == getUpdateDirSet()) + return SyncVariant::update; + } + return SyncVariant::custom; +} + + +SyncDirectionConfig fff::getDefaultSyncCfg(SyncVariant syncVar) +{ + switch (syncVar) + { + case SyncVariant::twoWay: return { .dirs = getTwoWayDirSet() }; + case SyncVariant::mirror: return { .dirs = getMirrorDirSet() }; + case SyncVariant::update: return { .dirs = getUpdateDirSet() }; + case SyncVariant::custom: return { .dirs = getDiffDirDefault(getTwoWayDirSet()) }; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); +} + + +size_t fff::getDeviceParallelOps(const std::map& deviceParallelOps, const AfsDevice& afsDevice) +{ + auto it = deviceParallelOps.find(afsDevice); + return std::max(it != deviceParallelOps.end() ? it->second : 1, 1); +} + + +void fff::setDeviceParallelOps(std::map& deviceParallelOps, const AfsDevice& afsDevice, size_t parallelOps) +{ + assert(parallelOps > 0); + if (!AFS::isNullDevice(afsDevice)) + { + if (parallelOps > 1) + deviceParallelOps[afsDevice] = parallelOps; + else + deviceParallelOps.erase(afsDevice); + } +} + + +size_t fff::getDeviceParallelOps(const std::map& deviceParallelOps, const Zstring& folderPathPhrase) +{ + return getDeviceParallelOps(deviceParallelOps, createAbstractPath(folderPathPhrase).afsDevice); +} + + +void fff::setDeviceParallelOps(std::map& deviceParallelOps, const Zstring& folderPathPhrase, size_t parallelOps) +{ + setDeviceParallelOps(deviceParallelOps, createAbstractPath(folderPathPhrase).afsDevice, parallelOps); +} + + +std::wstring fff::getSymbol(CompareFileResult cmpRes) +{ + switch (cmpRes) + { + case FILE_EQUAL: return L"'="; //added quotation mark to avoid error in Excel cell when exporting to *.cvs + case FILE_RENAMED: return L"renamed"; + case FILE_LEFT_ONLY: return L"only <-"; + case FILE_RIGHT_ONLY: return L"only ->"; + case FILE_LEFT_NEWER: return L"newer <-"; + case FILE_RIGHT_NEWER: return L"newer ->"; + case FILE_DIFFERENT_CONTENT: return L"!="; + case FILE_TIME_INVALID: + case FILE_CONFLICT: return L"conflict"; + } + assert(false); + return std::wstring(); +} + + +std::wstring fff::getSymbol(SyncOperation op) +{ + switch (op) + { + case SO_CREATE_LEFT: return L"create <-"; + case SO_CREATE_RIGHT: return L"create ->"; + case SO_DELETE_LEFT: return L"delete <-"; + case SO_DELETE_RIGHT: return L"delete ->"; + case SO_MOVE_LEFT_FROM: return L"move from <-"; + case SO_MOVE_LEFT_TO: return L"move to <-"; + case SO_MOVE_RIGHT_FROM: return L"move from ->"; + case SO_MOVE_RIGHT_TO: return L"move to ->"; + case SO_OVERWRITE_LEFT: return L"update <-"; + case SO_OVERWRITE_RIGHT: return L"update ->"; + case SO_RENAME_LEFT: return L"rename <-"; + case SO_RENAME_RIGHT: return L"rename ->"; + case SO_DO_NOTHING: return L" -"; + case SO_EQUAL: return L"'="; //added quotation mark to avoid error in Excel cell when exporting to *.cvs + case SO_UNRESOLVED_CONFLICT: return L"conflict"; //portable Unicode symbol: ⚡ + }; + assert(false); + return std::wstring(); +} + + +namespace +{ +time_t resolve(size_t value, UnitTime unit, time_t defaultVal) +{ + TimeComp tcLocal = getLocalTime(); //returns TimeComp() on error + if (tcLocal != TimeComp()) + switch (unit) + { + case UnitTime::none: + return defaultVal; + + case UnitTime::today: + case UnitTime::lastDays: + tcLocal.second = 0; //0-61 + tcLocal.minute = 0; //0-59 + tcLocal.hour = 0; //0-23 + break; + + case UnitTime::thisMonth: + tcLocal.second = 0; //0-61 + tcLocal.minute = 0; //0-59 + tcLocal.hour = 0; //0-23 + tcLocal.day = 1; //1-31 + break; + + case UnitTime::thisYear: + tcLocal.second = 0; //0-61 + tcLocal.minute = 0; //0-59 + tcLocal.hour = 0; //0-23 + tcLocal.day = 1; //1-31 + tcLocal.month = 1; //1-12 + break; + } + if (const auto [localTime, timeValid] = localToTimeT(tcLocal);//convert local time back to UTC + timeValid) + { + if (unit == UnitTime::lastDays) + return localTime - value * 24 * 3600; + + return localTime; + } + + assert(false); + return defaultVal; +} + + +uint64_t resolve(uint64_t value, UnitSize unit, uint64_t defaultVal) +{ + constexpr uint64_t maxVal = std::numeric_limits::max(); + + switch (unit) + { + case UnitSize::none: + return defaultVal; + case UnitSize::byte: + return value; + case UnitSize::kb: + return value > maxVal / bytesPerKilo ? maxVal : //prevent overflow!!! + value * bytesPerKilo; + case UnitSize::mb: + return value > maxVal / (bytesPerKilo * bytesPerKilo) ? maxVal : //prevent overflow!!! + value * bytesPerKilo * bytesPerKilo; + } + assert(false); + return defaultVal; +} +} + +void fff::resolveUnits(size_t timeSpan, UnitTime unitTimeSpan, + uint64_t sizeMin, UnitSize unitSizeMin, + uint64_t sizeMax, UnitSize unitSizeMax, + time_t& timeFrom, //unit: UTC time, seconds + uint64_t& sizeMinBy, //unit: bytes + uint64_t& sizeMaxBy) //unit: bytes +{ + timeFrom = resolve(timeSpan, unitTimeSpan, std::numeric_limits::min()); + sizeMinBy = resolve(sizeMin, unitSizeMin, 0U); + sizeMaxBy = resolve(sizeMax, unitSizeMax, std::numeric_limits::max()); +} + + +std::optional fff::getCommonCompVariant(const MainConfiguration& mainCfg) +{ + const CompareVariant firstVar = mainCfg.firstPair.localCmpCfg ? + mainCfg.firstPair.localCmpCfg->compareVar : + mainCfg.cmpCfg.compareVar; //fallback to main sync cfg + + //test if there's a deviating variant within the additional folder pairs + for (const LocalPairConfig& lpc : mainCfg.additionalPairs) + { + const CompareVariant localVariant = lpc.localCmpCfg ? + lpc.localCmpCfg->compareVar : + mainCfg.cmpCfg.compareVar; //fallback to main sync cfg + if (localVariant != firstVar) + return std::nullopt; + } + return firstVar; //seems to be all in sync... +} + + +std::optional fff::getCommonSyncVariant(const MainConfiguration& mainCfg) +{ + const SyncVariant firstVar = getSyncVariant(mainCfg.firstPair.localSyncCfg ? + mainCfg.firstPair.localSyncCfg->directionCfg : + mainCfg.syncCfg.directionCfg); //fallback to main sync cfg + + //test if there's a deviating variant within the additional folder pairs + for (const LocalPairConfig& lpc : mainCfg.additionalPairs) + { + const SyncVariant localVariant = getSyncVariant(lpc.localSyncCfg ? + lpc.localSyncCfg->directionCfg: + mainCfg.syncCfg.directionCfg); + if (localVariant != firstVar) + return std::nullopt; + } + return firstVar; //seems to be all in sync... +} diff --git a/FreeFileSync/Source/base/structures.h b/FreeFileSync/Source/base/structures.h new file mode 100644 index 0000000..d49a4b8 --- /dev/null +++ b/FreeFileSync/Source/base/structures.h @@ -0,0 +1,393 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STRUCTURES_H_8210478915019450901745 +#define STRUCTURES_H_8210478915019450901745 + +#include +#include +#include +#include +#include "../afs/abstract.h" + + +namespace fff +{ +using AFS = AbstractFileSystem; + +enum class CompareVariant +{ + timeSize, + content, + size +}; + + +enum class SymLinkHandling +{ + exclude, + asLink, + follow +}; + + +enum class SyncDirection : unsigned char //save space for use in FileSystemObject! +{ + none, + left, + right +}; + + +enum CompareFileResult +{ + FILE_EQUAL, + FILE_RENAMED, //both sides equal, except for different file name + FILE_LEFT_ONLY, + FILE_RIGHT_ONLY, + FILE_LEFT_NEWER, // + FILE_RIGHT_NEWER, //CompareVariant::timeSize only! + FILE_TIME_INVALID, // -> sync dirction can be determined (if leftNewer/rightNewer agree), unlike with FILE_CONFLICT + FILE_DIFFERENT_CONTENT, //CompareVariant::content, CompareVariant::size only! + FILE_CONFLICT +}; +//attention make sure these /|\ \|/ three enums match!!! +enum CompareDirResult +{ + DIR_EQUAL = FILE_EQUAL, + DIR_RENAMED = FILE_RENAMED, + DIR_LEFT_ONLY = FILE_LEFT_ONLY, + DIR_RIGHT_ONLY = FILE_RIGHT_ONLY, + DIR_CONFLICT = FILE_CONFLICT +}; + +enum CompareSymlinkResult +{ + SYMLINK_EQUAL = FILE_EQUAL, + SYMLINK_RENAMED = FILE_RENAMED, + SYMLINK_LEFT_ONLY = FILE_LEFT_ONLY, + SYMLINK_RIGHT_ONLY = FILE_RIGHT_ONLY, + SYMLINK_LEFT_NEWER = FILE_LEFT_NEWER, + SYMLINK_RIGHT_NEWER = FILE_RIGHT_NEWER, + SYMLINK_TIME_INVALID = FILE_TIME_INVALID, + SYMLINK_DIFFERENT_CONTENT = FILE_DIFFERENT_CONTENT, + SYMLINK_CONFLICT = FILE_CONFLICT +}; + + +std::wstring getSymbol(CompareFileResult cmpRes); + + +enum SyncOperation +{ + SO_CREATE_LEFT, + SO_CREATE_RIGHT, + SO_DELETE_LEFT, + SO_DELETE_RIGHT, + + SO_OVERWRITE_LEFT, + SO_OVERWRITE_RIGHT, + + SO_MOVE_LEFT_FROM, //SO_DELETE_LEFT - optimization! + SO_MOVE_LEFT_TO, //SO_CREATE_LEFT + + SO_MOVE_RIGHT_FROM, //SO_DELETE_RIGHT - optimization! + SO_MOVE_RIGHT_TO, //SO_CREATE_RIGHT + + SO_RENAME_LEFT, //items are otherwise equal + SO_RENAME_RIGHT, // + + SO_DO_NOTHING, //nothing will be synced: both sides differ + SO_EQUAL, //nothing will be synced: both sides are equal + SO_UNRESOLVED_CONFLICT +}; + +std::wstring getSymbol(SyncOperation op); //method used for exporting .csv file only! + + +enum class CudAction +{ + noChange, + create, + update, + delete_, //"delete" is a reserved keyword :( +}; + +struct DirectionByDiff +{ + SyncDirection leftOnly = SyncDirection::none; + SyncDirection rightOnly = SyncDirection::none; + SyncDirection leftNewer = SyncDirection::none; + SyncDirection rightNewer = SyncDirection::none; + + bool operator==(const DirectionByDiff&) const = default; +}; + + +struct DirectionByChange //=> requires sync.ffs_db +{ + struct Changes + { + SyncDirection create = SyncDirection::none; + SyncDirection update = SyncDirection::none; + SyncDirection delete_ = SyncDirection::none; //"delete" is a reserved keyword :( + + bool operator==(const Changes&) const = default; + } left, right; + + bool operator==(const DirectionByChange&) const = default; +}; + + +struct SyncDirectionConfig +{ + std::variant dirs; + + bool operator==(const SyncDirectionConfig&) const = default; +}; + + +inline +bool effectivelyEqual(const SyncDirectionConfig& lhs, const SyncDirectionConfig& rhs) { return lhs == rhs; } //no change in behavior + + +enum class SyncVariant +{ + twoWay, + mirror, + update, + custom, +}; +SyncVariant getSyncVariant(const SyncDirectionConfig& cfg); + +SyncDirectionConfig getDefaultSyncCfg(SyncVariant syncVar); + +DirectionByDiff getDiffDirDefault(const DirectionByChange& changeDirs); //= when sync.ffs_db not yet available +DirectionByChange getChangesDirDefault(const DirectionByDiff& diffDirs); + +std::wstring getVariantName(std::optional var); +std::wstring getVariantName(std::optional var); + +std::wstring getVariantNameWithSymbol(SyncVariant var); + + +struct CompConfig +{ + CompareVariant compareVar = CompareVariant::timeSize; + SymLinkHandling handleSymlinks = SymLinkHandling::exclude; + std::vector ignoreTimeShiftMinutes; //treat modification times with these offsets as equal + + bool operator==(const CompConfig&) const = default; +}; + +inline +bool effectivelyEqual(const CompConfig& lhs, const CompConfig& rhs) { return lhs == rhs; } //no change in behavior + + +enum class DeletionVariant +{ + permanent, + recycler, + versioning +}; + +enum class VersioningStyle +{ + replace, + timestampFolder, + timestampFile, +}; + +struct SyncConfig +{ + //sync direction settings + SyncDirectionConfig directionCfg = getDefaultSyncCfg(SyncVariant::twoWay); + + DeletionVariant deletionVariant = DeletionVariant::recycler; //use Recycle Bin, delete permanently or move to user-defined location + + //versioning options + Zstring versioningFolderPhrase; + VersioningStyle versioningStyle = VersioningStyle::replace; + + //limit number of versions per file: (if versioningStyle != replace) + int versionMaxAgeDays = 0; //<= 0 := no limit + int versionCountMin = 0; //only used if versionMaxAgeDays > 0 => < versionCountMax (if versionCountMax > 0) + int versionCountMax = 0; //<= 0 := no limit +}; + + +inline +bool operator==(const SyncConfig& lhs, const SyncConfig& rhs) +{ + return lhs.directionCfg == rhs.directionCfg && + lhs.deletionVariant == rhs.deletionVariant && //!= DeletionVariant::versioning => still consider versioningFolderPhrase: e.g. user temporarily + lhs.versioningFolderPhrase == rhs.versioningFolderPhrase && //switched to "permanent" deletion and accidentally saved cfg => versioning folder can be restored + lhs.versioningStyle == rhs.versioningStyle && + (lhs.versioningStyle == VersioningStyle::replace || + ( + lhs.versionMaxAgeDays == rhs.versionMaxAgeDays && + (lhs.versionMaxAgeDays <= 0 || + lhs.versionCountMin == rhs.versionCountMin) && + lhs.versionCountMax == rhs.versionCountMax + )); + //adapt effectivelyEqual() on changes, too! +} + + +inline +bool effectivelyEqual(const SyncConfig& lhs, const SyncConfig& rhs) +{ + return effectivelyEqual(lhs.directionCfg, rhs.directionCfg) && + lhs.deletionVariant == rhs.deletionVariant && + (lhs.deletionVariant != DeletionVariant::versioning || //only evaluate versioning folder if required! + ( + lhs.versioningFolderPhrase == rhs.versioningFolderPhrase && + lhs.versioningStyle == rhs.versioningStyle && + (lhs.versioningStyle == VersioningStyle::replace || + ( + lhs.versionMaxAgeDays == rhs.versionMaxAgeDays && + (lhs.versionMaxAgeDays <= 0 || + lhs.versionCountMin == rhs.versionCountMin) && + lhs.versionCountMax == rhs.versionCountMax + )) + )); +} + + +enum class UnitSize +{ + none, + byte, + kb, + mb +}; + +enum class UnitTime +{ + none, + today, + thisMonth, + thisYear, + lastDays +}; + +struct FilterConfig +{ + /* Semantics of PathFilter: + 1. using it creates a NEW folder hierarchy! -> must be considered by variant! (fortunately it turns out, doing nothing already has perfect semantics :) + 2. it applies equally to both sides => it always matches either both sides or none! => can be used while traversing a single folder! */ + Zstring includeFilter = Zstr("*"); + Zstring excludeFilter; + + /* Semantics of SoftFilter: + 1. It potentially may match only one side => it MUST NOT be applied while traversing a single folder to avoid mismatches + 2. => it is applied after traversing and just marks rows, (NO deletions after comparison are allowed) + 3. => equivalent to a user temporarily (de-)selecting rows -> not relevant for variant! ;) */ + unsigned int timeSpan = 0; + UnitTime unitTimeSpan = UnitTime::none; + + uint64_t sizeMin = 0; + UnitSize unitSizeMin = UnitSize::none; + + uint64_t sizeMax = 0; + UnitSize unitSizeMax = UnitSize::none; + + bool operator==(const FilterConfig&) const = default; +}; + + +void resolveUnits(size_t timeSpan, UnitTime unitTimeSpan, + uint64_t sizeMin, UnitSize unitSizeMin, + uint64_t sizeMax, UnitSize unitSizeMax, + time_t& timeFrom, //unit: UTC time, seconds + uint64_t& sizeMinBy, //unit: bytes + uint64_t& sizeMaxBy); //unit: bytes + + +struct LocalPairConfig //enhanced folder pairs with (optional) alternate configuration +{ + Zstring folderPathPhraseLeft; //unresolved directory names as entered by user! + Zstring folderPathPhraseRight; // + + std::optional localCmpCfg; + std::optional localSyncCfg; + FilterConfig localFilter; + + bool operator==(const LocalPairConfig& rhs) const = default; +}; + + +enum class ResultsNotification +{ + always, + errorWarning, + errorOnly, +}; + + +enum class PostSyncCondition +{ + completion, + errors, + success +}; + + +struct MainConfiguration +{ + CompConfig cmpCfg; //global compare settings: may be overwritten by folder pair settings + SyncConfig syncCfg; //global synchronisation settings: may be overwritten by folder pair settings + FilterConfig globalFilter; //global filter settings: combined with folder pair settings + + LocalPairConfig firstPair; //there needs to be at least one pair! + std::vector additionalPairs; + + std::map deviceParallelOps; //should only include devices with >= 2 parallel ops + + bool ignoreErrors = false; //true: errors will still be logged + size_t autoRetryCount = 0; + std::chrono::seconds autoRetryDelay{5}; + + Zstring postSyncCommand; //user-defined command line + PostSyncCondition postSyncCondition = PostSyncCondition::completion; + + Zstring altLogFolderPathPhrase; //fill to use different log file folder (other than the default %appdata%\FreeFileSync\Logs) + + std::string emailNotifyAddress; //optional + ResultsNotification emailNotifyCondition = ResultsNotification::always; + + bool operator==(const MainConfiguration&) const = default; +}; + + +size_t getDeviceParallelOps(const std::map& deviceParallelOps, const AfsDevice& afsDevice); +void setDeviceParallelOps( std::map& deviceParallelOps, const AfsDevice& afsDevice, size_t parallelOps); +size_t getDeviceParallelOps(const std::map& deviceParallelOps, const Zstring& folderPathPhrase); +void setDeviceParallelOps( std::map& deviceParallelOps, const Zstring& folderPathPhrase, size_t parallelOps); + + +std::optional getCommonCompVariant(const MainConfiguration& mainCfg); +std::optional getCommonSyncVariant(const MainConfiguration& mainCfg); + + +struct WarningDialogs +{ + bool warnFolderNotExisting = true; + bool warnFoldersDifferInCase = true; + bool warnDependentFolderPair = true; + bool warnDependentBaseFolders = true; + bool warnSignificantDifference = true; + bool warnNotEnoughDiskSpace = true; + bool warnUnresolvedConflicts = true; + bool warnRecyclerMissing = true; + bool warnDirectoryLockFailed = true; + bool warnVersioningFolderPartOfSync = true; + + bool operator==(const WarningDialogs&) const = default; +}; +} + +#endif //STRUCTURES_H_8210478915019450901745 diff --git a/FreeFileSync/Source/base/synchronization.cpp b/FreeFileSync/Source/base/synchronization.cpp new file mode 100644 index 0000000..751fb23 --- /dev/null +++ b/FreeFileSync/Source/base/synchronization.cpp @@ -0,0 +1,2994 @@ +// ***************************************************************************** +// * 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 "synchronization.h" +#include +#include +#include "algorithm.h" +#include "db_file.h" +#include "status_handler_impl.h" +#include "versioning.h" +#include "binary.h" +#include "../afs/concrete.h" +#include "../afs/native.h" + + #include //fsync + #include //open + +using namespace zen; +using namespace fff; + + +namespace +{ +const size_t CONFLICTS_PREVIEW_MAX = 25; //=> consider memory consumption, log file size, email size! + + +} + + +SyncStatistics::SyncStatistics(const FolderComparison& folderCmp) +{ + for (const BaseFolderPair& baseFolder : asRange(folderCmp)) + recurse(baseFolder); +} + + +SyncStatistics::SyncStatistics(const ContainerObject& conObj) +{ + recurse(conObj); +} + + +SyncStatistics::SyncStatistics(const FilePair& file) +{ + processFile(file); + ++rowsTotal_; +} + + +inline +void SyncStatistics::recurse(const ContainerObject& conObj) +{ + for (const FilePair& file : conObj.files()) + processFile(file); + for (const SymlinkPair& symlink : conObj.symlinks()) + processLink(symlink); + for (const FolderPair& folder : conObj.subfolders()) + processFolder(folder); + + rowsTotal_ += conObj.subfolders().size(); + rowsTotal_ += conObj.files ().size(); + rowsTotal_ += conObj.symlinks ().size(); +} + + +inline +void SyncStatistics::logConflict(const FileSystemObject& fsObj) +{ + if (conflictsPreview_.size() < CONFLICTS_PREVIEW_MAX) + { + const Zstring& relPathL = fsObj.getRelativePath(); + const Zstring& relPathR = fsObj.getRelativePath(); + + conflictsPreview_.push_back((getUnicodeNormalForm(relPathL) == getUnicodeNormalForm(relPathR) ? + utfTo(relPathL) : + utfTo(relPathL + Zstr('\n') + relPathR)) + L": " + fsObj.getSyncOpConflict()); + } +} + + +inline +void SyncStatistics::processFile(const FilePair& file) +{ + switch (file.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: + ++createLeft_; + bytesToProcess_ += static_cast(file.getFileSize()); + break; + + case SO_CREATE_RIGHT: + ++createRight_; + bytesToProcess_ += static_cast(file.getFileSize()); + break; + + case SO_DELETE_LEFT: + ++deleteLeft_; + break; + + case SO_DELETE_RIGHT: + ++deleteRight_; + break; + + case SO_MOVE_LEFT_TO: + ++updateLeft_; + break; + + case SO_MOVE_RIGHT_TO: + ++updateRight_; + break; + + case SO_MOVE_LEFT_FROM: //ignore; already counted + case SO_MOVE_RIGHT_FROM: //=> harmonize with FileView::applyActionFilter() + break; + + case SO_OVERWRITE_LEFT: + ++updateLeft_; + bytesToProcess_ += static_cast(file.getFileSize()); + break; + + case SO_OVERWRITE_RIGHT: + ++updateRight_; + bytesToProcess_ += static_cast(file.getFileSize()); + break; + + case SO_UNRESOLVED_CONFLICT: + ++conflictCount_; + logConflict(file); + break; + + case SO_RENAME_LEFT: + ++updateLeft_; + break; + + case SO_RENAME_RIGHT: + ++updateRight_; + break; + + case SO_DO_NOTHING: + case SO_EQUAL: + break; + } +} + + +inline +void SyncStatistics::processLink(const SymlinkPair& symlink) +{ + switch (symlink.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: + ++createLeft_; + break; + + case SO_CREATE_RIGHT: + ++createRight_; + break; + + case SO_DELETE_LEFT: + ++deleteLeft_; + break; + + case SO_DELETE_RIGHT: + ++deleteRight_; + break; + + case SO_OVERWRITE_LEFT: + case SO_RENAME_LEFT: + ++updateLeft_; + break; + + case SO_OVERWRITE_RIGHT: + case SO_RENAME_RIGHT: + ++updateRight_; + break; + + case SO_UNRESOLVED_CONFLICT: + ++conflictCount_; + logConflict(symlink); + break; + + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + assert(false); + break; + case SO_DO_NOTHING: + case SO_EQUAL: + break; + } +} + + +inline +void SyncStatistics::processFolder(const FolderPair& folder) +{ + switch (folder.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: + ++createLeft_; + break; + + case SO_CREATE_RIGHT: + ++createRight_; + break; + + case SO_DELETE_LEFT: //if deletion variant == versioning with user-defined directory existing on other volume, this results in a full copy + delete operation! + ++deleteLeft_; //however we cannot (reliably) anticipate this situation, fortunately statistics can be adapted during sync! + break; + + case SO_DELETE_RIGHT: + ++deleteRight_; + break; + + case SO_UNRESOLVED_CONFLICT: + ++conflictCount_; + logConflict(folder); + break; + + case SO_RENAME_LEFT: + ++updateLeft_; + break; + + case SO_RENAME_RIGHT: + ++updateRight_; + break; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + assert(false); + [[fallthrough]]; + case SO_DO_NOTHING: + case SO_EQUAL: + break; + } + + recurse(folder); //since we model logical stats, we recurse, even if deletion variant is "recycler" or "versioning + same volume", which is a single physical operation! +} + + +/* DeletionVariant::permanent: deletion frees space + DeletionVariant::recycler: won't free space until recycler is full, but then frees space + DeletionVariant::versioning: depends on whether versioning folder is on a different volume + -> if deleted item is a followed symlink, no space is freed + -> created/updated/deleted item may be on a different volume than base directory: consider symlinks, junctions! + + => generally assume deletion frees space; may avoid false-positive disk space warnings for recycler and versioning */ +class MinimumDiskSpaceNeeded +{ +public: + static std::pair calculate(const BaseFolderPair& baseFolder) + { + MinimumDiskSpaceNeeded inst; + inst.recurse(baseFolder); + return {inst.spaceNeededLeft_, inst.spaceNeededRight_}; + } + +private: + void recurse(const ContainerObject& conObj) + { + //process files + for (const FilePair& file : conObj.files()) + switch (file.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: + spaceNeededLeft_ += static_cast(file.getFileSize()); + break; + + case SO_CREATE_RIGHT: + spaceNeededRight_ += static_cast(file.getFileSize()); + break; + + case SO_DELETE_LEFT: + if (!file.isFollowedSymlink()) + spaceNeededLeft_ -= static_cast(file.getFileSize()); + break; + + case SO_DELETE_RIGHT: + if (!file.isFollowedSymlink()) + spaceNeededRight_ -= static_cast(file.getFileSize()); + break; + + case SO_OVERWRITE_LEFT: + if (!file.isFollowedSymlink()) + spaceNeededLeft_ -= static_cast(file.getFileSize()); + spaceNeededLeft_ += static_cast(file.getFileSize()); + break; + + case SO_OVERWRITE_RIGHT: + if (!file.isFollowedSymlink()) + spaceNeededRight_ -= static_cast(file.getFileSize()); + spaceNeededRight_ += static_cast(file.getFileSize()); + break; + + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + break; + } + + //symbolic links + //[...] + + //recurse into sub-dirs + for (const FolderPair& folder : conObj.subfolders()) + switch (folder.getSyncOperation()) + { + case SO_DELETE_LEFT: + if (!folder.isFollowedSymlink()) + recurse(folder); //not 100% correct: in fact more that what our model contains may be deleted (consider file filter!) + break; + case SO_DELETE_RIGHT: + if (!folder.isFollowedSymlink()) + recurse(folder); + break; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + assert(false); + [[fallthrough]]; + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + recurse(folder); //not 100% correct: what if left or right folder is symlink!? => file operations may happen on different volume! + break; + } + } + + int64_t spaceNeededLeft_ = 0; + int64_t spaceNeededRight_ = 0; +}; + +//----------------------------------------------------------------------------------------------------------- + +std::vector fff::extractSyncCfg(const MainConfiguration& mainCfg) +{ + //merge first and additional pairs + std::vector localCfgs = {mainCfg.firstPair}; + append(localCfgs, mainCfg.additionalPairs); + + std::vector output; + + for (const LocalPairConfig& lpc : localCfgs) + { + //const CompConfig cmpCfg = lpc.localCmpCfg ? *lpc.localCmpCfg : mainCfg.cmpCfg; + const SyncConfig syncCfg = lpc.localSyncCfg ? *lpc.localSyncCfg : mainCfg.syncCfg; + + output.push_back( + { + getSyncVariant(syncCfg.directionCfg), + !!std::get_if(&syncCfg.directionCfg.dirs), + syncCfg.deletionVariant, + syncCfg.versioningFolderPhrase, + syncCfg.versioningStyle, + syncCfg.versionMaxAgeDays, + syncCfg.versionCountMin, + syncCfg.versionCountMax + }); + } + return output; +} + +//------------------------------------------------------------------------------------------------------------ + +namespace +{ +//test if user accidentally selected the wrong folders to sync +bool significantDifferenceDetected(const SyncStatistics& folderPairStat) +{ + //initial file copying shall not be detected as major difference + if ((folderPairStat.createCount() == 0 || + folderPairStat.createCount() == 0) && + folderPairStat.updateCount () == 0 && + folderPairStat.deleteCount () == 0 && + folderPairStat.conflictCount() == 0) + return false; + + const int nonMatchingRows = folderPairStat.createCount() + + folderPairStat.deleteCount(); + //folderPairStat.updateCount() + -> not relevant when testing for "wrong folder selected" + //folderPairStat.conflictCount(); + + return nonMatchingRows >= 10 && nonMatchingRows > 0.5 * folderPairStat.rowCount(); +} + +//--------------------------------------------------------------------------------------------- + +template +bool plannedWriteAccess(const FileSystemObject& fsObj) +{ + switch (getEffectiveSyncDir(fsObj.getSyncOperation())) + { + case SyncDirection::none: return false; + case SyncDirection::left: return side == SelectSide::left; + case SyncDirection::right: return side == SelectSide::right; + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); +} + + +inline +AbstractPath getAbstractPath(const FileSystemObject& fsObj, SelectSide side) +{ + return side == SelectSide::left ? fsObj.getAbstractPath() : fsObj.getAbstractPath(); +} + + +struct PathRaceItem +{ + const FileSystemObject* fsObj; + SelectSide side; + + std::strong_ordering operator<=>(const PathRaceItem&) const = default; +}; + + +std::weak_ordering comparePathNoCase(const PathRaceItem& lhs, const PathRaceItem& rhs) +{ + const AbstractPath& itemPathL = getAbstractPath(*lhs.fsObj, lhs.side); + const AbstractPath& itemPathR = getAbstractPath(*rhs.fsObj, rhs.side); + + if (const std::weak_ordering cmp = itemPathL.afsDevice <=> itemPathR.afsDevice; + cmp != std::weak_ordering::equivalent) + return cmp; + + return compareNoCase(itemPathL.afsPath.value, //no hashing: want natural sort order! + itemPathR.afsPath.value); +} + + +std::wstring formatRaceItem(const PathRaceItem& item) +{ + const SyncDirection syncDir = getEffectiveSyncDir(item.fsObj->getSyncOperation()); + + const bool writeAcess = (syncDir == SyncDirection::left && item.side == SelectSide::left) || + (syncDir == SyncDirection::right && item.side == SelectSide::right); + + return AFS::getDisplayPath(item.side == SelectSide::left ? + item.fsObj->base().getAbstractPath() : + item.fsObj->base().getAbstractPath()) + + (writeAcess ? L" 💾 " : L" 👓 ") + + utfTo(item.side == SelectSide::left ? + item.fsObj->getRelativePath() : + item.fsObj->getRelativePath()); + //e.g. D:\folder 💾 subfolder\file.txt + // D:\folder\subfolder 👓 file.txt +} + + +struct ChildPathRef +{ + const FileSystemObject* fsObj = nullptr; + uint64_t afsPathHash = 0; //of *case-normalized* AfsPath +}; + + +template +class GetChildItemsHashed +{ +public: + static std::vector execute(const ContainerObject& folder) + { + FNV1aHash pathHash; + for (const Zstring& itemName : splitCpy(folder.getAbstractPath().afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip)) + hashAdd(pathHash, itemName); //not really needed ATM, but it's cleaner to hash *full* afsPath + + GetChildItemsHashed inst; + inst.recurse(folder, pathHash.get()); + return std::move(inst.childPathRefs_); + } + +private: + GetChildItemsHashed (const GetChildItemsHashed&) = delete; + GetChildItemsHashed& operator=(const GetChildItemsHashed&) = delete; + + GetChildItemsHashed() {} + + void recurse(const ContainerObject& conObj, uint64_t parentPathHash) + { + for (const FilePair& file : conObj.files()) + childPathRefs_.push_back({&file, getPathHash(file, parentPathHash)}); + //S1 -> T (update) is not a conflict (anymore) if S1, S2 contain different files + //S2 -> T (update) https://freefilesync.org/forum/viewtopic.php?t=9365#p36466 + for (const SymlinkPair& symlink : conObj.symlinks()) + childPathRefs_.push_back({&symlink, getPathHash(symlink, parentPathHash)}); + + for (const FolderPair& subFolder : conObj.subfolders()) + { + const uint64_t folderPathHash = getPathHash(subFolder, parentPathHash); + + childPathRefs_.push_back({&subFolder, folderPathHash}); + + recurse(subFolder, folderPathHash); + } + } + + static void hashAdd(FNV1aHash& hash, const Zstring& itemName) + { + if (isAsciiString(itemName)) //fast path: no need for extra memory allocation! + for (const Zchar c : itemName) + hash.add(asciiToUpper(c)); + else + for (const Zchar c : getUpperCase(itemName)) + hash.add(c); + } + + static uint64_t getPathHash(const FileSystemObject& fsObj, uint64_t parentPathHash) + { + FNV1aHash hash(parentPathHash); + hashAdd(hash, fsObj.getItemName()); + return hash.get(); + } + + std::vector childPathRefs_; +}; + + +template +std::weak_ordering comparePathNoCase(const ChildPathRef& lhs, const ChildPathRef& rhs) +{ + //assert(lhs.fsObj->getAbstractPath().afsDevice == -> too slow, even for debug build + // rhs.fsObj->getAbstractPath().afsDevice); + + if (const std::weak_ordering cmp = lhs.afsPathHash <=> rhs.afsPathHash; + cmp != std::weak_ordering::equivalent) + return cmp; //fast path! + + return compareNoCase(lhs.fsObj->getAbstractPath().afsPath.value, //fsObj may come from *different* BaseFolderPair + rhs.fsObj->getAbstractPath().afsPath.value); // => don't compare getRelativePath()! +} + + +template +void sortAndRemoveDuplicates(std::vector& pathRefs) +{ + std::sort(pathRefs.begin(), pathRefs.end(), [](const ChildPathRef& lhs, const ChildPathRef& rhs) + { + if (const std::weak_ordering cmp = comparePathNoCase(lhs, rhs); + cmp != std::weak_ordering::equivalent) + return cmp < 0; + + return //multiple (case-insensitive) relPaths? => order write-access before read-access, so that std::unique leaves a write if existing! + plannedWriteAccess(*lhs.fsObj) > + plannedWriteAccess(*rhs.fsObj); + }); + + pathRefs.erase(std::unique(pathRefs.begin(), pathRefs.end(), + [](const ChildPathRef& lhs, const ChildPathRef& rhs) { return comparePathNoCase(lhs, rhs) == std::weak_ordering::equivalent; }), + pathRefs.end()); + + //let's not use removeDuplicates(): we rely too much on implementation details! +} + + +//check if some files/folders are included more than once and form a race condition (:= multiple accesses of which at least one is a write) +// - checking filter for subfolder exclusion is not good enough: one folder may have a *.txt include-filter, the other a *.lng include filter => still no dependencies +// - user may have manually excluded the conflicting items or changed the filter settings without running a re-compare +template +void checkPathRaceCondition(const BaseFolderPair& baseFolderP, const BaseFolderPair& baseFolderC, std::vector& pathRaceItems) +{ + const AbstractPath basePathP = baseFolderP.getAbstractPath(); //parent/child notion is tentative at this point + const AbstractPath basePathC = baseFolderC.getAbstractPath(); //=> will be swapped if necessary + + assert(!AFS::isNullPath(basePathP) && !AFS::isNullPath(basePathC)); + if (basePathP.afsDevice == basePathC.afsDevice) + { + if (basePathP.afsPath.value.size() > basePathC.afsPath.value.size()) + return checkPathRaceCondition(baseFolderC, baseFolderP, pathRaceItems); + + const std::vector relPathP = splitCpy(basePathP.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip); + const std::vector relPathC = splitCpy(basePathC.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip); + + if (relPathP.size() <= relPathC.size() && + /**/std::equal(relPathP.begin(), relPathP.end(), relPathC.begin(), [](const Zstring& lhs, const Zstring& rhs) { return equalNoCase(lhs, rhs); })) + { + //=> at this point parent/child folders are confirmed + //now find child folder match inside baseFolderP + //e.g. C:\folder <-> C:\folder\sub => find "sub" inside C:\folder + std::vector childFolderP{&baseFolderP}; + + std::for_each(relPathC.begin() + relPathP.size(), relPathC.end(), [&](const Zstring& itemName) + { + std::vector childFolderP2; + + for (const ContainerObject* childFolder : childFolderP) + for (const FolderPair& folder : childFolder->subfolders()) + if (equalNoCase(folder.getItemName(), itemName)) + childFolderP2.push_back(&folder); + //no "break": yes, weird, but there could be more than one (for case-sensitive file system) + + childFolderP = std::move(childFolderP2); + }); + + std::vector pathRefsP; + for (const ContainerObject* childFolder : childFolderP) + append(pathRefsP, GetChildItemsHashed::execute(*childFolder)); + + std::vector pathRefsC = GetChildItemsHashed::execute(baseFolderC); + + //--------------------------------------------------------------------------------------------------- + //case-sensitive comparison because items were scanned by FFS (=> no messy user input)? + //not good enough! E.g. not-yet-existing files are set to be created with different case! + // + (weird) a file and a folder are set to be created with same name + // => (throw hands in the air) fine, check path only and don't consider case + sortAndRemoveDuplicates(pathRefsP); + sortAndRemoveDuplicates(pathRefsC); + + mergeTraversal(pathRefsP.begin(), pathRefsP.end(), + pathRefsC.begin(), pathRefsC.end(), + [](const ChildPathRef&) {} /*left only*/, + [&](const ChildPathRef& lhs, const ChildPathRef& rhs) + { + if (plannedWriteAccess(*lhs.fsObj) || + plannedWriteAccess(*rhs.fsObj)) + { + pathRaceItems.push_back({lhs.fsObj, sideP}); + pathRaceItems.push_back({rhs.fsObj, sideC}); + } + }, + [](const ChildPathRef&) {} /*right only*/, comparePathNoCase); + } + } +} + +//################################################################################################################# + +//--------------------- data verification ------------------------- +void flushFileBuffers(const Zstring& nativeFilePath) //throw FileError +{ + const int fdFile = ::open(nativeFilePath.c_str(), O_WRONLY | O_APPEND | O_CLOEXEC); + if (fdFile == -1) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(nativeFilePath)), "open"); + ZEN_ON_SCOPE_EXIT(::close(fdFile)); + + if (::fsync(fdFile) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(nativeFilePath)), "fsync"); +} + + +void verifyFiles(const AbstractPath& sourcePath, const AbstractPath& targetPath, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + try + { + //do like "copy /v": 1. flush target file buffers, 2. read again as usual (using OS buffers) + // => it seems OS buffers are not invalidated by this: snake oil??? + if (const Zstring& targetPathNative = getNativeItemPath(targetPath); + !targetPathNative.empty()) + flushFileBuffers(targetPathNative); //throw FileError + + if (!filesHaveSameContent(sourcePath, targetPath, notifyUnbufferedIO)) //throw FileError, X + throw FileError(replaceCpy(replaceCpy(_("%x and %y have different content."), + L"%x", L'\n' + fmtPath(AFS::getDisplayPath(sourcePath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(targetPath)))); + } + catch (const FileError& e) //add some context to error message + { + throw FileError(_("Data verification error:"), e.toString()); + } +} + +//################################################################################################################# +//################################################################################################################# + +/* ________________________________________________________________ + | | + | Multithreaded File Copy: Parallel API for expensive file I/O | + |______________________________________________________________| */ + +namespace parallel +{ +inline +AFS::ItemType getItemType(const AbstractPath& itemPath, std::mutex& singleThread) //throw FileError +{ return parallelScope([itemPath] { return AFS::getItemType(itemPath); /*throw FileError*/ }, singleThread); } + +inline +bool itemExists(const AbstractPath& itemPath, std::mutex& singleThread) //throw FileError +{ return parallelScope([itemPath] { return AFS::itemExists(itemPath); /*throw FileError*/ }, singleThread); } + +inline +void removeFileIfExists(const AbstractPath& filePath, std::mutex& singleThread) //throw FileError +{ parallelScope([filePath] { AFS::removeFileIfExists(filePath); /*throw FileError*/ }, singleThread); } + +inline +void removeSymlinkIfExists(const AbstractPath& linkPath, std::mutex& singleThread) //throw FileError +{ parallelScope([linkPath] { AFS::removeSymlinkIfExists(linkPath); /*throw FileError*/ }, singleThread); } + +inline +void moveAndRenameItem(const AbstractPath& pathFrom, const AbstractPath& pathTo, std::mutex& singleThread) //throw FileError, ErrorMoveUnsupported +{ parallelScope([pathFrom, pathTo] { AFS::moveAndRenameItem(pathFrom, pathTo); /*throw FileError, ErrorMoveUnsupported*/ }, singleThread); } + +inline +AbstractPath getSymlinkResolvedPath(const AbstractPath& linkPath, std::mutex& singleThread) //throw FileError +{ return parallelScope([linkPath] { return AFS::getSymlinkResolvedPath(linkPath); /*throw FileError*/ }, singleThread); } + +inline +void copySymlink(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions, std::mutex& singleThread) //throw FileError +{ parallelScope([sourcePath, targetPath, copyFilePermissions] { AFS::copySymlink(sourcePath, targetPath, copyFilePermissions); /*throw FileError*/ }, singleThread); } + +inline +void copyNewFolder(const AbstractPath& sourcePath, const AbstractPath& targetPath, bool copyFilePermissions, std::mutex& singleThread) //throw FileError +{ parallelScope([sourcePath, targetPath, copyFilePermissions] { return AFS::copyNewFolder(sourcePath, targetPath, copyFilePermissions); /*throw FileError*/ }, singleThread); } + +inline +void removeFilePlain(const AbstractPath& filePath, std::mutex& singleThread) //throw FileError +{ parallelScope([filePath] { AFS::removeFilePlain(filePath); /*throw FileError*/ }, singleThread); } + +//-------------------------------------------------------------- +//ATTENTION CALLBACKS: they also run asynchronously *outside* the singleThread lock! +//-------------------------------------------------------------- +inline +void removeFolderIfExistsRecursion(const AbstractPath& folderPath, //throw FileError + const std::function& onBeforeFileDeletion /*throw X*/, // + const std::function& onBeforeSymlinkDeletion /*throw X*/, //optional + const std::function& onBeforeFolderDeletion /*throw X*/, // + std::mutex& singleThread) +{ + parallelScope([folderPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion] + { AFS::removeFolderIfExistsRecursion(folderPath, onBeforeFileDeletion, onBeforeSymlinkDeletion, onBeforeFolderDeletion); /*throw FileError*/ }, singleThread); +} + + +inline +AFS::FileCopyResult copyFileTransactional(const AbstractPath& sourcePath, const AFS::StreamAttributes& attrSource, //throw FileError, ErrorFileLocked, X + const AbstractPath& targetPath, + bool copyFilePermissions, + bool transactionalCopy, + const std::function& onDeleteTargetFile /*throw X*/, + const IoCallback& notifyUnbufferedIO /*throw X*/, + std::mutex& singleThread) +{ + return parallelScope([=] + { + return AFS::copyFileTransactional(sourcePath, attrSource, targetPath, copyFilePermissions, transactionalCopy, onDeleteTargetFile, notifyUnbufferedIO); //throw FileError, ErrorFileLocked, X + }, singleThread); +} + +inline //RecycleSession::moveToRecycleBin() is internally synchronized! +void moveToRecycleBinIfExists(AFS::RecycleSession& recyclerSession, const AbstractPath& itemPath, const Zstring& logicalRelPath, std::mutex& singleThread) //throw FileError, RecycleBinUnavailable +{ parallelScope([=, &recyclerSession] { return recyclerSession.moveToRecycleBinIfExists(itemPath, logicalRelPath); /*throw FileError, RecycleBinUnavailable*/ }, singleThread); } + +inline //FileVersioner::revisionFile() is internally synchronized! +void revisionFile(FileVersioner& versioner, const FileDescriptor& fileDescr, const Zstring& relativePath, const IoCallback& notifyUnbufferedIO /*throw X*/, std::mutex& singleThread) //throw FileError, X +{ parallelScope([=, &versioner] { versioner.revisionFile(fileDescr, relativePath, notifyUnbufferedIO); /*throw FileError, X*/ }, singleThread); } + +inline //FileVersioner::revisionSymlink() is internally synchronized! +void revisionSymlink(FileVersioner& versioner, const AbstractPath& linkPath, const Zstring& relativePath, std::mutex& singleThread) //throw FileError +{ parallelScope([=, &versioner] { versioner.revisionSymlink(linkPath, relativePath); /*throw FileError*/ }, singleThread); } + +inline //FileVersioner::revisionFolder() is internally synchronized! +void revisionFolder(FileVersioner& versioner, + const AbstractPath& folderPath, const Zstring& relativePath, + const std::function& onBeforeFileMove /*throw X*/, + const std::function& onBeforeFolderMove /*throw X*/, + const IoCallback& notifyUnbufferedIO /*throw X*/, + std::mutex& singleThread) //throw FileError, X +{ parallelScope([=, &versioner] { versioner.revisionFolder(folderPath, relativePath, onBeforeFileMove, onBeforeFolderMove, notifyUnbufferedIO); /*throw FileError, X*/ }, singleThread); } + +inline +void verifyFiles(const AbstractPath& sourcePath, const AbstractPath& targetPath, const IoCallback& notifyUnbufferedIO /*throw X*/, std::mutex& singleThread) //throw FileError, X +{ parallelScope([=] { ::verifyFiles(sourcePath, targetPath, notifyUnbufferedIO); /*throw FileError, X*/ }, singleThread); } + +} + +//################################################################################################################# +//################################################################################################################# + +class DeletionHandler //abstract deletion variants: permanently, recycle bin, user-defined directory +{ +public: + DeletionHandler(const AbstractPath& baseFolderPath, + bool& recyclerMissingReportOnce, + bool& warnRecyclerMissing, + DeletionVariant deletionVariant, + const AbstractPath& versioningFolderPath, + VersioningStyle versioningStyle, + time_t syncStartTime); //nothrow! + + //clean-up temporary directory (recycle bin optimization) + void tryCleanup(PhaseCallback& cb /*throw X*/); //throw X + + void removeFileWithCallback(const FileDescriptor& fileDescr, const Zstring& relPath, bool beforeOverwrite, AsyncItemStatReporter& statReporter, std::mutex& singleThread); //throw FileError, ThreadStopRequest + void removeLinkWithCallback(const AbstractPath& linkPath, const Zstring& relPath, bool beforeOverwrite, AsyncItemStatReporter& statReporter, std::mutex& singleThread); // + void removeDirWithCallback (const AbstractPath& dirPath, const Zstring& relPath, AsyncItemStatReporter& statReporter, std::mutex& singleThread); // + +private: + DeletionHandler (const DeletionHandler&) = delete; + DeletionHandler& operator=(const DeletionHandler&) = delete; + + void moveToRecycleBinIfExists(const AbstractPath& itemPath, const Zstring& relPath, std::mutex& singleThread) //throw FileError, RecycleBinUnavailable + { + assert(deletionVariant_ == DeletionVariant::recycler); + + //might not be needed => create lazily: + if (!recyclerSession_ && !recyclerUnavailableExcept_) + try + { + recyclerSession_ = AFS::createRecyclerSession(baseFolderPath_); //throw FileError, RecycleBinUnavailable + //double-initialization caveat: do NOT run session initialization in parallel! + // => createRecyclerSession must *not* do file I/O! + } + catch (const RecycleBinUnavailable& e) { recyclerUnavailableExcept_ = e; } + + if (recyclerUnavailableExcept_) //add context, or user might think we're removing baseFolderPath_! + throw RecycleBinUnavailable(replaceCpy(_("Unable to move %x to the recycle bin."), L"%x", fmtPath(AFS::getDisplayPath(itemPath))), + replaceCpy(recyclerUnavailableExcept_->toString(), L"\n\n", L'\n')); + /* "Unable to move "Z:\folder\file.txt" to the recycle bin. + + The recycle bin is not available for "Z:\". + + Ignore and delete permanently each time recycle bin is unavailable?" */ + + parallel::moveToRecycleBinIfExists(*recyclerSession_, itemPath, relPath, singleThread); //throw FileError, RecycleBinUnavailable + } + + //might not be needed => create lazily: + FileVersioner& getOrCreateVersioner() //throw FileError + { + assert(deletionVariant_ == DeletionVariant::versioning); + if (!versioner_) + versioner_.emplace(versioningFolderPath_, versioningStyle_, syncStartTime_); //throw FileError + return *versioner_; + } + + bool& recyclerMissingReportOnce_; //shared by threads! access under "singleThread" lock! + bool& warnRecyclerMissing_; //WarningDialogs::warnRecyclerMissing + + const DeletionVariant deletionVariant_; //keep it invariant! e.g. consider getOrCreateVersioner() one-time construction! + + const AbstractPath baseFolderPath_; + + std::unique_ptr recyclerSession_; //it's one of these (or none if not yet initialized) + std::optional recyclerUnavailableExcept_; // + + //used only for DeletionVariant::versioning: + const AbstractPath versioningFolderPath_; + const VersioningStyle versioningStyle_; + const time_t syncStartTime_; + std::optional versioner_; + + //buffer status texts: + const std::wstring txtDelFilePermanent_ = _("Deleting file %x"); + const std::wstring txtDelFileRecycler_ = _("Moving file %x to the recycle bin"); + const std::wstring txtDelFileVersioning_ = replaceCpy(_("Moving file %x to %y"), L"%y", fmtPath(AFS::getDisplayPath(versioningFolderPath_))); + + const std::wstring txtDelSymlinkPermanent_ = _("Deleting symbolic link %x"); + const std::wstring txtDelSymlinkRecycler_ = _("Moving symbolic link %x to the recycle bin"); + const std::wstring txtDelSymlinkVersioning_ = replaceCpy(_("Moving symbolic link %x to %y"), L"%y", fmtPath(AFS::getDisplayPath(versioningFolderPath_))); + + const std::wstring txtDelFolderPermanent_ = _("Deleting folder %x"); + const std::wstring txtDelFolderRecycler_ = _("Moving folder %x to the recycle bin"); + const std::wstring txtDelFolderVersioning_ = replaceCpy(_("Moving folder %x to %y"), L"%y", fmtPath(AFS::getDisplayPath(versioningFolderPath_))); + + const std::wstring txtMovingFileXtoY_ = _("Moving file %x to %y"); + const std::wstring txtMovingFolderXtoY_ = _("Moving folder %x to %y"); +}; + + +DeletionHandler::DeletionHandler(const AbstractPath& baseFolderPath, + bool& recyclerMissingReportOnce, + bool& warnRecyclerMissing, + DeletionVariant deletionVariant, + const AbstractPath& versioningFolderPath, + VersioningStyle versioningStyle, + time_t syncStartTime) : + recyclerMissingReportOnce_(recyclerMissingReportOnce), + warnRecyclerMissing_(warnRecyclerMissing), + deletionVariant_(deletionVariant), + baseFolderPath_(baseFolderPath), + versioningFolderPath_(versioningFolderPath), + versioningStyle_(versioningStyle), + syncStartTime_(syncStartTime) {} + + +void DeletionHandler::tryCleanup(PhaseCallback& cb /*throw X*/) //throw X +{ + assert(runningOnMainThread()); + switch (deletionVariant_) + { + case DeletionVariant::recycler: + if (recyclerSession_) + { + auto notifyDeletionStatus = [&](const std::wstring& displayPath) + { + if (!displayPath.empty()) + cb.updateStatus(replaceCpy(txtDelFileRecycler_, L"%x", fmtPath(displayPath))); //throw X + else + cb.requestUiUpdate(); //throw X + }; + //move content of temporary directory to recycle bin in one go + tryReportingError([&] { recyclerSession_->tryCleanup(notifyDeletionStatus); /*throw FileError*/}, cb); //throw X + } + break; + + case DeletionVariant::permanent: + case DeletionVariant::versioning: + break; + } +} + + +void DeletionHandler::removeFileWithCallback(const FileDescriptor& fileDescr, const Zstring& relPath, bool beforeOverwrite, + AsyncItemStatReporter& statReporter, std::mutex& singleThread) //throw FileError, ThreadStopRequest +{ + if (deletionVariant_ != DeletionVariant::permanent && + endsWith(relPath, AFS::TEMP_FILE_ENDING)) //special rule: always delete .ffs_tmp files permanently! + { + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(fileDescr.path))), statReporter); //throw ThreadStopRequest + parallel::removeFileIfExists(fileDescr.path, singleThread); //throw FileError + } + else + /* don't use AsyncItemStatReporter if "beforeOverwrite": + - logInfo/updateStatus() is superfluous/confuses user, except: do show progress and allow cancel for versioning! + - no (logical) item count update desired + => BUT: total byte count should still be adjusted if versioning requires a file copy instead of a move! + - if fail-safe file copy is active, then the next operation will be a simple "rename" + => don't risk updateStatus() throwing ThreadStopRequest() leaving the target deleted rather than updated! */ + switch (deletionVariant_) + { + case DeletionVariant::permanent: + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(fileDescr.path))), statReporter); //throw ThreadStopRequest + parallel::removeFileIfExists(fileDescr.path, singleThread); //throw FileError + break; + + case DeletionVariant::recycler: + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelFileRecycler_, L"%x", fmtPath(AFS::getDisplayPath(fileDescr.path))), statReporter); //throw ThreadStopRequest + try + { + moveToRecycleBinIfExists(fileDescr.path, relPath, singleThread); //throw FileError, RecycleBinUnavailable + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce_) //shared by threads! access under "singleThread" lock! + { + recyclerMissingReportOnce_ = true; + statReporter.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing_); //throw ThreadStopRequest + } + if (!beforeOverwrite) statReporter.logMessage(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(fileDescr.path))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw ThreadStopRequest + parallel::removeFileIfExists(fileDescr.path, singleThread); //throw FileError + } + break; + + case DeletionVariant::versioning: + { + std::wstring statusMsg = replaceCpy(txtDelFileVersioning_, L"%x", fmtPath(AFS::getDisplayPath(fileDescr.path))); + PercentStatReporter percentReporter(statusMsg, fileDescr.attr.fileSize, statReporter); + + if (!beforeOverwrite) reportInfo(std::move(statusMsg), statReporter); //throw ThreadStopRequest + //else: 1. versioning is moving only: no (potentially throwing) status updates + // 2. versioning needs to copy: may throw ThreadStopRequest, but *no* status updates, unless copying takes so long that % needs to be displayed + + //callback runs *outside* singleThread_ lock! => fine + IoCallback notifyUnbufferedIO = [&](int64_t bytesDelta) + { + percentReporter.updateDeltaAndStatus(bytesDelta); //throw ThreadStopRequest + interruptionPoint(); //throw ThreadStopRequest => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! + }; + parallel::revisionFile(getOrCreateVersioner(), fileDescr, relPath, notifyUnbufferedIO, singleThread); //throw FileError, ThreadStopRequest + } + break; + } + + //even if the source item did not exist anymore, significant I/O work was done => report unconditionally + if (!beforeOverwrite) statReporter.reportDelta(1, 0); +} + + +void DeletionHandler::removeLinkWithCallback(const AbstractPath& linkPath, const Zstring& relPath, bool beforeOverwrite, + AsyncItemStatReporter& statReporter, std::mutex& singleThread) //throw FileError, throw ThreadStopRequest +{ + /* don't use AsyncItemStatReporter if "beforeOverwrite": + - logInfo() is superfluous/confuses user + - no (logical) item count update desired + - don't risk updateStatus() throwing ThreadStopRequest() leaving the target deleted rather than updated! */ + switch (deletionVariant_) + { + case DeletionVariant::permanent: + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(linkPath))), statReporter); //throw ThreadStopRequest + parallel::removeSymlinkIfExists(linkPath, singleThread); //throw FileError + break; + + case DeletionVariant::recycler: + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelSymlinkRecycler_, L"%x", fmtPath(AFS::getDisplayPath(linkPath))), statReporter); //throw ThreadStopRequest + try + { + moveToRecycleBinIfExists(linkPath, relPath, singleThread); //throw FileError, RecycleBinUnavailable + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce_) //shared by threads! access under "singleThread" lock! + { + recyclerMissingReportOnce_ = true; + statReporter.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing_); //throw ThreadStopRequest + } + if (!beforeOverwrite) statReporter.logMessage(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(linkPath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw ThreadStopRequest + parallel::removeSymlinkIfExists(linkPath, singleThread); //throw FileError + } + break; + + case DeletionVariant::versioning: + if (!beforeOverwrite) reportInfo(replaceCpy(txtDelSymlinkVersioning_, L"%x", fmtPath(AFS::getDisplayPath(linkPath))), statReporter); //throw ThreadStopRequest + parallel::revisionSymlink(getOrCreateVersioner(), linkPath, relPath, singleThread); //throw FileError + break; + } + //remain transactional as much as possible => no more callbacks that can throw after successful deletion! (next: update file model!) + + //even if the source item did not exist anymore, significant I/O work was done => report unconditionally + if (!beforeOverwrite) statReporter.reportDelta(1, 0); +} + + +void DeletionHandler::removeDirWithCallback(const AbstractPath& folderPath, const Zstring& relPath, + AsyncItemStatReporter& statReporter, std::mutex& singleThread) //throw FileError, ThreadStopRequest +{ + auto removeFolderPermanently = [&] + { + //callbacks run *outside* singleThread_ lock! => fine + auto onBeforeDeletion = [&statReporter](const std::wstring& statusText, const std::wstring& displayPath) + { + statReporter.updateStatus(replaceCpy(statusText, L"%x", fmtPath(displayPath))); //throw ThreadStopRequest + statReporter.reportDelta(1, 0); //it would be more correct to report *after* work was done! + }; + static_assert(std::is_const_v, "callbacks better be thread-safe!"); + + parallel::removeFolderIfExistsRecursion(folderPath, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFilePermanent_, displayPath); }, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelSymlinkPermanent_, displayPath); }, + [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFolderPermanent_, displayPath); }, singleThread); //throw FileError, ThreadStopRequest + }; + + switch (deletionVariant_) + { + case DeletionVariant::permanent: + { + reportInfo(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw ThreadStopRequest + removeFolderPermanently(); //throw FileError, ThreadStopRequest + } + break; + + case DeletionVariant::recycler: + reportInfo(replaceCpy(txtDelFolderRecycler_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw ThreadStopRequest + try + { + moveToRecycleBinIfExists(folderPath, relPath, singleThread); //throw FileError, RecycleBinUnavailable + statReporter.reportDelta(1, 0); //moving to recycler is ONE logical operation, irrespective of the number of child elements! + } + catch (const RecycleBinUnavailable& e) + { + if (!recyclerMissingReportOnce_) //shared by threads! access under "singleThread" lock! + { + recyclerMissingReportOnce_ = true; + statReporter.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing_); //throw ThreadStopRequest + } + statReporter.logMessage(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))) + + L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw ThreadStopRequest + removeFolderPermanently(); //throw FileError, ThreadStopRequest + } + break; + + case DeletionVariant::versioning: + { + reportInfo(replaceCpy(txtDelFolderVersioning_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw ThreadStopRequest + + //callbacks run *outside* singleThread_ lock! => fine + auto notifyMove = [&statReporter](const std::wstring& statusText, const std::wstring& displayPathFrom, const std::wstring& displayPathTo) + { + statReporter.updateStatus(replaceCpy(replaceCpy(statusText, L"%x", L'\n' + fmtPath(displayPathFrom)), L"%y", L'\n' + fmtPath(displayPathTo))); //throw ThreadStopRequest + statReporter.reportDelta(1, 0); //it would be more correct to report *after* work was done! + }; + static_assert(std::is_const_v, "callbacks better be thread-safe!"); + auto onBeforeFileMove = [&](const std::wstring& displayPathFrom, const std::wstring& displayPathTo) { notifyMove(txtMovingFileXtoY_, displayPathFrom, displayPathTo); }; + auto onBeforeFolderMove = [&](const std::wstring& displayPathFrom, const std::wstring& displayPathTo) { notifyMove(txtMovingFolderXtoY_, displayPathFrom, displayPathTo); }; + auto notifyUnbufferedIO = [&](int64_t bytesDelta) { statReporter.reportDelta(0, bytesDelta); interruptionPoint(); }; //throw ThreadStopRequest + + parallel::revisionFolder(getOrCreateVersioner(), folderPath, relPath, onBeforeFileMove, onBeforeFolderMove, notifyUnbufferedIO, singleThread); //throw FileError, ThreadStopRequest + } + break; + } +} + +//=================================================================================================== +//=================================================================================================== + +class Workload +{ +public: + Workload(size_t threadCount, AsyncCallback& acb) : acb_(acb), workload_(threadCount) { assert(threadCount > 0); } + + using WorkItem = std::function; + using WorkItems = RingBuffer; //FIFO! + + //blocking call: context of worker thread + WorkItem getNext(size_t threadIdx) //throw ThreadStopRequest + { + interruptionPoint(); //throw ThreadStopRequest + + std::unique_lock dummy(lockWork_); + for (;;) + { + if (!workload_[threadIdx].empty()) + { + auto wi = std::move(workload_[threadIdx]. front()); + /**/ workload_[threadIdx].pop_front(); + return wi; + } + if (!pendingWorkload_.empty()) + { + workload_[threadIdx] = std::move(pendingWorkload_. front()); + /**/ pendingWorkload_.pop_front(); + assert(!workload_[threadIdx].empty()); + } + else + { + WorkItems& items = *std::max_element(workload_.begin(), workload_.end(), [](const WorkItems& lhs, const WorkItems& rhs) { return lhs.size() < rhs.size(); }); + if (!items.empty()) //=> != workload_[threadIdx] + { + //steal half of largest workload from other thread + const size_t sz = items.size(); //[!] changes during loop! + for (size_t i = 0; i < sz; ++i) + { + auto wi = std::move(items. front()); + /**/ items.pop_front(); + if (i % 2 == 0) + workload_[threadIdx].push_back(std::move(wi)); + else + items.push_back(std::move(wi)); + } + } + else //wait... + { + if (++idleThreads_ == workload_.size()) + acb_.notifyAllDone(); //noexcept + ZEN_ON_SCOPE_EXIT(--idleThreads_); + + auto haveNewWork = [&] { return !pendingWorkload_.empty() || std::any_of(workload_.begin(), workload_.end(), [](const WorkItems& wi) { return !wi.empty(); }); }; + + interruptibleWait(conditionNewWork_, dummy, [&] { return haveNewWork(); }); //throw ThreadStopRequest + //it's sufficient to notify condition in addWorkItems() only (as long as we use std::condition_variable::notify_all()) + } + } + } + } + + void addWorkItems(RingBuffer&& buckets) + { + { + std::lock_guard dummy(lockWork_); + while (!buckets.empty()) + { + pendingWorkload_.push_back(std::move(buckets. front())); + /**/ buckets.pop_front(); + } + } + conditionNewWork_.notify_all(); + } + +private: + Workload (const Workload&) = delete; + Workload& operator=(const Workload&) = delete; + + AsyncCallback& acb_; + + std::mutex lockWork_; + std::condition_variable conditionNewWork_; + + size_t idleThreads_ = 0; + + std::vector workload_; //thread-specific buckets + RingBuffer pendingWorkload_; //FIFO: buckets of work items for use by any thread +}; + + +template inline +bool haveNameClash(const FileSystemObject& fsObj, const List& m) +{ + return std::any_of(m.begin(), m.end(), [itemName = fsObj.getItemName()](const FileSystemObject& sibling) + { return equalNoCase(sibling.getItemName(), itemName); }); //equalNoCase: when in doubt => assume name clash! +} + + +class FolderPairSyncer +{ +public: + struct SyncCtx + { + bool verifyCopiedFiles; + bool copyFilePermissions; + bool failSafeFileCopy; + DeletionHandler& delHandlerLeft; + DeletionHandler& delHandlerRight; + }; + + static void runSync(SyncCtx& syncCtx, BaseFolderPair& baseFolder, PhaseCallback& cb) + { + runPass(PassNo::zero, syncCtx, baseFolder, cb); //prepare file moves + runPass(PassNo::one, syncCtx, baseFolder, cb); //delete files (or overwrite big ones with smaller ones) + runPass(PassNo::two, syncCtx, baseFolder, cb); //copy rest + } + +private: + friend class Workload; + + enum class PassNo + { + zero, //prepare file moves + one, //delete files + two, //create, modify + never //skip item + }; + + FolderPairSyncer(SyncCtx& syncCtx, std::mutex& singleThread, AsyncCallback& acb) : + delHandlerLeft_ (syncCtx.delHandlerLeft), + delHandlerRight_ (syncCtx.delHandlerRight), + verifyCopiedFiles_ (syncCtx.verifyCopiedFiles), + copyFilePermissions_(syncCtx.copyFilePermissions), + failSafeFileCopy_ (syncCtx.failSafeFileCopy), + singleThread_(singleThread), + acb_(acb) {} + + static PassNo getPass(const FilePair& file); + static PassNo getPass(const SymlinkPair& symlink); + static PassNo getPass(const FolderPair& folder); + static bool needZeroPass(const FilePair& file); + static bool needZeroPass(const FolderPair& folder); + + static void runPass(PassNo pass, SyncCtx& syncCtx, BaseFolderPair& baseFolder, PhaseCallback& cb); //throw X + + RingBuffer getFolderLevelWorkItems(PassNo pass, ContainerObject& parentFolder, Workload& workload); + + static bool containsMoveTarget(const FolderPair& parent); + void executeFileMove(FilePair& file); //throw ThreadStopRequest + template void executeFileMoveImpl(FilePair& fileFrom, FilePair& fileTo); //throw ThreadStopRequest + + void synchronizeFile(FilePair& file); // + template void synchronizeFileInt(FilePair& file, SyncOperation syncOp); //throw FileError, ErrorMoveUnsupported, ThreadStopRequest + + void synchronizeLink(SymlinkPair& symlink); // + template void synchronizeLinkInt(SymlinkPair& symlink, SyncOperation syncOp); //throw FileError, ThreadStopRequest + + void synchronizeFolder(FolderPair& folder); // + template void synchronizeFolderInt(FolderPair& folder, SyncOperation syncOp); //throw FileError, ThreadStopRequest + + void reportItemInfo(const std::wstring& msgTemplate, const AbstractPath& itemPath) { reportInfo(replaceCpy(msgTemplate, L"%x", fmtPath(AFS::getDisplayPath(itemPath))), acb_); } + + void reportItemInfo(const std::wstring& msgTemplate, const AbstractPath& itemPath1, const AbstractPath& itemPath2) //throw ThreadStopRequest + { + reportInfo(replaceCpy(replaceCpy(msgTemplate, L"%x", L'\n' + fmtPath(AFS::getDisplayPath(itemPath1))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(itemPath2))), acb_); //throw ThreadStopRequest + } + + //already existing after onDeleteTargetFile(): undefined behavior! (e.g. fail/overwrite/auto-rename) + AFS::FileCopyResult copyFileWithCallback(const FileDescriptor& sourceDescr, + const AbstractPath& targetPath, + const std::function& onDeleteTargetFile /*throw X*/, //optional! + AsyncItemStatReporter& statReporter, //ThreadStopRequest + const std::wstring& statusMsg); //throw FileError, ThreadStopRequest, X + + DeletionHandler& delHandlerLeft_; + DeletionHandler& delHandlerRight_; + + const bool verifyCopiedFiles_; + const bool copyFilePermissions_; + const bool failSafeFileCopy_; + + std::mutex& singleThread_; + AsyncCallback& acb_; + + //preload status texts (premature?) + const std::wstring txtCreatingFile_ {_("Creating file %x" )}; + const std::wstring txtCreatingLink_ {_("Creating symbolic link %x")}; + const std::wstring txtCreatingFolder_ {_("Creating folder %x" )}; + const std::wstring txtUpdatingFile_ {_("Updating file %x" )}; + const std::wstring txtUpdatingLink_ {_("Updating symbolic link %x")}; + const std::wstring txtVerifyingFile_ {_("Verifying file %x" )}; + const std::wstring txtRenamingFileXtoY_ {_("Renaming file %x to %y" )}; + const std::wstring txtRenamingLinkXtoY_ {_("Renaming symbolic link %x to %y")}; + const std::wstring txtRenamingFolderXtoY_{_("Renaming folder %x to %y" )}; + const std::wstring txtMovingFileXtoY_ {_("Moving file %x to %y" )}; + const std::wstring txtSourceItemNotExist_{_("Source item %x is not existing")}; +}; + +//=================================================================================================== +//=================================================================================================== +/* ___________________________ + | | + | Multithreaded File Copy | + |_________________________| + + ---------------- ================= + |Async Callback| <-- |Worker Thread 1| + ---------------- ==================== + /|\ |Worker Thread 2| + | ================= + ============= | ... | + GUI <-- |Main Thread| \|/ \|/ +Callback ============= -------------------- + | Workload | + -------------------- + +Notes: - All threads share a single mutex, unlocked only during file I/O => do NOT require file_hierarchy.cpp classes to be thread-safe (i.e. internally synchronized)! + - Workload holds (folder-level-) items in buckets associated with each worker thread (FTP scenario: avoid CWDs) + - If a worker is idle, its Workload bucket is empty and no more pending buckets available: steal from other threads (=> take half of largest bucket) + - Maximize opportunity for parallelization ASAP: Workload buckets serve folder-items *before* files/symlinks => reduce risk of work-stealing + - Memory consumption: work items may grow indefinitely; however: test case "C:\" ~80MB per 1 million work items +*/ + +void FolderPairSyncer::runPass(PassNo pass, SyncCtx& syncCtx, BaseFolderPair& baseFolder, PhaseCallback& cb) //throw X +{ + std::mutex singleThread; //only a single worker thread may run at a time, except for parallel file I/O + + AsyncCallback acb; // + FolderPairSyncer fps(syncCtx, singleThread, acb); //manage life time: enclose InterruptibleThread's!!! + Workload workload(1, acb); + workload.addWorkItems(fps.getFolderLevelWorkItems(pass, baseFolder, workload)); //initial workload: set *before* threads get access! + + std::vector worker; + ZEN_ON_SCOPE_EXIT( for (InterruptibleThread& wt : worker) wt.requestStop(); ); //stop *all* at the same time before join! + + size_t threadIdx = 0; + Zstring threadName = Zstr("Sync"); + worker.emplace_back([threadIdx, &singleThread, &acb, &workload, threadName = std::move(threadName)] + { + setCurrentThreadName(threadName); + + while (/*blocking call:*/ std::function workItem = workload.getNext(threadIdx)) //throw ThreadStopRequest + { + acb.notifyTaskBegin(0 /*prio*/); //same prio, while processing only one folder pair at a time + ZEN_ON_SCOPE_EXIT(acb.notifyTaskEnd()); + + std::lock_guard dummy(singleThread); //protect ALL accesses to "fps" and workItem execution! + workItem(); //throw ThreadStopRequest + } + }); + acb.waitUntilDone(UI_UPDATE_INTERVAL / 2 /*every ~25 ms*/, cb); //throw X +} + + +//thread-safe thanks to std::mutex singleThread +RingBuffer FolderPairSyncer::getFolderLevelWorkItems(PassNo pass, ContainerObject& parentFolder, Workload& workload) +{ + RingBuffer buckets; + + RingBuffer foldersToInspect; + foldersToInspect.push_back(&parentFolder); + + while (!foldersToInspect.empty()) + { + ContainerObject& conObj = *foldersToInspect. front(); + /**/ foldersToInspect.pop_front(); + + RingBuffer> workItems; + + if (pass == PassNo::zero) + { + //create folders as required by file move targets: + for (FolderPair& folder : conObj.subfolders()) + if (needZeroPass(folder)) + workItems.push_back([this, &folder, &workload, pass] + { + tryReportingError([&] { synchronizeFolder(folder); }, acb_); //throw ThreadStopRequest + //error? => still process move targets (for delete + copy fall back!) + workload.addWorkItems(getFolderLevelWorkItems(pass, folder, workload)); + }); + else + foldersToInspect.push_back(&folder); + + for (FilePair& file : conObj.files()) + if (needZeroPass(file)) + workItems.push_back([this, &file] { executeFileMove(file); /*throw ThreadStopRequest*/ }); + } + else + { + //synchronize folders *first* (see comment above "Multithreaded File Copy") + for (FolderPair& folder : conObj.subfolders()) + if (pass == getPass(folder)) + workItems.push_back([this, &folder, &workload, pass] + { + tryReportingError([&]{ synchronizeFolder(folder); }, acb_); //throw ThreadStopRequest + + workload.addWorkItems(getFolderLevelWorkItems(pass, folder, workload)); + }); + else + foldersToInspect.push_back(&folder); + + //synchronize files: + for (FilePair& file : conObj.files()) + if (pass == getPass(file)) + workItems.push_back([this, &file] + { + tryReportingError([&]{ synchronizeFile(file); }, acb_); //throw ThreadStopRequest + }); + + //synchronize symbolic links: + for (SymlinkPair& symlink : conObj.symlinks()) + if (pass == getPass(symlink)) + workItems.push_back([this, &symlink] + { + tryReportingError([&] { synchronizeLink(symlink); }, acb_); //throw ThreadStopRequest + }); + } + + if (!workItems.empty()) + buckets.push_back(std::move(workItems)); + } + + return buckets; +} + + +/* __________________________ + |Move algorithm, 0th pass| + -------------------------- + 1. loop over hierarchy and find "move targets" => remember required parent folders + + 2. create required folders hierarchically: + - name-clash with other file/symlink (=> obscure!): fall back to delete and copy + - source folder missing: child items already deleted by synchronizeFolder() + - ignored error: fall back to delete and copy (in phases 1 and 2) + + 3. start file move (via targets) + - name-clash with other folder/symlink (=> obscure!): fall back to delete and copy + - ErrorMoveUnsupported: fall back to delete and copy + - ignored error: fall back to delete and copy + + __________________ + |killer-scenarios| + ------------------ + propagate the following move sequences: + I) a -> a/a caveat syncing parent directory first leads to circular dependency! + + II) a/a -> a caveat: fixing name clash will remove source! + + III) c -> d caveat: move-sequence needs to be processed in correct order! + b -> c/b + a -> b/a */ + +template +void FolderPairSyncer::executeFileMoveImpl(FilePair& fileFrom, FilePair& fileTo) //throw ThreadStopRequest +{ + assert(fileFrom.getMovePair() == &fileTo); + + const bool fallBackCopyDelete = [&] + { + //creation of parent folder has failed earlier? => fall back to delete + copy + const FolderPair* parentMissing = nullptr; //let's be more specific: go up in hierarchy until first missing parent folder + for (const FolderPair* f = dynamic_cast(&fileTo.parent()); f && f->isEmpty(); f = dynamic_cast(&f->parent())) + parentMissing = f; + + if (parentMissing) + { + reportInfo(AFS::generateMoveErrorMsg(fileFrom.getAbstractPath(), fileTo.getAbstractPath()) + L"\n\n" + + replaceCpy(_("Parent folder %x is not existing."), L"%x", fmtPath(AFS::getDisplayPath(parentMissing->getAbstractPath()))), acb_); //throw ThreadStopRequest + return true; + } + + //name clash with folders/symlinks? obscure => fall back to delete + copy + if (haveNameClash(fileTo, fileTo.parent().subfolders()) || + haveNameClash(fileTo, fileTo.parent().symlinks ())) + { + reportInfo(AFS::generateMoveErrorMsg(fileFrom.getAbstractPath(), fileTo.getAbstractPath()) + L"\n\n" + + replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(fileTo.getItemName())), acb_); //throw ThreadStopRequest + return true; + } + + bool moveSupported = true; + const std::wstring errMsg = tryReportingError([&] //throw ThreadStopRequest + { + try + { + synchronizeFile(fileTo); //throw FileError, ErrorMoveUnsupported, ThreadStopRequest + } + catch (const ErrorMoveUnsupported& e) + { + acb_.logMessage(e.toString(), PhaseCallback::MsgType::info); //let user know that move operation is not supported, then fall back: + moveSupported = false; + } + }, acb_); + + return !errMsg.empty() || !moveSupported; //move failed? We cannot allow to continue and have move source's parent directory deleted, messing up statistics! + }(); + + if (fallBackCopyDelete) + { + auto getStats = [&]() -> std::pair + { + SyncStatistics statSrc(fileFrom); + SyncStatistics statTrg(fileTo); + return {getCUD(statSrc) + getCUD(statTrg), statSrc.getBytesToProcess() + statTrg.getBytesToProcess()}; + }; + const auto [itemsBefore, bytesBefore] = getStats(); + fileFrom.setMovePair(nullptr); + const auto [itemsAfter, bytesAfter] = getStats(); + + //fix statistics total to match "copy + delete" + acb_.updateDataTotal(itemsAfter - itemsBefore, bytesAfter - bytesBefore); //noexcept + } +} + + +void FolderPairSyncer::executeFileMove(FilePair& file) //throw ThreadStopRequest +{ + const SyncOperation syncOp = file.getSyncOperation(); + switch (syncOp) + { + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + if (FilePair* fileFrom = file.getMovePair()) + { + if (syncOp == SO_MOVE_LEFT_TO) + executeFileMoveImpl(*fileFrom, file); //throw ThreadStopRequest + else + executeFileMoveImpl(*fileFrom, file); //throw ThreadStopRequest + } + else assert(false); + break; + + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_MOVE_LEFT_FROM: //don't try to move more than *once* per pair + case SO_MOVE_RIGHT_FROM: // + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + assert(false); //should have been filtered out by FolderPairSyncer::needZeroPass() + break; + } +} + +//--------------------------------------------------------------------------------------------------------------- + +bool FolderPairSyncer::containsMoveTarget(const FolderPair& parent) +{ + for (const FilePair& file : parent.files()) + if (needZeroPass(file)) + return true; + + for (const FolderPair& subFolder : parent.subfolders()) + if (containsMoveTarget(subFolder)) + return true; + return false; +} + + +//0th pass: execute file moves (+ optional fallback to delete/copy in passes 1 and 2) +bool FolderPairSyncer::needZeroPass(const FolderPair& folder) +{ + switch (folder.getSyncOperation()) + { + case SO_CREATE_LEFT: + return containsMoveTarget(folder) && //recursive! watch perf! + !haveNameClash(folder, folder.parent().files ()) && //name clash with files/symlinks? obscure => skip folder creation + !haveNameClash(folder, folder.parent().symlinks()); // => move: fall back to delete + copy + + case SO_CREATE_RIGHT: + return containsMoveTarget(folder) && //recursive! watch perf! + !haveNameClash(folder, folder.parent().files ()) && //name clash with files/symlinks? obscure => skip folder creation + !haveNameClash(folder, folder.parent().symlinks()); // => move: fall back to delete + copy + + case SO_DO_NOTHING: //implies !isEmpty(); see FolderPair::getSyncOperation() + case SO_UNRESOLVED_CONFLICT: // + case SO_EQUAL: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + assert(!containsMoveTarget(folder) || (!folder.isEmpty() && !folder.isEmpty())); + //we're good to move contained items + break; + case SO_DELETE_LEFT: //not possible in the context of planning to move a child item, see FolderPair::getSyncOperation() + case SO_DELETE_RIGHT: // + assert(!containsMoveTarget(folder)); + break; + case SO_OVERWRITE_LEFT: // + case SO_OVERWRITE_RIGHT: // + case SO_MOVE_LEFT_FROM: // + case SO_MOVE_RIGHT_FROM: //status not possible for folder + case SO_MOVE_LEFT_TO: // + case SO_MOVE_RIGHT_TO: // + assert(false); + break; + } + return false; +} + + +inline +bool FolderPairSyncer::needZeroPass(const FilePair& file) +{ + switch (file.getSyncOperation()) + { + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + return true; + + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_MOVE_LEFT_FROM: //don't try to move more than *once* per pair + case SO_MOVE_RIGHT_FROM: // + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + break; + } + return false; +} + + +//1st, 2nd pass benefits: +// - avoid disk space shortage: 1. delete files, 2. overwrite big with small files first +// - support change in type: overwrite file by directory, symlink by file, etc. + +inline +FolderPairSyncer::PassNo FolderPairSyncer::getPass(const FilePair& file) +{ + switch (file.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + return PassNo::one; + + case SO_OVERWRITE_LEFT: + return file.getFileSize() > file.getFileSize() ? PassNo::one : PassNo::two; + + case SO_OVERWRITE_RIGHT: + return file.getFileSize() < file.getFileSize() ? PassNo::one : PassNo::two; + + case SO_MOVE_LEFT_FROM: // + case SO_MOVE_RIGHT_FROM: // [!] + return PassNo::never; + case SO_MOVE_LEFT_TO: // + case SO_MOVE_RIGHT_TO: //make sure 2-step move is processed in second pass, after move *target* parent directory was created! + return PassNo::two; + + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + return PassNo::two; + + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + return PassNo::never; + } + assert(false); + return PassNo::never; +} + + +inline +FolderPairSyncer::PassNo FolderPairSyncer::getPass(const SymlinkPair& symlink) +{ + switch (symlink.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + return PassNo::one; //make sure to delete symlinks in first pass, and equally named file or dir in second pass: usecase "overwrite symlink with regular file"! + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + return PassNo::two; + + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + assert(false); + [[fallthrough]]; + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + return PassNo::never; + } + assert(false); + return PassNo::never; +} + + +inline +FolderPairSyncer::PassNo FolderPairSyncer::getPass(const FolderPair& folder) +{ + switch (folder.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + return PassNo::one; + + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + return PassNo::two; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + assert(false); + [[fallthrough]]; + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + return PassNo::never; + } + assert(false); + return PassNo::never; +} + +//--------------------------------------------------------------------------------------------------------------- + +inline +void FolderPairSyncer::synchronizeFile(FilePair& file) //throw FileError, ErrorMoveUnsupported, ThreadStopRequest +{ + assert(isLocked(singleThread_)); + const SyncOperation syncOp = file.getSyncOperation(); + + if (const SyncDirection syncDir = getEffectiveSyncDir(syncOp); + syncDir != SyncDirection::none) + { + if (syncDir == SyncDirection::left) + synchronizeFileInt(file, syncOp); + else + synchronizeFileInt(file, syncOp); + } +} + + +template +void FolderPairSyncer::synchronizeFileInt(FilePair& file, SyncOperation syncOp) //throw FileError, ErrorMoveUnsupported, ThreadStopRequest +{ + constexpr SelectSide sideSrc = getOtherSide; + DeletionHandler& delHandlerTrg = selectParam(delHandlerLeft_, delHandlerRight_); + + switch (syncOp) + { + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + { + if (auto parentFolder = dynamic_cast(&file.parent())) + if (parentFolder->isEmpty()) //BaseFolderPair OTOH is always non-empty and existing in this context => else: fatal error in zen::synchronize() + return; //if parent directory creation failed, there's no reason to show more errors! + + const AbstractPath targetPath = file.getAbstractPath(); + + const std::wstring& statusMsg = replaceCpy(txtCreatingFile_, L"%x", fmtPath(AFS::getDisplayPath(targetPath))); + reportInfo(std::wstring(statusMsg), acb_); //throw ThreadStopRequest + + AsyncItemStatReporter statReporter(1, file.getFileSize(), acb_); + try + { + const AFS::FileCopyResult result = copyFileWithCallback({file.getAbstractPath(), file.getAttributes()}, + targetPath, + nullptr, //onDeleteTargetFile: nothing to delete + //if existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + statReporter, + statusMsg); //throw FileError, ThreadStopRequest + statReporter.reportDelta(1, 0); + + //update FilePair + file.setSyncedTo(result.fileSize, + result.modTime, //target time set from source + result.modTime, + result.targetFilePrint, + result.sourceFilePrint, + false, file.isFollowedSymlink()); + + if (result.errorModTime) //log only; no popup + acb_.logMessage(result.errorModTime->toString(), PhaseCallback::MsgType::warning); //throw ThreadStopRequest + } + catch (const FileError& e) + { + bool sourceExists = true; + try { sourceExists = parallel::itemExists(file.getAbstractPath(), singleThread_); /*throw FileError*/ } + //abstract context => unclear which exception is more relevant/useless: + //e could be "item not found": doh; e2 devoid of any details after SFTP error: https://freefilesync.org/forum/viewtopic.php?t=7138#p24064 + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + //do not check on type (symlink, file, folder) -> if there is a type change, FFS should not be quiet about it! + if (!sourceExists) + { + reportItemInfo(txtSourceItemNotExist_, file.getAbstractPath()); //throw ThreadStopRequest + + statReporter.reportDelta(1, 0); + //even if the source item does not exist anymore, significant I/O work was done => report + file.removeItem(); //source deleted meanwhile...nothing was done (logical point of view!) + //remove only *after* evaluating "file, sideSrc"! + } + else + throw; + } + } + break; + + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + { + AsyncItemStatReporter statReporter(1, 0, acb_); + + if (file.isFollowedSymlink()) + delHandlerTrg.removeLinkWithCallback(file.getAbstractPath(), file.getRelativePath(), + false /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + else + delHandlerTrg.removeFileWithCallback({file.getAbstractPath(), file.getAttributes()}, file.getRelativePath(), + false /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + + file.removeItem(); //update FilePair + } + break; + + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + if (FilePair* fileFrom = file.getMovePair()) + { + FilePair* fileTo = &file; + + assert((fileFrom->getSyncOperation() == SO_MOVE_LEFT_FROM && fileTo->getSyncOperation() == SO_MOVE_LEFT_TO && sideTrg == SelectSide::left) || + (fileFrom->getSyncOperation() == SO_MOVE_RIGHT_FROM && fileTo->getSyncOperation() == SO_MOVE_RIGHT_TO && sideTrg == SelectSide::right)); + + const AbstractPath pathFrom = fileFrom->getAbstractPath(); + const AbstractPath pathTo = fileTo ->getAbstractPath(); + + reportItemInfo(txtMovingFileXtoY_, pathFrom, pathTo); //throw ThreadStopRequest + + AsyncItemStatReporter statReporter(1, 0, acb_); + + //already existing: undefined behavior! (e.g. fail/overwrite) + parallel::moveAndRenameItem(pathFrom, pathTo, singleThread_); //throw FileError, ErrorMoveUnsupported + + statReporter.reportDelta(1, 0); + + //update FilePair + assert(fileFrom->getFileSize() == fileTo->getFileSize()); + fileTo->setSyncedTo(fileTo ->getFileSize(), + fileFrom->getLastWriteTime(), + fileTo ->getLastWriteTime(), + fileFrom->getFilePrint(), + fileTo ->getFilePrint(), + fileFrom->isFollowedSymlink(), + fileTo ->isFollowedSymlink()); + fileFrom->removeItem(); //remove only *after* evaluating "fileFrom, sideTrg"! + } + else (assert(false)); + break; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + { + //respect differences in case of source object: + const AbstractPath targetPathLogical = AFS::appendRelPath(file.parent().getAbstractPath(), file.getItemName()); + + AbstractPath targetPathResolvedOld = file.getAbstractPath(); //support change in case when syncing to case-sensitive SFTP on Windows! + AbstractPath targetPathResolvedNew = targetPathLogical; + if (file.isFollowedSymlink()) //follow link when updating file rather than delete it and replace with regular file!!! + targetPathResolvedOld = targetPathResolvedNew = parallel::getSymlinkResolvedPath(file.getAbstractPath(), singleThread_); //throw FileError + + const std::wstring& statusMsg = replaceCpy(txtUpdatingFile_, L"%x", fmtPath(AFS::getDisplayPath(targetPathResolvedOld))); + reportInfo(std::wstring(statusMsg), acb_); //throw ThreadStopRequest + + AsyncItemStatReporter statReporter(1, file.getFileSize(), acb_); + + if (file.isFollowedSymlink()) //since we follow the link, we need to sync case sensitivity of the link manually! + if (!file.hasEquivalentItemNames()) + //already existing: undefined behavior! (e.g. fail/overwrite) + parallel::moveAndRenameItem(file.getAbstractPath(), targetPathLogical, singleThread_); //throw FileError, (ErrorMoveUnsupported) + + auto onDeleteTargetFile = [&] //delete target at appropriate time + { + assert(isLocked(singleThread_)); + FileAttributes followedTargetAttr = file.getAttributes(); + followedTargetAttr.isFollowedSymlink = false; + + if (file.isFollowedSymlink()) + delHandlerTrg.removeLinkWithCallback(targetPathResolvedOld, file.getRelativePath(), + true /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + else + delHandlerTrg.removeFileWithCallback({targetPathResolvedOld, followedTargetAttr}, file.getRelativePath(), + true /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + + //file.removeItem(); -> doesn't make sense for isFollowedSymlink(); "file, sideTrg" evaluated below! + }; + + const AFS::FileCopyResult result = copyFileWithCallback({file.getAbstractPath(), file.getAttributes()}, + targetPathResolvedNew, + onDeleteTargetFile, + statReporter, + statusMsg); //throw FileError, ThreadStopRequest + statReporter.reportDelta(1, 0); + //we model "delete + copy" as ONE logical operation + + //update FilePair + file.setSyncedTo(result.fileSize, + result.modTime, //target time set from source + result.modTime, + result.targetFilePrint, + result.sourceFilePrint, + file.isFollowedSymlink(), + file.isFollowedSymlink()); + + if (result.errorModTime) //log only; no popup + acb_.logMessage(result.errorModTime->toString(), PhaseCallback::MsgType::warning); //throw ThreadStopRequest + } + break; + + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + //harmonize with file_hierarchy.cpp::getSyncOpDescription!! + reportInfo(replaceCpy(replaceCpy(txtRenamingFileXtoY_, L"%x", fmtPath(AFS::getDisplayPath(file.getAbstractPath()))), + L"%y", fmtPath(file.getItemName())), acb_); //throw ThreadStopRequest + { + AsyncItemStatReporter statReporter(1, 0, acb_); + + if (!file.hasEquivalentItemNames()) + //already existing: undefined behavior! (e.g. fail/overwrite) + parallel::moveAndRenameItem(file.getAbstractPath(), //throw FileError, (ErrorMoveUnsupported) + AFS::appendRelPath(file.parent().getAbstractPath(), file.getItemName()), singleThread_); + else + assert(false); + +#if 0 //changing file time without copying content is not justified after CompareVariant::size finds "equal" files! + //Bonus: some devices don't support setting (precise) file times anyway, e.g. FAT or MTP! + if (file.getLastWriteTime() != file.getLastWriteTime()) + //- no need to call sameFileTime() or respect 2 second FAT/FAT32 precision in this comparison + //- do NOT read *current* source file time, but use buffered value which corresponds to time of comparison! + parallel::setModTime(file.getAbstractPath(), file.getLastWriteTime()); //throw FileError +#endif + statReporter.reportDelta(1, 0); + + //-> both sides *should* be completely equal now... + assert(file.getFileSize() == file.getFileSize()); + file.setSyncedTo(file.getFileSize(), + file.getLastWriteTime (), + file.getLastWriteTime (), + file.getFilePrint (), + file.getFilePrint (), + file.isFollowedSymlink(), + file.isFollowedSymlink()); + } + break; + + case SO_MOVE_LEFT_FROM: //use SO_MOVE_LEFT_TO/SO_MOVE_RIGHT_TO to execute move: + case SO_MOVE_RIGHT_FROM: //=> makes sure parent directory has been created + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + assert(false); //should have been filtered out by FolderPairSyncer::getPass() + return; //no update on processed data! + } +} + + +inline +void FolderPairSyncer::synchronizeLink(SymlinkPair& symlink) //throw FileError, ThreadStopRequest +{ + assert(isLocked(singleThread_)); + const SyncOperation syncOp = symlink.getSyncOperation(); + + if (const SyncDirection syncDir = getEffectiveSyncDir(syncOp); + syncDir != SyncDirection::none) + { + if (syncDir == SyncDirection::left) + synchronizeLinkInt(symlink, syncOp); + else + synchronizeLinkInt(symlink, syncOp); + } +} + + +template +void FolderPairSyncer::synchronizeLinkInt(SymlinkPair& symlink, SyncOperation syncOp) //throw FileError, ThreadStopRequest +{ + constexpr SelectSide sideSrc = getOtherSide; + DeletionHandler& delHandlerTrg = selectParam(delHandlerLeft_, delHandlerRight_); + + switch (syncOp) + { + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + { + if (auto parentFolder = dynamic_cast(&symlink.parent())) + if (parentFolder->isEmpty()) //BaseFolderPair OTOH is always non-empty and existing in this context => else: fatal error in zen::synchronize() + return; //if parent directory creation failed, there's no reason to show more errors! + + const AbstractPath targetPath = symlink.getAbstractPath(); + reportItemInfo(txtCreatingLink_, targetPath); //throw ThreadStopRequest + + AsyncItemStatReporter statReporter(1, 0, acb_); + try + { + parallel::copySymlink(symlink.getAbstractPath(), targetPath, copyFilePermissions_, singleThread_); //throw FileError + + statReporter.reportDelta(1, 0); + + //update SymlinkPair + symlink.setSyncedTo(symlink.getLastWriteTime(), //target time set from source + symlink.getLastWriteTime()); + + } + catch (const FileError& e) + { + bool sourceExists = true; + try { sourceExists = parallel::itemExists(symlink.getAbstractPath(), singleThread_); /*throw FileError*/ } + //abstract context => unclear which exception is more relevant/useless: + catch (const FileError& e2) { throw FileError(replaceCpy(e.toString(), L"\n\n", L'\n'), replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + //do not check on type (symlink, file, folder) -> if there is a type change, FFS should not be quiet about it! + if (!sourceExists) + { + reportItemInfo(txtSourceItemNotExist_, symlink.getAbstractPath()); //throw ThreadStopRequest + + //even if the source item does not exist anymore, significant I/O work was done => report + statReporter.reportDelta(1, 0); + symlink.removeItem(); //source deleted meanwhile...nothing was done (logical point of view!) + } + else + throw; + } + } + break; + + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + { + AsyncItemStatReporter statReporter(1, 0, acb_); + + delHandlerTrg.removeLinkWithCallback(symlink.getAbstractPath(), symlink.getRelativePath(), + false /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + + symlink.removeItem(); //update SymlinkPair + } + break; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + { + reportItemInfo(txtUpdatingLink_, symlink.getAbstractPath()); //throw ThreadStopRequest + + AsyncItemStatReporter statReporter(1, 0, acb_); + + delHandlerTrg.removeLinkWithCallback(symlink.getAbstractPath(), symlink.getRelativePath(), + true /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + + //symlink.removeItem(); -> "symlink, sideTrg" evaluated below! + + parallel::copySymlink(symlink.getAbstractPath(), + AFS::appendRelPath(symlink.parent().getAbstractPath(), symlink.getItemName()), //respect differences in case of source object + copyFilePermissions_, singleThread_); //throw FileError + + statReporter.reportDelta(1, 0); //we model "delete + copy" as ONE logical operation + + //update SymlinkPair + symlink.setSyncedTo(symlink.getLastWriteTime(), //target time set from source + symlink.getLastWriteTime()); + } + break; + + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + reportInfo(replaceCpy(replaceCpy(txtRenamingLinkXtoY_, L"%x", fmtPath(AFS::getDisplayPath(symlink.getAbstractPath()))), + L"%y", fmtPath(symlink.getItemName())), acb_); //throw ThreadStopRequest + { + AsyncItemStatReporter statReporter(1, 0, acb_); + + if (!symlink.hasEquivalentItemNames()) + //already existing: undefined behavior! (e.g. fail/overwrite) + parallel::moveAndRenameItem(symlink.getAbstractPath(), //throw FileError, (ErrorMoveUnsupported) + AFS::appendRelPath(symlink.parent().getAbstractPath(), symlink.getItemName()), singleThread_); + else + assert(false); + + statReporter.reportDelta(1, 0); + + //-> both sides *should* be completely equal now... + symlink.setSyncedTo(symlink.getLastWriteTime(), //target time set from source + symlink.getLastWriteTime()); + } + break; + + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + assert(false); //should have been filtered out by FolderPairSyncer::getPass() + return; //no update on processed data! + } +} + + +inline +void FolderPairSyncer::synchronizeFolder(FolderPair& folder) //throw FileError, ThreadStopRequest +{ + assert(isLocked(singleThread_)); + const SyncOperation syncOp = folder.getSyncOperation(); + + if (const SyncDirection syncDir = getEffectiveSyncDir(syncOp); + syncDir != SyncDirection::none) + { + if (syncDir == SyncDirection::left) + synchronizeFolderInt(folder, syncOp); + else + synchronizeFolderInt(folder, syncOp); + } +} + + +template +void FolderPairSyncer::synchronizeFolderInt(FolderPair& folder, SyncOperation syncOp) //throw FileError, ThreadStopRequest +{ + constexpr SelectSide sideSrc = getOtherSide; + DeletionHandler& delHandlerTrg = selectParam(delHandlerLeft_, delHandlerRight_); + + switch (syncOp) + { + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + { + if (auto parentFolder = dynamic_cast(&folder.parent())) + if (parentFolder->isEmpty()) //BaseFolderPair OTOH is always non-empty and existing in this context => else: fatal error in zen::synchronize() + return; //if parent directory creation failed, there's no reason to show more errors! + + const AbstractPath targetPath = folder.getAbstractPath(); + reportItemInfo(txtCreatingFolder_, targetPath); //throw ThreadStopRequest + + //shallow-"copying" a folder might not fail if source is missing, so we need to check this first: + if (parallel::itemExists(folder.getAbstractPath(), singleThread_)) //throw FileError + { + AsyncItemStatReporter statReporter(1, 0, acb_); + try + { + //already existing: fail + parallel::copyNewFolder(folder.getAbstractPath(), targetPath, copyFilePermissions_, singleThread_); //throw FileError + } + catch (FileError&) + { + bool folderAlreadyExists = false; + try { folderAlreadyExists = parallel::getItemType(targetPath, singleThread_) == AFS::ItemType::folder; } /*throw FileError*/ catch (FileError&) {} + //previous exception is more relevant; good enough? https://freefilesync.org/forum/viewtopic.php?t=5266 + + if (!folderAlreadyExists) + throw; + } + + statReporter.reportDelta(1, 0); + + //update FolderPair + folder.setSyncedTo(false, //isSymlinkTrg + folder.isFollowedSymlink()); + } + else //source deleted meanwhile... + { + reportItemInfo(txtSourceItemNotExist_, folder.getAbstractPath()); //throw ThreadStopRequest + + //attention when fixing statistics due to missing folder: child items may be scheduled for move, so deletion will have move-references flip back to copy + delete! + const SyncStatistics statsBefore(folder.base()); //=> don't bother considering individual move operations, just calculate over the whole tree + folder.clearFiles(); // + folder.clearSymlinks(); //update FolderPair + folder.clearSubfolders(); // + folder.removeItem(); // + const SyncStatistics statsAfter(folder.base()); + + acb_.updateDataProcessed(1, 0); //even if the source item does not exist anymore, significant I/O work was done => report + acb_.updateDataTotal(getCUD(statsAfter) - getCUD(statsBefore) + 1, statsAfter.getBytesToProcess() - statsBefore.getBytesToProcess()); //noexcept + } + } + break; + + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + { + const SyncStatistics subStats(folder); //counts sub-objects only! + AsyncItemStatReporter statReporter(1 + getCUD(subStats), subStats.getBytesToProcess(), acb_); + + if (folder.isFollowedSymlink()) + delHandlerTrg.removeLinkWithCallback(folder.getAbstractPath(), folder.getRelativePath(), + false /*beforeOverwrite*/, statReporter, singleThread_); //throw FileError, ThreadStopRequest + else + delHandlerTrg.removeDirWithCallback(folder.getAbstractPath(), folder.getRelativePath(), + statReporter, singleThread_); //throw FileError, ThreadStopRequest + + //TODO: implement parallel folder deletion + + folder.clearFiles(); // + folder.clearSymlinks(); //update FolderPair + folder.clearSubfolders(); // + folder.removeItem(); // + } + break; + + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + reportInfo(replaceCpy(replaceCpy(txtRenamingFolderXtoY_, L"%x", fmtPath(AFS::getDisplayPath(folder.getAbstractPath()))), + L"%y", fmtPath(folder.getItemName())), acb_); //throw ThreadStopRequest + { + AsyncItemStatReporter statReporter(1, 0, acb_); + + if (!folder.hasEquivalentItemNames()) + //already existing: undefined behavior! (e.g. fail/overwrite) + parallel::moveAndRenameItem(folder.getAbstractPath(), //throw FileError, (ErrorMoveUnsupported) + AFS::appendRelPath(folder.parent().getAbstractPath(), folder.getItemName()), singleThread_); + else + assert(false); + + statReporter.reportDelta(1, 0); + + //-> both sides *should* be completely equal now... + folder.setSyncedTo(folder.isFollowedSymlink(), + folder.isFollowedSymlink()); + } + break; + + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_TO: + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: + assert(false); //should have been filtered out by FolderPairSyncer::getPass() + return; //no update on processed data! + } +} + +//########################################################################################### + +//returns current attributes of source file +AFS::FileCopyResult FolderPairSyncer::copyFileWithCallback(const FileDescriptor& sourceDescr, + const AbstractPath& targetPath, + const std::function& onDeleteTargetFile /*throw X*/, + AsyncItemStatReporter& statReporter /*throw ThreadStopRequest*/, + const std::wstring& statusMsg) //throw FileError, ThreadStopRequest, X +{ + const AbstractPath& sourcePath = sourceDescr.path; + const AFS::StreamAttributes sourceAttr{sourceDescr.attr.modTime, sourceDescr.attr.fileSize, sourceDescr.attr.filePrint}; + + auto copyOperation = [&](const AbstractPath& sourcePathTmp) + { + PercentStatReporter percentReporter(statusMsg, sourceDescr.attr.fileSize, statReporter); + + //already existing + no onDeleteTargetFile: undefined behavior! (e.g. fail/overwrite/auto-rename) + const AFS::FileCopyResult result = parallel::copyFileTransactional(sourcePathTmp, sourceAttr, //throw FileError, ErrorFileLocked, ThreadStopRequest, X + targetPath, + copyFilePermissions_, + failSafeFileCopy_, [&] + { + if (onDeleteTargetFile) //running *outside* singleThread_ lock! => onDeleteTargetFile-callback expects lock being held: + { + std::lock_guard dummy(singleThread_); + onDeleteTargetFile(); //throw X + } + }, + [&](int64_t bytesDelta) //callback runs *outside* singleThread_ lock! => fine + { + percentReporter.updateDeltaAndStatus(bytesDelta); //throw ThreadStopRequest + interruptionPoint(); //throw ThreadStopRequest => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! + }, + singleThread_); + + //#################### Verification ############################# + if (verifyCopiedFiles_) + { + reportItemInfo(txtVerifyingFile_, targetPath); //throw ThreadStopRequest + + //delete target if verification fails + ZEN_ON_SCOPE_FAIL(try { parallel::removeFilePlain(targetPath, singleThread_); } + catch (const FileError& e) { statReporter.logMessage(e.toString(), PhaseCallback::MsgType::error); /*throw ThreadStopRequest*/ }); + + //callback runs *outside* singleThread_ lock! => fine + auto verifyCallback = [&](int64_t bytesDelta) { interruptionPoint(); }; //throw ThreadStopRequest + + parallel::verifyFiles(sourcePathTmp, targetPath, verifyCallback, singleThread_); //throw FileError, ThreadStopRequest + } + //#################### /Verification ############################# + + return result; + }; + + return copyOperation(sourcePath); //throw FileError, (ErrorFileLocked), ThreadStopRequest +} + +//########################################################################################### + +template +bool checkBaseFolderStatus(BaseFolderPair& baseFolder, PhaseCallback& callback /*throw X*/) +{ + const AbstractPath folderPath = baseFolder.getAbstractPath(); + + switch (baseFolder.getFolderStatus()) + { + case BaseFolderStatus::existing: + { + const std::wstring errMsg = tryReportingError([&] + { + AFS::getItemType(folderPath); //throw FileError + }, callback); //throw X + if (!errMsg.empty()) + return false; + } + break; + + case BaseFolderStatus::notExisting: + { + bool folderExisting = false; + + const std::wstring errMsg = tryReportingError([&] + { + folderExisting = AFS::itemExists(folderPath); //throw FileError + }, callback); //throw X + if (!errMsg.empty()) + return false; + if (folderExisting) //=> somebody else created it: problem? + { + /* Is it possible we're catching a "false positive" here, could FFS have created the directory indirectly after comparison? + 1. deletion handling: recycler -> no, temp directory created only at first deletion + 2. deletion handling: versioning -> " + 3. log file creates containing folder -> no, log only created in batch mode, and only *before* comparison + 4. yes, could be us! e.g. multiple folder pairs to non-yet-existing target folder => too obscure!? */ + callback.reportFatalError(replaceCpy(_("The folder %x is already existing, but was not found earlier during comparison."), + L"%x", fmtPath(AFS::getDisplayPath(folderPath)))); //throw X + return false; + } + } + break; + + case BaseFolderStatus::failure: + //e.g. TEMPORARY network drop! base directory not found during comparison + //=> sync-directions are based on false assumptions! Abort. + callback.reportFatalError(replaceCpy(_("Skipping folder pair because %x could not be accessed during comparison."), + L"%x", fmtPath(AFS::getDisplayPath(folderPath)))); //throw X + return false; + } + return true; +} + + +template //create base directories first (if not yet existing) -> no symlink or attribute copying! +bool createBaseFolder(BaseFolderPair& baseFolder, bool copyFilePermissions, PhaseCallback& callback /*throw X*/) //return false if fatal error occurred +{ + switch (baseFolder.getFolderStatus()) + { + case BaseFolderStatus::existing: + break; + + case BaseFolderStatus::notExisting: + { + //create target directory: user presumably ignored warning "dir not yet existing" in order to have it created automatically + const AbstractPath folderPath = baseFolder.getAbstractPath(); + constexpr SelectSide sideSrc = getOtherSide; + + const std::wstring errMsg = tryReportingError([&] + { + if (baseFolder.getFolderStatus() == BaseFolderStatus::existing) //copy file permissions + { + if (const std::optional parentPath = AFS::getParentPath(folderPath)) + AFS::createFolderIfMissingRecursion(*parentPath); //throw FileError + + AFS::copyNewFolder(baseFolder.getAbstractPath(), folderPath, copyFilePermissions); //throw FileError + } + else + AFS::createFolderIfMissingRecursion(folderPath); //throw FileError + assert(baseFolder.getFolderStatus() != BaseFolderStatus::failure); + + baseFolder.setFolderStatus(BaseFolderStatus::existing); //update our model! + }, callback); //throw X + + return errMsg.empty(); + } + + case BaseFolderStatus::failure: + assert(false); //already skipped after checkBaseFolderStatus() + break; + } + return true; +} +} + + +void fff::synchronize(const std::chrono::system_clock::time_point& syncStartTime, + bool verifyCopiedFiles, + bool copyLockedFiles, + bool copyFilePermissions, + bool failSafeFileCopy, + bool runWithBackgroundPriority, + const std::vector& syncConfig, + FolderComparison& folderCmp, + WarningDialogs& warnings, + ProcessCallback& callback /*throw X*/) //throw X +{ + //PERF_START; + + if (syncConfig.size() != folderCmp.size()) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + //aggregate basic information + std::vector folderPairStats; + { + int itemsTotal = 0; + int64_t bytesTotal = 0; + for (const BaseFolderPair& baseFolder : asRange(folderCmp)) + { + SyncStatistics fpStats(baseFolder); + itemsTotal += getCUD(fpStats); + bytesTotal += fpStats.getBytesToProcess(); + folderPairStats.push_back(fpStats); + } + + //inform about the total amount of data that will be processed from now on + //keep at beginning so that all gui elements are initialized properly + callback.initNewPhase(itemsTotal, //throw X + bytesTotal, + ProcessPhase::sync); + } + + //------------------------------------------------------------------------------- + + //prevent operating system going into sleep state + std::optional noStandby; + try + { + noStandby.emplace(runWithBackgroundPriority ? ProcessPriority::background : ProcessPriority::normal); //throw FileError + } + catch (const FileError& e) //failure is not critical => log only + { + callback.logMessage(e.toString(), PhaseCallback::MsgType::warning); //throw X + } + + //-------------------execute basic checks all at once BEFORE starting sync-------------------------------------- + std::vector skipFolderPair(folderCmp.size(), false); //folder pairs may be skipped after fatal errors were found + + std::vector>> checkUnresolvedConflicts; + + std::vector> checkBaseFolderRaceCondition; + + std::vector> checkSignificantDiffPairs; + + std::vector>> checkDiskSpaceMissing; //base folder / space required / space available + + std::set checkVersioningPaths; + std::vector> checkVersioningBasePaths; //hard filter creates new logical hierarchies for otherwise equal AbstractPath... + + std::set checkVersioningLimitPaths; + + //------------------- start checking folder pairs ------------------- + + //skip incomplete folder pairs with only one folder selected, or all => don't support "deletion via source folder" + { + bool haveFullPair = false; + std::wstring partialPairList; + + for (const BaseFolderPair& baseFolder : asRange(folderCmp)) + { + const AbstractPath& folderPathL = baseFolder.getAbstractPath(); + const AbstractPath& folderPathR = baseFolder.getAbstractPath(); + + if (AFS::isNullPath(folderPathL) != AFS::isNullPath(folderPathR)) + { + partialPairList += L"\n" + + (AFS::isNullPath(folderPathL) ? L"<" + _("empty") + L">" : AFS::getDisplayPath(folderPathL)) + L" | " + + (AFS::isNullPath(folderPathR) ? L"<" + _("empty") + L">" : AFS::getDisplayPath(folderPathR)); + } + else if (!AFS::isNullPath(folderPathL)) + haveFullPair = true; + } + + //error if: partial pairs or all empty -> single-folder comparison scenario doesn't include synchronization + if (!partialPairList.empty() || !haveFullPair) + callback.reportFatalError(trimCpy(_("A folder input field is empty.") + L" \n\n" + + _("Please select both left and right folders for synchronization.") + L"\n" + partialPairList)); //throw X + //"skipFolderPair[folderIndex] = true" will be set below + } + + + for (size_t folderIndex = 0; folderIndex < folderCmp.size(); ++folderIndex) + { + BaseFolderPair& baseFolder = folderCmp[folderIndex].ref(); + const FolderPairSyncCfg& folderPairCfg = syncConfig[folderIndex]; + const SyncStatistics& folderPairStat = folderPairStats[folderIndex]; + + //=============== start with checks that may SKIP folder pairs =============== + //============================================================================ + + //skip incomplete folder pairs (fatal error already reported above) + if (AFS::isNullPath(baseFolder.getAbstractPath()) || + AFS::isNullPath(baseFolder.getAbstractPath())) + { + skipFolderPair[folderIndex] = true; + continue; + } + + //exclude a few pathological cases + if (baseFolder.getAbstractPath() == + baseFolder.getAbstractPath()) + { + skipFolderPair[folderIndex] = true; + continue; + } + + //skip folder pair if there is nothing to do (except when DB files need to be updated for two-way mode and move-detection) + //=> avoid redundant errors in checkBaseFolderStatus() if base folder existence test failed during comparison + if (getCUD(folderPairStat) == 0 && !folderPairCfg.saveSyncDB) + { + skipFolderPair[folderIndex] = true; + continue; + } + + //check for network drops after comparison + // - convenience: exit sync right here instead of showing tons of errors during file copy + // - early failure! there's no point in evaluating subsequent warnings + if (!checkBaseFolderStatus(baseFolder, callback) || + !checkBaseFolderStatus(baseFolder, callback)) + { + skipFolderPair[folderIndex] = true; + continue; + } + + //allow propagation of deletions only from *empty* or *existing* source folder: + auto sourceFolderMissing = [&](const AbstractPath& baseFolderPath, BaseFolderStatus folderStatus) //we need to evaluate existence status from time of comparison! + { + //PERMANENT network drop: avoid data loss when source directory is not found AND user chose to ignore errors (else we wouldn't arrive here) + if (folderPairStat.deleteCount() > 0) //check deletions only... (respect filtered items!) + //folderPairStat.conflictCount() == 0 && -> there COULD be conflicts for variant if directory existence check fails, but loading sync.ffs_db succeeds + //https://sourceforge.net/tracker/?func=detail&atid=1093080&aid=3531351&group_id=234430 -> fixed, but still better not consider conflicts! + if (folderStatus != BaseFolderStatus::existing) //avoid race-condition: we need to evaluate existence status from time of comparison! + { + callback.reportFatalError(replaceCpy(_("Source folder %x not found."), L"%x", fmtPath(AFS::getDisplayPath(baseFolderPath)))); + return true; + } + return false; + }; + if (sourceFolderMissing(baseFolder.getAbstractPath(), baseFolder.getFolderStatus()) || + sourceFolderMissing(baseFolder.getAbstractPath(), baseFolder.getFolderStatus())) + { + skipFolderPair[folderIndex] = true; + continue; + } + + //check if user-defined directory for deletion was specified + const AbstractPath versioningFolderPath = createAbstractPath(folderPairCfg.versioningFolderPhrase); + + if (folderPairCfg.handleDeletion == DeletionVariant::versioning) + if (AFS::isNullPath(versioningFolderPath)) + { + callback.reportFatalError(_("Please enter a target folder.")); //user should never see this: already checked in SyncCfgDialog + skipFolderPair[folderIndex] = true; + continue; + } + + //================= Warnings (*after* folder pair skips) ================= + //======================================================================== + + //prepare conflict preview: + if (folderPairStat.conflictCount() > 0) + checkUnresolvedConflicts.emplace_back(&baseFolder, folderPairStat.conflictCount(), folderPairStat.getConflictsPreview()); + + //prepare: check if some files are used by multiple pairs in read/write access + const bool writeLeft = folderPairStat.createCount() + + folderPairStat.updateCount() + + folderPairStat.deleteCount() > 0; + + const bool writeRight = folderPairStat.createCount() + + folderPairStat.updateCount() + + folderPairStat.deleteCount() > 0; + + checkBaseFolderRaceCondition.emplace_back(&baseFolder, SelectSide::left, writeLeft); + checkBaseFolderRaceCondition.emplace_back(&baseFolder, SelectSide::right, writeRight); + + //prepare: check if versioning path itself will be synchronized (and was not excluded via filter) + if (folderPairCfg.handleDeletion == DeletionVariant::versioning) + checkVersioningPaths.insert(versioningFolderPath); + + checkVersioningBasePaths.emplace_back(baseFolder.getAbstractPath(), &baseFolder.getFilter()); + checkVersioningBasePaths.emplace_back(baseFolder.getAbstractPath(), &baseFolder.getFilter()); + + //prepare: versioning folder paths differing only in case + if (folderPairCfg.handleDeletion == DeletionVariant::versioning && + folderPairCfg.versioningStyle != VersioningStyle::replace) + if (folderPairCfg.versionMaxAgeDays > 0 || folderPairCfg.versionCountMax > 0) //same check as in applyVersioningLimit() + checkVersioningLimitPaths.insert(versioningFolderPath); + + //check if more than 50% of total number of files/dirs will be created/overwritten/deleted + if (significantDifferenceDetected(folderPairStat)) + checkSignificantDiffPairs.emplace_back(baseFolder.getAbstractPath(), + baseFolder.getAbstractPath()); + + //check for sufficient free diskspace (folderPath might not yet exist!) + auto checkSpace = [&](const AbstractPath& baseFolderPath, int64_t minSpaceNeeded) + { + if (minSpaceNeeded > 0) + try + { + const int64_t freeSpace = AFS::getFreeDiskSpace(baseFolderPath); //throw FileError, returns < 0 if not available + + if (0 <= freeSpace && + freeSpace < minSpaceNeeded) + checkDiskSpaceMissing.push_back({baseFolderPath, {minSpaceNeeded, freeSpace}}); + } + catch (const FileError& e) //not critical => log only + { + callback.logMessage(e.toString(), PhaseCallback::MsgType::warning); //throw X + } + }; + const std::pair spaceNeeded = MinimumDiskSpaceNeeded::calculate(baseFolder); + + if (baseFolder.getFolderStatus() != BaseFolderStatus::failure) checkSpace(baseFolder.getAbstractPath(), spaceNeeded.first); + if (baseFolder.getFolderStatus() != BaseFolderStatus::failure) checkSpace(baseFolder.getAbstractPath(), spaceNeeded.second); + } + //-------------------------------------------------------------------------------------- + + //check if unresolved conflicts exist + if (!checkUnresolvedConflicts.empty()) + { + //distribute CONFLICTS_PREVIEW_MAX over all pairs, not *per* pair, or else log size with many folder pairs can blow up! + std::vector> conflictPreviewTrim(checkUnresolvedConflicts.size()); + + size_t previewRemain = CONFLICTS_PREVIEW_MAX; + for (size_t i = 0; ; ++i) + { + const size_t previewRemainOld = previewRemain; + + for (size_t j = 0; j < checkUnresolvedConflicts.size(); ++j) + { + const auto& [baseFolder, conflictCount, conflictPreview] = checkUnresolvedConflicts[j]; + + if (i < conflictPreview.size()) + { + conflictPreviewTrim[j].push_back(conflictPreview[i]); + if (--previewRemain == 0) + goto break2; //sigh + } + } + if (previewRemain == previewRemainOld) + break; + } + break2: + + std::wstring msg = _("The following items have unresolved conflicts and will not be synchronized:"); + + auto itPrevi = conflictPreviewTrim.begin(); + for (const auto& [baseFolder, conflictCount, conflictPreview] : checkUnresolvedConflicts) + { + msg += L"\n\n" + _("Folder pair:") + L' ' + + AFS::getDisplayPath(baseFolder->getAbstractPath()) + L" <-> " + + AFS::getDisplayPath(baseFolder->getAbstractPath()); + + for (const std::wstring& conflictMsg : *itPrevi) + msg += L'\n' + conflictMsg; + + if (makeUnsigned(conflictCount) > itPrevi->size()) + msg += L"\n [...] " + replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", conflictCount), //%x used as plural form placeholder! + L"%y", formatNumber(itPrevi->size())); + ++itPrevi; + } + + callback.reportWarning(msg, warnings.warnUnresolvedConflicts); //throw X + } + + //check if user accidentally selected wrong directories for sync + if (!checkSignificantDiffPairs.empty()) + { + std::wstring msg = _("The following folders are significantly different. Please check that the correct folders are selected for synchronization."); + + for (const auto& [folderPathL, folderPathR] : checkSignificantDiffPairs) + msg += L"\n\n" + + AFS::getDisplayPath(folderPathL) + L" <-> " + L'\n' + + AFS::getDisplayPath(folderPathR); + + callback.reportWarning(msg, warnings.warnSignificantDifference); //throw X + } + + //check for sufficient free diskspace + if (!checkDiskSpaceMissing.empty()) + { + std::wstring msg = _("Not enough free disk space available in:"); + + for (const auto& [folderPath, space] : checkDiskSpaceMissing) + msg += L"\n\n" + AFS::getDisplayPath(folderPath) + L'\n' + + TAB_SPACE + _("Required:") + L' ' + formatFilesizeShort(space.first) + L'\n' + + TAB_SPACE + _("Available:") + L' ' + formatFilesizeShort(space.second); + + callback.reportWarning(msg, warnings.warnNotEnoughDiskSpace); //throw X + } + + //check if folders are used by multiple pairs in read/write access + { + std::vector pathRaceItems; + + //race condition := multiple accesses of which at least one is a write + //=> use "writeAccess" to reduce list of - not necessarily conflicting - candidates to check (=> perf!) + for (auto it = checkBaseFolderRaceCondition.begin(); it != checkBaseFolderRaceCondition.end(); ++it) + if (const auto& [baseFolder1, side1, writeAccess1] = *it; + writeAccess1) + for (auto it2 = checkBaseFolderRaceCondition.begin(); it2 != checkBaseFolderRaceCondition.end(); ++it2) + { + const auto& [baseFolder2, side2, writeAccess2] = *it2; + + if (!writeAccess2 || + it < it2) //avoid duplicate comparisons + { + //"The Things We Do for [Perf]" + /**/ if (side1 == SelectSide::left && side2 == SelectSide::left ) checkPathRaceCondition(*baseFolder1, *baseFolder2, pathRaceItems); + else if (side1 == SelectSide::left && side2 == SelectSide::right) checkPathRaceCondition(*baseFolder1, *baseFolder2, pathRaceItems); + else if (side1 == SelectSide::right && side2 == SelectSide::left ) checkPathRaceCondition(*baseFolder1, *baseFolder2, pathRaceItems); + else checkPathRaceCondition(*baseFolder1, *baseFolder2, pathRaceItems); + } + } + + removeDuplicates(pathRaceItems); + + //create mapping table for folder pair positions + std::unordered_map folderPairIdxs; + for (size_t folderIndex = 0; folderIndex < folderCmp.size(); ++folderIndex) + folderPairIdxs[&folderCmp[folderIndex].ref()] = folderIndex; + + std::partial_sort(pathRaceItems.begin(), + pathRaceItems.begin() + std::min(pathRaceItems.size(), CONFLICTS_PREVIEW_MAX), + pathRaceItems.end(), [&](const PathRaceItem& lhs, const PathRaceItem& rhs) + { + if (const std::weak_ordering cmp = comparePathNoCase(lhs, rhs); + cmp != std::weak_ordering::equivalent) + return cmp < 0; //1. order by device, and case-insensitive path + + return folderPairIdxs.find(&lhs.fsObj->base())->second < //2. order by folder pair position + folderPairIdxs.find(&rhs.fsObj->base())->second; + }); + + if (!pathRaceItems.empty()) + { + std::wstring msg = _("Some files will be synchronized as part of multiple folder pairs.") + L'\n' + + _("To avoid conflicts, set up exclude filters so that each updated file is included by only one folder pair.") + L"\n\n"; + + auto prevItem = pathRaceItems[0]; + std::for_each(pathRaceItems.begin(), pathRaceItems.begin() + std::min(pathRaceItems.size(), CONFLICTS_PREVIEW_MAX), [&](const PathRaceItem& item) + { + if (comparePathNoCase(item, prevItem) != std::weak_ordering::equivalent) + msg += L"\n"; //visually separate path groups + + msg += formatRaceItem(item) + L"\n"; + prevItem = item; + }); + + if (pathRaceItems.size() > CONFLICTS_PREVIEW_MAX) + msg += L"\n[...] " + replaceCpy(_P("Showing %y of 1 item", "Showing %y of %x items", pathRaceItems.size()), //%x used as plural form placeholder! + L"%y", formatNumber(CONFLICTS_PREVIEW_MAX)); + + msg += L"\n💾: " + _("Write access") + L" 👓: " + _("Read access"); + + callback.reportWarning(msg, warnings.warnDependentBaseFolders); //throw X + } + } + + //check if versioning folder itself will be synchronized (and was not excluded via filter) + { + std::wstring msg; + bool shouldExclude = false; + + for (const AbstractPath& versioningFolderPath : checkVersioningPaths) + { + std::set foldersWithWarnings; //=> at most one msg per base folder (*and* per versioningFolderPath) + + for (const auto& [folderPath, filter] : checkVersioningBasePaths) //may contain duplicate paths, but with *different* hard filter! + if (std::optional pd = getFolderPathDependency(versioningFolderPath, NullFilter(), folderPath, *filter)) + if (const auto [it, inserted] = foldersWithWarnings.insert(folderPath); + inserted) + { + msg += L"\n\n" + + _("Selected folder:") + L" \t" + AFS::getDisplayPath(folderPath) + L'\n' + + _("Versioning folder:") + L" \t" + AFS::getDisplayPath(versioningFolderPath); + + if (pd->itemPathParent == folderPath) //if versioning folder is a subfolder of a base folder + if (!pd->relPath.empty()) //this can be fixed via an exclude filter + { + assert(pd->itemPathParent == folderPath); //otherwise: what the fuck!? + shouldExclude = true; + msg += std::wstring() + L'\n' + + L"⇒ " + _("Exclude:") + L" \t" + utfTo(FILE_NAME_SEPARATOR + pd->relPath + FILE_NAME_SEPARATOR); + } + } + } + if (!msg.empty()) + callback.reportWarning(_("The versioning folder must not be part of the synchronization.") + + (shouldExclude ? L' ' + _("The folder should be excluded via filter.") : L"") + + msg, warnings.warnVersioningFolderPartOfSync); //throw X + } + + //warn if versioning folder paths differ only in case => possible pessimization for applyVersioningLimit() + { + std::map, std::set> ciPathAliases; + + for (const AbstractPath& folderPath : checkVersioningLimitPaths) + ciPathAliases[std::pair(folderPath.afsDevice, folderPath.afsPath.value)].insert(folderPath); + + if (std::any_of(ciPathAliases.begin(), ciPathAliases.end(), [](const auto& item) { return item.second/*aliases*/.size() > 1; })) + { + std::wstring msg = _("The following folder paths differ in case. Please use a single form in order to avoid duplicate accesses."); + for (const auto& [key, aliases] : ciPathAliases) + if (aliases.size() > 1) + { + msg += L'\n'; + for (const AbstractPath& aliasPath : aliases) + msg += L'\n' + AFS::getDisplayPath(aliasPath); + } + callback.reportWarning(msg, warnings.warnFoldersDifferInCase); //throw X + } + //what about /folder and /Folder/subfolder? => yes, inconsistent, but doesn't matter for FFS + } + //-------------------end of basic checks------------------------------------------ + + std::set versionLimitFolders; + + bool recyclerMissingReportOnce = false; //prompt user only *once* per sync, not per failed item! + + class PcbNoThrow : public PhaseCallback + { + public: + explicit PcbNoThrow(ProcessCallback& cb) : cb_(cb) {} + + void updateDataProcessed(int itemsDelta, int64_t bytesDelta) override {} //sync DB/del-handler: logically not part of sync data, so let's ignore + void updateDataTotal (int itemsDelta, int64_t bytesDelta) override {} // + + void requestUiUpdate(bool force) override { try { cb_.requestUiUpdate(force); /*throw X*/} catch (...) {}; } + + void updateStatus(std::wstring&& msg) override { try { cb_.updateStatus(std::move(msg)); /*throw X*/} catch (...) {}; } + void logMessage(const std::wstring& msg, MsgType type) override { try { cb_.logMessage(msg, type); /*throw X*/} catch (...) {}; } + + void reportWarning(const std::wstring& msg, bool& warningActive) override { logMessage(msg, MsgType::warning); /*ignore*/ } + Response reportError (const ErrorInfo& errorInfo) override { logMessage(errorInfo.msg, MsgType::error); return Response::ignore; } + void reportFatalError(const std::wstring& msg) override { logMessage(msg, MsgType::error); /*ignore*/ } + + private: + ProcessCallback& cb_; + } callbackNoThrow(callback); + + try + { + //loop through all directory pairs + for (size_t folderIndex = 0; folderIndex < folderCmp.size(); ++folderIndex) + { + BaseFolderPair& baseFolder = folderCmp[folderIndex].ref(); + const FolderPairSyncCfg& folderPairCfg = syncConfig[folderIndex]; + const SyncStatistics& folderPairStat = folderPairStats[folderIndex]; + + if (skipFolderPair[folderIndex]) //folder pairs may be skipped after fatal errors were found + continue; + + //------------------------------------------------------------------------------------------ + //checking a second time: 1. a long time may have passed since syncing the previous folder pairs! + // 2. expected to be run directly *before* createBaseFolder()! + if (!checkBaseFolderStatus(baseFolder, callback) || + !checkBaseFolderStatus(baseFolder, callback)) + continue; + + //create base folders if not yet existing + if (folderPairStat.createCount() > 0 || folderPairCfg.saveSyncDB) //else: temporary network drop leading to deletions already caught by "sourceFolderMissing" check! + if (!createBaseFolder(baseFolder, copyFilePermissions, callback) || //+ detect temporary network drop!! + !createBaseFolder(baseFolder, copyFilePermissions, callback)) // + continue; + + //------------------------------------------------------------------------------------------ + //update database even when sync is cancelled (or "nothing to sync"): + auto guardDbSave = makeGuard([&] + { + if (folderPairCfg.saveSyncDB) + saveLastSynchronousState(baseFolder, failSafeFileCopy, + callbackNoThrow); + }); + + //------------------------------------------------------------------------------------------ + //execute synchronization recursively + if (getCUD(folderPairStat) > 0) + { + callback.logMessage(_("Synchronizing folder pair:") + L' ' + getVariantNameWithSymbol(folderPairCfg.syncVar) + L'\n' + //throw X + TAB_SPACE + AFS::getDisplayPath(baseFolder.getAbstractPath()) + L'\n' + + TAB_SPACE + AFS::getDisplayPath(baseFolder.getAbstractPath()), PhaseCallback::MsgType::info); + + //guarantee removal of invalid entries (where element is empty on both sides) + ZEN_ON_SCOPE_EXIT(baseFolder.removeDoubleEmpty()); + + bool copyPermissionsFp = false; + tryReportingError([&] + { + copyPermissionsFp = copyFilePermissions && //copy permissions only if asked for and supported by *both* sides! + AFS::supportPermissionCopy(baseFolder.getAbstractPath(), + baseFolder.getAbstractPath()); //throw FileError + }, callback); //throw X + + const AbstractPath versioningFolderPath = createAbstractPath(folderPairCfg.versioningFolderPhrase); + + DeletionHandler delHandlerL(baseFolder.getAbstractPath(), + recyclerMissingReportOnce, + warnings.warnRecyclerMissing, + folderPairCfg.handleDeletion, + versioningFolderPath, + folderPairCfg.versioningStyle, + std::chrono::system_clock::to_time_t(syncStartTime)); + + DeletionHandler delHandlerR(baseFolder.getAbstractPath(), + recyclerMissingReportOnce, + warnings.warnRecyclerMissing, + folderPairCfg.handleDeletion, + versioningFolderPath, + folderPairCfg.versioningStyle, + std::chrono::system_clock::to_time_t(syncStartTime)); + + //always (try to) clean up, even if synchronization is aborted! + auto guardDelCleanup = makeGuard([&] + { + delHandlerL.tryCleanup(callbackNoThrow); + delHandlerR.tryCleanup(callbackNoThrow); + }); + + + FolderPairSyncer::SyncCtx syncCtx = + { + verifyCopiedFiles, copyPermissionsFp, failSafeFileCopy, + delHandlerL, delHandlerR, + }; + FolderPairSyncer::runSync(syncCtx, baseFolder, callback); + + //(try to gracefully) clean up temporary Recycle Bin folders and versioning + delHandlerL.tryCleanup(callback); //throw X + delHandlerR.tryCleanup(callback); // + guardDelCleanup.dismiss(); + + if (folderPairCfg.handleDeletion == DeletionVariant::versioning && + folderPairCfg.versioningStyle != VersioningStyle::replace) + versionLimitFolders.insert( + { + versioningFolderPath, + folderPairCfg.versionMaxAgeDays, + folderPairCfg.versionCountMin, + folderPairCfg.versionCountMax + }); + } + + //(try to gracefully) write database file + if (folderPairCfg.saveSyncDB) + { + saveLastSynchronousState(baseFolder, failSafeFileCopy, + callback /*throw X*/); //throw X + guardDbSave.dismiss(); //[!] dismiss *after* "graceful" try: user might cancel during DB write: ensure DB is still written + } + } + //----------------------------------------------------------------------------------------------------- + + applyVersioningLimit(versionLimitFolders, + callback /*throw X*/); //throw X + } + catch (const std::exception& e) + { + callback.reportFatalError(utfTo(e.what())); + } +} diff --git a/FreeFileSync/Source/base/synchronization.h b/FreeFileSync/Source/base/synchronization.h new file mode 100644 index 0000000..063563b --- /dev/null +++ b/FreeFileSync/Source/base/synchronization.h @@ -0,0 +1,103 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYNCHRONIZATION_H_8913470815943295 +#define SYNCHRONIZATION_H_8913470815943295 + +#include +#include "structures.h" +#include "file_hierarchy.h" +#include "process_callback.h" + + +namespace fff +{ +class SyncStatistics //count *logical* operations, (create, update, delete + bytes), *not* disk accesses! +{ + //-> note the fundamental difference compared to counting disk accesses! +public: + explicit SyncStatistics(const FolderComparison& folderCmp); + explicit SyncStatistics(const ContainerObject& conObj); + explicit SyncStatistics(const FilePair& file); + + template + int createCount() const { return selectParam(createLeft_, createRight_); } + int createCount() const { return createLeft_ + createRight_; } + + template + int updateCount() const { return selectParam(updateLeft_, updateRight_); } + int updateCount() const { return updateLeft_ + updateRight_; } + + template + int deleteCount() const { return selectParam(deleteLeft_, deleteRight_); } + int deleteCount() const { return deleteLeft_ + deleteRight_; } + + int64_t getBytesToProcess() const { return bytesToProcess_; } + size_t rowCount () const { return rowsTotal_; } + + const std::vector& getConflictsPreview() const { return conflictsPreview_; } + int conflictCount() const { return conflictCount_; } + +private: + void recurse(const ContainerObject& conObj); + void logConflict(const FileSystemObject& fsObj); + + void processFile (const FilePair& file); + void processLink (const SymlinkPair& symlink); + void processFolder(const FolderPair& folder); + + int createLeft_ = 0; + int createRight_ = 0; + int updateLeft_ = 0; + int updateRight_ = 0; + int deleteLeft_ = 0; + int deleteRight_ = 0; + + int64_t bytesToProcess_ = 0; + size_t rowsTotal_ = 0; + + int conflictCount_ = 0; + std::vector conflictsPreview_; //conflict texts to display as a warning message + //limit conflict count! e.g. there may be hundred thousands of "same date but a different size" +}; + + +inline +int getCUD(const SyncStatistics& stat) +{ + return stat.createCount() + + stat.updateCount() + + stat.deleteCount(); +} + +struct FolderPairSyncCfg +{ + SyncVariant syncVar; + bool saveSyncDB; //save database if in automatic mode or dection of moved files is active + DeletionVariant handleDeletion; + Zstring versioningFolderPhrase; //unresolved directory names as entered by user! + VersioningStyle versioningStyle; + int versionMaxAgeDays; + int versionCountMin; + int versionCountMax; +}; +std::vector extractSyncCfg(const MainConfiguration& mainCfg); + + +//FFS core routine: +void synchronize(const std::chrono::system_clock::time_point& syncStartTime, + bool verifyCopiedFiles, + bool copyLockedFiles, + bool copyFilePermissions, + bool failSafeFileCopy, + bool runWithBackgroundPriority, + const std::vector& syncConfig, //CONTRACT: syncConfig and folderCmp correspond row-wise! + FolderComparison& folderCmp, // + WarningDialogs& warnings, + ProcessCallback& callback /*throw X*/); //throw X +} + +#endif //SYNCHRONIZATION_H_8913470815943295 diff --git a/FreeFileSync/Source/base/versioning.cpp b/FreeFileSync/Source/base/versioning.cpp new file mode 100644 index 0000000..d26b778 --- /dev/null +++ b/FreeFileSync/Source/base/versioning.cpp @@ -0,0 +1,615 @@ +// ***************************************************************************** +// * 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 "versioning.h" +#include "parallel_scan.h" +#include "status_handler_impl.h" +#include "dir_exist_async.h" + +using namespace zen; +using namespace fff; + + +namespace +{ +inline +Zstring getDotExtension(const Zstring& filePath) //including "." if extension is existing, returns empty string otherwise +{ + //const Zstring& extension = getFileExtension(filePath); + //return extension.empty() ? extension : Zstr('.') + extension; + + auto it = findLast(filePath.begin(), filePath.end(), FILE_NAME_SEPARATOR); + if (it == filePath.end()) + it = filePath.begin(); + else + ++it; + + return Zstring(findLast(it, filePath.end(), Zstr('.')), filePath.end()); +} +} + + +//e.g. "Sample.txt 2012-05-15 131513.txt" +//or "Sample 2012-05-15 131513" +std::pair fff::impl::parseVersionedFileName(const Zstring& fileName) +{ + const auto ext = makeStringView(findLast(fileName.begin(), fileName.end(), Zstr('.')), fileName.end()); + + if (fileName.size() < 2 * ext.length() + 18) + return {}; + + const auto itExt1 = fileName.end() - (2 * ext.length() + 18); + if (!equalString(ext, makeStringView(itExt1, ext.length()))) + return {}; + + const auto itTs = itExt1 + ext.length(); + const TimeComp tc = parseTime(Zstr(" %Y-%m-%d %H%M%S"), makeStringView(itTs, 18)); //returns TimeComp() on error + + const auto [localTime, timeValid] = localToTimeT(tc); + if (!timeValid) + return {}; + + Zstring fileNameOrig(fileName.begin(), itTs); + if (fileNameOrig.empty()) + return {}; + + return {localTime, std::move(fileNameOrig)}; +} + + +//e.g. "2012-05-15 131513" +time_t fff::impl::parseVersionedFolderName(const Zstring& folderName) +{ + const TimeComp tc = parseTime(Zstr("%Y-%m-%d %H%M%S"), folderName); //returns TimeComp() on error + + const auto [localTime, timeValid] = localToTimeT(tc); + if (!timeValid) + return 0; + + return localTime; +} + + +AbstractPath FileVersioner::generateVersionedPath(const Zstring& relativePath) const +{ + assert(isValidRelPath(relativePath)); + assert(!relativePath.empty()); + + Zstring versionedRelPath; + switch (versioningStyle_) + { + case VersioningStyle::replace: + versionedRelPath = relativePath; + break; + case VersioningStyle::timestampFolder: + versionedRelPath = timeStamp_ + FILE_NAME_SEPARATOR + relativePath; + break; + case VersioningStyle::timestampFile: //assemble time-stamped version name + versionedRelPath = relativePath + Zstr(' ') + timeStamp_ + getDotExtension(relativePath); + assert(impl::parseVersionedFileName(getItemName(versionedRelPath)) == + std::pair(syncStartTime_, getItemName(relativePath))); + (void)syncStartTime_; //clang: -Wunused-private-field + break; + } + return AFS::appendRelPath(versioningFolderPath_, versionedRelPath); +} + + +namespace +{ +/* move source to target across volumes: + - source is expected to exist + - if target already exists, it is overwritten, unless it is of a different type, e.g. a directory! + - target parent directories are created if missing */ +template +void moveExistingItemToVersioning(const AbstractPath& sourcePath, const AbstractPath& targetPath, //throw FileError + Function copyNewItemPlain /*throw FileError*/) +{ + //start deleting existing target as required by copyFileTransactional()/moveAndRenameItem(): + //best amortized performance if "already existing" is the most common case + std::exception_ptr deletionError; + try { AFS::removeFilePlain(targetPath); /*throw FileError*/ } + catch (FileError&) { deletionError = std::current_exception(); } //probably "not existing" error, defer evaluation + //overwrite AFS::ItemType::folder with FILE? => highly dubious, do not allow + + auto fixTargetPathIssues = [&](const FileError& prevEx) //throw FileError + { + bool alreadyExisting = false; + try + { + AFS::getItemType(targetPath); //throw FileError + alreadyExisting = true; + } + catch (FileError&) {} //=> not yet existing (=> fine, no path issue) or access error: + //- let's pretend it doesn't happen :> if it does, worst case: the retry fails with (useless) already existing error + //- AFS::itemExists()? too expensive, considering that "already existing" is the most common case + + if (alreadyExisting) + { + if (deletionError) + std::rethrow_exception(deletionError); + throw prevEx; //yes, slicing, but not relevant here + } + + //parent folder missing => create + retry + //parent folder existing => maybe created shortly after move attempt by parallel thread! => retry + if (const std::optional targetParentPath = AFS::getParentPath(targetPath)) + AFS::createFolderIfMissingRecursion(*targetParentPath); //throw FileError + }; + + try //first try to move directly without copying + { + //already existing: undefined behavior! (e.g. fail/overwrite) + AFS::moveAndRenameItem(sourcePath, targetPath); //throw FileError, ErrorMoveUnsupported + //great, we get away cheaply! + } + catch (ErrorMoveUnsupported&) + { + try + { + copyNewItemPlain(); //throw FileError + } + catch (const FileError& e) + { + fixTargetPathIssues(e); //throw FileError + + //retry: + copyNewItemPlain(); //throw FileError + } + //[!] remove source file AFTER handling target path errors! + AFS::removeFilePlain(sourcePath); //throw FileError + } + catch (const FileError& e) + { + fixTargetPathIssues(e); //throw FileError + + try //retry + { + //already existing: undefined behavior! (e.g. fail/overwrite) + AFS::moveAndRenameItem(sourcePath, targetPath); //throw FileError, ErrorMoveUnsupported + } + catch (ErrorMoveUnsupported&) + { + copyNewItemPlain(); //throw FileError + AFS::removeFilePlain(sourcePath); //throw FileError + } + } +} +} + + +void FileVersioner::checkPathConflict(const AbstractPath& itemPath, const Zstring& relativePath) const //throw FileError +{ + if (std::optional pd = getPathDependency(itemPath, versioningFolderPath_)) + { + assert(pd->itemPathParent == versioningFolderPath_); //otherwise: what the fuck!? + //user ignored warning about versioning folder being part of sync => + //prevent files from being moved to versioning recursively: + throw FileError(trimCpy(replaceCpy(replaceCpy(_("Cannot move %x to %y."), + L"%x", L'\n' + fmtPath(AFS::getDisplayPath(itemPath))), + L"%y", L'\n' + fmtPath(AFS::getDisplayPath(generateVersionedPath(relativePath))))), + _("Item already located in the versioning folder.")); + } +} + + +void FileVersioner::revisionFile(const FileDescriptor& fileDescr, const Zstring& relativePath, const IoCallback& notifyUnbufferedIO /*throw X*/) const //throw FileError, X +{ + checkPathConflict(fileDescr.path, relativePath); //throw FileError + + if (const std::optional type = AFS::getItemTypeIfExists(fileDescr.path)) //throw FileError + { + assert(*type != AFS::ItemType::symlink); + + if (*type == AFS::ItemType::symlink) + revisionSymlinkImpl(fileDescr.path, relativePath, nullptr /*onBeforeMove*/); //throw FileError + else + revisionFileImpl(fileDescr, relativePath, nullptr /*onBeforeMove*/, notifyUnbufferedIO); //throw FileError, X + } + //else -> missing source item is not an error => check BEFORE deleting target +} + + +void FileVersioner::revisionFileImpl(const FileDescriptor& fileDescr, const Zstring& relativePath, //throw FileError, X + const std::function& onBeforeMove, + const IoCallback& notifyUnbufferedIO /*throw X*/) const +{ + const AbstractPath& filePath = fileDescr.path; + + const AbstractPath targetPath = generateVersionedPath(relativePath); + const AFS::StreamAttributes fileAttr{fileDescr.attr.modTime, fileDescr.attr.fileSize, fileDescr.attr.filePrint}; + + if (onBeforeMove) + onBeforeMove(AFS::getDisplayPath(filePath), AFS::getDisplayPath(targetPath)); + + moveExistingItemToVersioning(filePath, targetPath, [&] //throw FileError + { + //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename) + //=> not expected, but possible if target deletion failed + //already existing + no onDeleteTargetFile: undefined behavior! (e.g. fail/overwrite/auto-rename) + /*const AFS::FileCopyResult result =*/ AFS::copyFileTransactional(filePath, fileAttr, targetPath, //throw FileError, ErrorFileLocked, X + false, //copyFilePermissions + false, //transactionalCopy: not needed for versioning! partial copy will be overwritten next time + nullptr /*onDeleteTargetFile*/, notifyUnbufferedIO); + //result.errorModTime? => irrelevant for versioning! + }); +} + + +void FileVersioner::revisionSymlink(const AbstractPath& linkPath, const Zstring& relativePath) const //throw FileError +{ + checkPathConflict(linkPath, relativePath); //throw FileError + + if (AFS::itemExists(linkPath)) //throw FileError + revisionSymlinkImpl(linkPath, relativePath, nullptr /*onBeforeMove*/); //throw FileError + //else -> missing source item is not an error => check BEFORE deleting target +} + + +void FileVersioner::revisionSymlinkImpl(const AbstractPath& linkPath, const Zstring& relativePath, //throw FileError + const std::function& onBeforeMove) const +{ + + const AbstractPath targetPath = generateVersionedPath(relativePath); + + if (onBeforeMove) + onBeforeMove(AFS::getDisplayPath(linkPath), AFS::getDisplayPath(targetPath)); + + moveExistingItemToVersioning(linkPath, targetPath, [&] { AFS::copySymlink(linkPath, targetPath, false /*copy filesystem permissions*/); }); //throw FileError +} + + +void FileVersioner::revisionFolder(const AbstractPath& folderPath, const Zstring& relativePath, //throw FileError, X + const std::function& onBeforeFileMove /*throw X*/, + const std::function& onBeforeFolderMove /*throw X*/, + const IoCallback& notifyUnbufferedIO /*throw X*/) const +{ + checkPathConflict(folderPath, relativePath); //throw FileError + + //no error situation if directory is not existing! manual deletion relies on it! + if (const std::optional type = AFS::getItemTypeIfExists(folderPath)) //throw FileError + { + assert(*type != AFS::ItemType::symlink); + + if (*type == AFS::ItemType::symlink) //on Linux there is just one type of symlink, and since we do revision file symlinks, we should revision dir symlinks as well! + revisionSymlinkImpl(folderPath, relativePath, onBeforeFileMove); //throw FileError + else + revisionFolderImpl(folderPath, relativePath, onBeforeFileMove, onBeforeFolderMove, notifyUnbufferedIO); //throw FileError, X + } + else //even if the folder does not exist anymore, significant I/O work was done => report + if (onBeforeFolderMove) onBeforeFolderMove(AFS::getDisplayPath(folderPath), AFS::getDisplayPath(AFS::appendRelPath(versioningFolderPath_, relativePath))); +} + + +void FileVersioner::revisionFolderImpl(const AbstractPath& folderPath, const Zstring& relPath, //throw FileError, X + const std::function& onBeforeFileMove, + const std::function& onBeforeFolderMove, + const IoCallback& notifyUnbufferedIO /*throw X*/) const +{ + + //create target directories only when needed in moveFileToVersioning(): avoid empty directories! + std::vector folders; + { + std::vector files; + std::vector symlinks; + + AFS::traverseFolder(folderPath, //throw FileError + [&](const AFS::FileInfo& fi) { files .push_back(fi); assert(!files.back().isFollowedSymlink); }, + [&](const AFS::FolderInfo& fi) { folders .push_back(fi); }, + [&](const AFS::SymlinkInfo& si) { symlinks.push_back(si); }); + + for (const AFS::FileInfo& fileInfo : files) + { + const FileDescriptor fileDescr + { + .path = AFS::appendRelPath(folderPath, fileInfo.itemName), + .attr = {fileInfo.modTime, fileInfo.fileSize, fileInfo.filePrint, false /*isFollowedSymlink*/}, + }; + + revisionFileImpl(fileDescr, appendPath(relPath, fileInfo.itemName), onBeforeFileMove, notifyUnbufferedIO); //throw FileError, X + } + + for (const AFS::SymlinkInfo& linkInfo : symlinks) + revisionSymlinkImpl(AFS::appendRelPath(folderPath, linkInfo.itemName), + appendPath(relPath, linkInfo.itemName), onBeforeFileMove); //throw FileError + } + + //move folders recursively + for (const AFS::FolderInfo& folderInfo : folders) + revisionFolderImpl(AFS::appendRelPath(folderPath, folderInfo.itemName), //throw FileError, X + appendPath(relPath, folderInfo.itemName), + onBeforeFileMove, onBeforeFolderMove, notifyUnbufferedIO); + //delete source + if (onBeforeFolderMove) + onBeforeFolderMove(AFS::getDisplayPath(folderPath), AFS::getDisplayPath(AFS::appendRelPath(versioningFolderPath_, relPath))); + + AFS::removeFolderPlain(folderPath); //throw FileError +} + +//########################################################################################### + +namespace +{ +struct VersionInfo +{ + time_t versionTime = 0; + AbstractPath filePath; + bool isSymlink = false; +}; +using VersionInfoMap = std::unordered_map>; //relPathOrig => + +//subfolder\Sample.txt 2012-05-15 131513.txt => subfolder\Sample.txt version:2012-05-15 131513 +//2012-05-15 131513\subfolder\Sample.txt => " " + +void findFileVersions(VersionInfoMap& versions, + const FolderContainer& folderCont, + const AbstractPath& parentFolderPath, + const Zstring& relPathOrigParent, + const time_t* versionTimeParent) +{ + auto addVersion = [&](const Zstring& fileName, const Zstring& fileNameOrig, time_t versionTime, bool isSymlink) + { + const Zstring& relPathOrig = appendPath(relPathOrigParent, fileNameOrig); + const AbstractPath& filePath = AFS::appendRelPath(parentFolderPath, fileName); + + versions[relPathOrig].push_back(VersionInfo{versionTime, filePath, isSymlink}); + }; + + auto extractFileVersion = [&](const Zstring& fileName, bool isSymlink) + { + if (versionTimeParent) //VersioningStyle::timestampFolder + addVersion(fileName, fileName, *versionTimeParent, isSymlink); + else + { + const std::pair vfn = fff::impl::parseVersionedFileName(fileName); + if (vfn.first != 0) //VersioningStyle::timestampFile + addVersion(fileName, vfn.second, vfn.first, isSymlink); + } + }; + + for (const auto& [fileName, attr] : folderCont.files) + extractFileVersion(fileName, false /*isSymlink*/); + + for (const auto& [linkName, attr] : folderCont.symlinks) + extractFileVersion(linkName, true /*isSymlink*/); + + for (const auto& [folderName, attrAndSub] : folderCont.folders) + { + if (relPathOrigParent.empty() && !versionTimeParent) //VersioningStyle::timestampFolder? + { + assert(!versionTimeParent); + const time_t versionTime = fff::impl::parseVersionedFolderName(folderName); + if (versionTime != 0) + { + findFileVersions(versions, attrAndSub.second, + AFS::appendRelPath(parentFolderPath, folderName), + Zstring(), //[!] skip time-stamped folder + &versionTime); + continue; + } + } + + findFileVersions(versions, attrAndSub.second, + AFS::appendRelPath(parentFolderPath, folderName), + appendPath(relPathOrigParent, folderName), + versionTimeParent); + } +} + + +void getFolderItemCount(std::map& folderItemCount, const FolderContainer& folderCont, const AbstractPath& parentFolderPath) +{ + size_t& itemCount = folderItemCount[parentFolderPath]; + itemCount = std::max(itemCount, folderCont.files.size() + folderCont.symlinks.size() + folderCont.folders.size()); + //theoretically possible that the same folder is found in one case with items, in another case empty (due to an error) + //e.g. "subfolder" for versioning folders c:\folder and c:\folder\subfolder + + for (const auto& [folderName, attrAndSub] : folderCont.folders) + getFolderItemCount(folderItemCount, attrAndSub.second, AFS::appendRelPath(parentFolderPath, folderName)); +} +} + + +std::weak_ordering fff::operator<=>(const VersioningLimitFolder& lhs, const VersioningLimitFolder& rhs) +{ + if (const std::weak_ordering cmp = std::tie(lhs.versioningFolderPath, lhs.versionMaxAgeDays) <=> + std::tie(rhs.versioningFolderPath, rhs.versionMaxAgeDays); + cmp != std::weak_ordering::equivalent) + return cmp; + + if (lhs.versionMaxAgeDays > 0) + if (lhs.versionCountMin != rhs.versionCountMin) + return lhs.versionCountMin <=> rhs.versionCountMin; + + return lhs.versionCountMax <=> rhs.versionCountMax; +} + + +void fff::applyVersioningLimit(const std::set& folderLimits, + PhaseCallback& callback /*throw X*/) //throw X +{ + //--------- determine existing folder paths for traversal --------- + std::set foldersToRead; + std::set folderLimitsTmp; + { + std::set pathsToCheck; + + for (const VersioningLimitFolder& vlf : folderLimits) + if (vlf.versionMaxAgeDays > 0 || vlf.versionCountMax > 0) //only analyze versioning folders when needed! + { + pathsToCheck.insert(vlf.versioningFolderPath); + folderLimitsTmp.insert(vlf); + } + + //what if versioning folder paths differ only in case? => perf pessimization, but already checked, see fff::synchronize() + + //we don't want to show an error if version path does not yet exist! + tryReportingError([&] + { + const FolderStatus status = getFolderStatusParallel(pathsToCheck, + false /*authenticateAccess*/, nullptr /*requestPassword*/, callback); //throw X + foldersToRead.clear(); + for (const AbstractPath& folderPath : status.existing) + foldersToRead.insert(DirectoryKey({folderPath, makeSharedRef(), SymLinkHandling::asLink})); + + if (!status.failedChecks.empty()) + { + std::wstring msg = _("Cannot find the following folders:") + L'\n'; + + for (const auto& [folderPath, error] : status.failedChecks) + msg += L'\n' + AFS::getDisplayPath(folderPath); + + msg += L"\n___________________________________________"; + for (const auto& [folderPath, error] : status.failedChecks) + msg += L"\n\n" + replaceCpy(error.toString(), L"\n\n", L'\n'); + + throw FileError(msg); + } + }, callback); //throw X + } + + //--------- traverse all versioning folders --------- + const std::wstring textScanning = _("Searching for old file versions:") + L' '; + + auto onStatusUpdate = [&](const std::wstring& statusLine, int itemsTotal) + { + callback.updateStatus(textScanning + statusLine); //throw X + }; + + const std::map folderBuf = parallelFolderScan(foldersToRead, + [&](const PhaseCallback::ErrorInfo& errorInfo) { return callback.reportError(errorInfo); } /*throw X*/, + onStatusUpdate /*throw X*/, UI_UPDATE_INTERVAL / 2); //every ~25 ms + + //--------- group versions per (original) relative path --------- + std::map versionDetails; //versioningFolderPath => + std::map folderItemCount; // => for determination of empty folders + + for (const auto& [folderKey, folderVal] : folderBuf) + { + const AbstractPath versioningFolderPath = folderKey.folderPath; + + assert(!versionDetails.contains(versioningFolderPath)); + + findFileVersions(versionDetails[versioningFolderPath], + folderVal.folderCont, + versioningFolderPath, + Zstring() /*relPathOrigParent*/, + nullptr /*versionTimeParent*/); + + //determine item count per folder for later detection and removal of empty folders: + getFolderItemCount(folderItemCount, folderVal.folderCont, versioningFolderPath); + + //make sure the versioning folder is never found empty and is not deleted: + ++folderItemCount[versioningFolderPath]; + + //similarly, failed folder traversal should not make folders look empty: + for (const auto& [relPath, errorMsg] : folderVal.failedFolderReads) ++folderItemCount[AFS::appendRelPath(versioningFolderPath, relPath)]; + for (const auto& [relPath, errorMsg] : folderVal.failedItemReads ) ++folderItemCount[AFS::appendRelPath(versioningFolderPath, beforeLast(relPath, FILE_NAME_SEPARATOR, IfNotFoundReturn::none))]; + } + + //--------- calculate excess file versions --------- + std::map itemsToDelete; + + 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 + }(); + + for (const VersioningLimitFolder& vlf : folderLimitsTmp) + { + auto it = versionDetails.find(vlf.versioningFolderPath); + if (it != versionDetails.end()) + for (auto& [versioningFolderPath, versions] : it->second) + { + size_t versionsToKeep = versions.size(); + if (vlf.versionMaxAgeDays > 0) + { + const time_t cutOffTime = lastMidnightTime - static_cast(vlf.versionMaxAgeDays) * 24 * 3600; + + versionsToKeep = std::count_if(versions.begin(), versions.end(), [cutOffTime](const VersionInfo& vi) { return vi.versionTime >= cutOffTime; }); + + if (vlf.versionCountMin > 0) + versionsToKeep = std::max(versionsToKeep, vlf.versionCountMin); + } + if (vlf.versionCountMax > 0) + versionsToKeep = std::min(versionsToKeep, vlf.versionCountMax); + + if (versions.size() > versionsToKeep) + { + std::nth_element(versions.begin(), versions.end() - versionsToKeep, versions.end(), + [](const VersionInfo& lhs, const VersionInfo& rhs) { return lhs.versionTime < rhs.versionTime; }); + //oldest versions sorted to the front + + std::for_each(versions.begin(), versions.end() - versionsToKeep, [&](const VersionInfo& vi) + { + itemsToDelete.emplace(vi.filePath, vi.isSymlink); + }); + } + } + } + + //--------- remove excess file versions --------- + Protected&> protFolderItemCount(folderItemCount); + const std::wstring txtRemoving = _("Removing old file versions:") + L' '; + const std::wstring txtDeletingFolder = _("Deleting folder %x"); + + std::function deleteEmptyFolderTask; + deleteEmptyFolderTask = [&txtDeletingFolder, &protFolderItemCount, &deleteEmptyFolderTask](const AbstractPath& folderPath, AsyncCallback& acb) //throw ThreadStopRequest + { + const std::wstring errMsg = tryReportingError([&] //throw ThreadStopRequest + { + acb.updateStatus(replaceCpy(txtDeletingFolder, L"%x", fmtPath(AFS::getDisplayPath(folderPath)))); //throw ThreadStopRequest + AFS::removeEmptyFolderIfExists(folderPath); //throw FileError + }, acb); + + if (errMsg.empty()) + if (const std::optional parentPath = AFS::getParentPath(folderPath)) + { + bool deleteParent = false; + protFolderItemCount.access([&](auto& folderItemCount2) { deleteParent = --folderItemCount2[*parentPath] == 0; }); + if (deleteParent) //we're done here anyway => no need to schedule parent deletion in a separate task! + deleteEmptyFolderTask(*parentPath, acb); //throw ThreadStopRequest + } + }; + + std::vector> parallelWorkload; + + for (const auto& [folderPath, itemCount] : folderItemCount) + if (itemCount == 0) + parallelWorkload.emplace_back(folderPath, [&deleteEmptyFolderTask](ParallelContext& ctx) + { + deleteEmptyFolderTask(ctx.itemPath, ctx.acb); //throw ThreadStopRequest + }); + + for (const auto& [itemPath, isSymlink] : itemsToDelete) + parallelWorkload.emplace_back(itemPath, [isSymlink /*clang bug*/= isSymlink, &txtRemoving, &protFolderItemCount, &deleteEmptyFolderTask](ParallelContext& ctx) //throw ThreadStopRequest + { + const std::wstring errMsg = tryReportingError([&] //throw ThreadStopRequest + { + reportInfo(txtRemoving + AFS::getDisplayPath(ctx.itemPath), ctx.acb); //throw ThreadStopRequest + if (isSymlink) + AFS::removeSymlinkIfExists(ctx.itemPath); //throw FileError + else + AFS::removeFileIfExists(ctx.itemPath); //throw FileError + }, ctx.acb); + + if (errMsg.empty()) + if (const std::optional parentPath = AFS::getParentPath(ctx.itemPath)) + { + bool deleteParent = false; + protFolderItemCount.access([&](auto& folderItemCount2) { deleteParent = --folderItemCount2[*parentPath] == 0; }); + if (deleteParent) + deleteEmptyFolderTask(*parentPath, ctx.acb); //throw ThreadStopRequest + } + }); + + massParallelExecute(parallelWorkload, + Zstr("Versioning Limit"), callback /*throw X*/); //throw X +} diff --git a/FreeFileSync/Source/base/versioning.h b/FreeFileSync/Source/base/versioning.h new file mode 100644 index 0000000..2f852cd --- /dev/null +++ b/FreeFileSync/Source/base/versioning.h @@ -0,0 +1,114 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef VERSIONING_H_8760247652438056 +#define VERSIONING_H_8760247652438056 + +#include +#include +#include +#include "structures.h" +#include "algorithm.h" +#include "../afs/abstract.h" + + +namespace fff +{ +/* e.g. move C:\Source\subdir\Sample.txt -> D:\Revisions\subdir\Sample.txt 2012-05-15 131513.txt + scheme: \\. YYYY-MM-DD HHMMSS. + + - ignores missing source files/dirs + - creates missing intermediate directories + - does not create empty directories + - handles symlinks + - multi-threading: internally synchronized + - replaces already existing target files/dirs (supports retry) + => (unlikely) risk of data loss for naming convention "versioning": + race-condition if multiple folder pairs process the same filepath!! */ + +class FileVersioner +{ +public: + FileVersioner(const AbstractPath& versioningFolderPath, //throw FileError + VersioningStyle versioningStyle, + time_t syncStartTime) : + versioningFolderPath_(versioningFolderPath), + versioningStyle_(versioningStyle), + syncStartTime_(syncStartTime) + { + using namespace zen; + + if (AbstractFileSystem::isNullPath(versioningFolderPath_)) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + if (timeStamp_.size() != 17) //formatTime() returns empty string on error; unexpected length: e.g. problem in year 10,000! + throw FileError(_("Unable to create time stamp for versioning:") + L" \"" + utfTo(timeStamp_) + L'"'); + } + + //multi-threaded access: internally synchronized! + void revisionFile(const FileDescriptor& fileDescr, //throw FileError, X + const Zstring& relativePath, + //called frequently if move has to revert to copy + delete => see zen::copyFile for limitations when throwing exceptions! + const zen::IoCallback& notifyUnbufferedIO /*throw X*/) const; + + void revisionSymlink(const AbstractPath& linkPath, const Zstring& relativePath) const; //throw FileError + + void revisionFolder(const AbstractPath& folderPath, const Zstring& relPath, //throw FileError, X + const std::function& onBeforeFileMove, /*throw X*/ + const std::function& onBeforeFolderMove, /*throw X*/ + //called frequently if move has to revert to copy + delete => see zen::copyFile for limitations when throwing exceptions! + const zen::IoCallback& notifyUnbufferedIO /*throw X*/) const; + +private: + FileVersioner (const FileVersioner&) = delete; + FileVersioner& operator=(const FileVersioner&) = delete; + + void checkPathConflict(const AbstractPath& itemPath, const Zstring& relativePath) const; //throw FileError + + void revisionFileImpl(const FileDescriptor& fileDescr, const Zstring& relativePath, //throw FileError, X + const std::function& onBeforeMove, + const zen::IoCallback& notifyUnbufferedIO) const; + + void revisionSymlinkImpl(const AbstractPath& linkPath, const Zstring& relativePath, //throw FileError + const std::function& onBeforeMove) const; + + void revisionFolderImpl(const AbstractPath& folderPath, const Zstring& relativePath, + const std::function& onBeforeFileMove, + const std::function& onBeforeFolderMove, + const zen::IoCallback& notifyUnbufferedIO) const; //throw FileError, X + + AbstractPath generateVersionedPath(const Zstring& relativePath) const; + + const AbstractPath versioningFolderPath_; + const VersioningStyle versioningStyle_; + const time_t syncStartTime_; + const Zstring timeStamp_{zen::formatTime(Zstr("%Y-%m-%d %H%M%S"), zen::getLocalTime(syncStartTime_))}; //e.g. "2012-05-15 131513" +}; + +//-------------------------------------------------------------------------------- + +struct VersioningLimitFolder +{ + AbstractPath versioningFolderPath; + int versionMaxAgeDays = 0; //<= 0 := no limit + int versionCountMin = 0; //only used if versionMaxAgeDays > 0 => < versionCountMax (if versionCountMax > 0) + int versionCountMax = 0; //<= 0 := no limit +}; +std::weak_ordering operator<=>(const VersioningLimitFolder& lhs, const VersioningLimitFolder& rhs); + + +void applyVersioningLimit(const std::set& folderLimits, + PhaseCallback& callback /*throw X*/); + + +namespace impl //declare for unit tests: +{ +std::pair parseVersionedFileName (const Zstring& fileName); +time_t parseVersionedFolderName(const Zstring& folderName); +} +} + +#endif //VERSIONING_H_8760247652438056 diff --git a/FreeFileSync/Source/base_tools.cpp b/FreeFileSync/Source/base_tools.cpp new file mode 100644 index 0000000..75152d0 --- /dev/null +++ b/FreeFileSync/Source/base_tools.cpp @@ -0,0 +1,300 @@ +// ***************************************************************************** +// * 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 "base_tools.h" +#include "base/path_filter.h" + +using namespace zen; +using namespace fff; + + +std::vector fff::fromTimeShiftPhrase(const std::wstring_view timeShiftPhrase) +{ + std::vector minutes; + + split2(timeShiftPhrase, [](wchar_t c) { return c == L',' || c == L';' || c == L' '; }, //delimiters + [&minutes](const std::wstring_view block) + { + if (!block.empty()) + { + std::wstring part(block); + replace(part, L'-', L""); //there is no negative shift => treat as positive! + + const unsigned int timeShift = stringTo(beforeFirst(part, L':', IfNotFoundReturn::all)) * 60 + + stringTo(afterFirst (part, L':', IfNotFoundReturn::none)); + if (timeShift > 0) + minutes.push_back(timeShift); + } + }); + removeDuplicates(minutes); + return minutes; +} + + +std::wstring fff::toTimeShiftPhrase(const std::vector& ignoreTimeShiftMinutes) +{ + std::wstring phrase; + for (const unsigned int timeShift : ignoreTimeShiftMinutes) + { + if (!phrase.empty()) + phrase += L", "; + + phrase += numberTo(timeShift / 60); + + if (const unsigned int shiftRem = timeShift % 60; + shiftRem != 0) + phrase += L':' + printNumber(L"%02d", static_cast(shiftRem)); + } + return phrase; +} + + +void fff::logNonDefaultSettings(const GlobalConfig& globalCfg, PhaseCallback& callback) +{ + const GlobalConfig defaultSettings; + std::wstring changedSettingsMsg; + + if (globalCfg.failSafeFileCopy != defaultSettings.failSafeFileCopy) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Fail-safe file copy")) + L": " + (globalCfg.failSafeFileCopy ? _("Enabled") : _("Disabled")); + + if (globalCfg.copyLockedFiles != defaultSettings.copyLockedFiles) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Copy locked files")) + L": " + (globalCfg.copyLockedFiles ? _("Enabled") : _("Disabled")); + + if (globalCfg.copyFilePermissions != defaultSettings.copyFilePermissions) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Copy file access permissions")) + L": " + (globalCfg.copyFilePermissions ? _("Enabled") : _("Disabled")); + + if (globalCfg.fileTimeTolerance != defaultSettings.fileTimeTolerance) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("File time tolerance")) + L": " + formatNumber(globalCfg.fileTimeTolerance); + + if (globalCfg.runWithBackgroundPriority != defaultSettings.runWithBackgroundPriority) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Run with background priority")) + L": " + (globalCfg.runWithBackgroundPriority ? _("Enabled") : _("Disabled")); + + if (globalCfg.createLockFile != defaultSettings.createLockFile) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Lock directories during sync")) + L": " + (globalCfg.createLockFile ? _("Enabled") : _("Disabled")); + + if (globalCfg.verifyFileCopy != defaultSettings.verifyFileCopy) + changedSettingsMsg += L"\n" + (TAB_SPACE + _("Verify copied files")) + L": " + (globalCfg.verifyFileCopy ? _("Enabled") : _("Disabled")); + + if (!changedSettingsMsg.empty()) + callback.logMessage(_("Using non-default global settings:") + changedSettingsMsg, PhaseCallback::MsgType::info); //throw X +} + + +namespace +{ +FilterConfig mergeFilterConfig(const FilterConfig& global, const FilterConfig& local) +{ + FilterConfig out = local; + + //hard filter + if (NameFilter::isNull(local.includeFilter, Zstring())) //fancy way of checking for "*" include + out.includeFilter = global.includeFilter; + //else : if both global and local include filters are set, only local filter is preserved + + out.excludeFilter = trimCpy(trimCpy(global.excludeFilter) + Zstr("\n\n") + trimCpy(local.excludeFilter)); + + //soft filter + time_t loctimeFrom = 0; + uint64_t locSizeMinBy = 0; + uint64_t locSizeMaxBy = 0; + resolveUnits(out.timeSpan, out.unitTimeSpan, + out.sizeMin, out.unitSizeMin, + out.sizeMax, out.unitSizeMax, + loctimeFrom, //unit: UTC time, seconds + locSizeMinBy, //unit: bytes + locSizeMaxBy); //unit: bytes + + //soft filter + time_t glotimeFrom = 0; + uint64_t gloSizeMinBy = 0; + uint64_t gloSizeMaxBy = 0; + resolveUnits(global.timeSpan, global.unitTimeSpan, + global.sizeMin, global.unitSizeMin, + global.sizeMax, global.unitSizeMax, + glotimeFrom, + gloSizeMinBy, + gloSizeMaxBy); + + if (glotimeFrom > loctimeFrom) + { + out.timeSpan = global.timeSpan; + out.unitTimeSpan = global.unitTimeSpan; + } + if (gloSizeMinBy > locSizeMinBy) + { + out.sizeMin = global.sizeMin; + out.unitSizeMin = global.unitSizeMin; + } + if (gloSizeMaxBy < locSizeMaxBy) + { + out.sizeMax = global.sizeMax; + out.unitSizeMax = global.unitSizeMax; + } + return out; +} + + +inline +bool effectivelyEmpty(const LocalPairConfig& lpc) +{ + return AFS::isNullPath(createAbstractPath(lpc.folderPathPhraseLeft)) && + AFS::isNullPath(createAbstractPath(lpc.folderPathPhraseRight)); +} +} + + +FfsGuiConfig fff::merge(const std::vector& guiCfgs) +{ + assert(!guiCfgs.empty()); + if (guiCfgs.empty()) + return FfsGuiConfig(); + + if (guiCfgs.size() == 1) // + return guiCfgs[0]; //return "as is" + + //merge folder pair config + std::vector mergedCfgs; + + for (const FfsGuiConfig& guiCfg : guiCfgs) + { + std::vector tmpCfgs; + + //skip empty folder pairs + if (!effectivelyEmpty(guiCfg.mainCfg.firstPair)) + tmpCfgs.push_back(guiCfg.mainCfg.firstPair); + + for (const LocalPairConfig& lpc : guiCfg.mainCfg.additionalPairs) + if (!effectivelyEmpty(lpc)) + tmpCfgs.push_back(lpc); + + //move all configuration down to item level + for (LocalPairConfig& lpc : tmpCfgs) + { + if (!lpc.localCmpCfg) + lpc.localCmpCfg = guiCfg.mainCfg.cmpCfg; + + if (!lpc.localSyncCfg) + lpc.localSyncCfg = guiCfg.mainCfg.syncCfg; + + lpc.localFilter = mergeFilterConfig(guiCfg.mainCfg.globalFilter, lpc.localFilter); + } + append(mergedCfgs, tmpCfgs); + } + + if (mergedCfgs.empty()) + mergedCfgs.emplace_back(); + + //optimization: remove redundant configuration + + //######################################################################################################################## + //find out which comparison and synchronization setting are used most often and use them as new "header" + std::vector> cmpCfgStat; + std::vector> syncCfgStat; + for (const LocalPairConfig& lpc : mergedCfgs) + { + //a rather inefficient algorithm, but it does not require a less-than operator: + { + const CompConfig& cmpCfg = *lpc.localCmpCfg; + + auto it = std::find_if(cmpCfgStat.begin(), cmpCfgStat.end(), + [&](const std::pair& entry) { return effectivelyEqual(entry.first, cmpCfg); }); + if (it == cmpCfgStat.end()) + cmpCfgStat.emplace_back(cmpCfg, 1); + else + ++(it->second); + } + { + const SyncConfig& syncCfg = *lpc.localSyncCfg; + + auto it = std::find_if(syncCfgStat.begin(), syncCfgStat.end(), + [&](const std::pair& entry) { return effectivelyEqual(entry.first, syncCfg); }); + if (it == syncCfgStat.end()) + syncCfgStat.emplace_back(syncCfg, 1); + else + ++(it->second); + } + } + + //set most-used comparison and synchronization settings as new header options + const CompConfig cmpCfgHead = cmpCfgStat.empty() ? CompConfig() : + std::max_element(cmpCfgStat.begin(), cmpCfgStat.end(), + [](const std::pair& lhs, const std::pair& rhs) { return lhs.second < rhs.second; })->first; + + const SyncConfig syncCfgHead = syncCfgStat.empty() ? SyncConfig() : + std::max_element(syncCfgStat.begin(), syncCfgStat.end(), + [](const std::pair& lhs, const std::pair& rhs) { return lhs.second < rhs.second; })->first; + //######################################################################################################################## + + FilterConfig globalFilter; + const bool allFiltersEqual = std::all_of(mergedCfgs.begin(), mergedCfgs.end(), [&](const LocalPairConfig& lpc) { return lpc.localFilter == mergedCfgs[0].localFilter; }); + if (allFiltersEqual) + globalFilter = mergedCfgs[0].localFilter; + + //strip redundancy... + for (LocalPairConfig& lpc : mergedCfgs) + { + //if local config matches output global config we don't need local one + if (lpc.localCmpCfg && + effectivelyEqual(*lpc.localCmpCfg, cmpCfgHead)) + lpc.localCmpCfg = {}; + + if (lpc.localSyncCfg && + effectivelyEqual(*lpc.localSyncCfg, syncCfgHead)) + lpc.localSyncCfg = {}; + + if (allFiltersEqual) //use global filter in this case + lpc.localFilter = FilterConfig(); + } + + std::map mergedParallelOps; + for (const FfsGuiConfig& guiCfg : guiCfgs) + for (const auto& [rootPath, parallelOps] : guiCfg.mainCfg.deviceParallelOps) + mergedParallelOps[rootPath] = std::max(mergedParallelOps[rootPath], parallelOps); + + //final assembly + FfsGuiConfig cfgOut + { + .mainCfg = MainConfiguration + { + .cmpCfg = cmpCfgHead, + .syncCfg = syncCfgHead, + .globalFilter = globalFilter, + .firstPair = mergedCfgs[0], + .deviceParallelOps = mergedParallelOps, + + .ignoreErrors = std::all_of(guiCfgs.begin(), guiCfgs.end(), [](const FfsGuiConfig& guiCfg) { return guiCfg.mainCfg.ignoreErrors; }), + + .autoRetryCount = std::max_element(guiCfgs.begin(), guiCfgs.end(), + [](const FfsGuiConfig& lhs, const FfsGuiConfig& rhs) { return lhs.mainCfg.autoRetryCount < rhs.mainCfg.autoRetryCount; })->mainCfg.autoRetryCount, + + .autoRetryDelay = std::max_element(guiCfgs.begin(), guiCfgs.end(), + [](const FfsGuiConfig& lhs, const FfsGuiConfig& rhs) { return lhs.mainCfg.autoRetryDelay < rhs.mainCfg.autoRetryDelay; })->mainCfg.autoRetryDelay, + } + }; + cfgOut.mainCfg.additionalPairs.assign(mergedCfgs.begin() + 1, mergedCfgs.end()); + + + for (const FfsGuiConfig& guiCfg : guiCfgs) + { + if (cfgOut.mainCfg.altLogFolderPathPhrase.empty()) + cfgOut.mainCfg.altLogFolderPathPhrase = guiCfg.mainCfg.altLogFolderPathPhrase; + + if (cfgOut.mainCfg.emailNotifyAddress.empty()) + { + cfgOut.mainCfg.emailNotifyAddress = guiCfg.mainCfg.emailNotifyAddress; + cfgOut.mainCfg.emailNotifyCondition = guiCfg.mainCfg.emailNotifyCondition; + } + + if (!guiCfg.notes.empty()) + cfgOut.notes += guiCfg.notes + L"\n\n"; + } + trim(cfgOut.notes); + + //cfgOut.mainCfg.postSyncCommand = -> better leave at default ... !? + //cfgOut.mainCfg.postSyncCondition = -> + //cfgOut.gridViewType -> + return cfgOut; +} diff --git a/FreeFileSync/Source/base_tools.h b/FreeFileSync/Source/base_tools.h new file mode 100644 index 0000000..c8d47ca --- /dev/null +++ b/FreeFileSync/Source/base_tools.h @@ -0,0 +1,28 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STRUCTURE_TOOLS_H_7823097420397434 +#define STRUCTURE_TOOLS_H_7823097420397434 + +#include "base/structures.h" +#include "base/process_callback.h" +#include "config.h" + + +namespace fff +{ +//convert "ignoreTimeShiftMinutes" into compact format: +std::vector fromTimeShiftPhrase(const std::wstring_view timeShiftPhrase); +std::wstring toTimeShiftPhrase (const std::vector& ignoreTimeShiftMinutes); + +//inform about (important) non-default global settings related to comparison and synchronization +void logNonDefaultSettings(const GlobalConfig& globalCfg, PhaseCallback& callback); + +//facilitate drag & drop config merge: +FfsGuiConfig merge(const std::vector& guiCfgs); +} + +#endif //STRUCTURE_TOOLS_H_7823097420397434 diff --git a/FreeFileSync/Source/config.cpp b/FreeFileSync/Source/config.cpp new file mode 100644 index 0000000..55828bc --- /dev/null +++ b/FreeFileSync/Source/config.cpp @@ -0,0 +1,2451 @@ +// ***************************************************************************** +// * 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 "config.h" +#include +#include +#include +#include +#include +#include "ffs_paths.h" +#include "base_tools.h" + +using namespace zen; +using namespace fff; //required for correct overload resolution! + + +namespace +{ +//------------------------------------------------------------------------------------------------------------------------------- +const int XML_FORMAT_GLOBAL_CFG = 28; //2025-09-25 +const int XML_FORMAT_SYNC_CFG = 23; //2023-08-24 +//------------------------------------------------------------------------------------------------------------------------------- +} + + +const ExternalApp fff::extCommandFileManager +//"xdg-open %parent_path%" -> not good enough: we need %local_path% for proper MTP/Google Drive handling +{L"Show in file manager", "xdg-open \"$(dirname %local_path%)\""}; +//mark for extraction: _("Show in file manager") Linux doesn't use the term "folder" + + +const ExternalApp fff::extCommandOpenDefault +{L"Open with default application", "xdg-open %local_path%"}; + + + + +GlobalConfig::GlobalConfig() : + soundFileSyncFinished(appendPath(getResourceDirPath(), Zstr("bell.wav"))), + soundFileAlertPending(appendPath(getResourceDirPath(), Zstr("remind.wav"))) +{ +} + +//################################################################################################################ + +Zstring fff::getGlobalConfigDefaultPath() { return appendPath(getConfigDirPath(), Zstr("GlobalSettings.xml")); } +Zstring fff::getLogFolderDefaultPath () { return appendPath(getConfigDirPath(), Zstr("Logs")); } + +namespace +{ +std::vector splitFilterByLines(Zstring filterPhrase) +{ + trim(filterPhrase); + if (filterPhrase.empty()) + return {}; + + return splitCpy(filterPhrase, Zstr('\n'), SplitOnEmpty::allow); +} + +Zstring mergeFilterLines(const std::vector& filterLines) +{ + Zstring out; + for (const Zstring& line : filterLines) + { + out += line; + out += Zstr('\n'); + } + return trimCpy(out); +} +} + +namespace zen +{ +template <> inline +void writeText(const wxLanguage& value, std::string& output) +{ + //use description as unique wxLanguage identifier, see localization.cpp + //=> handle changes to wxLanguage enum between wxWidgets versions + + const wxString& canonicalName = wxUILocale::GetLanguageCanonicalName(value); + assert(!canonicalName.empty()); + if (!canonicalName.empty()) + output = utfTo(canonicalName); + else + output = utfTo(wxUILocale::GetLanguageCanonicalName(wxLANGUAGE_ENGLISH_US)); +} + +template <> inline +bool readText(const std::string& input, wxLanguage& value) +{ + if (const wxLanguageInfo* lngInfo = wxUILocale::FindLanguageInfo(utfTo(input))) + { + value = static_cast(lngInfo->Language); + return true; + } + return false; +} + + +template <> inline +void writeText(const ColorTheme& value, std::string& output) +{ + switch (value) + { + case ColorTheme::System: + output = "Default"; + break; + case ColorTheme::Light: + output = "Light"; + break; + case ColorTheme::Dark: + output = "Dark"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ColorTheme& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Default") + value = ColorTheme::System; + else if (tmp == "Light") + value = ColorTheme::Light; + else if (tmp == "Dark") + value = ColorTheme::Dark; + else + return false; + return true; +} + + +template <> inline +void writeText(const CompareVariant& value, std::string& output) +{ + switch (value) + { + case CompareVariant::timeSize: + output = "TimeAndSize"; + break; + case CompareVariant::content: + output = "Content"; + break; + case CompareVariant::size: + output = "Size"; + break; + } +} + +template <> inline +bool readText(const std::string& input, CompareVariant& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "TimeAndSize") + value = CompareVariant::timeSize; + else if (tmp == "Content") + value = CompareVariant::content; + else if (tmp == "Size") + value = CompareVariant::size; + else + return false; + return true; +} + + +template <> inline +void writeText(const SyncDirection& value, std::string& output) +{ + switch (value) + { + case SyncDirection::left: + output = "left"; + break; + case SyncDirection::right: + output = "right"; + break; + case SyncDirection::none: + output = "none"; + break; + } +} + +template <> inline +bool readText(const std::string& input, SyncDirection& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "left") + value = SyncDirection::left; + else if (tmp == "right") + value = SyncDirection::right; + else if (tmp == "none") + value = SyncDirection::none; + else + return false; + return true; +} + + +template <> inline +void writeText(const BatchErrorHandling& value, std::string& output) +{ + switch (value) + { + case BatchErrorHandling::showPopup: + output = "Show"; + break; + case BatchErrorHandling::cancel: + output = "Cancel"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ResultsNotification& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Always") + value = ResultsNotification::always; + else if (tmp == "ErrorWarning") + value = ResultsNotification::errorWarning; + else if (tmp == "ErrorOnly") + value = ResultsNotification::errorOnly; + else + return false; + return true; +} + + +template <> inline +void writeText(const ResultsNotification& value, std::string& output) +{ + switch (value) + { + case ResultsNotification::always: + output = "Always"; + break; + case ResultsNotification::errorWarning: + output = "ErrorWarning"; + break; + case ResultsNotification::errorOnly: + output = "ErrorOnly"; + break; + } +} + + +template <> inline +bool readText(const std::string& input, BatchErrorHandling& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Show") + value = BatchErrorHandling::showPopup; + else if (tmp == "Cancel") + value = BatchErrorHandling::cancel; + else + return false; + return true; +} + + +template <> inline +void writeText(const PostSyncCondition& value, std::string& output) +{ + switch (value) + { + case PostSyncCondition::completion: + output = "Completion"; + break; + case PostSyncCondition::errors: + output = "Errors"; + break; + case PostSyncCondition::success: + output = "Success"; + break; + } +} + +template <> inline +bool readText(const std::string& input, PostSyncCondition& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Completion") + value = PostSyncCondition::completion; + else if (tmp == "Errors") + value = PostSyncCondition::errors; + else if (tmp == "Success") + value = PostSyncCondition::success; + else + return false; + return true; +} + + +template <> inline +void writeText(const PostBatchAction& value, std::string& output) +{ + switch (value) + { + case PostBatchAction::none: + output = "None"; + break; + case PostBatchAction::sleep: + output = "Sleep"; + break; + case PostBatchAction::shutdown: + output = "Shutdown"; + break; + } +} + +template <> inline +bool readText(const std::string& input, PostBatchAction& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "None") + value = PostBatchAction::none; + else if (tmp == "Sleep") + value = PostBatchAction::sleep; + else if (tmp == "Shutdown") + value = PostBatchAction::shutdown; + else + return false; + return true; +} + + +template <> inline +void writeText(const GridIconSize& value, std::string& output) +{ + switch (value) + { + case GridIconSize::small: + output = "Small"; + break; + case GridIconSize::medium: + output = "Medium"; + break; + case GridIconSize::large: + output = "Large"; + break; + } +} + +template <> inline +bool readText(const std::string& input, GridIconSize& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Small") + value = GridIconSize::small; + else if (tmp == "Medium") + value = GridIconSize::medium; + else if (tmp == "Large") + value = GridIconSize::large; + else + return false; + return true; +} + + +template <> inline +void writeText(const DeletionVariant& value, std::string& output) +{ + switch (value) + { + case DeletionVariant::permanent: + output = "Permanent"; + break; + case DeletionVariant::recycler: + output = "RecycleBin"; + break; + case DeletionVariant::versioning: + output = "Versioning"; + break; + } +} + +template <> inline +bool readText(const std::string& input, DeletionVariant& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Permanent") + value = DeletionVariant::permanent; + else if (tmp == "RecycleBin") + value = DeletionVariant::recycler; + else if (tmp == "Versioning") + value = DeletionVariant::versioning; + else + return false; + return true; +} + + +template <> inline +void writeText(const SymLinkHandling& value, std::string& output) +{ + switch (value) + { + case SymLinkHandling::exclude: + output = "Exclude"; + break; + case SymLinkHandling::asLink: + output = "Direct"; + break; + case SymLinkHandling::follow: + output = "Follow"; + break; + } +} + +template <> inline +bool readText(const std::string& input, SymLinkHandling& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Exclude") + value = SymLinkHandling::exclude; + else if (tmp == "Direct") + value = SymLinkHandling::asLink; + else if (tmp == "Follow") + value = SymLinkHandling::follow; + else + return false; + return true; +} + + +template <> inline +void writeText(const GridViewType& value, std::string& output) +{ + switch (value) + { + case GridViewType::difference: + output = "Difference"; + break; + case GridViewType::action: + output = "Action"; + break; + } +} + +template <> inline +bool readText(const std::string& input, GridViewType& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Difference") + value = GridViewType::difference; + else if (tmp == "Action") + value = GridViewType::action; + else + return false; + return true; +} + + +template <> inline +void writeText(const ColumnTypeRim& value, std::string& output) +{ + switch (value) + { + case ColumnTypeRim::path: + output = "Path"; + break; + case ColumnTypeRim::size: + output = "Size"; + break; + case ColumnTypeRim::date: + output = "Date"; + break; + case ColumnTypeRim::extension: + output = "Ext"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ColumnTypeRim& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Path") + value = ColumnTypeRim::path; + else if (tmp == "Size") + value = ColumnTypeRim::size; + else if (tmp == "Date") + value = ColumnTypeRim::date; + else if (tmp == "Ext") + value = ColumnTypeRim::extension; + else + return false; + return true; +} + + +template <> inline +void writeText(const ItemPathFormat& value, std::string& output) +{ + switch (value) + { + case ItemPathFormat::name: + output = "Item"; + break; + case ItemPathFormat::relative: + output = "Relative"; + break; + case ItemPathFormat::full: + output = "Full"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ItemPathFormat& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Item") + value = ItemPathFormat::name; + else if (tmp == "Relative") + value = ItemPathFormat::relative; + else if (tmp == "Full") + value = ItemPathFormat::full; + else + return false; + return true; +} + +template <> inline +void writeText(const ColumnTypeCfg& value, std::string& output) +{ + switch (value) + { + case ColumnTypeCfg::name: + output = "Name"; + break; + case ColumnTypeCfg::lastSync: + output = "Last"; + break; + case ColumnTypeCfg::lastLog: + output = "Log"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ColumnTypeCfg& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Name") + value = ColumnTypeCfg::name; + else if (tmp == "Last") + value = ColumnTypeCfg::lastSync; + else if (tmp == "Log") + value = ColumnTypeCfg::lastLog; + else + return false; + return true; +} + + +template <> inline +void writeText(const ColumnTypeOverview& value, std::string& output) +{ + switch (value) + { + case ColumnTypeOverview::folder: + output = "Tree"; + break; + case ColumnTypeOverview::itemCount: + output = "Count"; + break; + case ColumnTypeOverview::bytes: + output = "Bytes"; + break; + } +} + +template <> inline +bool readText(const std::string& input, ColumnTypeOverview& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Tree") + value = ColumnTypeOverview::folder; + else if (tmp == "Count") + value = ColumnTypeOverview::itemCount; + else if (tmp == "Bytes") + value = ColumnTypeOverview::bytes; + else + return false; + return true; +} + + +template <> inline +void writeText(const UnitSize& value, std::string& output) +{ + switch (value) + { + case UnitSize::none: + output = "None"; + break; + case UnitSize::byte: + output = "Byte"; + break; + case UnitSize::kb: + output = "KB"; + break; + case UnitSize::mb: + output = "MB"; + break; + } +} + +template <> inline +bool readText(const std::string& input, UnitSize& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "None") + value = UnitSize::none; + else if (tmp == "Byte") + value = UnitSize::byte; + else if (tmp == "KB") + value = UnitSize::kb; + else if (tmp == "MB") + value = UnitSize::mb; + else + return false; + return true; +} + +template <> inline +void writeText(const UnitTime& value, std::string& output) +{ + switch (value) + { + case UnitTime::none: + output = "None"; + break; + case UnitTime::today: + output = "Today"; + break; + case UnitTime::thisMonth: + output = "Month"; + break; + case UnitTime::thisYear: + output = "Year"; + break; + case UnitTime::lastDays: + output = "x-days"; + break; + } +} + +template <> inline +bool readText(const std::string& input, UnitTime& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "None") + value = UnitTime::none; + else if (tmp == "Today") + value = UnitTime::today; + else if (tmp == "Month") + value = UnitTime::thisMonth; + else if (tmp == "Year") + value = UnitTime::thisYear; + else if (tmp == "x-days") + value = UnitTime::lastDays; + else + return false; + return true; +} + + +template <> inline +void writeText(const LogFileFormat& value, std::string& output) +{ + switch (value) + { + case LogFileFormat::html: + output = "HTML"; + break; + case LogFileFormat::text: + output = "Text"; + break; + } +} + +template <> inline +bool readText(const std::string& input, LogFileFormat& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "HTML") + value = LogFileFormat::html; + else if (tmp == "Text") + value = LogFileFormat::text; + else + return false; + return true; +} + + +template <> inline +void writeText(const VersioningStyle& value, std::string& output) +{ + switch (value) + { + case VersioningStyle::replace: + output = "Replace"; + break; + case VersioningStyle::timestampFolder: + output = "TimeStamp-Folder"; + break; + case VersioningStyle::timestampFile: + output = "TimeStamp-File"; + break; + } +} + +template <> inline +bool readText(const std::string& input, VersioningStyle& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Replace") + value = VersioningStyle::replace; + else if (tmp == "TimeStamp-Folder") + value = VersioningStyle::timestampFolder; + else if (tmp == "TimeStamp-File") + value = VersioningStyle::timestampFile; + else + return false; + return true; +} + + +template <> inline +void writeStruc(const ColAttributesRim& value, XmlElement& output) +{ + output.setAttribute("Type", value.type); + output.setAttribute("Visible", value.visible); + output.setAttribute("Width", value.offset); + output.setAttribute("Stretch", value.stretch); +} + +template <> inline +bool readStruc(const XmlElement& input, ColAttributesRim& value) +{ + bool success = true; + success = input.getAttribute("Type", value.type) && success; + success = input.getAttribute("Visible", value.visible) && success; + success = input.getAttribute("Width", value.offset) && success; //offset == width if stretch is 0 + success = input.getAttribute("Stretch", value.stretch) && success; + return success; //[!] avoid short-circuit evaluation +} + + +template <> inline +void writeStruc(const ColAttributesCfg& value, XmlElement& output) +{ + output.setAttribute("Type", value.type); + output.setAttribute("Visible", value.visible); + output.setAttribute("Width", value.offset); + output.setAttribute("Stretch", value.stretch); +} + +template <> inline +bool readStruc(const XmlElement& input, ColAttributesCfg& value) +{ + bool success = true; + success = input.getAttribute("Type", value.type) && success; + success = input.getAttribute("Visible", value.visible) && success; + success = input.getAttribute("Width", value.offset) && success; //offset == width if stretch is 0 + success = input.getAttribute("Stretch", value.stretch) && success; + return success; //[!] avoid short-circuit evaluation +} + + +template <> inline +void writeStruc(const ColumnAttribOverview& value, XmlElement& output) +{ + output.setAttribute("Type", value.type); + output.setAttribute("Visible", value.visible); + output.setAttribute("Width", value.offset); + output.setAttribute("Stretch", value.stretch); +} + +template <> inline +bool readStruc(const XmlElement& input, ColumnAttribOverview& value) +{ + bool success = true; + success = input.getAttribute("Type", value.type) && success; + success = input.getAttribute("Visible", value.visible) && success; + success = input.getAttribute("Width", value.offset) && success; //offset == width if stretch is 0 + success = input.getAttribute("Stretch", value.stretch) && success; + return success; //[!] avoid short-circuit evaluation +} + + +template <> inline +void writeStruc(const ExternalApp& value, XmlElement& output) +{ + output.setValue(value.cmdLine); + output.setAttribute("Label", value.description); +} + +template <> inline +bool readStruc(const XmlElement& input, ExternalApp& value) +{ + const bool rv1 = input.getValue(value.cmdLine); + const bool rv2 = input.getAttribute("Label", value.description); + return rv1 && rv2; +} + + +template <> inline +void writeText(const TaskResult& value, std::string& output) +{ + switch (value) + { + case TaskResult::success: + output = "Success"; + break; + case TaskResult::warning: + output = "Warning"; + break; + case TaskResult::error: + output = "Error"; + break; + case TaskResult::cancelled: + output = "Stopped"; + break; + } +} + +template <> inline +bool readText(const std::string& input, TaskResult& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "Success") + value = TaskResult::success; + else if (tmp == "Warning") + value = TaskResult::warning; + else if (tmp == "Error") + value = TaskResult::error; + else if (tmp == "Stopped") + value = TaskResult::cancelled; + else + return false; + return true; +} +} + + +namespace +{ + + +Zstring makePortablePath(const Zstring& pathPhrase) +{ + const Zstring& pathTrm = trimCpy(pathPhrase); + const Zstring& ffsPath = getInstallDirPath(); + + if (pathTrm == ffsPath) + return Zstr("%ffs_path%"); + + if (startsWith(pathTrm, appendSeparator(ffsPath))) //don't allow *partial* component match! + return Zstring(Zstr("%ffs_path%")) + (pathTrm.c_str() + appendSeparator(ffsPath).size() - 1); + + return pathPhrase; +} + + +Zstring resolvePortablePath(const Zstring& portablePathPhrase) +{ + const Zstring& pathTrm = trimCpy(portablePathPhrase); + + if (startsWith(pathTrm, Zstr("%ffs_path%"))) + return appendPath(getInstallDirPath(), afterFirst(pathTrm, FILE_NAME_SEPARATOR, IfNotFoundReturn::none)); //caveat: appendPath() requires relPath! + + //TODO: remove parameter migration after some time! 2022-06-14 + if (startsWith(pathTrm, Zstr("%ffs_resource%"))) + return appendPath(getResourceDirPath(), afterFirst(pathTrm, FILE_NAME_SEPARATOR, IfNotFoundReturn::none)); + + return portablePathPhrase; +} + + +std::vector makePortablePath(std::vector pathPhrases) +{ + for (Zstring& pathPhrase : pathPhrases) + pathPhrase = makePortablePath(pathPhrase); + return pathPhrases; +} + + +std::vector resolvePortablePath(std::vector pathPhrases) +{ + for (Zstring& pathPhrase : pathPhrases) + pathPhrase = resolvePortablePath(pathPhrase); + return pathPhrases; +} +} + + +namespace zen +{ +template <> inline +bool readStruc(const XmlElement& input, ConfigFileItem& value) +{ + bool success = true; + success = input.getAttribute("LastSync", value.lastRunStats.startTime) && success; + success = input.getAttribute("Result", value.lastRunStats.syncResult) && success; + + if (input.hasAttribute("CfgPath")) //TODO: remove after migration! 2020-02-09 + success = input.getAttribute("CfgPath", value.cfgFilePath) && success; // + else + success = input.getAttribute("Config", value.cfgFilePath) && success; + + //FFS portable: use special syntax for config file paths: e.g. "%ffs_drive%\SyncJob.ffs_gui" + value.cfgFilePath = resolvePortablePath(value.cfgFilePath); + + Zstring logFilePhrase; + if (input.hasAttribute("LogPath")) //TODO: remove after migration! 2020-02-09 + success = input.getAttribute("LogPath", logFilePhrase) && success; // + else + success = input.getAttribute("Log", logFilePhrase) && success; + + value.lastRunStats.logFilePath = createAbstractPath(resolvePortablePath(logFilePhrase)); + + if (!input.hasAttribute("Items")) //TODO: remove after migration! 2023-05-13 + ; + else + success = input.getAttribute("Items", value.lastRunStats.itemsProcessed) && success; + + if (!input.hasAttribute("Bytes")) //TODO: remove after migration! 2023-05-13 + ; + else + success = input.getAttribute("Bytes", value.lastRunStats.bytesProcessed) && success; + + if (!input.hasAttribute("TotalTime")) //TODO: remove after migration! 2023-05-13 + ; + else + success = input.getAttribute("TotalTime", value.lastRunStats.totalTime) && success; + + if (!input.hasAttribute("Errors")) //TODO: remove after migration! 2023-05-13 + ; + else + success = input.getAttribute("Errors", value.lastRunStats.errors) && success; + + if (!input.hasAttribute("Warnings")) //TODO: remove after migration! 2023-05-13 + ; + else + success = input.getAttribute("Warnings", value.lastRunStats.warnings) && success; + + std::string hexColor; //optional XML attribute! + if (input.getAttribute("Color", hexColor) && hexColor.size() == 6) + value.backColor.Set(unhexify(hexColor[0], hexColor[1]), + unhexify(hexColor[2], hexColor[3]), + unhexify(hexColor[4], hexColor[5])); + + return success; //[!] avoid short-circuit evaluation +} + +template <> inline +void writeStruc(const ConfigFileItem& value, XmlElement& output) +{ + output.setAttribute("LastSync", value.lastRunStats.startTime); + output.setAttribute("Result", value.lastRunStats.syncResult); + + output.setAttribute("Config", makePortablePath(value.cfgFilePath)); + output.setAttribute("Log", makePortablePath(AFS::getInitPathPhrase(value.lastRunStats.logFilePath))); + + output.setAttribute("Items", value.lastRunStats.itemsProcessed); + output.setAttribute("Bytes", value.lastRunStats.bytesProcessed); + + output.setAttribute("TotalTime", value.lastRunStats.totalTime); + + output.setAttribute("Errors", value.lastRunStats.errors); + output.setAttribute("Warnings", value.lastRunStats.warnings); + + if (value.backColor.IsOk()) + { + assert(value.backColor.Alpha() == wxALPHA_OPAQUE); + const auto [rh, rl] = hexify(value.backColor.Red ()); + const auto [gh, gl] = hexify(value.backColor.Green()); + const auto [bh, bl] = hexify(value.backColor.Blue ()); + output.setAttribute("Color", std::string({rh, rl, gh, gl, bh, bl})); + } +} +} + + +namespace +{ +void readConfig(const XmlIn& in, CompConfig& cmpCfg) +{ + in["Variant" ](cmpCfg.compareVar); + in["Symlinks"](cmpCfg.handleSymlinks); + + std::wstring timeShiftPhrase; + if (in["IgnoreTimeShift"](timeShiftPhrase)) + cmpCfg.ignoreTimeShiftMinutes = fromTimeShiftPhrase(timeShiftPhrase); +} + + +void readConfig(const XmlIn& in, SyncDirectionConfig& dirCfg, int formatVer) +{ + if (formatVer < 21) //TODO: remove if parameter migration after some time! 2023-08-09 + { + std::string varName; + in["Variant"](varName); + trim(varName); + + if (varName == "TwoWay") + dirCfg = getDefaultSyncCfg(SyncVariant::twoWay); + else if (varName == "Mirror") + { + dirCfg = getDefaultSyncCfg(SyncVariant::mirror); + + bool detectMovedFiles = false; + in["DetectMovedFiles"](detectMovedFiles); + if (detectMovedFiles) + { + if (const DirectionByDiff* diffDirs = std::get_if(&dirCfg.dirs)) + dirCfg.dirs = getChangesDirDefault(*diffDirs); //convert to "changes"-based mirror, so that move detection is enabled + else assert(false); + } + } + else if (varName == "Update") + dirCfg.dirs = DirectionByDiff + { + .leftOnly = SyncDirection::right, + .rightOnly = SyncDirection::none, + .leftNewer = SyncDirection::right, + .rightNewer = SyncDirection::none, //note: will be fixed below for CompareVariant::content/size + }; + else + { + assert(varName == "Custom"); + + dirCfg.dirs = DirectionByDiff(); + + XmlIn inCustDir = in["CustomDirections"]; + inCustDir["LeftOnly" ](std::get(dirCfg.dirs).leftOnly); + inCustDir["RightOnly" ](std::get(dirCfg.dirs).rightOnly); + inCustDir["LeftNewer" ](std::get(dirCfg.dirs).leftNewer); //note: will be fixed below for CompareVariant::content/size + inCustDir["RightNewer"](std::get(dirCfg.dirs).rightNewer); // + } + } + else + { + if (XmlIn inDirs = in["Differences"]) + { + dirCfg.dirs = DirectionByDiff(); + inDirs.attribute("LeftOnly", std::get(dirCfg.dirs).leftOnly); + inDirs.attribute("LeftNewer", std::get(dirCfg.dirs).leftNewer); + inDirs.attribute("RightNewer", std::get(dirCfg.dirs).rightNewer); + inDirs.attribute("RightOnly", std::get(dirCfg.dirs).rightOnly); + } + else + { + assert(in["Changes"]); + dirCfg.dirs = DirectionByChange(); + + XmlIn inDirsL = in["Changes"]["Left"]; + inDirsL.attribute("Create", std::get(dirCfg.dirs).left.create); + inDirsL.attribute("Update", std::get(dirCfg.dirs).left.update); + inDirsL.attribute("Delete", std::get(dirCfg.dirs).left.delete_); + + XmlIn inDirsR = in["Changes"]["Right"]; + inDirsR.attribute("Create", std::get(dirCfg.dirs).right.create); + inDirsR.attribute("Update", std::get(dirCfg.dirs).right.update); + inDirsR.attribute("Delete", std::get(dirCfg.dirs).right.delete_); + } + } +} + + +void readConfig(const XmlIn& in, SyncConfig& syncCfg, std::map& deviceParallelOps, int formatVer) +{ + readConfig(in, syncCfg.directionCfg, formatVer); + + in["DeletionPolicy" ](syncCfg.deletionVariant); + in["VersioningFolder"](syncCfg.versioningFolderPhrase); + + XmlIn verFolder = in["VersioningFolder"]; + + size_t parallelOps = 1; + if (verFolder.hasAttribute("Threads")) //*no error* if not available + verFolder.attribute("Threads", parallelOps); //try to get attribute + + const size_t parallelOpsPrev = getDeviceParallelOps(deviceParallelOps, syncCfg.versioningFolderPhrase); + /**/ setDeviceParallelOps(deviceParallelOps, syncCfg.versioningFolderPhrase, std::max(parallelOps, parallelOpsPrev)); + + in["VersioningFolder"].attribute("Style", syncCfg.versioningStyle); + + if (syncCfg.versioningStyle != VersioningStyle::replace) + { + if (verFolder.hasAttribute("MaxAge")) //try to get attributes if available => *no error* if not available + verFolder.attribute("MaxAge", syncCfg.versionMaxAgeDays); + + if (verFolder.hasAttribute("MinCount")) + verFolder.attribute("MinCount", syncCfg.versionCountMin); // => *no error* if not available + if (verFolder.hasAttribute("MaxCount")) + verFolder.attribute("MaxCount", syncCfg.versionCountMax); // + } +} + + +void readConfig(const XmlIn& in, FilterConfig& filter /*int formatVer? but which one; Filter is used by GlobalConfig and FfsGuiConfig! :( */) +{ + std::vector tmpIn; + if (in["Include"](tmpIn)) //else: keep default value + filter.includeFilter = mergeFilterLines(tmpIn); + + std::vector tmpEx; + if (in["Exclude"](tmpEx)) //else: keep default value + filter.excludeFilter = mergeFilterLines(tmpEx); + + in["SizeMin"](filter.sizeMin); + in["SizeMin"].attribute("Unit", filter.unitSizeMin); + + in["SizeMax"](filter.sizeMax); + in["SizeMax"].attribute("Unit", filter.unitSizeMax); + + in["TimeSpan"](filter.timeSpan); + in["TimeSpan"].attribute("Type", filter.unitTimeSpan); +} + + +void readConfig(const XmlIn& in, LocalPairConfig& lpc, std::map& deviceParallelOps, int formatVer) +{ + //read folder pairs + in["Left" ](lpc.folderPathPhraseLeft); + in["Right"](lpc.folderPathPhraseRight); + + size_t parallelOpsL = 1; + size_t parallelOpsR = 1; + if (in["Left" ].hasAttribute("Threads")) in["Left" ].attribute("Threads", parallelOpsL); //try to get attributes: + if (in["Right"].hasAttribute("Threads")) in["Right"].attribute("Threads", parallelOpsR); // => *no error* if not available + + auto setParallelOps = [&](const Zstring& folderPathPhrase, size_t parallelOps) + { + const size_t parallelOpsPrev = getDeviceParallelOps(deviceParallelOps, folderPathPhrase); + /**/ setDeviceParallelOps(deviceParallelOps, folderPathPhrase, std::max(parallelOps, parallelOpsPrev)); + }; + setParallelOps(lpc.folderPathPhraseLeft, parallelOpsL); + setParallelOps(lpc.folderPathPhraseRight, parallelOpsR); + + //TODO: remove after migration! 2020-04-24 + if (formatVer < 16) + { + replaceAsciiNoCase(lpc.folderPathPhraseLeft, Zstr("%weekday%"), Zstr("%WeekDayName%")); + replaceAsciiNoCase(lpc.folderPathPhraseRight, Zstr("%weekday%"), Zstr("%WeekDayName%")); + } + + //########################################################### + //alternate comp configuration (optional) + if (XmlIn inLocalCmp = in["Compare"]) + { + CompConfig cmpCfg; + readConfig(inLocalCmp, cmpCfg); + + lpc.localCmpCfg = cmpCfg; + } + //########################################################### + //alternate sync configuration (optional) + if (XmlIn inLocalSync = in["Synchronize"]) + { + SyncConfig syncCfg; + readConfig(inLocalSync, syncCfg, deviceParallelOps, formatVer); + + lpc.localSyncCfg = syncCfg; + } + + //########################################################### + //alternate filter configuration + if (XmlIn inLocFilter = in["Filter"]) + readConfig(inLocFilter, lpc.localFilter); +} + + +void readConfig(const XmlIn& in, MainConfiguration& mainCfg, int formatVer) +{ + readConfig(in["Compare"], mainCfg.cmpCfg); + + readConfig(in["Synchronize"], mainCfg.syncCfg, mainCfg.deviceParallelOps, formatVer); + + if (formatVer < 20) //TODO: remove if parameter migration after some time! 2023-08-09 + if (mainCfg.cmpCfg.compareVar == CompareVariant::content || + mainCfg.cmpCfg.compareVar == CompareVariant::size) + if (std::string varName; + in["Synchronize"]["Variant"](varName)) + { + if (varName == "Update") + std::get(mainCfg.syncCfg.directionCfg.dirs).rightNewer = SyncDirection::right; + else if (varName == "Custom") + { + SyncDirection different = SyncDirection::none; + in["Synchronize"]["CustomDirections"]["Different"](different); + + std::get(mainCfg.syncCfg.directionCfg.dirs).leftNewer = + std::get(mainCfg.syncCfg.directionCfg.dirs).rightNewer = different; + } + } + + if (formatVer < 23) //TODO: remove if parameter migration after some time! 2023-08-24 + { + bool detectMovedFiles = false; + in["Synchronize"]["DetectMovedFiles"](detectMovedFiles); + if (detectMovedFiles) + if (getSyncVariant(mainCfg.syncCfg.directionCfg) == SyncVariant::mirror) + { + if (const DirectionByDiff* diffDirs = std::get_if(&mainCfg.syncCfg.directionCfg.dirs)) + mainCfg.syncCfg.directionCfg.dirs = getChangesDirDefault(*diffDirs); //convert to "changes"-based mirror, so that move detection is enabled + else assert(false); + } + } + + readConfig(in["Filter"], mainCfg.globalFilter); + + //########################################################### + //read folder pairs + bool firstItem = true; + in["FolderPairs"].visitChildren([&](const XmlIn& inPair) + { + assert(*inPair.getName() == "Pair"); + + LocalPairConfig lpc; + readConfig(inPair, lpc, mainCfg.deviceParallelOps, formatVer); + + if (formatVer < 20) //TODO: remove if parameter migration after some time! 2023-08-09 + if (lpc.localSyncCfg) + { + const CompConfig& cmpCfg = lpc.localCmpCfg ? *lpc.localCmpCfg : mainCfg.cmpCfg; + if (cmpCfg.compareVar == CompareVariant::content || + cmpCfg.compareVar == CompareVariant::size) + if (std::string varName; + inPair["Synchronize"]["Variant"](varName)) + { + if (varName == "Update") + std::get(lpc.localSyncCfg->directionCfg.dirs).rightNewer = SyncDirection::right; + else if (varName == "Custom") + if (inPair["Synchronize"]["CustomDirections"]["Different"]) + { + SyncDirection different = SyncDirection::none; + inPair["Synchronize"]["CustomDirections"]["Different"](different); + + std::get(lpc.localSyncCfg->directionCfg.dirs).leftNewer = + std::get(lpc.localSyncCfg->directionCfg.dirs).rightNewer = different; + } + } + } + if (formatVer < 23) //TODO: remove if parameter migration after some time! 2023-08-24 + if (lpc.localSyncCfg) + { + bool detectMovedFiles = false; + inPair["Synchronize"]["DetectMovedFiles"](detectMovedFiles); + if (detectMovedFiles) + if (getSyncVariant(lpc.localSyncCfg->directionCfg) == SyncVariant::mirror) + { + if (const DirectionByDiff* diffDirs = std::get_if(&lpc.localSyncCfg->directionCfg.dirs)) + lpc.localSyncCfg->directionCfg.dirs = getChangesDirDefault(*diffDirs); //convert to "changes"-based mirror, so that move detection is enabled + else assert(false); + } + } + + if (firstItem) + { + firstItem = false; + mainCfg.firstPair = lpc; + mainCfg.additionalPairs.clear(); + } + else + mainCfg.additionalPairs.push_back(lpc); + }); + + in["Errors"].attribute("Ignore", mainCfg.ignoreErrors); + in["Errors"].attribute("Retry", mainCfg.autoRetryCount); + in["Errors"].attribute("Delay", mainCfg.autoRetryDelay); + + in["PostSyncCommand"](mainCfg.postSyncCommand); + in["PostSyncCommand"].attribute("Condition", mainCfg.postSyncCondition); + + in["LogFolder"](mainCfg.altLogFolderPathPhrase); + + //TODO: remove after migration! 2020-04-24 + if (formatVer < 16) + replaceAsciiNoCase(mainCfg.altLogFolderPathPhrase, Zstr("%weekday%"), Zstr("%WeekDayName%")); + + //TODO: remove if parameter migration after some time! 2020-01-30 + if (formatVer < 15) + ; + else + { + in["EmailNotification"](mainCfg.emailNotifyAddress); + in["EmailNotification"].attribute("Condition", mainCfg.emailNotifyCondition); + } +} + + +void readConfig(const XmlIn& in, FfsGuiConfig& cfg, int formatVer) +{ + if (formatVer < 18) //TODO: remove if parameter migration after some time! 2023-05-15 + ; + else + in["Notes"](cfg.notes); + + readConfig(in, cfg.mainCfg, formatVer); + + if (formatVer < 19) //TODO: remove after migration! 2023-06-09 + { + XmlIn inGui = in["Gui"]; + //TODO: remove after migration! 2020-10-14 + if (formatVer < 17) + { + if (inGui["MiddleGridView"]) + { + std::string tmp; + inGui["MiddleGridView"](tmp); + + if (tmp == "Category") + cfg.gridViewType = GridViewType::difference; + else if (tmp == "Action") + cfg.gridViewType = GridViewType::action; + } + } + else + inGui["GridViewType"](cfg.gridViewType); + } + else + in["GridViewType"](cfg.gridViewType); +} + + +void readConfig(const XmlIn& in, FfsBatchConfig& cfg, int formatVer) +{ + if (formatVer < 19) //TODO: remove after migration! 2023-06-09 + readConfig(in, cfg.guiCfg.mainCfg, formatVer); + else + readConfig(in, cfg.guiCfg, formatVer); + + XmlIn inBatch = in["Batch"]; + inBatch["ProgressDialog"].attribute("Minimized", cfg.batchExCfg.runMinimized); + inBatch["ProgressDialog"].attribute("AutoClose", cfg.batchExCfg.autoCloseSummary); + inBatch["ErrorDialog"](cfg.batchExCfg.batchErrorHandling); + inBatch["PostSyncAction"](cfg.batchExCfg.postBatchAction); +} + + +void readConfig(const XmlIn& in, GlobalConfig& cfg, int formatVer) +{ + assert(cfg.dpiLayouts.empty()); + + XmlIn in2 = in; + + if (in["General"]) //TODO: remove old parameter after migration! 2020-12-03 + in2 = in["General"]; + + //TODO: remove after migration! 2022-04-18 + if (in2["Language"].hasAttribute("Name")) + { + std::string lngName; + in2["Language"].attribute("Name", lngName); + + if (lngName == "English (US)") + cfg.programLanguage = wxLANGUAGE_ENGLISH_US; + else if (lngName == "Chinese (Simplified)") + cfg.programLanguage = wxLANGUAGE_CHINESE_CHINA; + else if (lngName == "Chinese (Traditional)") + cfg.programLanguage = wxLANGUAGE_CHINESE_TAIWAN; + else if (lngName == "English (U.K.)") + cfg.programLanguage = wxLANGUAGE_ENGLISH_UK; + else if (lngName == "Norwegian (Bokmal)") + cfg.programLanguage = wxLANGUAGE_NORWEGIAN; + else if (lngName == "Portuguese (Brazilian)") + cfg.programLanguage = wxLANGUAGE_PORTUGUESE_BRAZILIAN; + else if (const wxLanguageInfo* lngInfo = wxUILocale::FindLanguageInfo(utfTo(lngName))) + cfg.programLanguage = static_cast(lngInfo->Language); + } + else + in2["Language"].attribute("Code", cfg.programLanguage); + + in2["ColorTheme"].attribute("Appearance", cfg.appColorTheme); + + in2["FailSafeFileCopy" ].attribute("Enabled", cfg.failSafeFileCopy); + in2["CopyLockedFiles" ].attribute("Enabled", cfg.copyLockedFiles); + in2["CopyFilePermissions" ].attribute("Enabled", cfg.copyFilePermissions); + in2["FileTimeTolerance" ].attribute("Seconds", cfg.fileTimeTolerance); + in2["RunWithBackgroundPriority"].attribute("Enabled", cfg.runWithBackgroundPriority); + in2["LockDirectoriesDuringSync"].attribute("Enabled", cfg.createLockFile); + in2["VerifyCopiedFiles" ].attribute("Enabled", cfg.verifyFileCopy); + in2["LogFiles" ].attribute("MaxAge", cfg.logfilesMaxAgeDays); + in2["LogFiles" ].attribute("Format", cfg.logFormat); + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + { + cfg.dpiLayouts[getDpiScalePercent()].progressDlg.size = wxSize(); + in2["ProgressDialog"].attribute("Width", cfg.dpiLayouts[getDpiScalePercent()].progressDlg.size->x); + in2["ProgressDialog"].attribute("Height", cfg.dpiLayouts[getDpiScalePercent()].progressDlg.size->y); + in2["ProgressDialog"].attribute("Maximized", cfg.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized); + } + + in2["ProgressDialog"].attribute("AutoClose", cfg.progressDlgAutoClose); + + XmlIn inOpt = in2["OptionalDialogs"]; + inOpt["ConfirmStartSync" ].attribute("Show", cfg.confirmDlgs.confirmSyncStart); + inOpt["ConfirmSaveConfig" ].attribute("Show", cfg.confirmDlgs.confirmSaveConfig); + inOpt["ConfirmSwapSides" ].attribute("Show", cfg.confirmDlgs.confirmSwapSides); + if (formatVer < 12) //TODO: remove old parameter after migration! 2019-02-09 + inOpt["ConfirmExternalCommandMassInvoke"].attribute("Show", cfg.confirmDlgs.confirmCommandMassInvoke); + else + inOpt["ConfirmCommandMassInvoke"].attribute("Show", cfg.confirmDlgs.confirmCommandMassInvoke); + inOpt["WarnFolderNotExisting" ].attribute("Show", cfg.warnDlgs.warnFolderNotExisting); + inOpt["WarnFoldersDifferInCase" ].attribute("Show", cfg.warnDlgs.warnFoldersDifferInCase); + inOpt["WarnUnresolvedConflicts" ].attribute("Show", cfg.warnDlgs.warnUnresolvedConflicts); + inOpt["WarnNotEnoughDiskSpace" ].attribute("Show", cfg.warnDlgs.warnNotEnoughDiskSpace); + inOpt["WarnSignificantDifference" ].attribute("Show", cfg.warnDlgs.warnSignificantDifference); + inOpt["WarnRecycleBinNotAvailable" ].attribute("Show", cfg.warnDlgs.warnRecyclerMissing); + inOpt["WarnDependentFolderPair" ].attribute("Show", cfg.warnDlgs.warnDependentFolderPair); + inOpt["WarnDependentBaseFolders" ].attribute("Show", cfg.warnDlgs.warnDependentBaseFolders); + inOpt["WarnDirectoryLockFailed" ].attribute("Show", cfg.warnDlgs.warnDirectoryLockFailed); + inOpt["WarnVersioningFolderPartOfSync"].attribute("Show", cfg.warnDlgs.warnVersioningFolderPartOfSync); + + //TODO: remove after migration! 2022-08-26 + if (formatVer < 25) + cfg.warnDlgs.warnDependentBaseFolders = true; //new semantics! should not be ignored + + //TODO: remove after migration! 2021-12-02 + if (formatVer < 23) + { + in2["NotificationSound"].attribute("CompareFinished", cfg.soundFileCompareFinished); + in2["NotificationSound"].attribute("SyncFinished", cfg.soundFileSyncFinished); + } + else + { + in2["Sounds"]["CompareFinished"].attribute("Path", cfg.soundFileCompareFinished); + in2["Sounds"]["SyncFinished" ].attribute("Path", cfg.soundFileSyncFinished); + in2["Sounds"]["AlertPending" ].attribute("Path", cfg.soundFileAlertPending); + } + + //TODO: remove if parameter migration after some time! 2019-05-29 + if (formatVer < 13) + { + if (!cfg.soundFileCompareFinished.empty()) cfg.soundFileCompareFinished = appendPath(getResourceDirPath(), cfg.soundFileCompareFinished); + if (!cfg.soundFileSyncFinished .empty()) cfg.soundFileSyncFinished = appendPath(getResourceDirPath(), cfg.soundFileSyncFinished); + } + else + { + cfg.soundFileCompareFinished = resolvePortablePath(cfg.soundFileCompareFinished); + cfg.soundFileSyncFinished = resolvePortablePath(cfg.soundFileSyncFinished); + cfg.soundFileAlertPending = resolvePortablePath(cfg.soundFileAlertPending); + } + + XmlIn inMainWin = in["MainDialog"]; + + //TODO: remove old parameter after migration! 2020-12-03 + if (in["Gui"]) + inMainWin = in["Gui"]["MainDialog"]; + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + { + cfg.dpiLayouts[getDpiScalePercent()].mainDlg.size = wxSize(); + inMainWin.attribute("Width", cfg.dpiLayouts[getDpiScalePercent()].mainDlg.size->x); + inMainWin.attribute("Height", cfg.dpiLayouts[getDpiScalePercent()].mainDlg.size->y); + cfg.dpiLayouts[getDpiScalePercent()].mainDlg.pos = wxPoint(); + inMainWin.attribute("PosX", cfg.dpiLayouts[getDpiScalePercent()].mainDlg.pos->x); + inMainWin.attribute("PosY", cfg.dpiLayouts[getDpiScalePercent()].mainDlg.pos->y); + inMainWin.attribute("Maximized", cfg.dpiLayouts[getDpiScalePercent()].mainDlg.isMaximized); + } + + //########################################################### + + inMainWin["SearchPanel"].attribute("CaseSensitive", cfg.mainDlg.textSearchRespectCase); + + //########################################################### + + XmlIn inConfig = inMainWin["ConfigPanel"]; + inConfig.attribute("ScrollPos", cfg.mainDlg.config.topRowPos); + inConfig.attribute("SyncOverdue", cfg.mainDlg.config.syncOverdueDays); + inConfig.attribute("SortByColumn", cfg.mainDlg.config.lastSortColumn); + inConfig.attribute("SortAscending", cfg.mainDlg.config.lastSortAscending); + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + inConfig["Columns"](cfg.dpiLayouts[getDpiScalePercent()].configColumnAttribs); + + inConfig["Configurations"].attribute("MaxSize", cfg.mainDlg.config.histItemsMax); + inConfig["Configurations"].attribute("LastSelected", cfg.mainDlg.config.lastSelectedFile); + cfg.mainDlg.config.lastSelectedFile = resolvePortablePath(cfg.mainDlg.config.lastSelectedFile); + + inConfig["Configurations"](cfg.mainDlg.config.fileHistory); + + //TODO: remove after migration! 2019-11-30 + if (formatVer < 15) + { + const Zstring lastRunConfigPath = appendPath(getConfigDirPath(), Zstr("LastRun.ffs_gui")); + for (ConfigFileItem& item : cfg.mainDlg.config.fileHistory) + if (equalNativePath(item.cfgFilePath, lastRunConfigPath)) + item.backColor = wxColor(0xdd, 0xdd, 0xdd); //light grey from onCfgGridContext() + } + + inConfig["LastUsed"](cfg.mainDlg.config.lastUsedFiles); + cfg.mainDlg.config.lastUsedFiles = resolvePortablePath(cfg.mainDlg.config.lastUsedFiles); + + //########################################################### + + XmlIn inOverview = inMainWin["OverviewPanel"]; + inOverview.attribute("ShowPercentage", cfg.mainDlg.overview.showPercentBar); + inOverview.attribute("SortByColumn", cfg.mainDlg.overview.lastSortColumn); + inOverview.attribute("SortAscending", cfg.mainDlg.overview.lastSortAscending); + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + inOverview["Columns"](cfg.dpiLayouts[getDpiScalePercent()].overviewColumnAttribs); + + XmlIn inFilePanel = inMainWin["FilePanel"]; + + //TODO: remove after migration! 2020-10-13 + if (formatVer < 19) + ; //new icon layout => let user re-evaluate settings + else + { + inFilePanel.attribute("ShowIcons", cfg.mainDlg.showIcons); + inFilePanel.attribute("IconSize", cfg.mainDlg.iconSize); + } + inFilePanel.attribute("SashOffset", cfg.mainDlg.sashOffset); + + //TODO: remove if parameter migration after some time! 2020-01-30 + if (formatVer < 16) + inFilePanel.attribute("MaxFolderPairsShown", cfg.mainDlg.folderPairsVisibleMax); + else + inFilePanel.attribute("FolderPairsMax", cfg.mainDlg.folderPairsVisibleMax); + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + { + inFilePanel["ColumnsLeft" ](cfg.dpiLayouts[getDpiScalePercent()].fileColumnAttribsLeft); + inFilePanel["ColumnsRight"](cfg.dpiLayouts[getDpiScalePercent()].fileColumnAttribsRight); + + inFilePanel["ColumnsLeft" ].attribute("PathFormat", cfg.mainDlg.itemPathFormatLeftGrid); + inFilePanel["ColumnsRight"].attribute("PathFormat", cfg.mainDlg.itemPathFormatRightGrid); + } + else + { + inFilePanel.attribute("PathFormatLeft", cfg.mainDlg.itemPathFormatLeftGrid); + inFilePanel.attribute("PathFormatRight", cfg.mainDlg.itemPathFormatRightGrid); + } + + inFilePanel["FolderHistoryLeft" ](cfg.mainDlg.folderHistoryLeft); + inFilePanel["FolderHistoryRight"](cfg.mainDlg.folderHistoryRight); + cfg.mainDlg.folderHistoryLeft = resolvePortablePath(cfg.mainDlg.folderHistoryLeft); + cfg.mainDlg.folderHistoryRight = resolvePortablePath(cfg.mainDlg.folderHistoryRight); + + inFilePanel["FolderHistoryLeft" ].attribute("LastSelected", cfg.mainDlg.folderLastSelectedLeft); + inFilePanel["FolderHistoryRight"].attribute("LastSelected", cfg.mainDlg.folderLastSelectedRight); + cfg.mainDlg.folderLastSelectedLeft = resolvePortablePath(cfg.mainDlg.folderLastSelectedLeft); + cfg.mainDlg.folderLastSelectedRight = resolvePortablePath(cfg.mainDlg.folderLastSelectedRight); + + //########################################################### + XmlIn inCopyTo = inMainWin["ManualCopyTo"]; + inCopyTo.attribute("KeepRelativePaths", cfg.mainDlg.copyToCfg.keepRelPaths); + inCopyTo.attribute("OverwriteIfExists", cfg.mainDlg.copyToCfg.overwriteIfExists); + + XmlIn inCopyToHistory = inCopyTo["FolderHistory"]; + + inCopyToHistory(cfg.mainDlg.copyToCfg.folderHistory); + inCopyToHistory.attribute("TargetFolder", cfg.mainDlg.copyToCfg.targetFolderPath); + inCopyToHistory.attribute("LastSelected", cfg.mainDlg.copyToCfg.targetFolderLastSelected); + cfg.mainDlg.copyToCfg.folderHistory = resolvePortablePath(cfg.mainDlg.copyToCfg.folderHistory); + cfg.mainDlg.copyToCfg.targetFolderPath = resolvePortablePath(cfg.mainDlg.copyToCfg.targetFolderPath); + cfg.mainDlg.copyToCfg.targetFolderLastSelected = resolvePortablePath(cfg.mainDlg.copyToCfg.targetFolderLastSelected); + //########################################################### + + XmlIn inDefFilter = inMainWin["DefaultViewFilter"]; + + inDefFilter.attribute("Equal", cfg.mainDlg.viewFilterDefault.equal); + inDefFilter.attribute("Conflict", cfg.mainDlg.viewFilterDefault.conflict); + inDefFilter.attribute("Excluded", cfg.mainDlg.viewFilterDefault.excluded); + + XmlIn diffView = inDefFilter["Difference"]; + //TODO: remove after migration! 2020-10-13 + if (formatVer < 19) + diffView = inDefFilter["CategoryView"]; + + diffView.attribute("LeftOnly", cfg.mainDlg.viewFilterDefault.leftOnly); + diffView.attribute("RightOnly", cfg.mainDlg.viewFilterDefault.rightOnly); + diffView.attribute("LeftNewer", cfg.mainDlg.viewFilterDefault.leftNewer); + diffView.attribute("RightNewer", cfg.mainDlg.viewFilterDefault.rightNewer); + diffView.attribute("Different", cfg.mainDlg.viewFilterDefault.different); + + XmlIn actView = inDefFilter["Action"]; + //TODO: remove after migration! 2020-10-13 + if (formatVer < 19) + actView = inDefFilter["ActionView"]; + + actView.attribute("CreateLeft", cfg.mainDlg.viewFilterDefault.createLeft); + actView.attribute("CreateRight", cfg.mainDlg.viewFilterDefault.createRight); + actView.attribute("UpdateLeft", cfg.mainDlg.viewFilterDefault.updateLeft); + actView.attribute("UpdateRight", cfg.mainDlg.viewFilterDefault.updateRight); + actView.attribute("DeleteLeft", cfg.mainDlg.viewFilterDefault.deleteLeft); + actView.attribute("DeleteRight", cfg.mainDlg.viewFilterDefault.deleteRight); + actView.attribute("DoNothing", cfg.mainDlg.viewFilterDefault.doNothing); + + + //TODO: remove old parameter after migration! 2021-03-06 + if (formatVer < 21) + inMainWin["Perspective"](cfg.dpiLayouts[getDpiScalePercent()].panelLayout); + + //TODO: remove after migration! 2019-11-30 + auto splitEditMerge = [](wxString& perspective, wchar_t delim, const std::function& editItem) + { + std::vector v = splitCpy(perspective, delim, SplitOnEmpty::allow); + assert(!v.empty()); + perspective.clear(); + + std::for_each(v.begin(), v.end() - 1, [&](wxString& item) + { + editItem(item); + perspective += item; + perspective += delim; + }); + editItem(v.back()); + perspective += v.back(); + }; + + //TODO: remove after migration! 2019-11-30 + if (formatVer < 15) + { + //set minimal TopPanel height => search and set actual height to 0 and let MainDialog's min-size handling kick in: + std::optional tpDir; + std::optional tpLayer; + std::optional tpRow; + splitEditMerge(cfg.dpiLayouts[getDpiScalePercent()].panelLayout, L'|', [&](wxString& paneCfg) + { + if (contains(paneCfg, L"name=TopPanel")) + splitEditMerge(paneCfg, L';', [&](wxString& paneAttr) + { + if (startsWith(paneAttr, L"dir=")) + tpDir = stringTo(afterFirst(paneAttr, L'=', IfNotFoundReturn::none)); + else if (startsWith(paneAttr, L"layer=")) + tpLayer = stringTo(afterFirst(paneAttr, L'=', IfNotFoundReturn::none)); + else if (startsWith(paneAttr, L"row=")) + tpRow = stringTo(afterFirst(paneAttr, L'=', IfNotFoundReturn::none)); + }); + }); + + if (tpDir && tpLayer && tpRow) + { + const wxString tpSize = L"dock_size(" + + numberTo(*tpDir ) + L"," + + numberTo(*tpLayer) + L"," + + numberTo(*tpRow ) + L")="; + + splitEditMerge(cfg.dpiLayouts[getDpiScalePercent()].panelLayout, L'|', [&](wxString& paneCfg) + { + if (startsWith(paneCfg, tpSize)) + paneCfg = tpSize + L"0"; + }); + } + } + + //TODO: remove if parameter migration after some time! 2020-01-30 + if (formatVer < 16) + ; + else if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + in["Gui"]["FolderHistory" ].attribute("MaxSize", cfg.folderHistoryMax); + else + in["FolderHistory" ].attribute("MaxSize", cfg.folderHistoryMax); + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["SftpKeyFile"].attribute("LastSelected", cfg.sftpKeyFileLastSelected); + } + else + { + in["SftpKeyFile"].attribute("LastSelected", cfg.sftpKeyFileLastSelected); + cfg.sftpKeyFileLastSelected = resolvePortablePath(cfg.sftpKeyFileLastSelected); + } + + if (formatVer < 22) //TODO: remove old parameter after migration! 2021-07-31 + { + } + else + readConfig(in["DefaultFilter"], cfg.defaultFilter); + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["VersioningFolderHistory"](cfg.versioningFolderHistory); + in["Gui"]["VersioningFolderHistory"].attribute("LastSelected", cfg.versioningFolderLastSelected); + } + else + { + in["VersioningFolderHistory"](cfg.versioningFolderHistory); + in["VersioningFolderHistory"].attribute("LastSelected", cfg.versioningFolderLastSelected); + cfg.versioningFolderLastSelected = resolvePortablePath(cfg.versioningFolderLastSelected); + } + in["LogFolder"](cfg.logFolderPhrase); + cfg.logFolderPhrase = resolvePortablePath(cfg.logFolderPhrase); + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["LogFolderHistory"](cfg.logFolderHistory); + in["Gui"]["LogFolderHistory"].attribute("LastSelected", cfg.logFolderLastSelected); + } + else + { + in["LogFolderHistory"](cfg.logFolderHistory); + in["LogFolderHistory"].attribute("LastSelected", cfg.logFolderLastSelected); + cfg.logFolderHistory = resolvePortablePath(cfg.logFolderHistory); + cfg.logFolderLastSelected = resolvePortablePath(cfg.logFolderLastSelected); + } + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["EmailHistory"](cfg.emailHistory); + in["Gui"]["EmailHistory"].attribute("MaxSize", cfg.emailHistoryMax); + } + else + { + in["EmailHistory"](cfg.emailHistory); + in["EmailHistory"].attribute("MaxSize", cfg.emailHistoryMax); + } + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["CommandHistory"](cfg.commandHistory); + in["Gui"]["CommandHistory"].attribute("MaxSize", cfg.commandHistoryMax); + } + else + { + in["CommandHistory"](cfg.commandHistory); + in["CommandHistory"].attribute("MaxSize", cfg.commandHistoryMax); + } + + //TODO: remove if parameter migration after some time! 2020-01-30 + if (formatVer < 15) + if (cfg.commandHistoryMax <= 8) + cfg.commandHistoryMax = GlobalConfig().commandHistoryMax; + + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + in["Gui"]["ExternalApps"](cfg.externalApps); + else + in["ExternalApps"](cfg.externalApps); + + //TODO: remove after migration! 2019-11-30 + if (formatVer < 15) + for (ExternalApp& item : cfg.externalApps) + { + replace(item.cmdLine, Zstr("%folder_path%"), Zstr("%parent_path%")); + replace(item.cmdLine, Zstr("%folder_path2%"), Zstr("%parent_path2%")); + } + + //TODO: remove after migration! 2020-06-13 + if (formatVer < 18) + for (ExternalApp& item : cfg.externalApps) + { + trim(item.cmdLine); + if (item.cmdLine == "xdg-open \"%parent_path%\"") + item.cmdLine = "xdg-open \"$(dirname %local_path%)\""; + } + + //TODO: remove after migration! 2022-04-29 + if (formatVer < 24) + for (ExternalApp& item : cfg.externalApps) + if (item.description == L"Browse directory") + item.description = L"Show in file manager"; + + //TODO: remove after migration! 2025-09-25 + if (formatVer < 28) + for (ExternalApp& item : cfg.externalApps) + { + trim(item.cmdLine); + + auto removeQuotes = [&](const ZstringView macroName) { replace(item.cmdLine, Zstring() + Zstr('"') + macroName + Zstr('"'), macroName); }; + removeQuotes(Zstr("%item_path%")); + removeQuotes(Zstr("%item_path2%")); + removeQuotes(Zstr("%item_paths%")); + removeQuotes(Zstr("%local_path%")); + removeQuotes(Zstr("%local_path2%")); + removeQuotes(Zstr("%local_paths%")); + removeQuotes(Zstr("%item_name%")); + removeQuotes(Zstr("%item_name2%")); + removeQuotes(Zstr("%item_names%")); + removeQuotes(Zstr("%parent_path%")); + removeQuotes(Zstr("%parent_path2%")); + removeQuotes(Zstr("%parent_paths%")); + } + + if (formatVer < 20) //TODO: remove old parameter after migration! 2020-12-03 + { + in["Gui"]["LastOnlineCheck" ](cfg.lastUpdateCheck); + in["Gui"]["LastOnlineVersion"](cfg.lastOnlineVersion); + } + else + { + in["LastOnlineCheck" ](cfg.lastUpdateCheck); + in["LastOnlineVersion"](cfg.lastOnlineVersion); + } + + in["WelcomeDialogVersion"](cfg.welcomeDialogLastVersion); + + //cfg.dpiLayouts.clear(); -> NO: honor migration code above! + + in["DpiLayouts"].visitChildren([&](const XmlIn& inLayout) + { + assert(*inLayout.getName() == "Layout"); + if (std::string scaleTxt; + inLayout.attribute("Scale", scaleTxt)) + { + const int scalePercent = stringTo(beforeLast(scaleTxt, '%', IfNotFoundReturn::none)); + DpiLayout layout; + + //TODO: remove parameter migration after some time! 2023-02-18 + if (formatVer < 26) + { + XmlIn inLayoutMain = inLayout["MainDialog"]; + layout.mainDlg.size = wxSize(); + inLayoutMain.attribute("Width", layout.mainDlg.size->x); + inLayoutMain.attribute("Height", layout.mainDlg.size->y); + + layout.mainDlg.pos = wxPoint(); + inLayoutMain.attribute("PosX", layout.mainDlg.pos->x); + inLayoutMain.attribute("PosY", layout.mainDlg.pos->y); + + inLayoutMain.attribute("Maximized", layout.mainDlg.isMaximized); + + inLayoutMain["PanelLayout" ](layout.panelLayout); + inLayoutMain["ConfigPanel" ](layout.configColumnAttribs); + inLayoutMain["OverviewPanel" ](layout.overviewColumnAttribs); + inLayoutMain["FilePanelLeft" ](layout.fileColumnAttribsLeft); + inLayoutMain["FilePanelRight"](layout.fileColumnAttribsRight); + + XmlIn inLayoutProgress = inLayout["ProgressDialog"]; + layout.progressDlg.size = wxSize(); + inLayoutProgress.attribute("Width", layout.progressDlg.size->x); + inLayoutProgress.attribute("Height", layout.progressDlg.size->y); + + inLayoutProgress.attribute("Maximized", layout.progressDlg.isMaximized); + } + else + { + XmlIn inLayoutMain = inLayout["MainWindow"]; + if (inLayoutMain.hasAttribute("Width") && + inLayoutMain.hasAttribute("Height")) + { + layout.mainDlg.size = wxSize(); + inLayoutMain.attribute("Width", layout.mainDlg.size->x); + inLayoutMain.attribute("Height", layout.mainDlg.size->y); + } + if (inLayoutMain.hasAttribute("PosX") && + inLayoutMain.hasAttribute("PosY")) + { + layout.mainDlg.pos = wxPoint(); + inLayoutMain.attribute("PosX", layout.mainDlg.pos->x); + inLayoutMain.attribute("PosY", layout.mainDlg.pos->y); + } + inLayoutMain.attribute("Maximized", layout.mainDlg.isMaximized); + + XmlIn inLayoutProgress = inLayout["ProgressDialog"]; + if (inLayoutProgress.hasAttribute("Width") && + inLayoutProgress.hasAttribute("Height")) + { + layout.progressDlg.size = wxSize(); + inLayoutProgress.attribute("Width", layout.progressDlg.size->x); + inLayoutProgress.attribute("Height", layout.progressDlg.size->y); + } + inLayoutProgress.attribute("Maximized", layout.progressDlg.isMaximized); + + inLayout["Panels" ](layout.panelLayout); + inLayout["ConfigPanel" ](layout.configColumnAttribs); + inLayout["OverviewPanel" ](layout.overviewColumnAttribs); + inLayout["FilePanelLeft" ](layout.fileColumnAttribsLeft); + inLayout["FilePanelRight"](layout.fileColumnAttribsRight); + } + + cfg.dpiLayouts.emplace(scalePercent, std::move(layout)); + } + }); +} + + +template +std::pair parseConfig(const XmlDoc& doc, const Zstring& filePath, int currentXmlFormatVer) //noexcept +{ + int formatVer = 0; + /*bool success =*/ doc.root().getAttribute("XmlFormat", formatVer); + + XmlIn in(doc); + ConfigType cfg; + readConfig(in, cfg, formatVer); + + std::wstring warningMsg; + + if (const std::wstring& errors = in.getErrors(); + !errors.empty()) + warningMsg = replaceCpy(_("Configuration file %x is incomplete. The missing elements have been set to their default values."), L"%x", fmtPath(filePath)) + L"\n\n" + + _("The following XML elements could not be read:") + L'\n' + errors; + else //(try to) migrate old configuration if needed + if (formatVer < currentXmlFormatVer) + try + { + fff::writeConfig(cfg, filePath); //throw FileError + } + catch (const FileError& e) { warningMsg = e.toString(); } + + return {cfg, warningMsg}; +} + + +template +std::pair readConfig(const Zstring& filePath, const char* expectedCfgType, int currentXmlFormatVer) //throw FileError +{ + XmlDoc doc = loadXml(filePath); //throw FileError + + const std::string cfgType = [&] + { + if (doc.root().getName() == "FreeFileSync") + { + std::string type; + if (doc.root().getAttribute("XmlType", type)) + return type; + } + return std::string(); + }(); + if (cfgType != expectedCfgType) + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath))); + + return parseConfig(doc, filePath, currentXmlFormatVer); +} +} + + +std::pair fff::readGuiConfig(const Zstring& filePath) +{ + return readConfig(filePath, "GUI", XML_FORMAT_SYNC_CFG); //throw FileError +} + + +std::pair fff::readBatchConfig(const Zstring& filePath) +{ + return readConfig(filePath, "BATCH", XML_FORMAT_SYNC_CFG); //throw FileError +} + + +std::pair fff::readGlobalConfig(const Zstring& filePath) +{ + return readConfig(filePath, "GLOBAL", XML_FORMAT_GLOBAL_CFG); //throw FileError +} + + +std::pair fff::readAnyConfig(const std::vector& filePaths) //throw FileError +{ + assert(!filePaths.empty()); + + std::wstring warningMsgAll; + std::vector guiCfgs; + + for (const Zstring& filePath : filePaths) + if (endsWithAsciiNoCase(filePath, Zstr(".ffs_gui"))) + { + const auto& [guiCfg, warningMsg] = readGuiConfig(filePath); //throw FileError + guiCfgs.push_back(guiCfg); + + if (!warningMsg.empty()) + warningMsgAll += warningMsg + L"\n\n"; + } + else if (endsWithAsciiNoCase(filePath, Zstr(".ffs_batch"))) + { + const auto& [batchCfg, warningMsg] = readBatchConfig(filePath); //throw FileError + guiCfgs.push_back(batchCfg.guiCfg); + + if (!warningMsg.empty()) + warningMsgAll += warningMsg + L"\n\n"; + } + else + throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(filePath)), + _("Unexpected file extension:") + L' ' + fmtPath(getFileExtension(filePath)) + L'\n' + + _("Expected:") + L" ffs_gui, ffs_batch"); + + return {merge(guiCfgs), trimCpy(warningMsgAll)}; +} + +//################################################################################################ + +namespace +{ +void writeConfig(const CompConfig& cmpCfg, XmlOut& out) +{ + out["Variant" ](cmpCfg.compareVar); + out["Symlinks"](cmpCfg.handleSymlinks); + out["IgnoreTimeShift"](toTimeShiftPhrase(cmpCfg.ignoreTimeShiftMinutes)); +} + + +void writeConfig(const SyncDirectionConfig& dirCfg, XmlOut& out) +{ + if (const DirectionByDiff* diffDirs = std::get_if(&dirCfg.dirs)) + { + XmlOut outDirs = out["Differences"]; + outDirs.attribute("LeftOnly", diffDirs->leftOnly); + outDirs.attribute("LeftNewer", diffDirs->leftNewer); + outDirs.attribute("RightNewer", diffDirs->rightNewer); + outDirs.attribute("RightOnly", diffDirs->rightOnly); + } + else + { + const DirectionByChange& changeDirs = std::get(dirCfg.dirs); + + XmlOut outDirsL = out["Changes"]["Left"]; + outDirsL.attribute("Create", changeDirs.left.create); + outDirsL.attribute("Update", changeDirs.left.update); + outDirsL.attribute("Delete", changeDirs.left.delete_); + + XmlOut outDirsR = out["Changes"]["Right"]; + outDirsR.attribute("Create", changeDirs.right.create); + outDirsR.attribute("Update", changeDirs.right.update); + outDirsR.attribute("Delete", changeDirs.right.delete_); + } +} + + +void writeConfig(const SyncConfig& syncCfg, const std::map& deviceParallelOps, XmlOut& out) +{ + writeConfig(syncCfg.directionCfg, out); + + out["DeletionPolicy" ](syncCfg.deletionVariant); + out["VersioningFolder"](syncCfg.versioningFolderPhrase); + + const size_t parallelOps = getDeviceParallelOps(deviceParallelOps, syncCfg.versioningFolderPhrase); + if (parallelOps > 1) out["VersioningFolder"].attribute("Threads", parallelOps); + + out["VersioningFolder"].attribute("Style", syncCfg.versioningStyle); + + if (syncCfg.versioningStyle != VersioningStyle::replace) + { + if (syncCfg.versionMaxAgeDays > 0) out["VersioningFolder"].attribute("MaxAge", syncCfg.versionMaxAgeDays); + if (syncCfg.versionCountMin > 0) out["VersioningFolder"].attribute("MinCount", syncCfg.versionCountMin); + if (syncCfg.versionCountMax > 0) out["VersioningFolder"].attribute("MaxCount", syncCfg.versionCountMax); + } +} + + +void writeConfig(const FilterConfig& filter, XmlOut& out) +{ + out["Include"](splitFilterByLines(filter.includeFilter)); + out["Exclude"](splitFilterByLines(filter.excludeFilter)); + + out["SizeMin"](filter.sizeMin); + out["SizeMin"].attribute("Unit", filter.unitSizeMin); + + out["SizeMax"](filter.sizeMax); + out["SizeMax"].attribute("Unit", filter.unitSizeMax); + + out["TimeSpan"](filter.timeSpan); + out["TimeSpan"].attribute("Type", filter.unitTimeSpan); +} + + +void writeConfig(const LocalPairConfig& lpc, const std::map& deviceParallelOps, XmlOut& out) +{ + XmlOut outPair = out.addChild("Pair"); + + //read folder pairs + outPair["Left" ](lpc.folderPathPhraseLeft); + outPair["Right"](lpc.folderPathPhraseRight); + + const size_t parallelOpsL = getDeviceParallelOps(deviceParallelOps, lpc.folderPathPhraseLeft); + const size_t parallelOpsR = getDeviceParallelOps(deviceParallelOps, lpc.folderPathPhraseRight); + + if (parallelOpsL > 1) outPair["Left" ].attribute("Threads", parallelOpsL); + if (parallelOpsR > 1) outPair["Right"].attribute("Threads", parallelOpsR); + + //avoid "fake" changed configs by only storing "real" parallel-enabled devices in deviceParallelOps + assert(std::all_of(deviceParallelOps.begin(), deviceParallelOps.end(), [](const auto& item) { return item.second > 1; })); + + //########################################################### + //alternate comp configuration (optional) + if (lpc.localCmpCfg) + { + XmlOut outLocalCmp = outPair["Compare"]; + writeConfig(*lpc.localCmpCfg, outLocalCmp); + } + //########################################################### + //alternate sync configuration (optional) + if (lpc.localSyncCfg) + { + XmlOut outLocalSync = outPair["Synchronize"]; + writeConfig(*lpc.localSyncCfg, deviceParallelOps, outLocalSync); + } + + //########################################################### + //alternate filter configuration + if (lpc.localFilter != FilterConfig()) //don't spam .ffs_gui file with default filter entries + { + XmlOut outFilter = outPair["Filter"]; + writeConfig(lpc.localFilter, outFilter); + } +} + + +void writeConfig(const MainConfiguration& mainCfg, XmlOut& out) +{ + XmlOut outCmp = out["Compare"]; + writeConfig(mainCfg.cmpCfg, outCmp); + //########################################################### + + XmlOut outSync = out["Synchronize"]; + writeConfig(mainCfg.syncCfg, mainCfg.deviceParallelOps, outSync); + //########################################################### + + XmlOut outFilter = out["Filter"]; + writeConfig(mainCfg.globalFilter, outFilter); + + //########################################################### + XmlOut outFp = out["FolderPairs"]; + //write folder pairs + writeConfig(mainCfg.firstPair, mainCfg.deviceParallelOps, outFp); + + for (const LocalPairConfig& lpc : mainCfg.additionalPairs) + writeConfig(lpc, mainCfg.deviceParallelOps, outFp); + + out["Errors"].attribute("Ignore", mainCfg.ignoreErrors); + out["Errors"].attribute("Retry", mainCfg.autoRetryCount); + out["Errors"].attribute("Delay", mainCfg.autoRetryDelay); + + out["PostSyncCommand"](mainCfg.postSyncCommand); + out["PostSyncCommand"].attribute("Condition", mainCfg.postSyncCondition); + + out["LogFolder"](mainCfg.altLogFolderPathPhrase); + + out["EmailNotification"](mainCfg.emailNotifyAddress); + out["EmailNotification"].attribute("Condition", mainCfg.emailNotifyCondition); +} + + +void writeConfig(const FfsGuiConfig& cfg, XmlOut& out) +{ + out["Notes"](cfg.notes); + + writeConfig(cfg.mainCfg, out); //write main config + + out["GridViewType"](cfg.gridViewType); +} + + +void writeConfig(const FfsBatchConfig& cfg, XmlOut& out) +{ + writeConfig(cfg.guiCfg, out); + + XmlOut outBatch = out["Batch"]; + outBatch["ProgressDialog"].attribute("Minimized", cfg.batchExCfg.runMinimized); + outBatch["ProgressDialog"].attribute("AutoClose", cfg.batchExCfg.autoCloseSummary); + outBatch["ErrorDialog" ](cfg.batchExCfg.batchErrorHandling); + outBatch["PostSyncAction"](cfg.batchExCfg.postBatchAction); +} + + +void writeConfig(const GlobalConfig& cfg, XmlOut& out) +{ + out["Language"].attribute("Code", cfg.programLanguage); + out["ColorTheme"].attribute("Appearance", cfg.appColorTheme); + + out["FailSafeFileCopy" ].attribute("Enabled", cfg.failSafeFileCopy); + out["CopyLockedFiles" ].attribute("Enabled", cfg.copyLockedFiles); + out["CopyFilePermissions" ].attribute("Enabled", cfg.copyFilePermissions); + out["FileTimeTolerance" ].attribute("Seconds", cfg.fileTimeTolerance); + out["RunWithBackgroundPriority"].attribute("Enabled", cfg.runWithBackgroundPriority); + out["LockDirectoriesDuringSync"].attribute("Enabled", cfg.createLockFile); + out["VerifyCopiedFiles" ].attribute("Enabled", cfg.verifyFileCopy); + out["LogFiles" ].attribute("MaxAge", cfg.logfilesMaxAgeDays); + out["LogFiles" ].attribute("Format", cfg.logFormat); + + out["ProgressDialog"].attribute("AutoClose", cfg.progressDlgAutoClose); + + XmlOut outOpt = out["OptionalDialogs"]; + outOpt["ConfirmStartSync" ].attribute("Show", cfg.confirmDlgs.confirmSyncStart); + outOpt["ConfirmSaveConfig" ].attribute("Show", cfg.confirmDlgs.confirmSaveConfig); + outOpt["ConfirmSwapSides" ].attribute("Show", cfg.confirmDlgs.confirmSwapSides); + outOpt["ConfirmCommandMassInvoke" ].attribute("Show", cfg.confirmDlgs.confirmCommandMassInvoke); + outOpt["WarnFolderNotExisting" ].attribute("Show", cfg.warnDlgs.warnFolderNotExisting); + outOpt["WarnFoldersDifferInCase" ].attribute("Show", cfg.warnDlgs.warnFoldersDifferInCase); + outOpt["WarnUnresolvedConflicts" ].attribute("Show", cfg.warnDlgs.warnUnresolvedConflicts); + outOpt["WarnNotEnoughDiskSpace" ].attribute("Show", cfg.warnDlgs.warnNotEnoughDiskSpace); + outOpt["WarnSignificantDifference" ].attribute("Show", cfg.warnDlgs.warnSignificantDifference); + outOpt["WarnRecycleBinNotAvailable" ].attribute("Show", cfg.warnDlgs.warnRecyclerMissing); + outOpt["WarnDependentFolderPair" ].attribute("Show", cfg.warnDlgs.warnDependentFolderPair); + outOpt["WarnDependentBaseFolders" ].attribute("Show", cfg.warnDlgs.warnDependentBaseFolders); + outOpt["WarnDirectoryLockFailed" ].attribute("Show", cfg.warnDlgs.warnDirectoryLockFailed); + outOpt["WarnVersioningFolderPartOfSync"].attribute("Show", cfg.warnDlgs.warnVersioningFolderPartOfSync); + + out["Sounds"]["CompareFinished"].attribute("Path", makePortablePath(cfg.soundFileCompareFinished)); + out["Sounds"]["SyncFinished" ].attribute("Path", makePortablePath(cfg.soundFileSyncFinished)); + out["Sounds"]["AlertPending" ].attribute("Path", makePortablePath(cfg.soundFileAlertPending)); + + //gui specific global settings (optional) + XmlOut outMainWin = out["MainDialog"]; + + //########################################################### + outMainWin["SearchPanel"].attribute("CaseSensitive", cfg.mainDlg.textSearchRespectCase); + //########################################################### + + XmlOut outConfig = outMainWin["ConfigPanel"]; + outConfig.attribute("ScrollPos", cfg.mainDlg.config.topRowPos); + outConfig.attribute("SyncOverdue", cfg.mainDlg.config.syncOverdueDays); + outConfig.attribute("SortByColumn", cfg.mainDlg.config.lastSortColumn); + outConfig.attribute("SortAscending", cfg.mainDlg.config.lastSortAscending); + + outConfig["Configurations"].attribute("MaxSize", cfg.mainDlg.config.histItemsMax); + outConfig["Configurations"].attribute("LastSelected", makePortablePath(cfg.mainDlg.config.lastSelectedFile)); + outConfig["Configurations"](cfg.mainDlg.config.fileHistory); + + outConfig["LastUsed"](makePortablePath(cfg.mainDlg.config.lastUsedFiles)); + + //########################################################### + + XmlOut outOverview = outMainWin["OverviewPanel"]; + outOverview.attribute("ShowPercentage", cfg.mainDlg.overview.showPercentBar); + outOverview.attribute("SortByColumn", cfg.mainDlg.overview.lastSortColumn); + outOverview.attribute("SortAscending", cfg.mainDlg.overview.lastSortAscending); + + XmlOut outFilePanel = outMainWin["FilePanel"]; + outFilePanel.attribute("ShowIcons", cfg.mainDlg.showIcons); + outFilePanel.attribute("IconSize", cfg.mainDlg.iconSize); + outFilePanel.attribute("SashOffset", cfg.mainDlg.sashOffset); + outFilePanel.attribute("FolderPairsMax", cfg.mainDlg.folderPairsVisibleMax); + outFilePanel.attribute("PathFormatLeft", cfg.mainDlg.itemPathFormatLeftGrid); + outFilePanel.attribute("PathFormatRight", cfg.mainDlg.itemPathFormatRightGrid); + + outFilePanel["FolderHistoryLeft" ](makePortablePath(cfg.mainDlg.folderHistoryLeft)); + outFilePanel["FolderHistoryRight"](makePortablePath(cfg.mainDlg.folderHistoryRight)); + + outFilePanel["FolderHistoryLeft" ].attribute("LastSelected", makePortablePath(cfg.mainDlg.folderLastSelectedLeft)); + outFilePanel["FolderHistoryRight"].attribute("LastSelected", makePortablePath(cfg.mainDlg.folderLastSelectedRight)); + + //########################################################### + XmlOut outCopyTo = outMainWin["ManualCopyTo"]; + outCopyTo.attribute("KeepRelativePaths", cfg.mainDlg.copyToCfg.keepRelPaths); + outCopyTo.attribute("OverwriteIfExists", cfg.mainDlg.copyToCfg.overwriteIfExists); + + XmlOut outCopyToHistory = outCopyTo["FolderHistory"]; + + outCopyToHistory(makePortablePath(cfg.mainDlg.copyToCfg.folderHistory)); + outCopyToHistory.attribute("TargetFolder", makePortablePath(cfg.mainDlg.copyToCfg.targetFolderPath)); + outCopyToHistory.attribute("LastSelected", makePortablePath(cfg.mainDlg.copyToCfg.targetFolderLastSelected)); + //########################################################### + + XmlOut outDefFilter = outMainWin["DefaultViewFilter"]; + outDefFilter.attribute("Equal", cfg.mainDlg.viewFilterDefault.equal); + outDefFilter.attribute("Conflict", cfg.mainDlg.viewFilterDefault.conflict); + outDefFilter.attribute("Excluded", cfg.mainDlg.viewFilterDefault.excluded); + + XmlOut catView = outDefFilter["Difference"]; + catView.attribute("LeftOnly", cfg.mainDlg.viewFilterDefault.leftOnly); + catView.attribute("RightOnly", cfg.mainDlg.viewFilterDefault.rightOnly); + catView.attribute("LeftNewer", cfg.mainDlg.viewFilterDefault.leftNewer); + catView.attribute("RightNewer", cfg.mainDlg.viewFilterDefault.rightNewer); + catView.attribute("Different", cfg.mainDlg.viewFilterDefault.different); + + XmlOut actView = outDefFilter["Action"]; + actView.attribute("CreateLeft", cfg.mainDlg.viewFilterDefault.createLeft); + actView.attribute("CreateRight", cfg.mainDlg.viewFilterDefault.createRight); + actView.attribute("UpdateLeft", cfg.mainDlg.viewFilterDefault.updateLeft); + actView.attribute("UpdateRight", cfg.mainDlg.viewFilterDefault.updateRight); + actView.attribute("DeleteLeft", cfg.mainDlg.viewFilterDefault.deleteLeft); + actView.attribute("DeleteRight", cfg.mainDlg.viewFilterDefault.deleteRight); + actView.attribute("DoNothing", cfg.mainDlg.viewFilterDefault.doNothing); + + out["FolderHistory" ].attribute("MaxSize", cfg.folderHistoryMax); + + out["SftpKeyFile"].attribute("LastSelected", makePortablePath(cfg.sftpKeyFileLastSelected)); + + XmlOut outFileFilter = out["DefaultFilter"]; + writeConfig(cfg.defaultFilter, outFileFilter); + + out["VersioningFolderHistory"](cfg.versioningFolderHistory); + out["VersioningFolderHistory"].attribute("LastSelected", makePortablePath(cfg.versioningFolderLastSelected)); + + out["LogFolder"](makePortablePath(cfg.logFolderPhrase)); + out["LogFolderHistory"](makePortablePath(cfg.logFolderHistory)); + out["LogFolderHistory"].attribute("LastSelected", makePortablePath(cfg.logFolderLastSelected)); + + out["EmailHistory"](cfg.emailHistory); + out["EmailHistory"].attribute("MaxSize", cfg.emailHistoryMax); + + out["CommandHistory"](cfg.commandHistory); + out["CommandHistory"].attribute("MaxSize", cfg.commandHistoryMax); + + //external applications + out["ExternalApps"](cfg.externalApps); + + //last update check + out["LastOnlineCheck" ](cfg.lastUpdateCheck); + out["LastOnlineVersion"](cfg.lastOnlineVersion); + + out["WelcomeDialogVersion"](cfg.welcomeDialogLastVersion); + + + for (const auto& [scalePercent, layout] : cfg.dpiLayouts) + { + XmlOut outLayout = out["DpiLayouts"].addChild("Layout"); + outLayout.attribute("Scale", numberTo(scalePercent) + '%'); + + XmlOut outLayoutMain = outLayout["MainWindow"]; + if (layout.mainDlg.size) + { + outLayoutMain.attribute("Width", layout.mainDlg.size->x); + outLayoutMain.attribute("Height", layout.mainDlg.size->y); + } + if (layout.mainDlg.pos) + { + outLayoutMain.attribute("PosX", layout.mainDlg.pos->x); + outLayoutMain.attribute("PosY", layout.mainDlg.pos->y); + } + outLayoutMain.attribute("Maximized", layout.mainDlg.isMaximized); + + XmlOut outLayoutProgress = outLayout["ProgressDialog"]; + if (layout.progressDlg.size) + { + outLayoutProgress.attribute("Width", layout.progressDlg.size->x); + outLayoutProgress.attribute("Height", layout.progressDlg.size->y); + } + outLayoutProgress.attribute("Maximized", layout.progressDlg.isMaximized); + + outLayout["Panels" ](layout.panelLayout); + outLayout["ConfigPanel" ](layout.configColumnAttribs); + outLayout["OverviewPanel" ](layout.overviewColumnAttribs); + outLayout["FilePanelLeft" ](layout.fileColumnAttribsLeft); + outLayout["FilePanelRight"](layout.fileColumnAttribsRight); + } +} + + +template +void writeConfig(const ConfigType& cfg, const char* cfgType, int xmlFormatVer, const Zstring& filePath) +{ + XmlDoc doc("FreeFileSync"); + doc.root().setAttribute("XmlType", cfgType); + doc.root().setAttribute("XmlFormat", xmlFormatVer); + + XmlOut out(doc); + writeConfig(cfg, out); + + saveXml(doc, filePath); //throw FileError +} +} + +void fff::writeConfig(const FfsGuiConfig& cfg, const Zstring& filePath) +{ + ::writeConfig(cfg, "GUI", XML_FORMAT_SYNC_CFG, filePath); //throw FileError +} + + +void fff::writeConfig(const FfsBatchConfig& cfg, const Zstring& filePath) +{ + ::writeConfig(cfg, "BATCH", XML_FORMAT_SYNC_CFG, filePath); //throw FileError +} + + +void fff::writeConfig(const GlobalConfig& cfg, const Zstring& filePath) +{ + ::writeConfig(cfg, "GLOBAL", XML_FORMAT_GLOBAL_CFG, filePath); //throw FileError +} + + +std::wstring fff::extractJobName(const Zstring& cfgFilePath) +{ + const Zstring fileName = getItemName(cfgFilePath); + const Zstring jobName = beforeLast(fileName, Zstr('.'), IfNotFoundReturn::all); + return utfTo(jobName); +} + + +std::string fff::serializeFilter(const FilterConfig& filterCfg) +{ + XmlDoc doc("Filter"); + doc.setEncoding(""); + + XmlOut out(doc); + ::writeConfig(filterCfg, out); + + return serializeXml(doc); //noexcept +} + + +std::optional fff::parseFilterBuf(const std::string& filterBuf) +{ + try + { + XmlDoc doc = parseXml(filterBuf); //throw XmlParsingError + XmlIn in(doc); + + FilterConfig filterCfg; + ::readConfig(in, filterCfg); + if (in.getErrors().empty()) + return filterCfg; + } + catch (XmlParsingError&) {} + + return std::nullopt; +} + + +void fff::saveErrorLog(const ErrorLog& log, const Zstring& filePath) //throw FileError +{ + XmlDoc doc("Log"); + doc.setEncoding(""); + + XmlOut out(doc); + + for (const LogEntry& e : log) + { + XmlOut outMsg = out.addChild(e.type == MessageType::MSG_TYPE_ERROR ? "Error" : (e.type == MessageType::MSG_TYPE_WARNING ? "Warning" : "Info")); + outMsg.attribute("Time", formatTime(formatIsoDateTimeTag, getLocalTime(e.time))); + outMsg(e.message); + } + + saveXml(doc, filePath); //throw FileError +} + + +ErrorLog fff::loadErrorLog(const Zstring& filePath) //throw FileError +{ + XmlDoc doc = loadXml(filePath); //throw FileError + + XmlIn in(doc); + ErrorLog log; + + in.visitChildren([&](const XmlIn& inMsg) + { + Zstring timeStr; + inMsg.attribute("Time", timeStr); + + Zstringc msg; + inMsg(msg); + + log.push_back( + { + .time = localToTimeT(parseTime(formatIsoDateTimeTag, timeStr)).first, + .type = *inMsg.getName() == "Error" ? MessageType::MSG_TYPE_ERROR : (*inMsg.getName() == "Warning" ? MessageType::MSG_TYPE_WARNING : MessageType::MSG_TYPE_INFO), + .message = std::move(msg), + }); + }); + + if (const std::wstring& errors = in.getErrors(); + !errors.empty()) + throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(filePath)), + _("The following XML elements could not be read:") + L'\n' + errors); + return log; +} diff --git a/FreeFileSync/Source/config.h b/FreeFileSync/Source/config.h new file mode 100644 index 0000000..99d918a --- /dev/null +++ b/FreeFileSync/Source/config.h @@ -0,0 +1,283 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PROCESS_XML_H_28345825704254262435 +#define PROCESS_XML_H_28345825704254262435 + +#include +#include +#include +#include "localization.h" +#include "log_file.h" +#include "base/structures.h" +#include "ui/file_grid_attr.h" +#include "ui/tree_grid_attr.h" //RTS: avoid tree grid's "file_hierarchy.h" dependency! +#include "ui/cfg_grid.h" + + +namespace fff +{ +enum class BatchErrorHandling +{ + showPopup, + cancel +}; + + +enum class PostBatchAction +{ + none, + sleep, + shutdown +}; + +struct ExternalApp +{ + std::wstring description; //must be translated *after* loading from config file + Zstring cmdLine; +}; + +extern const ExternalApp extCommandFileManager; +extern const ExternalApp extCommandOpenDefault; + +//--------------------------------------------------------------------- +struct FfsGuiConfig +{ + MainConfiguration mainCfg; + + //"GuiExclusiveConfig": + std::wstring notes; + GridViewType gridViewType = GridViewType::action; //keep "visual" setting out of "MainConfiguration" + + bool operator==(const FfsGuiConfig&) const = default; +}; + + +struct BatchExclusiveConfig +{ + bool runMinimized = false; + bool autoCloseSummary = false; + BatchErrorHandling batchErrorHandling = BatchErrorHandling::showPopup; + PostBatchAction postBatchAction = PostBatchAction::none; +}; + + +struct FfsBatchConfig +{ + FfsGuiConfig guiCfg; //batch config can be used in GUI (but not the other way round) + BatchExclusiveConfig batchExCfg; +}; + + +struct ConfirmationDialogs +{ + bool confirmSaveConfig = true; + bool confirmSyncStart = true; + bool confirmCommandMassInvoke = true; + bool confirmSwapSides = true; + + bool operator==(const ConfirmationDialogs&) const = default; +}; + + +enum class GridIconSize +{ + small, + medium, + large +}; + + +struct ViewFilterDefault +{ + //shared + bool equal = false; + bool conflict = true; + bool excluded = false; + //difference view + bool leftOnly = true; + bool rightOnly = true; + bool leftNewer = true; + bool rightNewer = true; + bool different = true; + //action view + bool createLeft = true; + bool createRight = true; + bool updateLeft = true; + bool updateRight = true; + bool deleteLeft = true; + bool deleteRight = true; + bool doNothing = true; +}; + + +Zstring getGlobalConfigDefaultPath(); +Zstring getLogFolderDefaultPath(); + +struct DpiLayout +{ + struct + { + std::optional size; + std::optional pos; + bool isMaximized = false; + } mainDlg; //WindowLayout::getBeforeClose() + + struct + { + std::optional size; + //std::optional pos; -> most users probably want it centered, but others at a fixed (relative??) location + bool isMaximized = false; + } progressDlg; + + wxString panelLayout; //for wxAuiManager::LoadPerspective + + std::vector configColumnAttribs = getCfgGridDefaultColAttribs(); + std::vector overviewColumnAttribs = getOverviewDefaultColAttribs(); + std::vector fileColumnAttribsLeft = getFileGridDefaultColAttribsLeft(); + std::vector fileColumnAttribsRight = getFileGridDefaultColAttribsRight(); +}; + + +struct GlobalConfig +{ + GlobalConfig(); + + //--------------------------------------------------------------------- + //Shared (GUI/BATCH) settings + wxLanguage programLanguage = getDefaultLanguage(); + zen::ColorTheme appColorTheme = zen::ColorTheme::System; + bool failSafeFileCopy = true; + bool copyLockedFiles = false; //safer default: avoid copies of partially written files + bool copyFilePermissions = false; + + unsigned int fileTimeTolerance = zen::FAT_FILE_TIME_PRECISION_SEC; //default 2s: FAT vs NTFS + bool runWithBackgroundPriority = false; + bool createLockFile = true; + bool verifyFileCopy = false; + int logfilesMaxAgeDays = 30; //<= 0 := no limit; for log files under %AppData%\FreeFileSync\Logs + LogFileFormat logFormat = LogFileFormat::html; + + Zstring soundFileCompareFinished; + Zstring soundFileSyncFinished; + Zstring soundFileAlertPending; + + ConfirmationDialogs confirmDlgs; + WarningDialogs warnDlgs; + + //--------------------------------------------------------------------- + + struct + { + bool textSearchRespectCase = false; //good default for Linux, too! + int folderPairsVisibleMax = 6; + + struct + { + size_t topRowPos = 0; + int syncOverdueDays = 7; + ColumnTypeCfg lastSortColumn = cfgGridLastSortColumnDefault; + bool lastSortAscending = getDefaultSortDirection(cfgGridLastSortColumnDefault); + size_t histItemsMax = 100; //do we need to limit config items at all? + Zstring lastSelectedFile; + std::vector fileHistory; + std::vector lastUsedFiles; + } config; + + struct + { + bool showPercentBar = overviewPanelShowPercentageDefault; + ColumnTypeOverview lastSortColumn = overviewPanelLastSortColumnDefault; //remember sort on overview panel + bool lastSortAscending = getDefaultSortDirection(overviewPanelLastSortColumnDefault); // + } overview; + + struct + { + bool keepRelPaths = false; + bool overwriteIfExists = false; + Zstring targetFolderPath; + Zstring targetFolderLastSelected; + std::vector folderHistory; + } copyToCfg; + + std::vector folderHistoryLeft; + std::vector folderHistoryRight; + Zstring folderLastSelectedLeft; + Zstring folderLastSelectedRight; + + bool showIcons = true; + GridIconSize iconSize = GridIconSize::small; + int sashOffset = 0; + + ItemPathFormat itemPathFormatLeftGrid = defaultItemPathFormatLeftGrid; + ItemPathFormat itemPathFormatRightGrid = defaultItemPathFormatRightGrid; + + ViewFilterDefault viewFilterDefault; + } mainDlg; + + bool progressDlgAutoClose = false; + + FilterConfig defaultFilter = [] + { + FilterConfig def; + assert(def.excludeFilter.empty()); + def.excludeFilter = + "*/.Trash-*/" "\n" + "*/.recycle/"; + return def; + }(); + + size_t folderHistoryMax = 20; + + Zstring sftpKeyFileLastSelected; + + std::vector versioningFolderHistory; + Zstring versioningFolderLastSelected; + + Zstring logFolderPhrase = getLogFolderDefaultPath(); + std::vector logFolderHistory; + Zstring logFolderLastSelected; + + std::vector emailHistory; + size_t emailHistoryMax = 10; + + std::vector commandHistory; + size_t commandHistoryMax = 10; + + std::vector externalApps{extCommandFileManager, extCommandOpenDefault}; + + time_t lastUpdateCheck = 0; //number of seconds since Jan 1, 1970 GMT + std::string lastOnlineVersion; + + std::string welcomeDialogLastVersion; + + std::unordered_map dpiLayouts; +}; + +//read/write specific config types +std::pair readGuiConfig (const Zstring& filePath); // +std::pair readBatchConfig (const Zstring& filePath); //throw FileError +std::pair readGlobalConfig(const Zstring& filePath); // + +void writeConfig(const FfsGuiConfig& cfg, const Zstring& filePath); // +void writeConfig(const FfsBatchConfig& cfg, const Zstring& filePath); //throw FileError +void writeConfig(const GlobalConfig& cfg, const Zstring& filePath); // + +//convert (multiple) *.ffs_gui, *.ffs_batch files or combinations of both into target config structure: +std::pair readAnyConfig(const std::vector& filePaths); //throw FileError + + +std::wstring extractJobName(const Zstring& cfgFilePath); + +//human-readable/editable format suitable for clipboard +std::string serializeFilter(const FilterConfig& filterCfg); +std::optional parseFilterBuf(const std::string& filterBuf); + +void saveErrorLog(const zen::ErrorLog& log, const Zstring& filePath); //throw FileError +zen::ErrorLog loadErrorLog(const Zstring& filePath); //throw FileError +} + +#endif //PROCESS_XML_H_28345825704254262435 diff --git a/FreeFileSync/Source/ffs_paths.cpp b/FreeFileSync/Source/ffs_paths.cpp new file mode 100644 index 0000000..01a9a69 --- /dev/null +++ b/FreeFileSync/Source/ffs_paths.cpp @@ -0,0 +1,97 @@ +// ***************************************************************************** +// * 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 "ffs_paths.h" +#include +#include + + #include //std::cerr + +using namespace zen; + + +namespace +{ +Zstring getProcessParentFolderPath() +{ + //buffer getSymlinkResolvedPath()! + //note: compiler generates magic-statics code => fine, we don't expect accesses during shutdown => don't need FunStatGlobal<> + static const Zstring exeFolderParentPath = [] + { + try + { + const Zstring& processPath = getProcessPath(); //throw FileError + /* no need for getSymlinkResolvedPath(): + => support file systems with buggy GetFinalPathNameByHandle() implementation, e.g. Dokany-based: https://freefilesync.org/forum/viewtopic.php?t=8828 + => we're already supporting calling FFS via symlink for launcher executable, which guarantees: */ + assert(getItemType( processPath) != ItemType::symlink); //throw FileError + assert(getItemType(*getParentFolderPath(processPath)) != ItemType::symlink); //throw FileError + + return *getParentFolderPath(*getParentFolderPath(processPath)); //no parent folder!!? => let it crash! + } + catch (const FileError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to get process parent folder. " + utfTo(e.toString())); + } + }(); + return exeFolderParentPath; +} +} + + +Zstring fff::getInstallDirPath() +{ + return getProcessParentFolderPath(); + +} + + + + +Zstring fff::getResourceDirPath() +{ + return appendPath(getProcessParentFolderPath(), Zstr("Resources")); +} + + +Zstring fff::getConfigDirPath() +{ + //note: compiler generates magic-statics code => fine, we don't expect accesses during shutdown + static const Zstring ffsConfigPath = [] + { + /* Windows: %AppData%\FreeFileSync + macOS: ~/Library/Application Support/FreeFileSync + Linux (XDG layout): ~/.config/FreeFileSync */ + const Zstring& configPath = [] + { + try + { + return appendPath(getUserDataPath(), Zstr("FreeFileSync")); //throw FileError + } + catch (const FileError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to get config path. " + utfTo(e.toString())); + } + }(); + + try + { + createDirectoryIfMissingRecursion(configPath); //throw FileError + } + catch (const FileError& e) { logExtraError(e.toString()); } + + return configPath; + }(); + return ffsConfigPath; +} + + +//this function is called by RealTimeSync!!! +Zstring fff::getFreeFileSyncLauncherPath() //throw FileError +{ + return appendPath(getInstallDirPath(), Zstr("FreeFileSync")); + +} diff --git a/FreeFileSync/Source/ffs_paths.h b/FreeFileSync/Source/ffs_paths.h new file mode 100644 index 0000000..6cf122f --- /dev/null +++ b/FreeFileSync/Source/ffs_paths.h @@ -0,0 +1,29 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FFS_PATHS_H_842759083425342534253 +#define FFS_PATHS_H_842759083425342534253 + +#include + + +namespace fff +{ +//------------------------------------------------------------------------------ +//global program directories +//------------------------------------------------------------------------------ +Zstring getResourceDirPath(); +Zstring getConfigDirPath(); +//------------------------------------------------------------------------------ + + +Zstring getInstallDirPath(); + +Zstring getFreeFileSyncLauncherPath(); //throw FileError +//full path to application launcher C:\...\FreeFileSync.exe +} + +#endif //FFS_PATHS_H_842759083425342534253 diff --git a/FreeFileSync/Source/icon_buffer.cpp b/FreeFileSync/Source/icon_buffer.cpp new file mode 100644 index 0000000..9502fcf --- /dev/null +++ b/FreeFileSync/Source/icon_buffer.cpp @@ -0,0 +1,467 @@ +// ***************************************************************************** +// * 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 "icon_buffer.h" +#include +#include +#include //includes +#include +#include +#include +#include "base/icon_loader.h" + + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace +{ +const size_t BUFFER_SIZE_MAX = 1000; //maximum number of icons to hold in buffer: must be big enough to hold visible icons + preload buffer! + + +} + +//################################################################################################################################################ + +std::variant getDisplayIcon(const AbstractPath& itemPath, IconBuffer::IconSize sz) +{ + //1. try to load thumbnails + switch (sz) + { + case IconBuffer::IconSize::small: + break; + case IconBuffer::IconSize::medium: + case IconBuffer::IconSize::large: + try + { + if (ImageHolder ih = AFS::getThumbnailImage(itemPath, IconBuffer::getPixSize(sz))) //throw FileError; optional return value + return ih; + } + catch (FileError&) {} + + //else: fallback to non-thumbnail icon + break; + } + + //2. retrieve file icons + try + { + if (FileIconHolder fih = AFS::getFileIcon(itemPath, IconBuffer::getPixSize(sz))) //throw FileError; optional return value + return fih; + } + catch (FileError&) {} + + //run getIconByTemplatePath()/genericFileIcon() fallbacks on main thread: + //extractWxImage() might fail if icon theme is missing a MIME type! + return ImageHolder(); +} + +//################################################################################################################################################ + +//---------------------- Shared Data ------------------------- +class WorkLoad +{ +public: + //context of main thread + void set(const std::vector& newLoad) + { + assert(runningOnMainThread()); + { + std::lock_guard dummy(lockFiles_); + workLoad_ = newLoad; + } + conditionNewWork_.notify_all(); //instead of notify_one(); work around bug: https://svn.boost.org/trac/boost/ticket/7796 + //condition handling, see: https://www.boost.org/doc/libs/1_43_0/doc/html/thread/synchronization.html#thread.synchronization.condvar_ref + } + + void add(const AbstractPath& filePath) //context of main thread + { + assert(runningOnMainThread()); + { + std::lock_guard dummy(lockFiles_); + workLoad_.emplace_back(filePath); //set as next item to retrieve + } + conditionNewWork_.notify_all(); + } + + //context of worker thread, blocking: + AbstractPath extractNext() //throw ThreadStopRequest + { + assert(!runningOnMainThread()); + std::unique_lock dummy(lockFiles_); + + interruptibleWait(conditionNewWork_, dummy, [this] { return !workLoad_.empty(); }); //throw ThreadStopRequest + + AbstractPath filePath = workLoad_. back(); //yes, no strong exception guarantee (std::bad_alloc) + /**/ workLoad_.pop_back(); // + return filePath; + } + +private: + //AbstractPath is thread-safe like an int! + std::mutex lockFiles_; + std::condition_variable conditionNewWork_; //signal event: data for processing available + std::vector workLoad_; //processes last elements of vector first! +}; + + +class Buffer +{ +public: + //called by main and worker thread: + bool hasIcon(const AbstractPath& filePath) const + { + std::lock_guard dummy(lockIconList_); + return iconList.contains(filePath); + } + + //- must be called by main thread only! => wxImage is NOT thread-safe like an int (non-atomic ref-count!!!) + //- check wxImage::IsOk() + implement fallback if needed + std::optional retrieve(const AbstractPath& filePath) + { + assert(runningOnMainThread()); + std::lock_guard dummy(lockIconList_); + + auto it = iconList.find(filePath); + if (it == iconList.end()) + return {}; + + markAsHot(it); + + IconData& idata = refData(it); + + if (ImageHolder* ih = std::get_if(&idata.iconHolder)) + { + if (*ih) //if not yet converted... + { + idata.iconImg = std::make_unique(extractWxImage(std::move(*ih))); //convert in main thread! + assert(!*ih); + } + } + else + { + if (FileIconHolder& fih = std::get(idata.iconHolder)) //if not yet converted... + { + idata.iconImg = std::make_unique(extractWxImage(std::move(fih))); //convert in main thread! + assert(!fih); + //!idata.iconImg->IsOk(): extractWxImage() might fail if icon theme is missing a MIME type! + } + } + + return idata.iconImg ? *idata.iconImg : wxNullImage; //idata.iconHolder may be inserted as empty from worker thread! + } + + //called by main and worker thread: + void insert(const AbstractPath& filePath, std::variant&& ih) + { + std::lock_guard dummy(lockIconList_); + + //thread safety: moving ImageHolder is free from side effects, but ~wxImage() is NOT! => do NOT delete items from iconList here! + const auto [it, inserted] = iconList.try_emplace(filePath); + assert(inserted); + if (inserted) + { + refData(it).iconHolder = std::move(ih); + priorityListPushBack(it); + } + } + + //must be called by main thread only! => ~wxImage() is NOT thread-safe! + //call at an appropriate time, e.g. after Workload::set() + void limitSize() + { + assert(runningOnMainThread()); + std::lock_guard dummy(lockIconList_); + + while (iconList.size() > BUFFER_SIZE_MAX) + { + auto itDelPos = firstInsertPos_; + priorityListPopFront(); + iconList.erase(itDelPos); //remove oldest element + } + } + +private: + struct IconData; + using FileIconMap = std::map; + IconData& refData(FileIconMap::iterator it) { return it->second; } + + //call while holding lock: + void priorityListPopFront() + { + assert(firstInsertPos_!= iconList.end()); + firstInsertPos_ = refData(firstInsertPos_).next; + + if (firstInsertPos_ != iconList.end()) + refData(firstInsertPos_).prev = iconList.end(); + else //priority list size > BUFFER_SIZE_MAX in this context, but still for completeness: + lastInsertPos_ = iconList.end(); + } + + //call while holding lock: + void priorityListPushBack(FileIconMap::iterator it) + { + if (lastInsertPos_ == iconList.end()) + { + assert(firstInsertPos_ == iconList.end()); + firstInsertPos_ = lastInsertPos_ = it; + refData(it).prev = refData(it).next = iconList.end(); + } + else + { + refData(it).next = iconList.end(); + refData(it).prev = lastInsertPos_; + refData(lastInsertPos_).next = it; + lastInsertPos_ = it; + } + } + + //call while holding lock: + void markAsHot(FileIconMap::iterator it) //mark existing buffer entry as if newly inserted + { + assert(it != iconList.end()); + if (refData(it).next != iconList.end()) + { + if (refData(it).prev != iconList.end()) + { + refData(refData(it).prev).next = refData(it).next; //remove somewhere from the middle + refData(refData(it).next).prev = refData(it).prev; // + } + else + { + assert(it == firstInsertPos_); + priorityListPopFront(); + } + priorityListPushBack(it); + } + else + { + if (refData(it).prev != iconList.end()) + assert(it == lastInsertPos_); //nothing to do + else + assert(iconList.size() == 1 && it == firstInsertPos_ && it == lastInsertPos_); //nothing to do + } + } + + struct IconData + { + IconData() {} + IconData(IconData&& tmp) noexcept : iconHolder(std::move(tmp.iconHolder)), iconImg(std::move(tmp.iconImg)), prev(tmp.prev), next(tmp.next) {} + + std::variant iconHolder; //native icon representation: may be used by any thread + + std::unique_ptr iconImg; //use ONLY from main thread! + //wxImage is NOT thread-safe: non-atomic ref-count just to begin with... + //- prohibit implicit calls to wxImage() + //- prohibit calls to ~wxImage() and transitively ~IconData() + //- prohibit even wxImage() default constructor - better be safe than sorry! + + FileIconMap::iterator prev; //store list sorted by time of insertion into buffer + FileIconMap::iterator next; // + }; + + mutable std::mutex lockIconList_; + FileIconMap iconList; //shared resource; Zstring is thread-safe like an int + FileIconMap::iterator firstInsertPos_ = iconList.end(); + FileIconMap::iterator lastInsertPos_ = iconList.end(); +}; + +//################################################################################################################################################ + + +//######################### redirect to impl ##################################################### + +struct IconBuffer::Impl +{ + //communication channel used by threads: + WorkLoad workload; //manage life time: enclose InterruptibleThread's (until joined)!!! + Buffer buffer; // + + InterruptibleThread worker; + //------------------------- + //------------------------- + std::unordered_map extensionIcons; //no item count limit!? Test case C:\ ~ 3800 unique file extensions +}; + + +IconBuffer::IconBuffer(IconSize sz) : pimpl_(std::make_unique()), iconSizeType_(sz) +{ + pimpl_->worker = InterruptibleThread([&workload = pimpl_->workload, &buffer = pimpl_->buffer, sz] + { + setCurrentThreadName(Zstr("Icon Buffer")); + + for (;;) + { + //start work: blocks until next icon to load is retrieved: + const AbstractPath itemPath = workload.extractNext(); //throw ThreadStopRequest + + if (!buffer.hasIcon(itemPath)) //perf: workload may contain duplicate entries? + buffer.insert(itemPath, getDisplayIcon(itemPath, sz)); + } + }); +} + + +IconBuffer::~IconBuffer() +{ + setWorkload({}); //make sure interruption point is always reached! needed??? + pimpl_->worker.requestStop(); //end thread life time *before* + pimpl_->worker.join(); //IconBuffer::Impl member clean up! +} + + +int IconBuffer::getPixSize(IconSize sz) +{ + //coordinate with getIconByIndexImpl() and linkOverlayIcon()! + switch (sz) + { + case IconSize::small: + return dipToScreen(getMenuIconDipSize()); + case IconSize::medium: + return dipToScreen(48); + case IconSize::large: + return dipToScreen(128); + } + assert(false); + return 0; +} + + +bool IconBuffer::readyForRetrieval(const AbstractPath& filePath) +{ + return pimpl_->buffer.hasIcon(filePath); +} + + +std::optional IconBuffer::retrieveFileIcon(const AbstractPath& filePath) +{ + const Zstring fileName = AFS::getItemName(filePath); + if (std::optional ico = pimpl_->buffer.retrieve(filePath)) + { + if (ico->IsOk()) + return ico; + else //fallback + return this->getIconByExtension(fileName); //buffered! + } + + //since this icon seems important right now, we don't want to wait until next setWorkload() to start retrieving + pimpl_->workload.add(filePath); + pimpl_->buffer.limitSize(); + return {}; +} + + +void IconBuffer::setWorkload(const std::vector& load) +{ + assert(load.size() < BUFFER_SIZE_MAX / 2); + + pimpl_->workload.set(load); //since buffer can only increase due to new workload, + pimpl_->buffer.limitSize(); //this is the place to impose the limit from main thread! +} + + +wxImage IconBuffer::getIconByExtension(const Zstring& filePath) +{ + const Zstring& ext = getFileExtension(filePath); + + assert(runningOnMainThread()); + + auto it = pimpl_->extensionIcons.find(ext); + if (it == pimpl_->extensionIcons.end()) + { + const Zstring& templateName(ext.empty() ? Zstr("file") : Zstr("file.") + ext); + //don't pass actual file name to getIconByTemplatePath(), e.g. "AUTHORS" has own mime type on Linux!!! + //=> buffer by extension to minimize buffer-misses! + + wxImage img; + try + { + img = extractWxImage(getIconByTemplatePath(templateName, getPixSize(iconSizeType_))); //throw SysError + } + catch (SysError&) {} + if (!img.IsOk()) //Linux: not all MIME types have icons! + img = IconBuffer::genericFileIcon(iconSizeType_); + + it = pimpl_->extensionIcons.emplace(ext, img).first; + } + //need buffer size limit??? + return it->second; +} + + +wxImage IconBuffer::genericFileIcon(IconSize sz) +{ + try + { + return extractWxImage(fff::genericFileIcon(IconBuffer::getPixSize(sz))); //throw SysError + } + catch (SysError&) { assert(false); return wxNullImage; } +} + + +wxImage IconBuffer::genericDirIcon(IconSize sz) +{ + try + { + return extractWxImage(fff::genericDirIcon(IconBuffer::getPixSize(sz))); //throw SysError + } + catch (SysError&) { assert(false); return wxNullImage; } +} + + +wxImage IconBuffer::linkOverlayIcon(IconSize sz) +{ + //coordinate with IconBuffer::getPixSize()! + return loadImage([sz] + { + const int iconSize = IconBuffer::getPixSize(sz); + + if (iconSize >= dipToScreen(128)) return "file_link_128"; + if (iconSize >= dipToScreen( 48)) return "file_link_48"; + if (iconSize >= dipToScreen( 20)) return "file_link_20"; + return "file_link_16"; + }()); +} + + +wxImage IconBuffer::plusOverlayIcon(IconSize sz) +{ + //coordinate with IconBuffer::getPixSize()! + return loadImage([sz] + { + const int iconSize = IconBuffer::getPixSize(sz); + + if (iconSize >= dipToScreen(128)) return "file_plus_128"; + if (iconSize >= dipToScreen( 48)) return "file_plus_48"; + if (iconSize >= dipToScreen( 20)) return "file_plus_20"; + return "file_plus_16"; + }()); +} + + +wxImage IconBuffer::minusOverlayIcon(IconSize sz) +{ + //coordinate with IconBuffer::getPixSize()! + return loadImage([sz] + { + const int iconSize = IconBuffer::getPixSize(sz); + + if (iconSize >= dipToScreen(128)) return "file_minus_128"; + if (iconSize >= dipToScreen( 48)) return "file_minus_48"; + if (iconSize >= dipToScreen( 20)) return "file_minus_20"; + return "file_minus_16"; + }()); +} + + +bool fff::hasLinkExtension(const Zstring& filepath) +{ + const Zstring& ext = getFileExtension(filepath); + return ext == "desktop"; + +} diff --git a/FreeFileSync/Source/icon_buffer.h b/FreeFileSync/Source/icon_buffer.h new file mode 100644 index 0000000..cb66508 --- /dev/null +++ b/FreeFileSync/Source/icon_buffer.h @@ -0,0 +1,57 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ICON_BUFFER_H_8425703245726394256 +#define ICON_BUFFER_H_8425703245726394256 + +#include +#include +#include +#include +#include "afs/abstract.h" + + +namespace fff +{ +class IconBuffer +{ +public: + enum class IconSize + { + small, + medium, + large + }; + + explicit IconBuffer(IconSize sz); + ~IconBuffer(); + + static int getPixSize(IconSize sz); //expected and *maximum* icon size in pixel + int getPixSize() const { return getPixSize(iconSizeType_); } // + + void setWorkload (const std::vector& load); //(re-)set new workload of icons to be retrieved; + bool readyForRetrieval(const AbstractPath& filePath); + std::optional retrieveFileIcon (const AbstractPath& filePath); //... and mark as hot + wxImage getIconByExtension(const Zstring& filePath); //...and add to buffer + //retrieveFileIcon() + getIconByExtension() are safe to call from within WM_PAINT handler! no COM calls (...on calling thread) + + static wxImage genericFileIcon (IconSize sz); + static wxImage genericDirIcon (IconSize sz); + static wxImage linkOverlayIcon (IconSize sz); + static wxImage plusOverlayIcon (IconSize sz); + static wxImage minusOverlayIcon(IconSize sz); + +private: + struct Impl; + const std::unique_ptr pimpl_; + + const IconSize iconSizeType_; +}; + +bool hasLinkExtension(const Zstring& filepath); +} + +#endif //ICON_BUFFER_H_8425703245726394256 diff --git a/FreeFileSync/Source/localization.cpp b/FreeFileSync/Source/localization.cpp new file mode 100644 index 0000000..37ccd83 --- /dev/null +++ b/FreeFileSync/Source/localization.cpp @@ -0,0 +1,451 @@ +// ***************************************************************************** +// * 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 "localization.h" +#include //setlocale +#include +#include +#include +#include +#include +#include "parse_lng.h" + +using namespace zen; +using namespace fff; + + +namespace +{ +class FFSTranslation : public TranslationHandler +{ +public: + FFSTranslation(const std::string& lngStream, bool haveRtlLayout); //throw lng::ParsingError, plural::ParsingError + + std::wstring translate(const std::wstring& text) const override + { + //look for translation in buffer table + auto it = transMapping_.find(text); + if (it != transMapping_.end() && !it->second.empty()) + return it->second; + return text; //fallback + } + + std::wstring translate(const std::wstring& singular, const std::wstring& plural, int64_t n) const override + { + auto it = transMappingPl_.find({singular, plural}); + if (it != transMappingPl_.end()) + { + const size_t formNo = pluralParser_->getForm(n); + assert(formNo < it->second.size()); + if (formNo < it->second.size()) + return replaceCpy(it->second[formNo], L"%x", formatNumber(n)); + } + return replaceCpy(std::abs(n) == 1 ? singular : plural, L"%x", formatNumber(n)); //fallback + } + + bool layoutIsRtl() const override { return haveRtlLayout_; } + +private: + using Translation = std::unordered_map; //hash_map is 15% faster than std::map on GCC + using TranslationPlural = std::map, std::vector>; + + Translation transMapping_; //map original text |-> translation + TranslationPlural transMappingPl_; + std::optional pluralParser_; //bound! + const bool haveRtlLayout_; +}; + + +FFSTranslation::FFSTranslation(const std::string& lngStream, bool haveRtlLayout) ://throw lng::ParsingError, plural::ParsingError + haveRtlLayout_(haveRtlLayout) +{ + lng::TransHeader header; + lng::TranslationMap transUtf; + lng::TranslationPluralMap transPluralUtf; + lng::parseLng(lngStream, header, transUtf, transPluralUtf); //throw ParsingError + + pluralParser_.emplace(header.pluralDefinition); //throw plural::ParsingError + + for (const auto& [original, translation] : transUtf) + transMapping_.emplace(utfTo(original), + utfTo(translation)); + + for (const auto& [singAndPlural, pluralForms] : transPluralUtf) + { + std::vector transPluralForms; + for (const std::string& pf : pluralForms) + transPluralForms.push_back(utfTo(pf)); + + transMappingPl_.insert({{ + utfTo(singAndPlural.first), + utfTo(singAndPlural.second) + }, + std::move(transPluralForms)}); + } +} + + +std::vector loadTranslations(const Zstring& zipPath) //throw FileError +{ + std::vector> streams; + [&] + { + std::string rawStream; + try //to load from ZIP first: + { + rawStream = getFileContent(zipPath, nullptr /*notifyUnbufferedIO*/); //throw FileError + } + catch (FileError&) //fall back to folder: dev build (only!?) + { + const Zstring fallbackFolder = beforeLast(zipPath, Zstr(".zip"), IfNotFoundReturn::none); + if (!itemExists(fallbackFolder)) //throw FileError + throw; + + traverseFolder(fallbackFolder, [&](const FileInfo& fi) + { + if (endsWith(fi.fullPath, Zstr(".lng"))) + { + std::string stream = getFileContent(fi.fullPath, nullptr /*notifyUnbufferedIO*/); //throw FileError + streams.emplace_back(fi.fullPath, std::move(stream)); + } + }, nullptr, nullptr); //throw FileError + return; + } + //------------------------------------------------------------- + + wxMemoryInputStream byteStream(rawStream.c_str(), rawStream.size()); //does not take ownership + wxZipInputStream zipStream(byteStream, wxConvUTF8); + + while (const auto& entry = std::unique_ptr(zipStream.GetNextEntry())) //take ownership! + { + if (entry->IsDir()) //e.g. translators accidentally ZIPing "Languages" directory + throw FileError(replaceCpy(replaceCpy(L"ZIP file %x contains unexpected sub directory %y.", + L"%x", fmtPath(zipPath)), + L"%y", fmtPath(utfTo(entry->GetName())))); + + if (std::string stream(entry->GetSize(), '\0'); + zipStream.ReadAll(stream.data(), stream.size())) + streams.emplace_back(zipPath + Zstr(':') + utfTo(entry->GetName()), std::move(stream)); + else + assert(false); + } + }(); + //-------------------------------------------------------------------- + + std::vector translations + { + //default entry: + { + .languageID = wxLANGUAGE_ENGLISH_US, + .locale = "en_US", + .languageName = L"English", + .translatorName = L"Zenju", + .languageFlag = "flag_usa", + .lngFileName = Zstr(""), + .lngStream = "", + } + }; + + for (/*const*/ auto& [filePath, stream] : streams) + try + { + const lng::TransHeader lngHeader = lng::parseHeader(stream); //throw ParsingError + assert(!lngHeader.languageName .empty()); + assert(!lngHeader.translatorName.empty()); + assert(!lngHeader.locale .empty()); + assert(!lngHeader.flagFile .empty()); + + const wxLanguageInfo* lngInfo = wxUILocale::FindLanguageInfo(utfTo(lngHeader.locale)); + assert(lngInfo && lngInfo->CanonicalName == utfTo(lngHeader.locale)); + if (lngInfo) + translations.push_back( + { + .languageID = static_cast(lngInfo->Language), + .locale = lngHeader.locale, + .languageName = utfTo(lngHeader.languageName), + .translatorName = utfTo(lngHeader.translatorName), + .languageFlag = lngHeader.flagFile, + .lngFileName = filePath, + .lngStream = std::move(stream), + }); + } + catch (const lng::ParsingError& e) + { + throw FileError(replaceCpy(replaceCpy(replaceCpy(_("Error parsing file %x, row %y, column %z."), + L"%x", fmtPath(filePath)), + L"%y", formatNumber(e.row + 1)), + L"%z", formatNumber(e.col + 1)) + + L"\n\n" + e.msg); + } + + std::sort(translations.begin(), translations.end(), [](const TranslationInfo& lhs, const TranslationInfo& rhs) + { + return LessNaturalSort()(utfTo(lhs.languageName), + utfTo(rhs.languageName)); //"natural" sort: ignore case and diacritics + }); + return translations; +} + + +/* Some ISO codes are used by multiple wxLanguage IDs which can lead to incorrect mapping by wxUILocale::FindLanguageInfo()!!! + => Identify by description, e.g. "Chinese (Traditional)". The following IDs are affected: + - zh_TW: wxLANGUAGE_CHINESE_TAIWAN, wxLANGUAGE_CHINESE, wxLANGUAGE_CHINESE_TRADITIONAL_EXPLICIT + - en_GB: wxLANGUAGE_ENGLISH_UK, wxLANGUAGE_ENGLISH + - es_ES: wxLANGUAGE_SPANISH, wxLANGUAGE_SPANISH_SPAIN */ +wxLanguage mapLanguageDialect(wxLanguage lng) +{ + if (const wxString& canonicalName = wxUILocale::GetLanguageCanonicalName(lng); + !canonicalName.empty()) + { + assert(!contains(canonicalName, L'-')); + const std::string locale = beforeFirst(utfTo(canonicalName), '@', IfNotFoundReturn::all); //e.g. "sr_RS@latin"; see wxUILocale::InitLanguagesDB() + const std::string lngCode = beforeFirst(locale, '_', IfNotFoundReturn::all); + + if (lngCode == "zh") + { + if (lng == wxLANGUAGE_CHINESE) //wxWidgets assigns this to "zh_TW" for some reason + return wxLANGUAGE_CHINESE_CHINA; + + for (const char* l : {"zh_HK", "zh_MO", "zh_TW"}) + if (locale == l) + return wxLANGUAGE_CHINESE_TAIWAN; + + return wxLANGUAGE_CHINESE_CHINA; + } + + if (lngCode == "en") + { + if (lng == wxLANGUAGE_ENGLISH || //wxWidgets assigns this to "en_GB" for some reason + lng == wxLANGUAGE_ENGLISH_WORLD) + return wxLANGUAGE_ENGLISH_US; + + for (const char* l : {"en_US", "en_CA", "en_AS", "en_UM", "en_VI"}) + if (locale == l) + return wxLANGUAGE_ENGLISH_US; + + return wxLANGUAGE_ENGLISH_UK; + } + + if (lngCode == "nb" || lngCode == "nn") //wxLANGUAGE_NORWEGIAN_BOKMAL, wxLANGUAGE_NORWEGIAN_NYNORSK + return wxLANGUAGE_NORWEGIAN; + + if (locale == "pt_BR") + return wxLANGUAGE_PORTUGUESE_BRAZILIAN; + + //all other cases: map to primary language code + if (contains(locale, '_')) + if (const wxLanguageInfo* lngInfo2 = wxUILocale::FindLanguageInfo(utfTo(lngCode))) + return static_cast(lngInfo2->Language); + } + return lng; //including wxLANGUAGE_DEFAULT, wxLANGUAGE_UNKNOWN +} + + +//we need to interface with wxWidgets' translation handling for a few translations used in their internal source files +// => since there is no better API: dynamically generate a MO file and feed it to wxTranslation +class MemoryTranslationLoader : public wxTranslationsLoader +{ +public: + MemoryTranslationLoader(wxLanguage langId, std::map&& transMapping) : + canonicalName_(wxUILocale::GetLanguageCanonicalName(langId)) + { + assert(!canonicalName_.empty()); + static_assert(std::is_same_v, std::map>); //translations *must* be sorted in MO file! + + //https://www.gnu.org/software/gettext/manual/html_node/MO-Files.html + transMapping[""] = L"Content-Type: text/plain; charset=UTF-8\n"; + + const int headerSize = 7 * sizeof(uint32_t); + writeNumber(moBuf_, 0x950412de); //magic number + writeNumber(moBuf_, 0); //format version + writeNumber(moBuf_, transMapping.size()); //string count + writeNumber(moBuf_, headerSize); //string references offset: original + writeNumber(moBuf_, headerSize + (2 * sizeof(uint32_t)) * transMapping.size()); //string references offset: translation + writeNumber(moBuf_, 0); //size of hashing table + writeNumber(moBuf_, 0); //offset of hashing table + + const int stringsOffset = headerSize + 2 * (2 * sizeof(uint32_t)) * transMapping.size(); + std::string stringsList; + + for (const auto& [original, translation] : transMapping) + { + writeNumber(moBuf_, original.size()); //string length + writeNumber(moBuf_, stringsOffset + stringsList.size()); //string offset + stringsList.append(original.c_str(), original.size() + 1); //include 0-termination + } + + for (const auto& [original, translationW] : transMapping) + { + const auto& translation = utfTo(translationW); + writeNumber(moBuf_, translation.size()); //string length + writeNumber(moBuf_, stringsOffset + stringsList.size()); //string offset + stringsList.append(translation.c_str(), translation.size() + 1); //include 0-termination + } + + writeArray(moBuf_, stringsList.c_str(), stringsList.size()); + } + + wxMsgCatalog* LoadCatalog(const wxString& domain, const wxString& lang) override + { + //"lang" is NOT (exactly) what we return from GetAvailableTranslations(), but has a little "extra" + //e.g.: de_DE.WINDOWS-1252 ar.WINDOWS-1252 zh_TW.MacRoman + auto extractIsoLangCode = [](wxString langCode) { return beforeLast(langCode, L".", IfNotFoundReturn::all); }; + + if (equalAsciiNoCase(extractIsoLangCode(lang), extractIsoLangCode(canonicalName_))) + return wxMsgCatalog::CreateFromData(wxScopedCharBuffer::CreateNonOwned(moBuf_.ref().c_str(), moBuf_.ref().size()), domain); + + assert(false); + return nullptr; + } + + wxArrayString GetAvailableTranslations(const wxString& domain) const override + { + wxArrayString available; + available.push_back(canonicalName_); + return available; + } + +private: + const wxString canonicalName_; + MemoryStreamOut moBuf_; +}; + + +std::vector globalTranslations; +wxLanguage globalLang = wxLANGUAGE_UNKNOWN; +} + + +void fff::localizationInit(const Zstring& zipPath) //throw FileError +{ + /* wxLocale vs wxUILocale (since wxWidgets 3.1.6) + ------------------------------------------|-------------------- + calls setlocale() Windows, Linux, maCOS | Linux only + wxTranslations initialized | not initialized + + caveat: setlocale() calls on macOS lead to bugs: + - breaks wxWidgets file drag and drop! https://freefilesync.org/forum/viewtopic.php?t=8215 + - "under macOS C locale must not be changed, as doing this exposes bugs in the system": https://docs.wxwidgets.org/trunk/classwx_u_i_locale.html + + reproduce: - std::setlocale(LC_ALL, ""); + - double-click the app (*) + - drag and drop folder named "アアアア" + - wxFileDropTarget::OnDropFiles() called with empty file array! + + *) CAVEAT: context matters! this yields a different user-preferred locale than running Contents/MacOS/FreeFileSync_main!!! + e.g. 1. locale after wxLocale creation is "en_US" + 2. call std::setlocale(LC_ALL, ""): + a) app was double-clicked: locale is "C" => drag/drop FAILS! + b) run Contents/MacOS/FreeFileSync_main: locale is "en_US.UTF-8" => drag/drop works! */ + [[maybe_unused]] const bool rv = wxUILocale::UseDefault(); + assert(rv); + + //const char* currentLocale = std::setlocale(LC_ALL, nullptr); + + assert(!wxTranslations::Get()); + wxTranslations::Set(new wxTranslations() /*pass ownership*/); //implicitly done by wxLocale, but *not* wxUILocale + + //throw *after* mandatory initialization: setLanguage() requires wxTranslations::Get()! + + assert(globalTranslations.empty()); + globalTranslations = loadTranslations(zipPath); //throw FileError + + setLanguage(getDefaultLanguage()); //throw FileError +} + + +void fff::localizationCleanup() +{ + assert(!globalTranslations.empty()); +#if 0 //good place for clean up rather than some time during static destruction: is this an actual benefit??? + globalLang = wxLANGUAGE_UNKNOWN; + + setTranslator(nullptr); + + globalTranslations.clear(); +#endif +} + + +void fff::setLanguage(wxLanguage lng) //throw FileError +{ + if (globalLang == lng) + return; //support polling + + //(try to) retrieve language file + std::string lngStream; + Zstring lngFileName; + + for (const TranslationInfo& e : getAvailableTranslations()) + if (e.languageID == lng) + { + lngStream = e.lngStream; + lngFileName = e.lngFileName; + break; + } + + //load language file into buffer + if (lngStream.empty()) //if file stream is empty, texts will be English (US) by default + { + setTranslator(nullptr); + lng = wxLANGUAGE_ENGLISH_US; + } + else + try + { + bool haveRtlLayout = false; + if (const wxLanguageInfo* selLngInfo = wxUILocale::GetLanguageInfo(lng)) + haveRtlLayout = selLngInfo->LayoutDirection == wxLayout_RightToLeft; + + setTranslator(std::make_unique(lngStream, haveRtlLayout)); //throw lng::ParsingError, plural::ParsingError + } + catch (const lng::ParsingError& e) + { + throw FileError(replaceCpy(replaceCpy(replaceCpy(_("Error parsing file %x, row %y, column %z."), + L"%x", fmtPath(lngFileName)), + L"%y", formatNumber(e.row + 1)), + L"%z", formatNumber(e.col + 1)) + + L"\n\n" + e.msg); + } + catch (plural::ParsingError&) + { + throw FileError(L"Invalid plural form definition: " + fmtPath(lngFileName)); //user should never see this! + } + //------------------------------------------------------------ + + globalLang = lng; + + //add translation for wxWidgets-internal strings: + std::map transMapping = + { + {"&OK", _("OK")}, //for wxTextEntryDialog + {"&Cancel", _("Cancel")}, //=> shouldn't use accelerator keys! + }; + wxTranslations& wxtrans = *wxTranslations::Get(); //*assert* creation by localizationInit()! + wxtrans.SetLanguage(lng); //!= wxLocale's language, which could be wxLANGUAGE_DEFAULT + wxtrans.SetLoader(new MemoryTranslationLoader(lng, std::move(transMapping))); + [[maybe_unused]] const bool catalogAdded = wxtrans.AddCatalog(wxString()); + assert(catalogAdded || lng == wxLANGUAGE_ENGLISH_US); +} + + +const std::vector& fff::getAvailableTranslations() +{ + assert(!globalTranslations.empty()); //localizationInit() not called, or failed!? + return globalTranslations; +} + + +wxLanguage fff::getDefaultLanguage() +{ + static const wxLanguage defaultLng = mapLanguageDialect(static_cast(wxUILocale::GetSystemLanguage())); + //uses GetUserPreferredUILanguages() since wxWidgets 1.3.6, not GetUserDefaultUILanguage() anymore: + // https://github.com/wxWidgets/wxWidgets/blob/master/src/common/intl.cpp + return defaultLng; +} + + +wxLanguage fff::getLanguage() { return globalLang; } diff --git a/FreeFileSync/Source/localization.h b/FreeFileSync/Source/localization.h new file mode 100644 index 0000000..a701217 --- /dev/null +++ b/FreeFileSync/Source/localization.h @@ -0,0 +1,39 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef LOCALIZATION_H_8917342083178321534 +#define LOCALIZATION_H_8917342083178321534 + +#include +#include +#include + + +namespace fff +{ +struct TranslationInfo +{ + wxLanguage languageID = wxLANGUAGE_UNKNOWN; + std::string locale; + std::wstring languageName; + std::wstring translatorName; + std::string languageFlag; + Zstring lngFileName; + std::string lngStream; +}; +const std::vector& getAvailableTranslations(); + +wxLanguage getDefaultLanguage(); +wxLanguage getLanguage(); + +void setLanguage(wxLanguage lng); //throw FileError + +void localizationInit(const Zstring& zipPath); //throw FileError +void localizationCleanup(); //wxLocale crashes miserably on wxGTK when destructor runs during global cleanup => call in wxApp::OnExit +//"You should delete all wxWidgets object that you created by the time OnExit() finishes. In particular, do not destroy them from application class' destructor!" +} + +#endif //LOCALIZATION_H_8917342083178321534 diff --git a/FreeFileSync/Source/log_file.cpp b/FreeFileSync/Source/log_file.cpp new file mode 100644 index 0000000..64f6aad --- /dev/null +++ b/FreeFileSync/Source/log_file.cpp @@ -0,0 +1,673 @@ +// ***************************************************************************** +// * 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()); } +} diff --git a/FreeFileSync/Source/log_file.h b/FreeFileSync/Source/log_file.h new file mode 100644 index 0000000..1598eac --- /dev/null +++ b/FreeFileSync/Source/log_file.h @@ -0,0 +1,40 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GENERATE_LOGFILE_H_931726432167489732164 +#define GENERATE_LOGFILE_H_931726432167489732164 + +#include +#include "status_handler.h" +#include "afs/abstract.h" + + +namespace fff +{ +enum class LogFileFormat +{ + html, + text +}; + +Zstring generateLogFileName(LogFileFormat logFormat, const ProcessSummary& summary); + +void saveLogFile(const AbstractPath& logFilePath, //throw FileError, X + const ProcessSummary& summary, + const zen::ErrorLog& log, + int logfilesMaxAgeDays, + LogFileFormat logFormat, + const std::set& logsToKeepPaths, + const std::function& notifyStatus /*throw X*/); + +void sendLogAsEmail(const std::string& email, //throw FileError, X + const ProcessSummary& summary, + const zen::ErrorLog& log, + const AbstractPath& logFilePath, + const std::function& notifyStatus /*throw X*/); +} + +#endif //GENERATE_LOGFILE_H_931726432167489732164 diff --git a/FreeFileSync/Source/parse_lng.h b/FreeFileSync/Source/parse_lng.h new file mode 100644 index 0000000..c008855 --- /dev/null +++ b/FreeFileSync/Source/parse_lng.h @@ -0,0 +1,742 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PARSE_LNG_H_46794693622675638 +#define PARSE_LNG_H_46794693622675638 + +#include +#include +#include +#include "parse_plural.h" + + +namespace lng +{ +//singular forms +using TranslationMap = std::unordered_map; //orig |-> translation + +//plural forms +using SingularPluralPair = std::pair; //1 house | %x houses +using PluralForms = std::vector; //1 dom | 2 domy | %x domów +using TranslationPluralMap = std::unordered_map; //(sing/plu) |-> pluralforms + +struct TransHeader +{ + std::string languageName; //display name: "English (UK)" + std::string translatorName; //"Zenju" + std::string locale; //ISO 639 language code + (optional) ISO 3166 country code, e.g. "de", "en_GB", or "en_US" + std::string flagFile; //"england.png" + int pluralCount = 0; //2 + std::string pluralDefinition; //"n == 1 ? 0 : 1" +}; + +struct ParsingError +{ + std::wstring msg; + size_t row = 0; //starting with 0 + size_t col = 0; // +}; +TransHeader parseHeader(const std::string& byteStream); //throw ParsingError +void parseLng(const std::string& byteStream, TransHeader& header, TranslationMap& out, TranslationPluralMap& pluralOut); //throw ParsingError + +class TranslationUnorderedList; //unordered list of unique translation items +std::string generateLng(const TranslationUnorderedList& in, const TransHeader& header, bool untranslatedToTop); + + + + + + + + + + + + + + + + + + + +//--------------------------- implementation --------------------------- +} + +template<> struct std::hash +{ + size_t operator()(const lng::SingularPluralPair& str) const + { + zen::FNV1aHash hash2; //shut up "GCC: shadow declaration" + for (const char c : str.first ) hash2.add(c); + for (const char c : str.second) hash2.add(c); + return hash2.get(); + } +}; + +namespace lng +{ +class TranslationUnorderedList //unordered list of unique translation items +{ +public: + TranslationUnorderedList(TranslationMap&& transOld, TranslationPluralMap&& transPluralOld) : + transOld_(std::move(transOld)), transPluralOld_(std::move(transPluralOld)) {} + + void addItem(const std::string& orig) + { + if (!transUnique_.insert(orig).second) return; + auto it = transOld_.find(orig); + if (it != transOld_.end() && !it->second.empty()) //preserve old translation from .lng file if existing + sequence_.push_back(std::make_shared(std::make_pair(orig, it->second))); + else + sequence_.push_back(std::make_shared(std::make_pair(orig, std::string()))); + } + + void addItem(const SingularPluralPair& orig) + { + if (!pluralUnique_.insert(orig).second) return; + auto it = transPluralOld_.find(orig); + if (it != transPluralOld_.end() && !it->second.empty()) //preserve old translation from .lng file if existing + sequence_.push_back(std::make_shared(std::make_pair(orig, it->second))); + else + sequence_.push_back(std::make_shared(std::make_pair(orig, PluralForms()))); + } + + bool untranslatedTextExists() const { return std::any_of(sequence_.begin(), sequence_.end(), [](const std::shared_ptr& item) { return !item->hasTranslation(); }); } + + template + void visitItems(Function onTrans, Function2 onPluralTrans) const //onTrans takes (const TranslationMap::value_type&), onPluralTrans takes (const TranslationPluralMap::value_type&) + { + for (const std::shared_ptr& item : sequence_) + if (auto regular = dynamic_cast(item.get())) + onTrans(regular->value); + else if (auto plural = dynamic_cast(item.get())) + onPluralTrans(plural->value); + else assert(false); + } + +private: + struct Item { virtual ~Item() {} virtual bool hasTranslation() const = 0; }; + + struct SingularItem : public Item + { + explicit SingularItem(const TranslationMap::value_type& val) : value(val) {} + bool hasTranslation() const override { return !value.second.empty(); } + TranslationMap::value_type value; + }; + + struct PluralItem : public Item + { + explicit PluralItem(const TranslationPluralMap::value_type& val) : value(val) {} + bool hasTranslation() const override { return !value.second.empty(); } + TranslationPluralMap::value_type value; + }; + + std::vector> sequence_; //ordered list of translation elements + + std::unordered_set transUnique_; //check uniqueness + std::unordered_set pluralUnique_; // + + const TranslationMap transOld_; //reuse existing translation + const TranslationPluralMap transPluralOld_; // +}; + + +enum class TokenType +{ + header, + source, + target, + empty, + text, + plural, + end, +}; + +struct Token +{ + Token(TokenType t) : type(t) {} + + TokenType type; + std::string text; +}; + + +class KnownTokens +{ +public: + KnownTokens() {} //clang wants it, clang gets it + + using TokenMap = std::unordered_map; + + const TokenMap& getList() const { return tokens_; } + + std::string text(TokenType t) const + { + auto it = tokens_.find(t); + if (it != tokens_.end()) + return it->second; + assert(false); + return std::string(); + } + +private: + const TokenMap tokens_ = + { + {TokenType::header, "
"}, + {TokenType::source, ""}, + {TokenType::target, ""}, + {TokenType::empty, ""}, + {TokenType::plural, ""}, + }; +}; + + +class Scanner +{ +public: + explicit Scanner(const std::string& byteStream) : stream_(byteStream), pos_(stream_.begin()) + { + if (zen::startsWith(stream_, zen::BYTE_ORDER_MARK_UTF8)) + pos_ += zen::strLength(zen::BYTE_ORDER_MARK_UTF8); + } + + Token getNextToken() + { + //skip whitespace + pos_ = std::find_if_not(pos_, stream_.end(), zen::isWhiteSpace); + + if (pos_ == stream_.end()) + return Token(TokenType::end); + + for (const auto& [tokenEnum, tokenString] : tokens_.getList()) + if (startsWith(tokenString)) + { + pos_ += tokenString.size(); + return Token(tokenEnum); + } + + //otherwise assume "text" + auto itBegin = pos_; + while (pos_ != stream_.end() && !startsWithKnownTag()) + pos_ = std::find(pos_ + 1, stream_.end(), '<'); + + std::string text(itBegin, pos_); + + normalize(text); //remove whitespace from end etc. + + if (text.empty() && pos_ == stream_.end()) + return Token(TokenType::end); + + Token out(TokenType::text); + out.text = std::move(text); + return out; + } + + size_t posRow() const //current row beginning with 0 + { + //count line endings + const size_t crSum = std::count(stream_.begin(), pos_, '\r'); //carriage returns + const size_t nlSum = std::count(stream_.begin(), pos_, '\n'); //new lines + assert(crSum == 0 || nlSum == 0 || crSum == nlSum); + return std::max(crSum, nlSum); //be compatible with Linux/Mac/Win + } + + size_t posCol() const //current col beginning with 0 + { + //seek beginning of line + for (auto it = pos_; it != stream_.begin(); ) + { + --it; + if (zen::isLineBreak(*it)) + return pos_ - it - 1; + } + return pos_ - stream_.begin(); + } + +private: + bool startsWithKnownTag() const + { + return std::any_of(tokens_.getList().begin(), tokens_.getList().end(), + [&](const KnownTokens::TokenMap::value_type& p) { return startsWith(p.second); }); + } + + bool startsWith(const std::string& prefix) const + { + return zen::startsWith(zen::makeStringView(pos_, stream_.end()), prefix); + } + + static void normalize(std::string& text) + { + zen::trim(text); //remove whitespace from both ends + + //Delimiter: + //---------- + //Linux: 0xA \n + //Mac: 0xD \r + //Win: 0xD 0xA \r\n <- language files are in Windows format + zen::replace(text, "\r\n", '\n'); // + zen::replace(text, '\r', '\n'); //ensure c-style line breaks + } + + const std::string stream_; + std::string::const_iterator pos_; + const KnownTokens tokens_; //no need for static non-POD! +}; + + +class LngParser +{ +public: + explicit LngParser(const std::string& byteStream) : scn_(byteStream), tk_(scn_.getNextToken()) {} + + void parse(TranslationMap& out, TranslationPluralMap& pluralOut, TransHeader& header) //throw ParsingError + { + parseHeader(header); //throw ParsingError + + try + { + plural::PluralFormInfo pi(header.pluralDefinition, header.pluralCount); //throw InvalidPluralForm + + while (token().type != TokenType::end) + parseRegular(out, pluralOut, pi); //throw ParsingError + } + catch (const plural::InvalidPluralForm&) + { + throw ParsingError({L"Invalid plural form definition", scn_.posRow(), scn_.posCol()}); + } + } + + void parseHeader(TransHeader& header) //throw ParsingError + { + using namespace zen; + + consumeToken(TokenType::header); //throw ParsingError + + const std::string headerRaw = token().text; + consumeToken(TokenType::text); //throw ParsingError + + std::unordered_map items; + + split2(headerRaw, [](const char c) { return isLineBreak(c); }, + [&items](const std::string_view block) + { + const std::string_view name = trimCpy(beforeFirst(block, ':', IfNotFoundReturn::none)); + if (!name.empty()) + items.emplace(name, trimCpy(afterFirst(block, ':', IfNotFoundReturn::none))); + }); + + auto getValue = [&](const std::string_view name) + { + auto it = items.find(name); + if (it == items.end()) + throw ParsingError({replaceCpy(L"Cannot find header item \"%x:\"", L"%x", utfTo(name)), scn_.posRow(), scn_.posCol()}); + return it->second; + }; + + header.languageName = getValue("language"); //throw ParsingError + header.locale = getValue("locale"); //throw ParsingError + header.flagFile = getValue("image"); //throw ParsingError + header.pluralCount = stringTo(getValue("plural_count")); //throw ParsingError + header.pluralDefinition = getValue("plural_definition"); //throw ParsingError + header.translatorName = getValue("translator"); //throw ParsingError + } + +private: + void parseRegular(TranslationMap& out, TranslationPluralMap& pluralOut, const plural::PluralFormInfo& pluralInfo) //throw ParsingError + { + consumeToken(TokenType::source); //throw ParsingError + + if (token().type == TokenType::plural) + return parsePlural(pluralOut, pluralInfo); //throw ParsingError + + std::string original = token().text; + consumeToken(TokenType::text); //throw ParsingError + + consumeToken(TokenType::target); //throw ParsingError + std::string translation; + if (token().type == TokenType::text) + { + translation = token().text; + nextToken(); + } + else + consumeToken(TokenType::empty); //throw ParsingError + + validateTranslation(original, translation); //throw ParsingError + + out.emplace(std::move(original), std::move(translation)); + } + + void parsePlural(TranslationPluralMap& pluralOut, const plural::PluralFormInfo& pluralInfo) //throw ParsingError + { + //TokenType::source already consumed + + consumeToken(TokenType::plural); //throw ParsingError + std::string engSingular = token().text; + consumeToken(TokenType::text); //throw ParsingError + + consumeToken(TokenType::plural); //throw ParsingError + std::string engPlural = token().text; + consumeToken(TokenType::text); //throw ParsingError + + const SingularPluralPair original(engSingular, engPlural); + + consumeToken(TokenType::target); //throw ParsingError + + PluralForms pluralList; + while (token().type == TokenType::plural) + { + nextToken(); + std::string pluralForm = token().text; + consumeToken(TokenType::text); //throw ParsingError + + pluralList.push_back(pluralForm); + } + + if (pluralList.empty()) + consumeToken(TokenType::empty); //throw ParsingError + + validateTranslation(original, pluralList, pluralInfo); + + pluralOut.emplace(original, std::move(pluralList)); + } + + void validateTranslation(const std::string& original, const std::string& translation) //throw ParsingError + { + using namespace zen; + + if (original.empty()) + throw ParsingError({L"Translation source text is empty", scn_.posRow(), scn_.posCol()}); + + if (!isValidUtf(original)) + throw ParsingError({L"Translation source text contains UTF-8 encoding error", scn_.posRow(), scn_.posCol()}); + if (!isValidUtf(translation)) + throw ParsingError({L"Translation text contains UTF-8 encoding error", scn_.posRow(), scn_.posCol()}); + + if (!translation.empty()) + { + //if original contains placeholder, so must translation! + auto checkPlaceholder = [&](const std::string& placeholder) + { + if (contains(original, placeholder) && + !contains(translation, placeholder)) + throw ParsingError({replaceCpy(L"Placeholder %x missing in translation", L"%x", utfTo(placeholder)), scn_.posRow(), scn_.posCol()}); + }; + checkPlaceholder("%x"); + checkPlaceholder("%y"); + checkPlaceholder("%z"); + + //if source is a one-liner, so should be the translation + if (!contains(original, '\n') && contains(translation, '\n')) + throw ParsingError({L"Source text is a one-liner, but translation consists of multiple lines", scn_.posRow(), scn_.posCol()}); + + //if source contains ampersand to mark menu accellerator key, so must translation + const size_t ampCount = ampersandTokenCount(original); + if (ampCount > 1 || ampCount != ampersandTokenCount(translation)) + throw ParsingError({L"Source and translation both need exactly one & character to mark a menu item access key or none at all", scn_.posRow(), scn_.posCol()}); + + //ampersand at the end makes buggy wxWidgets crash miserably + if (endsWithSingleAmp(original) || endsWithSingleAmp(translation)) + throw ParsingError({L"The & character to mark a menu item access key must not occur at the end of a string", scn_.posRow(), scn_.posCol()}); + + //if source ends with colon, so must translation + if (endsWithColon(original) && !endsWithColon(translation)) + throw ParsingError({L"Source text ends with a colon character \":\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //if source ends with period, so must translation + if (endsWithSingleDot(original) && !endsWithSingleDot(translation)) + throw ParsingError({L"Source text ends with a punctuation mark character \".\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //if source ends with ellipsis, so must translation + if (endsWithEllipsis(original) && !endsWithEllipsis(translation)) + throw ParsingError({L"Source text ends with an ellipsis \"...\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //check for not-to-be-translated texts + for (const char* fixedStr : {"FreeFileSync", "RealTimeSync", "ffs_gui", "ffs_batch", "ffs_real", "ffs_tmp", "GlobalSettings.xml"}) + if (contains(original, fixedStr) && !contains(translation, fixedStr)) + throw ParsingError({replaceCpy(L"Misspelled \"%x\" in translation", L"%x", utfTo(fixedStr)), scn_.posRow(), scn_.posCol()}); + + //some languages (French!) put a space before punctuation mark => must be a no-brake space! + for (const char punctChar : std::string_view(".!?:;$#")) + if (contains(original, std::string(" ") + punctChar) || + contains(translation, std::string(" ") + punctChar)) + throw ParsingError({replaceCpy(L"Text contains a space before the \"%x\" character. Are line-breaks really allowed here?" + L" Maybe this should be a \"non-breaking space\" (Windows: Alt 0160 UTF8: 0xC2 0xA0)?", + L"%x", utfTo(punctChar)), scn_.posRow(), scn_.posCol()}); + } + } + + void validateTranslation(const SingularPluralPair& original, const PluralForms& translation, const plural::PluralFormInfo& pluralInfo) //throw ParsingError + { + using namespace zen; + + if (original.first.empty() || original.second.empty()) + throw ParsingError({L"Translation source text is empty", scn_.posRow(), scn_.posCol()}); + + const std::vector allTexts = [&] + { + std::vector at{original.first, original.second}; + at.insert(at.end(), translation.begin(), translation.end()); + return at; + }(); + + for (const std::string& str : allTexts) + if (!isValidUtf(str)) + throw ParsingError({L"Text contains UTF-8 encoding error", scn_.posRow(), scn_.posCol()}); + + //check the primary placeholder is existing at least for the second english text + if (!contains(original.second, "%x")) + throw ParsingError({L"Plural form source text does not contain %x placeholder", scn_.posRow(), scn_.posCol()}); + + if (!translation.empty()) + { + //check for invalid number of plural forms + if (pluralInfo.getCount() != translation.size()) + throw ParsingError({replaceCpy(replaceCpy(L"Invalid number of plural forms; actual: %x, expected: %y", + L"%x", formatNumber(translation.size())), + L"%y", formatNumber(pluralInfo.getCount())), scn_.posRow(), scn_.posCol()}); + + //check for duplicate plural form translations (catch copy & paste errors for single-number form translations) + for (auto it = translation.begin(); it != translation.end(); ++it) + if (!contains(*it, "%x")) + { + auto it2 = std::find(it + 1, translation.end(), *it); + if (it2 != translation.end()) + throw ParsingError({replaceCpy(L"Duplicate plural form translation at index position %x", + L"%x", formatNumber(it2 - translation.begin())), scn_.posRow(), scn_.posCol()}); + } + + for (size_t pos = 0; pos < translation.size(); ++pos) + if (pluralInfo.isSingleNumberForm(pos)) + { + //translation needs to use decimal number if english source does so (e.g. frequently changing text like statistics) + if (contains(original.first, "%x") || + contains(original.first, "1")) + { + const int firstNumber = pluralInfo.getFirstNumber(pos); + if (!contains(translation[pos], "%x") && + !contains(translation[pos], numberTo(firstNumber))) + throw ParsingError({replaceCpy(replaceCpy(L"Plural form translation at index position %y needs to use the decimal number %z or the %x placeholder", + L"%y", formatNumber(pos)), L"%z", formatNumber(firstNumber)), scn_.posRow(), scn_.posCol()}); + } + } + else + { + //ensure the placeholder is used when needed + if (!contains(translation[pos], "%x")) + throw ParsingError({replaceCpy(L"Plural form at index position %y is missing the %x placeholder", L"%y", formatNumber(pos)), scn_.posRow(), scn_.posCol()}); + } + + auto checkSecondaryPlaceholder = [&](const std::string& placeholder) + { + //make sure secondary placeholder is used for both source texts (or none) and all plural forms + if (contains(original.first, placeholder) || + contains(original.second, placeholder)) + for (const std::string& str : allTexts) + if (!contains(str, placeholder)) + throw ParsingError({replaceCpy(L"Placeholder %x missing in text", L"%x", utfTo(placeholder)), scn_.posRow(), scn_.posCol()}); + }; + checkSecondaryPlaceholder("%y"); + checkSecondaryPlaceholder("%z"); + + //if source is a one-liner, so should be the translation + if (!contains(original.first, '\n') && !contains(original.second, '\n') && + /**/std::any_of(translation.begin(), translation.end(), [](const std::string& pform) { return contains(pform, '\n'); })) + /**/throw ParsingError({L"Source text is a one-liner, but at least one plural form translation consists of multiple lines", scn_.posRow(), scn_.posCol()}); + + //if source contains ampersand to mark menu accellerator key, so must translation + const size_t ampCount = ampersandTokenCount(original.first); + for (const std::string& str : allTexts) + if (ampCount > 1 || ampersandTokenCount(str) != ampCount) + throw ParsingError({L"Source and translation both need exactly one & character to mark a menu item access key or none at all", scn_.posRow(), scn_.posCol()}); + + //ampersand at the end makes buggy wxWidgets crash miserably + for (const std::string& str : allTexts) + if (endsWithSingleAmp(str)) + throw ParsingError({L"The & character to mark a menu item access key must not occur at the end of a string", scn_.posRow(), scn_.posCol()}); + + //if source ends with colon, so must translation (note: character seems to be universally used, even for asian and arabic languages) + if (endsWith(original.first, ':') || endsWith(original.second, ':')) + for (const std::string& str : allTexts) + if (!endsWithColon(str)) + throw ParsingError({L"Source text ends with a colon character \":\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //if source ends with a period, so must translation (note: character seems to be universally used, even for asian and arabic languages) + if (endsWithSingleDot(original.first) || endsWithSingleDot(original.second)) + for (const std::string& str : allTexts) + if (!endsWithSingleDot(str)) + throw ParsingError({L"Source text ends with a punctuation mark character \".\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //if source ends with an ellipsis, so must translation (note: character seems to be universally used, even for asian and arabic languages) + if (endsWithEllipsis(original.first) || endsWithEllipsis(original.second)) + for (const std::string& str : allTexts) + if (!endsWithEllipsis(str)) + throw ParsingError({L"Source text ends with an ellipsis \"...\", but translation does not", scn_.posRow(), scn_.posCol()}); + + //check for not-to-be-translated texts + for (const char* fixedStr : {"FreeFileSync", "RealTimeSync", "ffs_gui", "ffs_batch", "ffs_tmp", "GlobalSettings.xml"}) + if (contains(original.first, fixedStr) || contains(original.second, fixedStr)) + for (const std::string& str : allTexts) + if (!contains(str, fixedStr)) + throw ParsingError({replaceCpy(L"Misspelled \"%x\" in translation", L"%x", utfTo(fixedStr)), scn_.posRow(), scn_.posCol()}); + + //some languages (French!) put a space before punctuation mark => must be a no-brake space! + for (const char punctChar : std::string(".!?:;$#")) + for (const std::string& str : allTexts) + if (contains(str, std::string(" ") + punctChar)) + throw ParsingError({replaceCpy(L"Text contains a space before the \"%x\" character. Are line-breaks really allowed here?" + L" Maybe this should be a \"non-breaking space\" (Windows: Alt 0160 UTF8: 0xC2 0xA0)?", + L"%x", utfTo(punctChar)), scn_.posRow(), scn_.posCol()}); + } + } + + static size_t ampersandTokenCount(const std::string& str) + { + using namespace zen; + const std::string tmp = replaceCpy(str, "&&", ""); //make sure to not catch && which windows resolves as just one & for display! + return std::count(tmp.begin(), tmp.end(), '&'); + } + + static bool endsWithSingleAmp(const std::string& s) + { + using namespace zen; + return endsWith(s, "&") && !endsWith(s, "&&"); + } + + static bool endsWithEllipsis(const std::string& s) + { + using namespace zen; + return endsWith(s, "...") || + endsWith(s, "\xe2\x80\xa6"); //narrow ellipsis (spanish?) + } + + static bool endsWithColon(const std::string& s) + { + using namespace zen; + return endsWith(s, ':') || + endsWith(s, "\xef\xbc\x9a"); //chinese colon + } + + static bool endsWithSingleDot(const std::string& s) + { + using namespace zen; + return (endsWith(s, ".") || + endsWith(s, "\xe0\xa5\xa4") || //hindi period + endsWith(s, "\xe3\x80\x82")) //chinese period + && + (!endsWith(s, "..") && + !endsWith(s, "\xe0\xa5\xa4" "\xe0\xa5\xa4") && //hindi period + !endsWith(s, "\xe3\x80\x82" "\xe3\x80\x82")); //chinese period + } + + + const Token& token() const { return tk_; } + + void nextToken() { tk_ = scn_.getNextToken(); } + + void expectToken(TokenType t) //throw ParsingError + { + if (token().type != t) + throw ParsingError({L"Unexpected token", scn_.posRow(), scn_.posCol()}); + } + + void consumeToken(TokenType t) //throw ParsingError + { + expectToken(t); //throw ParsingError + nextToken(); + } + + Scanner scn_; + Token tk_; +}; + + +inline +void parseLng(const std::string& byteStream, TransHeader& header, TranslationMap& out, TranslationPluralMap& pluralOut) //throw ParsingError +{ + out.clear(); + pluralOut.clear(); + + LngParser(byteStream).parse(out, pluralOut, header); //throw ParsingError +} + + +inline +TransHeader parseHeader(const std::string& byteStream) //throw ParsingError +{ + TransHeader header; + LngParser(byteStream).parseHeader(header); //throw ParsingError + return header; +} + + +inline +std::string generateLng(const TranslationUnorderedList& in, const TransHeader& header, bool untranslatedToTop) +{ + using namespace zen; + + const KnownTokens tokens; //no need for static non-POD! + + std::string headerLines; + headerLines += tokens.text(TokenType::header) + '\n'; + + headerLines += "\t" "language: " + header.languageName + '\n'; + headerLines += "\t" "locale: " + header.locale + '\n'; + headerLines += "\t" "image: " + header.flagFile + '\n'; + headerLines += "\t" "plural_count: " + numberTo(header.pluralCount) + '\n'; + headerLines += "\t" "plural_definition: " + header.pluralDefinition + '\n'; + headerLines += "\t" "translator: " + header.translatorName; + + + std::string topLines; //untranslated items first? + std::string mainLines; + + in.visitItems([&](const TranslationMap::value_type& trans) + { + std::string& out = untranslatedToTop && trans.second.empty() ? topLines : mainLines; + + std::string original = trans.first; + std::string translation = trans.second; + + out += + "\n\n" + tokens.text(TokenType::source) + ' ' + original + '\n'; + + if (contains(original, '\n')) //multiple lines + out += + '\n'; + + out += tokens.text(TokenType::target) + ' ' + translation; + + if (translation.empty()) //help translators search for untranslated items + out += tokens.text(TokenType::empty); + }, + [&](const TranslationPluralMap::value_type& transPlural) + { + std::string& out = untranslatedToTop && transPlural.second.empty() ? topLines : mainLines; + + std::string engSingular = transPlural.first.first; + std::string engPlural = transPlural.first.second; + const PluralForms& forms = transPlural.second; + + out += "\n\n" + tokens.text(TokenType::source) + '\n'; + out += '\t' + tokens.text(TokenType::plural) + ' ' + engSingular + '\n'; + out += '\t' + tokens.text(TokenType::plural) + ' ' + engPlural + '\n'; + + out += tokens.text(TokenType::target); + + for (std::string plForm : forms) + out += + "\n\t" + tokens.text(TokenType::plural) + ' ' + plForm; + + if (forms.empty()) //help translators search for untranslated items + out += ' ' + tokens.text(TokenType::empty); + }); + + std::string output = headerLines + topLines + mainLines; + assert(!contains(output, "\r\n") && !contains(output, "\r")); + return replaceCpy(output, '\n', "\r\n"); //back to Windows line endings +} +} + +#endif //PARSE_LNG_H_46794693622675638 diff --git a/FreeFileSync/Source/parse_plural.h b/FreeFileSync/Source/parse_plural.h new file mode 100644 index 0000000..665fc28 --- /dev/null +++ b/FreeFileSync/Source/parse_plural.h @@ -0,0 +1,475 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PARSE_PLURAL_H_180465845670839576 +#define PARSE_PLURAL_H_180465845670839576 + +#include + + +namespace plural +{ +//expression interface +struct Expression { virtual ~Expression() {} }; + +template +struct Expr : public Expression +{ + virtual T eval() const = 0; +}; + + +class ParsingError {}; + +class PluralForm +{ +public: + explicit PluralForm(const std::string& stream); //throw ParsingError + size_t getForm(int64_t n) const { n_ = std::abs(n) ; return static_cast(expr_->eval()); } + +private: + std::shared_ptr> expr_; + mutable int64_t n_ = 0; +}; + + +//validate plural form +class InvalidPluralForm {}; + +class PluralFormInfo +{ +public: + PluralFormInfo(const std::string& definition, int pluralCount); //throw InvalidPluralForm + + size_t getCount() const { return forms_.size(); } + bool isSingleNumberForm(size_t index) const { return index < forms_.size() ? forms_[index].count == 1 : false; } + int getFirstNumber (size_t index) const { return index < forms_.size() ? forms_[index].firstNumber : -1; } + +private: + struct FormInfo + { + int count = 0; + int firstNumber = 0; //which maps to the plural form index position + }; + std::vector forms_; +}; + + + + + +//--------------------------- implementation --------------------------- +/* https://www.gnu.org/software/hello/manual/gettext/Plural-forms.html + https://translate.sourceforge.net/wiki/l10n/pluralforms + + Grammar for Plural forms parser + ------------------------------- + expression: + conditional-expression + + conditional-expression: + logical-or-expression + logical-or-expression ? expression : expression + + logical-or-expression: + logical-and-expression + logical-or-expression || logical-and-expression + + logical-and-expression: + equality-expression + logical-and-expression && equality-expression + + equality-expression: + relational-expression + relational-expression == relational-expression + relational-expression != relational-expression + + relational-expression: + multiplicative-expression + multiplicative-expression > multiplicative-expression + multiplicative-expression < multiplicative-expression + multiplicative-expression >= multiplicative-expression + multiplicative-expression <= multiplicative-expression + + multiplicative-expression: + pm-expression + multiplicative-expression % pm-expression + + pm-expression: + variable-number-n-expression + constant-number-expression + ( expression ) + + + .po format,e.g.: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) */ + +namespace impl +{ +template +struct BinaryExp : public Expr +{ + using ExpLhs = std::shared_ptr>; + using ExpRhs = std::shared_ptr>; + + BinaryExp(const ExpLhs& lhs, const ExpRhs& rhs) : lhs_(lhs), rhs_(rhs) { assert(lhs && rhs); } + ResultType eval() const override { return BinaryOp()(lhs_->eval(), rhs_->eval()); } +private: + ExpLhs lhs_; + ExpRhs rhs_; +}; + + +template inline +std::shared_ptr makeBiExp(const std::shared_ptr& lhs, const std::shared_ptr& rhs) //throw ParsingError +{ + auto exLeft = std::dynamic_pointer_cast>(lhs); + auto exRight = std::dynamic_pointer_cast>(rhs); + if (!exLeft || !exRight) + throw ParsingError(); + + using ResultType = decltype(BinaryOp()(std::declval(), std::declval())); + return std::make_shared>(exLeft, exRight); +} + + +template +struct ConditionalExp : public Expr +{ + ConditionalExp(const std::shared_ptr>& ifExp, + const std::shared_ptr>& thenExp, + const std::shared_ptr>& elseExp) : ifExp_(ifExp), thenExp_(thenExp), elseExp_(elseExp) { assert(ifExp && thenExp && elseExp); } + + T eval() const override { return ifExp_->eval() ? thenExp_->eval() : elseExp_->eval(); } +private: + std::shared_ptr> ifExp_; + std::shared_ptr> thenExp_; + std::shared_ptr> elseExp_; +}; + + +struct ConstNumberExp : public Expr +{ + explicit ConstNumberExp(int64_t n) : n_(n) {} + int64_t eval() const override { return n_; } +private: + int64_t n_; +}; + + +struct VariableNumberNExp : public Expr +{ + explicit VariableNumberNExp(int64_t& n) : n_(n) {} + int64_t eval() const override { return n_; } +private: + int64_t& n_; +}; + +//------------------------------------------------------------------------------- + +enum class TokenType +{ + ternaryQuest, + ternaryColon, + logicOr, + logicAnd, + equal, + notEqual, + less, + lessEqual, + greater, + greaterEqual, + modulus, + variableN, + constNumber, + bracketLeft, + bracketRight, + end, +}; + +struct Token +{ + Token(TokenType t) : type(t) {} + Token(int64_t num) : number(num) {} + + TokenType type = TokenType::constNumber; + int64_t number = 0; //if type == TokenType::constNumber +}; + +class Scanner +{ +public: + explicit Scanner(const std::string& stream) : stream_(stream), pos_(stream_.begin()) {} + + Token getNextToken() //throw ParsingError + { + //skip whitespace + pos_ = std::find_if_not(pos_, stream_.end(), zen::isWhiteSpace); + + if (pos_ == stream_.end()) + return TokenType::end; + + for (const auto& [tokenString, tokenEnum] : tokens_) + if (startsWith(tokenString)) + { + pos_ += tokenString.size(); + return Token(tokenEnum); + } + + auto digitEnd = std::find_if_not(pos_, stream_.end(), zen::isDigit); + if (pos_ == digitEnd) + throw ParsingError(); //unknown token + + auto number = zen::stringTo(std::string(pos_, digitEnd)); + pos_ = digitEnd; + return number; + } + +private: + bool startsWith(const std::string& prefix) const + { + return zen::startsWith(zen::makeStringView(pos_, stream_.end()), prefix); + } + + using TokenList = std::vector>; + const TokenList tokens_ + { + {"?", TokenType::ternaryQuest}, + {":", TokenType::ternaryColon}, + {"||", TokenType::logicOr }, + {"&&", TokenType::logicAnd }, + {"==", TokenType::equal }, + {"!=", TokenType::notEqual }, + {"<=", TokenType::lessEqual }, + {"<", TokenType::less }, + {">=", TokenType::greaterEqual}, + {">", TokenType::greater }, + {"%", TokenType::modulus }, + {"n", TokenType::variableN }, + {"N", TokenType::variableN }, + {"(", TokenType::bracketLeft }, + {")", TokenType::bracketRight}, + }; + + const std::string stream_; + std::string::const_iterator pos_; +}; + +//------------------------------------------------------------------------------- + +class Parser +{ +public: + Parser(const std::string& stream, int64_t& n) : + scn_(stream), + tk_(scn_.getNextToken()), //throw ParsingError + n_(n) {} + + std::shared_ptr> parse() //throw ParsingError; return value always bound! + { + auto e = std::dynamic_pointer_cast>(parseExpression()); //throw ParsingError + if (!e) + throw ParsingError(); + expectToken(TokenType::end); //throw ParsingError + return e; + } + +private: + std::shared_ptr parseExpression() { return parseConditional(); }//throw ParsingError + + std::shared_ptr parseConditional() //throw ParsingError + { + std::shared_ptr e = parseLogicalOr(); + + if (token().type == TokenType::ternaryQuest) + { + nextToken(); //throw ParsingError + + auto ifExp = std::dynamic_pointer_cast>(e); + auto thenExp = std::dynamic_pointer_cast>(parseExpression()); //associativity: <- + + consumeToken(TokenType::ternaryColon); //throw ParsingError + + auto elseExp = std::dynamic_pointer_cast>(parseExpression()); // + if (!ifExp || !thenExp || !elseExp) + throw ParsingError(); + return std::make_shared>(ifExp, thenExp, elseExp); + } + return e; + } + + std::shared_ptr parseLogicalOr() + { + std::shared_ptr e = parseLogicalAnd(); + while (token().type == TokenType::logicOr) //associativity: -> + { + nextToken(); //throw ParsingError + + std::shared_ptr rhs = parseLogicalAnd(); + e = makeBiExp, bool>(e, rhs); //throw ParsingError + } + return e; + } + + std::shared_ptr parseLogicalAnd() + { + std::shared_ptr e = parseEquality(); + while (token().type == TokenType::logicAnd) //associativity: -> + { + nextToken(); //throw ParsingError + std::shared_ptr rhs = parseEquality(); + + e = makeBiExp, bool>(e, rhs); //throw ParsingError + } + return e; + } + + std::shared_ptr parseEquality() + { + std::shared_ptr e = parseRelational(); + + TokenType t = token().type; + if (t == TokenType::equal || //associativity: n/a + t == TokenType::notEqual) + { + nextToken(); //throw ParsingError + std::shared_ptr rhs = parseRelational(); + + if (t == TokenType::equal) return makeBiExp, int64_t>(e, rhs); //throw ParsingError + if (t == TokenType::notEqual) return makeBiExp, int64_t>(e, rhs); // + } + return e; + } + + std::shared_ptr parseRelational() + { + std::shared_ptr e = parseMultiplicative(); + + TokenType t = token().type; + if (t == TokenType::less || //associativity: n/a + t == TokenType::lessEqual || + t == TokenType::greater || + t == TokenType::greaterEqual) + { + nextToken(); //throw ParsingError + std::shared_ptr rhs = parseMultiplicative(); + + if (t == TokenType::less) return makeBiExp, int64_t>(e, rhs); // + if (t == TokenType::lessEqual) return makeBiExp, int64_t>(e, rhs); //throw ParsingError + if (t == TokenType::greater) return makeBiExp, int64_t>(e, rhs); // + if (t == TokenType::greaterEqual) return makeBiExp, int64_t>(e, rhs); // + } + return e; + } + + std::shared_ptr parseMultiplicative() + { + std::shared_ptr e = parsePrimary(); + + while (token().type == TokenType::modulus) //associativity: -> + { + nextToken(); //throw ParsingError + std::shared_ptr rhs = parsePrimary(); + + //"compile-time" check: n % 0 + if (auto literal = std::dynamic_pointer_cast(rhs)) + if (literal->eval() == 0) + throw ParsingError(); + + e = makeBiExp, int64_t>(e, rhs); //throw ParsingError + } + return e; + } + + std::shared_ptr parsePrimary() + { + if (token().type == TokenType::variableN) + { + nextToken(); //throw ParsingError + return std::make_shared(n_); + } + else if (token().type == TokenType::constNumber) + { + const int64_t number = token().number; + nextToken(); //throw ParsingError + return std::make_shared(number); + } + else if (token().type == TokenType::bracketLeft) + { + nextToken(); //throw ParsingError + std::shared_ptr e = parseExpression(); + + expectToken(TokenType::bracketRight); //throw ParsingError + nextToken(); // + return e; + } + else + throw ParsingError(); + } + + const Token& token() const { return tk_; } + + void nextToken() { tk_ = scn_.getNextToken(); } //throw ParsingError + + void expectToken(TokenType t) //throw ParsingError + { + if (token().type != t) + throw ParsingError(); + } + + void consumeToken(TokenType t) //throw ParsingError + { + expectToken(t); //throw ParsingError + nextToken(); + } + + Scanner scn_; + Token tk_; + int64_t& n_; +}; +} + + +inline +PluralFormInfo::PluralFormInfo(const std::string& definition, int pluralCount) //throw InvalidPluralForm +{ + if (pluralCount < 1) + throw InvalidPluralForm(); + + forms_.resize(pluralCount); + try + { + PluralForm pf(definition); //throw ParsingError + //PERF_START + + //perf: 80ns per iteration max (for Arabic) + //=> 1000 iterations should be fast enough and still detect all "single number forms" + for (int j = 0; j < 1000; ++j) + if (const size_t formNo = pf.getForm(j); + formNo < forms_.size()) + { + if (forms_[formNo].count == 0) + forms_[formNo].firstNumber = j; + ++forms_[formNo].count; + } + else + throw InvalidPluralForm(); + } + catch (const plural::ParsingError&) + { + throw InvalidPluralForm(); + } + + //ensure each form is used at least once: + if (!std::all_of(forms_.begin(), forms_.end(), [](const FormInfo& fi) { return fi.count >= 1; })) + throw InvalidPluralForm(); +} + + +inline +PluralForm::PluralForm(const std::string& stream) : expr_(impl::Parser(stream, n_).parse()) {} //throw ParsingError +} + +#endif //PARSE_PLURAL_H_180465845670839576 diff --git a/FreeFileSync/Source/return_codes.h b/FreeFileSync/Source/return_codes.h new file mode 100644 index 0000000..60b6c2e --- /dev/null +++ b/FreeFileSync/Source/return_codes.h @@ -0,0 +1,57 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RETURN_CODES_H_81307482137054156 +#define RETURN_CODES_H_81307482137054156 + +#include + + +namespace fff +{ +enum class FfsExitCode //as returned on process exit +{ + success = 0, + warning, + error, + cancelled, + exception, +}; + + +inline +void raiseExitCode(FfsExitCode& rc, FfsExitCode rcProposed) +{ + if (rc < rcProposed) + rc = rcProposed; +} + + +enum class TaskResult +{ + success, + warning, + error, + cancelled, +}; + + +inline +std::wstring getSyncResultLabel(TaskResult syncResult) +{ + switch (syncResult) + { + case TaskResult::success: return _("Completed successfully"); + case TaskResult::warning: return _("Completed with warnings"); + case TaskResult::error: return _("Completed with errors"); + case TaskResult::cancelled: return _("Stopped"); + } + assert(false); + return std::wstring(); +} +} + +#endif //RETURN_CODES_H_81307482137054156 diff --git a/FreeFileSync/Source/status_handler.cpp b/FreeFileSync/Source/status_handler.cpp new file mode 100644 index 0000000..69e6964 --- /dev/null +++ b/FreeFileSync/Source/status_handler.cpp @@ -0,0 +1,47 @@ +// ***************************************************************************** +// * 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 "status_handler.h" +#include +#include + +using namespace zen; + + +namespace +{ +std::chrono::steady_clock::time_point lastExec; +} + + +bool fff::uiUpdateDue(bool force) +{ + const auto now = std::chrono::steady_clock::now(); + + if (force || now >= lastExec + UI_UPDATE_INTERVAL) + { + lastExec = now; + return true; + } + return false; +} + + +void fff::delayAndCountDown(std::chrono::nanoseconds delay, const std::function& notifyStatus) +{ + assert(notifyStatus); + if (notifyStatus) + while (delay > std::chrono::nanoseconds(0)) + { + const auto timeRemMs = std::chrono::duration_cast(delay).count(); + notifyStatus(_P("1 sec", "%x sec", numeric::intDivCeil(timeRemMs, 1000))); + + std::this_thread::sleep_for(UI_UPDATE_INTERVAL / 2); + delay -= UI_UPDATE_INTERVAL / 2; //support "Pause" => don't count time spent in notifyStatus()! + } + else + std::this_thread::sleep_for(delay /*may be negative*/); +} diff --git a/FreeFileSync/Source/status_handler.h b/FreeFileSync/Source/status_handler.h new file mode 100644 index 0000000..d009a76 --- /dev/null +++ b/FreeFileSync/Source/status_handler.h @@ -0,0 +1,176 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STATUS_HANDLER_H_81704805908341534 +#define STATUS_HANDLER_H_81704805908341534 + +#include +#include "base/process_callback.h" +#include "return_codes.h" + +namespace fff +{ +bool uiUpdateDue(bool force = false); //test if a specific amount of time is over + +/* Updating GUI is fast! time per call to ProcessCallback::forceUiRefresh() + - Comparison 0.025 ms + - Synchronization 0.74 ms (despite complex graph control!) */ + +//Exception class used to abort the "compare" and "sync" process +class CancelProcess {}; + + +enum class CancelReason +{ + user, + firstError, +}; + +//GUI may want to abort process +struct CancelCallback +{ + virtual ~CancelCallback() {} + virtual void userRequestCancel() = 0; +}; + + +struct ProgressStats +{ + int items = 0; + int64_t bytes = 0; + + bool operator==(const ProgressStats&) const = default; +}; + + +//common statistics "everybody" needs +struct Statistics +{ + virtual ~Statistics() {} + + virtual ProcessPhase currentPhase() const = 0; + + virtual ProgressStats getCurrentStats() const = 0; + virtual ProgressStats getTotalStats () const = 0; + + struct ErrorStats + { + int errorCount; + int warningCount; + }; + virtual ErrorStats getErrorStats() const = 0; + + virtual std::optional taskCancelled() const = 0; + virtual const std::wstring& currentStatusText() const = 0; +}; + + +struct ProcessSummary +{ + std::chrono::system_clock::time_point startTime; + TaskResult result = TaskResult::cancelled; + std::vector jobNames; //may be empty + ProgressStats statsProcessed; + ProgressStats statsTotal; + std::chrono::milliseconds totalTime{}; +}; + + +//partial callback implementation with common functionality for "batch", "GUI/Compare" and "GUI/Sync" +class StatusHandler : public ProcessCallback, public CancelCallback, public Statistics +{ +public: + //StatusHandler() {} + + //implement parts of ProcessCallback + void initNewPhase(int itemsTotal, int64_t bytesTotal, ProcessPhase phase) override //(throw X) + { + assert((itemsTotal < 0) == (bytesTotal < 0)); + currentPhase_ = phase; + statsCurrent_ = {}; + statsTotal_ = {itemsTotal, bytesTotal}; + } + + void updateDataProcessed(int itemsDelta, int64_t bytesDelta) override { updateData(statsCurrent_, itemsDelta, bytesDelta); } //note: these methods MUST NOT throw in order + void updateDataTotal (int itemsDelta, int64_t bytesDelta) override { updateData(statsTotal_, itemsDelta, bytesDelta); } //to allow usage within destructors! + + void requestUiUpdate(bool force) final //throw CancelProcess + { + if (uiUpdateDue(force)) + { + const bool abortRequestedBefore = static_cast(cancelRequested_); + + forceUiUpdateNoThrow(); + + //triggered by userRequestCancel() + // => sufficient to evaluate occasionally when uiUpdateDue()! + // => refresh *before* throwing: support requestUiUpdate() during destruction + if (cancelRequested_) + { + if (!abortRequestedBefore) + forceUiUpdateNoThrow(); //immediately show the "Stop requested..." status after user clicked cancel + throw CancelProcess(); + } + } + } + + virtual void forceUiUpdateNoThrow() = 0; //noexcept + + void updateStatus(std::wstring&& msg) final //throw CancelProcess + { + //assert(!msg.empty()); -> possible, e.g. start of parallel scan + statusText_ = std::move(msg); //update *before* running operations that can throw + requestUiUpdate(false /*force*/); //throw CancelProcess + } + + [[noreturn]] void cancelProcessNow(CancelReason reason) + { + if (!cancelRequested_ || reason == CancelReason::user) //CancelReason::user overwrites CancelReason::firstError + cancelRequested_ = reason; + + forceUiUpdateNoThrow(); //flush GUI to show new cancelled state + throw CancelProcess(); + } + + //implement CancelCallback + void userRequestCancel() final + { + cancelRequested_ = CancelReason::user; //may overwrite CancelReason::firstError + } //called from GUI code: this does NOT call cancelProcessNow() immediately, but later when we're out of the C GUI call stack + //=> don't call forceUiUpdateNoThrow() here! + + //implement Statistics + ProcessPhase currentPhase() const final { return currentPhase_; } + + ProgressStats getCurrentStats() const override { return statsCurrent_; } + ProgressStats getTotalStats () const override { return statsTotal_; } + + const std::wstring& currentStatusText() const override { return statusText_; } + + std::optional taskCancelled() const override { return cancelRequested_; } + +private: + void updateData(ProgressStats& stats, int itemsDelta, int64_t bytesDelta) + { + assert(stats.items >= 0); + assert(stats.bytes >= 0); + stats.items += itemsDelta; + stats.bytes += bytesDelta; + } + + ProcessPhase currentPhase_ = ProcessPhase::none; + ProgressStats statsCurrent_; + ProgressStats statsTotal_{-1, -1}; + std::wstring statusText_; + + std::optional cancelRequested_; +}; + + +void delayAndCountDown(std::chrono::nanoseconds delay, const std::function& notifyStatus); +} + +#endif //STATUS_HANDLER_H_81704805908341534 diff --git a/FreeFileSync/Source/ui/abstract_folder_picker.cpp b/FreeFileSync/Source/ui/abstract_folder_picker.cpp new file mode 100644 index 0000000..81b6256 --- /dev/null +++ b/FreeFileSync/Source/ui/abstract_folder_picker.cpp @@ -0,0 +1,392 @@ +// ***************************************************************************** +// * 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 "abstract_folder_picker.h" +#include +#include +#include +#include +#include "gui_generated.h" +#include "../icon_buffer.h" + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +namespace +{ +enum class NodeLoadStatus +{ + notLoaded, + loading, + loaded +}; + +struct AfsTreeItemData : public wxTreeItemData +{ + AfsTreeItemData(const AbstractPath& path) : folderPath(path) {} + + const AbstractPath folderPath; + std::wstring errorMsg; //optional + NodeLoadStatus loadStatus = NodeLoadStatus::notLoaded; + std::vector> onLoadCompleted; //bound! +}; + + +wxString getNodeDisplayName(const AbstractPath& folderPath) +{ + if (!AFS::getParentPath(folderPath)) //server root + return utfTo(FILE_NAME_SEPARATOR); + + return utfTo(AFS::getItemName(folderPath)); +} + + +class AbstractFolderPickerDlg : public AbstractFolderPickerGenerated +{ +public: + AbstractFolderPickerDlg(wxWindow* parent, AbstractPath& folderPath); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onLocalKeyEvent(wxKeyEvent& event); + void onExpandNode(wxTreeEvent& event) override; + void onItemTooltip(wxTreeEvent& event); + + void populateNodeThen(const wxTreeItemId& itemId, const std::function& evalOnGui /*optional*/, bool popupErrors); + + void findAndNavigateToExistingPath(const AbstractPath& folderPath); + void navigateToExistingPath(const wxTreeItemId& itemId, const std::vector& nodeRelPath, AFS::ItemType leafType); + + enum class TreeNodeImage + { + root = 0, //used as zero-based wxImageList index! + folder, + folderSymlink, + error + }; + + AsyncGuiQueue guiQueue_{25 /*polling [ms]*/}; //schedule and run long-running tasks asynchronously, but process results on GUI queue + + //output-only parameters: + AbstractPath& folderPathOut_; +}; + + +AbstractFolderPickerDlg::AbstractFolderPickerDlg(wxWindow* parent, AbstractPath& folderPath) : + AbstractFolderPickerGenerated(parent), + folderPathOut_(folderPath) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + m_staticTextStatus->SetLabel(L""); + m_treeCtrlFileSystem->SetMinSize({dipToWxsize(350), dipToWxsize(400)}); + + const int iconSize = screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small)); + auto imgList = std::make_unique(iconSize, iconSize); + + //add images in same sequence like TreeNodeImage enum!!! + imgList->Add(toScaledBitmap(loadImage("server", wxsizeToScreen(iconSize)))); + imgList->Add(toScaledBitmap( IconBuffer::genericDirIcon (IconBuffer::IconSize::small))); + imgList->Add(toScaledBitmap(layOver(IconBuffer::genericDirIcon (IconBuffer::IconSize::small), + IconBuffer::linkOverlayIcon(IconBuffer::IconSize::small)))); + imgList->Add(toScaledBitmap(loadImage("msg_error", wxsizeToScreen(iconSize)))); + assert(imgList->GetImageCount() == static_cast(TreeNodeImage::error) + 1); + + m_treeCtrlFileSystem->AssignImageList(imgList.release()); //pass ownership + + const AbstractPath rootPath(folderPath.afsDevice, AfsPath()); + + const wxTreeItemId rootId = m_treeCtrlFileSystem->AddRoot(getNodeDisplayName(rootPath), static_cast(TreeNodeImage::root), -1, + new AfsTreeItemData(rootPath)); + m_treeCtrlFileSystem->SetItemHasChildren(rootId); + + if (!AFS::getParentPath(folderPath)) //server root + populateNodeThen(rootId, [this, rootId] { m_treeCtrlFileSystem->Expand(rootId); }, true /*popupErrors*/); + else + try //folder picker has dual responsibility: + { + //1. test server connection: + const AFS::ItemType type = AFS::getItemType(folderPath); //throw FileError + //2. navigate + select path + navigateToExistingPath(rootId, splitCpy(folderPath.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip), type); + } + catch (const FileError& e) //not existing or access error + { + findAndNavigateToExistingPath(*AFS::getParentPath(folderPath)); //let's run async while the error message is shown :) + + showNotificationDialog(parent /*"this" not yet shown!*/, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } + + //---------------------------------------------------------------------- + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //dialog-specific local key events + Bind(wxEVT_TREE_ITEM_GETTOOLTIP, [this](wxTreeEvent& event) { onItemTooltip (event); }); + + m_treeCtrlFileSystem->SetFocus(); +} + + +void AbstractFolderPickerDlg::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + //wxTreeCtrl seems to eat up ENTER without adding any functionality; we can do better: + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +struct FlatTraverserCallback : public AFS::TraverserCallback +{ + struct Result + { + std::unordered_map folderNames; + std::wstring errorMsg; + }; + + const Result& getResult() { return result_; } + +private: + void onFile (const AFS::FileInfo& fi) override {} + std::shared_ptr onFolder (const AFS::FolderInfo& fi) override { result_.folderNames.emplace(fi.itemName, fi.isFollowedSymlink); return nullptr; } + HandleLink onSymlink(const AFS::SymlinkInfo& si) override { return HandleLink::follow; } + HandleError reportDirError (const ErrorInfo& errorInfo) override { logError(errorInfo.msg); return HandleError::ignore; } + HandleError reportItemError(const ErrorInfo& errorInfo, const Zstring& itemName) override { logError(errorInfo.msg); return HandleError::ignore; } + + void logError(const std::wstring& msg) + { + if (result_.errorMsg.empty()) + result_.errorMsg = msg; + } + + Result result_; +}; + + +void AbstractFolderPickerDlg::populateNodeThen(const wxTreeItemId& itemId, const std::function& evalOnGui, bool popupErrors) +{ + if (auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(itemId))) + { + switch (itemData->loadStatus) + { + case NodeLoadStatus::notLoaded: + { + if (evalOnGui) + itemData->onLoadCompleted.push_back(evalOnGui); + + itemData->loadStatus = NodeLoadStatus::loading; + + m_treeCtrlFileSystem->SetItemText(itemId, getNodeDisplayName(itemData->folderPath) + L" (" + _("Loading...") + L')'); + + guiQueue_.processAsync([folderPath = itemData->folderPath] //AbstractPath is thread-safe like an int! + { + auto ft = std::make_shared(); //noexcept, traverse directory one level deep + AFS::traverseFolderRecursive(folderPath.afsDevice, {{folderPath.afsPath, ft}}, 1 /*parallelOps*/); + return ft->getResult(); + }, + + [this, itemId, popupErrors](const FlatTraverserCallback::Result& result) + { + if (auto itemData2 = dynamic_cast(m_treeCtrlFileSystem->GetItemData(itemId))) + { + m_treeCtrlFileSystem->SetItemText(itemId, getNodeDisplayName(itemData2->folderPath)); //remove "loading" phrase + + if (result.folderNames.empty()) + m_treeCtrlFileSystem->SetItemHasChildren(itemId, false); + else + { + //let's not use the wxTreeCtrl::OnCompareItems() abomination to implement sorting: + std::vector> folderNamesSorted(result.folderNames.begin(), result.folderNames.end()); + std::sort(folderNamesSorted.begin(), folderNamesSorted.end(), [](const auto& lhs, const auto& rhs) { return LessNaturalSort()(lhs.first, rhs.first); }); + + for (const auto& [childName, isSymlink] : folderNamesSorted) + { + const AbstractPath childFolderPath = AFS::appendRelPath(itemData2->folderPath, childName); + + wxTreeItemId childId = m_treeCtrlFileSystem->AppendItem(itemId, getNodeDisplayName(childFolderPath), + static_cast(isSymlink ? TreeNodeImage::folderSymlink : TreeNodeImage::folder), -1, + new AfsTreeItemData(childFolderPath)); + m_treeCtrlFileSystem->SetItemHasChildren(childId); + } + } + + if (!result.errorMsg.empty()) + { + m_treeCtrlFileSystem->SetItemImage(itemId, static_cast(TreeNodeImage::error)); + itemData2->errorMsg = result.errorMsg; + + if (popupErrors) + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(result.errorMsg)); + } + + itemData2->loadStatus = NodeLoadStatus::loaded; //set status *before* running callbacks + for (const auto& evalOnGui2 : itemData2->onLoadCompleted) + evalOnGui2(); + } + }); + } + break; + + case NodeLoadStatus::loading: + if (evalOnGui) itemData->onLoadCompleted.push_back(evalOnGui); + break; + + case NodeLoadStatus::loaded: + if (evalOnGui) evalOnGui(); + break; + } + } +} + + +//1. find longest existing/accessible (parent) path +void AbstractFolderPickerDlg::findAndNavigateToExistingPath(const AbstractPath& folderPath) +{ + if (!AFS::getParentPath(folderPath)) + return m_staticTextStatus->SetLabel(L""); + + m_staticTextStatus->SetLabelText(_("Scanning...") + L' ' + utfTo(FILE_NAME_SEPARATOR + folderPath.afsPath.value)); //keep it short! + + guiQueue_.processAsync([folderPath]() -> std::optional + { + try + { + return AFS::getItemType(folderPath); //throw FileError + } + catch (FileError&) { return std::nullopt; } //not existing or access error + }, + + [this, folderPath](std::optional type) + { + if (type) + { + m_staticTextStatus->SetLabel(L""); + navigateToExistingPath(m_treeCtrlFileSystem->GetRootItem(), splitCpy(folderPath.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip), *type); + } + else //split into multiple small async tasks rather than a single large one! + findAndNavigateToExistingPath(*AFS::getParentPath(folderPath)); + }); +} + + +//2. navgiate while ignoring any intermediate (access) errors or problems with hidden folders +void AbstractFolderPickerDlg::navigateToExistingPath(const wxTreeItemId& itemId, const std::vector& nodeRelPath, AFS::ItemType leafType) +{ + if (nodeRelPath.empty() || + (nodeRelPath.size() == 1 && leafType == AFS::ItemType::file)) //let's be *uber* correct + { + m_treeCtrlFileSystem->SelectItem(itemId); + //m_treeCtrlFileSystem->EnsureVisible(itemId); -> not needed: maybe wxTreeCtrl::Expand() does this? + return; + } + + populateNodeThen(itemId, [this, itemId, nodeRelPath, leafType] + { + const Zstring childFolderName = nodeRelPath.front(); + const std::vector childFolderRelPath{nodeRelPath.begin() + 1, nodeRelPath.end()}; + + wxTreeItemId childIdMatch; + size_t insertPos = 0; //let's not use the wxTreeCtrl::OnCompareItems() abomination to implement sorting + + wxTreeItemIdValue cookie = nullptr; + for (wxTreeItemId childId = m_treeCtrlFileSystem->GetFirstChild(itemId, cookie); + childId.IsOk(); + childId = m_treeCtrlFileSystem->GetNextChild(itemId, cookie)) + if (auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(childId))) + { + const Zstring& itemName = AFS::getItemName(itemData->folderPath); + + if (LessNaturalSort()(itemName, childFolderName)) + ++insertPos; //assume items are already naturally sorted, see populateNodeThen() + + if (equalNoCase(itemName, childFolderName)) + { + childIdMatch = childId; + if (itemName == childFolderName) + break; //exact match => no need to search further! + } + } + + //we *know* that childFolder exists: Maybe it's just hidden during browsing: https://freefilesync.org/forum/viewtopic.php?t=3809 + if (!childIdMatch.IsOk()) // or access to root folder is denied: https://freefilesync.org/forum/viewtopic.php?t=5999 + if (auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(itemId))) + { + m_treeCtrlFileSystem->SetItemHasChildren(itemId); + + const AbstractPath childFolderPath = AFS::appendRelPath(itemData->folderPath, childFolderName); + + childIdMatch = m_treeCtrlFileSystem->InsertItem(itemId, insertPos, getNodeDisplayName(childFolderPath), + static_cast(childFolderRelPath.empty() && leafType == AFS::ItemType::symlink ? + TreeNodeImage::folderSymlink : TreeNodeImage::folder), -1, + new AfsTreeItemData(childFolderPath)); + m_treeCtrlFileSystem->SetItemHasChildren(childIdMatch); + } + + m_treeCtrlFileSystem->Expand(itemId); //wxTreeCtr::Expand emits wxTreeEvent!!! + + navigateToExistingPath(childIdMatch, childFolderRelPath, leafType); + }, false /*popupErrors*/); +} + + +void AbstractFolderPickerDlg::onExpandNode(wxTreeEvent& event) +{ + const wxTreeItemId itemId = event.GetItem(); + + if (auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(itemId))) + if (itemData->loadStatus != NodeLoadStatus::loaded) + populateNodeThen(itemId, [this, itemId]() { m_treeCtrlFileSystem->Expand(itemId); }, true /*popupErrors*/); //wxTreeCtr::Expand emits wxTreeEvent!!! watch out for recursion! +} + + +void AbstractFolderPickerDlg::onItemTooltip(wxTreeEvent& event) +{ + wxString tooltip; + if (auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(event.GetItem()))) + tooltip = itemData->errorMsg; + event.SetToolTip(tooltip); +} + + +void AbstractFolderPickerDlg::onOkay(wxCommandEvent& event) +{ + const wxTreeItemId itemId = m_treeCtrlFileSystem->GetFocusedItem(); + + auto itemData = dynamic_cast(m_treeCtrlFileSystem->GetItemData(itemId)); + assert(itemData); + if (itemData) + folderPathOut_ = itemData->folderPath; + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + + +ConfirmationButton fff::showAbstractFolderPicker(wxWindow* parent, AbstractPath& folderPath) +{ + AbstractFolderPickerDlg pickerDlg(parent, folderPath); + return static_cast(pickerDlg.ShowModal()); +} diff --git a/FreeFileSync/Source/ui/abstract_folder_picker.h b/FreeFileSync/Source/ui/abstract_folder_picker.h new file mode 100644 index 0000000..aba6201 --- /dev/null +++ b/FreeFileSync/Source/ui/abstract_folder_picker.h @@ -0,0 +1,19 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ABSTRACT_FOLDER_PICKER_HEADER_324872346895690 +#define ABSTRACT_FOLDER_PICKER_HEADER_324872346895690 + +#include +#include "../afs/abstract.h" + + +namespace fff +{ +zen::ConfirmationButton showAbstractFolderPicker(wxWindow* parent, AbstractPath& folderPath); +} + +#endif //ABSTRACT_FOLDER_PICKER_HEADER_324872346895690 diff --git a/FreeFileSync/Source/ui/app_icon.h b/FreeFileSync/Source/ui/app_icon.h new file mode 100644 index 0000000..4dc9f61 --- /dev/null +++ b/FreeFileSync/Source/ui/app_icon.h @@ -0,0 +1,30 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef APP_ICON_H_6748179634932174683214 +#define APP_ICON_H_6748179634932174683214 + +#include +#include + + +namespace fff +{ +inline +wxIcon getFfsIcon() +{ + using namespace zen; + //wxWidgets' bitmap to icon conversion on macOS can only deal with very specific sizes => check on all platforms! + assert(loadImage("FreeFileSync").GetWidth () == loadImage("FreeFileSync").GetHeight() && + loadImage("FreeFileSync").GetWidth() == dipToScreen(128)); + wxIcon icon; //Ubuntu-Linux does a bad job at down-scaling in Unity dash (blocky icons!) => prepare: + icon.CopyFromBitmap(loadImage("FreeFileSync", dipToScreen(64))); + return icon; + +} +} + +#endif //APP_ICON_H_6748179634932174683214 diff --git a/FreeFileSync/Source/ui/batch_config.cpp b/FreeFileSync/Source/ui/batch_config.cpp new file mode 100644 index 0000000..259107a --- /dev/null +++ b/FreeFileSync/Source/ui/batch_config.cpp @@ -0,0 +1,197 @@ +// ***************************************************************************** +// * 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 "batch_config.h" +#include +#include +#include +#include +#include +#include "gui_generated.h" + + +using namespace zen; +using namespace fff; + + +namespace +{ +struct BatchDialogConfig +{ + BatchExclusiveConfig batchExCfg; + bool ignoreErrors = false; +}; + + +class BatchDialog : public BatchDlgGenerated +{ +public: + BatchDialog(wxWindow* parent, BatchDialogConfig& dlgCfg); + +private: + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onCancel (wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onSaveBatchJob(wxCommandEvent& event) override; + + void onToggleIgnoreErrors(wxCommandEvent& event) override { updateGui(); } + void onToggleRunMinimized(wxCommandEvent& event) override + { + m_checkBoxAutoClose->SetValue(m_checkBoxRunMinimized->GetValue()); //usually user wants to change both + updateGui(); + } + + void onLocalKeyEvent(wxKeyEvent& event); + + void updateGui(); //re-evaluate gui after config changes + + void setConfig(const BatchDialogConfig& batchCfg); + BatchDialogConfig getConfig() const; + + //output-only parameters + BatchDialogConfig& dlgCfgOut_; + + EnumDescrList enumPostBatchAction_ + { + *m_choicePostSyncAction, + { + {PostBatchAction::none, L"", {}/*tooltip*/}, + {PostBatchAction::sleep, _("System: Sleep"), {}/*tooltip*/}, + {PostBatchAction::shutdown, _("System: Shut down"), {}/*tooltip*/}, + } + }; +}; + +//################################################################################################################################### + +BatchDialog::BatchDialog(wxWindow* parent, BatchDialogConfig& dlgCfg) : + BatchDlgGenerated(parent), + dlgCfgOut_(dlgCfg) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonSaveAs).setCancel(m_buttonCancel)); + + m_staticTextHeader->SetLabelText(replaceCpy(m_staticTextHeader->GetLabelText(), L"%x", L"FreeFileSync.exe <" + _("configuration file") + L">.ffs_batch")); + m_staticTextHeader->Wrap(dipToWxsize(520)); + + setImage(*m_bitmapBatchJob, loadImage("cfg_batch")); + + setConfig(dlgCfg); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonSaveAs->SetFocus(); +} + + +void BatchDialog::updateGui() //re-evaluate gui after config changes +{ + const BatchDialogConfig dlgCfg = getConfig(); //resolve parameter ownership: some on GUI controls, others member variables + + setImage(*m_bitmapIgnoreErrors, greyScaleIfDisabled(loadImage("error_ignore_active"), dlgCfg.ignoreErrors)); + + m_radioBtnErrorDialogShow ->Enable(!dlgCfg.ignoreErrors); + m_radioBtnErrorDialogCancel->Enable(!dlgCfg.ignoreErrors); + + setImage(*m_bitmapMinimizeToTray, greyScaleIfDisabled(loadImage("minimize_to_tray"), dlgCfg.batchExCfg.runMinimized)); +} + + +void BatchDialog::setConfig(const BatchDialogConfig& dlgCfg) +{ + m_checkBoxIgnoreErrors->SetValue(dlgCfg.ignoreErrors); + + //transfer parameter ownership to GUI + m_radioBtnErrorDialogShow ->SetValue(false); + m_radioBtnErrorDialogCancel->SetValue(false); + + switch (dlgCfg.batchExCfg.batchErrorHandling) + { + case BatchErrorHandling::showPopup: + m_radioBtnErrorDialogShow->SetValue(true); + break; + case BatchErrorHandling::cancel: + m_radioBtnErrorDialogCancel->SetValue(true); + break; + } + + m_checkBoxRunMinimized->SetValue(dlgCfg.batchExCfg.runMinimized); + m_checkBoxAutoClose ->SetValue(dlgCfg.batchExCfg.autoCloseSummary); + enumPostBatchAction_.set(dlgCfg.batchExCfg.postBatchAction); + + updateGui(); //re-evaluate gui after config changes +} + + +BatchDialogConfig BatchDialog::getConfig() const +{ + return + { + .batchExCfg + { + .runMinimized = m_checkBoxRunMinimized->GetValue(), + .autoCloseSummary = m_checkBoxAutoClose ->GetValue(), + .batchErrorHandling = m_radioBtnErrorDialogCancel->GetValue() ? BatchErrorHandling::cancel : BatchErrorHandling::showPopup, + .postBatchAction = enumPostBatchAction_.get(), + }, + .ignoreErrors = m_checkBoxIgnoreErrors->GetValue(), + }; +} + + +void BatchDialog::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonSaveAs->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void BatchDialog::onSaveBatchJob(wxCommandEvent& event) +{ + //BatchDialogConfig dlgCfg = getConfig(); + + //------- parameter validation (BEFORE writing output!) ------- + + //------------------------------------------------------------- + + dlgCfgOut_ = getConfig(); + EndModal(static_cast(ConfirmationButton::accept)); +} +} + + +ConfirmationButton fff::showBatchConfigDialog(wxWindow* parent, + BatchExclusiveConfig& batchExCfg, + bool& ignoreErrors) +{ + BatchDialogConfig dlgCfg = {batchExCfg, ignoreErrors}; + + BatchDialog batchDlg(parent, dlgCfg); + + const auto rv = static_cast(batchDlg.ShowModal()); + if (rv == ConfirmationButton::accept) + { + batchExCfg = dlgCfg.batchExCfg; + ignoreErrors = dlgCfg.ignoreErrors; + } + return rv; +} diff --git a/FreeFileSync/Source/ui/batch_config.h b/FreeFileSync/Source/ui/batch_config.h new file mode 100644 index 0000000..15e15c6 --- /dev/null +++ b/FreeFileSync/Source/ui/batch_config.h @@ -0,0 +1,22 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BATCH_CONFIG_H_3921674832168945 +#define BATCH_CONFIG_H_3921674832168945 + +#include +#include "../config.h" + + +namespace fff +{ +//show and let user customize batch settings (without saving) +zen::ConfirmationButton showBatchConfigDialog(wxWindow* parent, + BatchExclusiveConfig& batchExCfg, + bool& ignoreErrors); +} + +#endif //BATCH_CONFIG_H_3921674832168945 diff --git a/FreeFileSync/Source/ui/batch_status_handler.cpp b/FreeFileSync/Source/ui/batch_status_handler.cpp new file mode 100644 index 0000000..a6334bf --- /dev/null +++ b/FreeFileSync/Source/ui/batch_status_handler.cpp @@ -0,0 +1,433 @@ +// ***************************************************************************** +// * 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 "batch_status_handler.h" +#include +#include +#include +#include + +using namespace zen; +using namespace fff; + + +BatchStatusHandler::BatchStatusHandler(bool showProgress, + const std::wstring& jobName, + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileSyncComplete, + const Zstring& soundFileAlertPending, + const WindowLayout::Dimensions& dims, + bool autoCloseDialog, + PostBatchAction postBatchAction, + BatchErrorHandling batchErrorHandling) : + jobName_(jobName), + startTime_(startTime), + autoRetryCount_(autoRetryCount), + autoRetryDelay_(autoRetryDelay), + soundFileSyncComplete_(soundFileSyncComplete), + soundFileAlertPending_(soundFileAlertPending), + batchErrorHandling_(batchErrorHandling) +{ + //set *after* initializer list => callbacks during construction to getErrorStats()! + progressDlg_ = SyncProgressDialog::create(dims, [this] { userRequestCancel(); }, *this, nullptr /*parentWindow*/, showProgress, autoCloseDialog, + {jobName}, std::chrono::system_clock::to_time_t(startTime), ignoreErrors, autoRetryCount, [&] + { + switch (postBatchAction) + { + case PostBatchAction::none: + return PostSyncAction::none; + case PostBatchAction::sleep: + return PostSyncAction::sleep; + case PostBatchAction::shutdown: + return PostSyncAction::shutdown; + } + assert(false); + return PostSyncAction::none; + }()); + //ATTENTION: "progressDlg_" is an unmanaged resource!!! However, at this point we already consider construction complete! => + //ZEN_ON_SCOPE_FAIL( cleanup(); ); //destructor call would lead to member double clean-up!!! +} + + +BatchStatusHandler::~BatchStatusHandler() +{ + if (progressDlg_) //prepareResult() was not called! + std::abort(); +} + + +BatchStatusHandler::Result BatchStatusHandler::prepareResult() +{ + //keep correct summary window stats considering count down timer, system sleep + const std::chrono::milliseconds totalTime = progressDlg_->pauseAndGetTotalTime(); + + //append "extra" log for sync errors that could not otherwise be reported: + if (const ErrorLog extraLog = fetchExtraLog(); + !extraLog.empty()) + { + append(errorLog_.ref(), extraLog); + std::stable_sort(errorLog_.ref().begin(), errorLog_.ref().end(), [](const LogEntry& lhs, const LogEntry& rhs) { return lhs.time < rhs.time; }); + } + + //determine post-sync status irrespective of further errors during tear-down + assert(!syncResult_); + syncResult_ = [&] + { + if (taskCancelled()) + { + logMsg(errorLog_.ref(), _("Stopped"), MSG_TYPE_ERROR); //= user cancel or "stop on first error" + return TaskResult::cancelled; + } + const ErrorLogStats logCount = getStats(errorLog_.ref()); + if (logCount.errors > 0) + return TaskResult::error; + else if (logCount.warnings > 0) + return TaskResult::warning; + + if (getTotalStats() == ProgressStats()) + logMsg(errorLog_.ref(), _("Nothing to synchronize"), MSG_TYPE_INFO); + return TaskResult::success; + }(); + + assert(*syncResult_ == TaskResult::cancelled || currentPhase() == ProcessPhase::sync); + + const ProcessSummary summary + { + startTime_, *syncResult_, {jobName_}, + getCurrentStats(), + getTotalStats (), + totalTime + }; + + return {summary, errorLog_}; +} + + +BatchStatusHandler::DlgOptions BatchStatusHandler::showResult() +{ + bool autoClose = false; + bool suspend = false; + FinalRequest finalRequest = FinalRequest::none; + + if (taskCancelled() && *taskCancelled() == CancelReason::user) + { + /* user cancelled => don't run post sync command + => don't send email notification + => don't play sound notification + => don't run post sync action */ + if (switchToGuiRequested_) //-> avoid recursive yield() calls, thous switch not before ending batch mode + { + autoClose = true; + finalRequest = FinalRequest::switchGui; + } + } + else + { + //--------------------- post sync actions ---------------------- + auto proceedWithShutdown = [&](const std::wstring& operationName) + { + if (progressDlg_->getWindowIfVisible()) + try + { + assert(!endsWith(operationName, L".")); + auto notifyStatusThrowOnCancel = [&](const std::wstring& timeRemMsg) + { + try { updateStatus(operationName + L"... " + timeRemMsg); /*throw CancelProcess*/ } + catch (CancelProcess&) + { + if (taskCancelled() && *taskCancelled() == CancelReason::user) + throw; + } + }; + delayAndCountDown(std::chrono::seconds(10), notifyStatusThrowOnCancel); //throw CancelProcess + } + catch (CancelProcess&) { return false; } + + return true; + }; + + switch (progressDlg_->getAndFreezePostSyncAction()) + { + case PostSyncAction::none: + autoClose = progressDlg_->getOptionAutoCloseDialog(); + break; + case PostSyncAction::exit: + assert(false); + break; + case PostSyncAction::sleep: + if (proceedWithShutdown(_("System: Sleep"))) + { + autoClose = progressDlg_->getOptionAutoCloseDialog(); + suspend = true; + } + break; + case PostSyncAction::shutdown: + if (proceedWithShutdown(_("System: Shut down"))) + { + autoClose = true; + finalRequest = FinalRequest::shutdown; //system shutdown must be handled by calling context! + } + break; + } + } + + if (suspend) //...*before* results dialog is shown + try + { + suspendSystem(); //throw FileError + } + catch (const FileError& e) { logMsg(errorLog_.ref(), e.toString(), MSG_TYPE_ERROR); } + + //--------------------- sound notification ---------------------- + if (taskCancelled() && *taskCancelled() == CancelReason::user) + ; + else if (!suspend && !autoClose && //only play when actually showing results dialog + !soundFileSyncComplete_.empty()) + { + //wxWidgets shows modal error dialog by default => "no, wxWidgets, NO!" + wxLog* oldLogTarget = wxLog::SetActiveTarget(new wxLogStderr); //transfer and receive ownership! + ZEN_ON_SCOPE_EXIT(delete wxLog::SetActiveTarget(oldLogTarget)); + + wxSound::Play(utfTo(soundFileSyncComplete_), wxSOUND_ASYNC); + } + //if (::GetForegroundWindow() != GetHWND()) + // RequestUserAttention(); -> probably too much since task bar is already colorized with Taskbar::Status::error or Status::normal + + const auto [autoCloseDialog, dim] = progressDlg_->destroy(autoClose, + true /*restoreParentFrame: n/a here*/, + *syncResult_, errorLog_); + //caveat: calls back to getErrorStats() => share errorLog_ + progressDlg_ = nullptr; + + return {dim, finalRequest}; +} + + +wxWindow* BatchStatusHandler::getWindowIfVisible() +{ + return progressDlg_ ? progressDlg_->getWindowIfVisible() : nullptr; +} + + +void BatchStatusHandler::initNewPhase(int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) +{ + StatusHandler::initNewPhase(itemsTotal, bytesTotal, phaseID); + progressDlg_->initNewPhase(); //call after "StatusHandler::initNewPhase" + + //macOS needs a full yield to update GUI and get rid of "dummy" texts + requestUiUpdate(true /*force*/); //throw CancelProcess +} + + +void BatchStatusHandler::updateDataProcessed(int itemsDelta, int64_t bytesDelta) //noexcept! +{ + StatusHandler::updateDataProcessed(itemsDelta, bytesDelta); + + //note: this method should NOT throw in order to properly allow undoing setting of statistics! + progressDlg_->notifyProgressChange(); //noexcept + //for "curveDataBytes_->addRecord()" +} + + +void BatchStatusHandler::logMessage(const std::wstring& msg, MsgType type) +{ + logMsg(errorLog_.ref(), msg, [&] + { + switch (type) + { + case MsgType::info: return MSG_TYPE_INFO; + case MsgType::warning: return MSG_TYPE_WARNING; + case MsgType::error: return MSG_TYPE_ERROR; + } + assert(false); + return MSG_TYPE_ERROR; + }()); + requestUiUpdate(false /*force*/); //throw CancelProcess +} + + +void BatchStatusHandler::reportWarning(const std::wstring& msg, bool& warningActive) +{ + PauseTimers dummy(*progressDlg_); + + logMsg(errorLog_.ref(), msg, MSG_TYPE_WARNING); + + if (!warningActive) + return; + + if (!progressDlg_->getOptionIgnoreErrors()) + switch (batchErrorHandling_) + { + case BatchErrorHandling::showPopup: + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + bool dontWarnAgain = false; + switch (showQuestionDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::warning, + PopupDialogCfg().setDetailInstructions(msg + L"\n\n" + _("You can switch to FreeFileSync's main window to resolve this issue.")). + alertWhenPending(soundFileAlertPending_). + setCheckBox(dontWarnAgain, _("&Don't show this warning again"), static_cast(QuestionButton2::no)), + _("&Ignore"), _("&Switch"))) + { + case QuestionButton2::yes: //ignore + warningActive = !dontWarnAgain; + break; + + case QuestionButton2::no: //switch + logMsg(errorLog_.ref(), _("Switching to FreeFileSync's main window"), MSG_TYPE_INFO); + switchToGuiRequested_ = true; //treat as a special kind of cancel + cancelProcessNow(CancelReason::user); //throw CancelProcess + + case QuestionButton2::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + break; //keep it! last switch might not find match + + case BatchErrorHandling::cancel: + cancelProcessNow(CancelReason::firstError); //throw CancelProcess + break; + } +} + + +ProcessCallback::Response BatchStatusHandler::reportError(const ErrorInfo& errorInfo) +{ + PauseTimers dummy(*progressDlg_); + + //log actual fail time (not "now"!) + const time_t failTime = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - + std::chrono::duration_cast(std::chrono::steady_clock::now() - errorInfo.failTime)); + //auto-retry + if (errorInfo.retryNumber < autoRetryCount_) + { + logMsg(errorLog_.ref(), errorInfo.msg + L"\n-> " + _("Automatic retry"), MSG_TYPE_INFO, failTime); + delayAndCountDown(errorInfo.failTime + autoRetryDelay_ - std::chrono::steady_clock::now(), + [&, statusPrefix = _("Automatic retry") + + (errorInfo.retryNumber == 0 ? L"" : L' ' + formatNumber(errorInfo.retryNumber + 1)) + SPACED_DASH, + statusPostfix = SPACED_DASH + _("Error") + L": " + replaceCpy(errorInfo.msg, L'\n', L' ')](const std::wstring& timeRemMsg) + { this->updateStatus(statusPrefix + timeRemMsg + statusPostfix); }); //throw CancelProcess + return ProcessCallback::retry; + } + + //always, except for "retry": + auto guardWriteLog = makeGuard([&] { logMsg(errorLog_.ref(), errorInfo.msg, MSG_TYPE_ERROR, failTime); }); + + if (!progressDlg_->getOptionIgnoreErrors()) + { + switch (batchErrorHandling_) + { + case BatchErrorHandling::showPopup: + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(errorInfo.msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"), _("&Retry"))) + { + case ConfirmationButton3::accept: //ignore + return ProcessCallback::ignore; + + case ConfirmationButton3::accept2: //ignore all + progressDlg_->setOptionIgnoreErrors(true); + return ProcessCallback::ignore; + + case ConfirmationButton3::decline: //retry + guardWriteLog.dismiss(); + logMsg(errorLog_.ref(), errorInfo.msg + L"\n-> " + _("Retrying operation..."), MSG_TYPE_INFO, failTime); + return ProcessCallback::retry; + + case ConfirmationButton3::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + break; //used if last switch didn't find a match + + case BatchErrorHandling::cancel: + cancelProcessNow(CancelReason::firstError); //throw CancelProcess + break; + } + } + else + return ProcessCallback::ignore; + + assert(false); + return ProcessCallback::ignore; //dummy value +} + + +void BatchStatusHandler::reportFatalError(const std::wstring& msg) +{ + PauseTimers dummy(*progressDlg_); + + logMsg(errorLog_.ref(), msg, MSG_TYPE_ERROR); + + if (!progressDlg_->getOptionIgnoreErrors()) + switch (batchErrorHandling_) + { + case BatchErrorHandling::showPopup: + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"))) + { + case ConfirmationButton2::accept: //ignore + break; + + case ConfirmationButton2::accept2: //ignore all + progressDlg_->setOptionIgnoreErrors(true); + break; + + case ConfirmationButton2::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + break; + + case BatchErrorHandling::cancel: + cancelProcessNow(CancelReason::firstError); //throw CancelProcess + break; + } +} + + +Statistics::ErrorStats BatchStatusHandler::getErrorStats() const +{ + //errorLog_ is an "append only" structure, so we can make getErrorStats() complexity "constant time": + std::for_each(errorLog_.ref().begin() + errorStatsRowsChecked_, errorLog_.ref().end(), [&](const LogEntry& entry) + { + switch (entry.type) + { + case MSG_TYPE_INFO: + break; + case MSG_TYPE_WARNING: + ++errorStatsBuf_.warningCount; + break; + case MSG_TYPE_ERROR: + ++errorStatsBuf_.errorCount; + break; + } + }); + errorStatsRowsChecked_ = errorLog_.ref().size(); + + return errorStatsBuf_; +} + + +void BatchStatusHandler::forceUiUpdateNoThrow() +{ + progressDlg_->updateGui(); +} diff --git a/FreeFileSync/Source/ui/batch_status_handler.h b/FreeFileSync/Source/ui/batch_status_handler.h new file mode 100644 index 0000000..27b1809 --- /dev/null +++ b/FreeFileSync/Source/ui/batch_status_handler.h @@ -0,0 +1,86 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BATCH_STATUS_HANDLER_H_857390451451234566 +#define BATCH_STATUS_HANDLER_H_857390451451234566 + +#include +#include "progress_indicator.h" +#include "../config.h" +#include "../status_handler.h" + + +namespace fff +{ +//BatchStatusHandler(SyncProgressDialog) will internally process Window messages! disable GUI controls to avoid unexpected callbacks! +class BatchStatusHandler : public StatusHandler +{ +public: + BatchStatusHandler(bool showProgress, + const std::wstring& jobName, //should not be empty for a batch job! + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileSyncComplete, + const Zstring& soundFileAlertPending, + const zen::WindowLayout::Dimensions& dim, + bool autoCloseDialog, + PostBatchAction postBatchAction, + BatchErrorHandling batchErrorHandling); //noexcept!! + ~BatchStatusHandler(); + + void initNewPhase (int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) override; // + void logMessage (const std::wstring& msg, MsgType type) override; // + void reportWarning (const std::wstring& msg, bool& warningActive) override; //throw CancelProcess + Response reportError (const ErrorInfo& errorInfo) override; // + void reportFatalError(const std::wstring& msg) override; // + ErrorStats getErrorStats() const override; + + void updateDataProcessed(int itemsDelta, int64_t bytesDelta) override; //noexcept + void forceUiUpdateNoThrow() override; // + + struct Result + { + ProcessSummary summary; + zen::SharedRef errorLog; + }; + Result prepareResult(); + + enum class FinalRequest + { + none, + switchGui, + shutdown + }; + struct DlgOptions + { + zen::WindowLayout::Dimensions dim; + FinalRequest finalRequest; + }; + DlgOptions showResult(); + + wxWindow* getWindowIfVisible(); + +private: + const std::wstring jobName_; + const std::chrono::system_clock::time_point startTime_; + const size_t autoRetryCount_; + const std::chrono::seconds autoRetryDelay_; + const Zstring soundFileSyncComplete_; + const Zstring soundFileAlertPending_; + + SyncProgressDialog* progressDlg_; //managed to have the same lifetime as this handler! + zen::SharedRef errorLog_ = zen::makeSharedRef(); + mutable Statistics::ErrorStats errorStatsBuf_{}; + mutable size_t errorStatsRowsChecked_ = 0; + const BatchErrorHandling batchErrorHandling_; + bool switchToGuiRequested_ = false; + std::optional syncResult_; +}; +} + +#endif //BATCH_STATUS_HANDLER_H_857390451451234566 diff --git a/FreeFileSync/Source/ui/cfg_grid.cpp b/FreeFileSync/Source/ui/cfg_grid.cpp new file mode 100644 index 0000000..0cd3a2f --- /dev/null +++ b/FreeFileSync/Source/ui/cfg_grid.cpp @@ -0,0 +1,782 @@ +// ***************************************************************************** +// * 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 "cfg_grid.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../ffs_paths.h" +#include "../afs/native.h" + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +Zstring fff::getLastRunConfigPath() +{ + return appendPath(getConfigDirPath(), Zstr("LastRun.ffs_gui")); +} + + +std::vector ConfigView::get() const +{ + std::map> itemsSorted; //sort by last use; put most recent items *first* (looks better in XML than reverted) + + for (const auto& [filePath, details] : cfgList_) + itemsSorted.emplace(details.lastUseIndex, details.cfgItem); + + std::vector cfgHistory; + for (const auto& [lastUseIndex, cfgItem] : itemsSorted) + cfgHistory.emplace_back(cfgItem); + + return cfgHistory; +} + + +void ConfigView::set(const std::vector& cfgItems) +{ + std::vector filePaths; + for (const ConfigFileItem& item : cfgItems) + filePaths.push_back(item.cfgFilePath); + + //list is stored with last used files first in XML, however addCfgFilesImpl() expects them last!!! + std::reverse(filePaths.begin(), filePaths.end()); + + cfgList_ .clear(); + cfgListView_.clear(); + addCfgFilesImpl(filePaths); + + for (const ConfigFileItem& item : cfgItems) + cfgList_.find(item.cfgFilePath)->second.cfgItem = item; //cfgFilePath must exist after addCfgFilesImpl()! + + sortListView(); +} + + +void ConfigView::addCfgFiles(const std::vector& filePaths) +{ + addCfgFilesImpl(filePaths); + sortListView(); +} + + +void ConfigView::addCfgFilesImpl(const std::vector& filePaths) +{ + //determine highest "last use" index number of m_listBoxHistory + int lastUseIndexMax = 0; + for (const auto& [filePath, details] : cfgList_) + lastUseIndexMax = std::max(lastUseIndexMax, details.lastUseIndex); + + for (const Zstring& filePath : filePaths) + if (auto it = cfgList_.find(filePath); + it == cfgList_.end()) + { + Details detail{.lastUseIndex = ++lastUseIndexMax}; + detail.cfgItem.cfgFilePath = filePath; + + std::tie(detail.name, detail.cfgType, detail.isLastRunCfg) = [&] + { + if (equalNativePath(filePath, lastRunConfigPath_)) + return std::make_tuple(utfTo(L'[' + _("Last session") + L']'), Details::CFG_TYPE_GUI, true); + + const Zstring fileName = getItemName(filePath); + + if (endsWithAsciiNoCase(fileName, ".ffs_gui")) + return std::make_tuple(beforeLast(fileName, Zstr('.'), IfNotFoundReturn::none), Details::CFG_TYPE_GUI, false); + else if (endsWithAsciiNoCase(fileName, ".ffs_batch")) + return std::make_tuple(beforeLast(fileName, Zstr('.'), IfNotFoundReturn::none), Details::CFG_TYPE_BATCH, false); + else + return std::make_tuple(fileName, Details::CFG_TYPE_NONE, false); + }(); + + auto itNew = cfgList_.emplace_hint(cfgList_.end(), filePath, std::move(detail)); + cfgListView_.push_back(itNew); + } + else + it->second.lastUseIndex = ++lastUseIndexMax; +} + + +void ConfigView::removeItems(const std::vector& filePaths) +{ + for (const Zstring& filePath : filePaths) + if (auto it = cfgList_.find(filePath); + it != cfgList_.end()) + { + std::erase(cfgListView_, it); + cfgList_.erase(it); + } + else assert(false); + + assert(cfgList_.size() == cfgListView_.size()); + + if (sortColumn_ == ColumnTypeCfg::name) + sortListView(); //needed if top element of colored-group is removed +} + + +void ConfigView::renameItem(const Zstring& pathFrom, const Zstring& pathTo) +{ + auto it = cfgList_.find(pathFrom); + assert(it != cfgList_.end()); + if (it != cfgList_.end()) + { + const Details detailsOld = it->second; + + std::erase(cfgListView_, it); + cfgList_.erase(it); + assert(cfgList_.size() == cfgListView_.size()); + + addCfgFilesImpl({pathTo}); + + //let's not lose certain metadata after renaming! + auto it2 = cfgList_.find(pathTo); + assert(it2 != cfgList_.end()); + if (it2 != cfgList_.end()) + { + it2->second.cfgItem.lastRunStats = detailsOld.cfgItem.lastRunStats; + it2->second.cfgItem.backColor = detailsOld.cfgItem.backColor; + it2->second.lastUseIndex = detailsOld.lastUseIndex; + it2->second.notes = detailsOld.notes; + } + sortListView(); + } +} + + +void ConfigView::setNotes(const Zstring& filePath, const std::wstring& notes) +{ + if (auto it = cfgList_.find(filePath); + it != cfgList_.end()) + it->second.notes = notes; + else assert(false); +} + + +void ConfigView::setLastRunStats(const std::vector& filePaths, const LastRunStats& lastRun) +{ + for (const Zstring& filePath : filePaths) + { + auto it = cfgList_.find(filePath); + assert(it != cfgList_.end()); + if (it != cfgList_.end()) + it->second.cfgItem.lastRunStats = lastRun; + } + + if (sortColumn_ != ColumnTypeCfg::name) + sortListView(); //needed if sorted by last sync time, or log +} + + +void ConfigView::setLastInSyncTime(const std::vector& filePaths, time_t lastRunTime) +{ + for (const Zstring& filePath : filePaths) + { + auto it = cfgList_.find(filePath); + assert(it != cfgList_.end()); + if (it != cfgList_.end()) + it->second.cfgItem.lastRunStats.startTime = lastRunTime; + } + + if (sortColumn_ != ColumnTypeCfg::name) + sortListView(); //needed if sorted by last sync time, or log +} + + +void ConfigView::setBackColor(const std::vector& filePaths, const wxColor& col, bool previewOnly) +{ + for (const Zstring& filePath : filePaths) + if (auto it = cfgList_.find(filePath); + it != cfgList_.end()) + { + if (previewOnly) + it->second.cfgItem.backColorPreview = col; + else + { + it->second.cfgItem.backColor = col; + it->second.cfgItem.backColorPreview = wxNullColour; + } + } + else assert(false); + + if (!previewOnly && sortColumn_ == ColumnTypeCfg::name) + sortListView(); //needed if top element of colored-group is removed +} + + +const ConfigView::Details* ConfigView::getItem(size_t row) const +{ + if (row < cfgListView_.size()) + return &cfgListView_[row]->second; + return nullptr; +} + + +std::pair ConfigView::getItem(const Zstring& filePath) const +{ + if (auto it = cfgList_.find(filePath); + it != cfgList_.end()) + return {&it->second, std::find(cfgListView_.begin(), cfgListView_.end(), it) - cfgListView_.begin()}; + return {}; +} + + +void ConfigView::setSortDirection(ColumnTypeCfg colType, bool ascending) +{ + sortColumn_ = colType; + sortAscending_ = ascending; + + sortListView(); +} + + +template +void ConfigView::sortListViewImpl() +{ + const auto lessCfgName = [](CfgFileList::iterator lhs, CfgFileList::iterator rhs) + { + if (lhs->second.isLastRunCfg != rhs->second.isLastRunCfg) + return lhs->second.isLastRunCfg; //"last session" should be at top position! + + return LessNaturalSort()(lhs->second.name, rhs->second.name); + }; + + const auto lessLastSync = [](CfgFileList::iterator lhs, CfgFileList::iterator rhs) + { + if (lhs->second.isLastRunCfg != rhs->second.isLastRunCfg) + return lhs->second.isLastRunCfg < rhs->second.isLastRunCfg; //"last session" label should be (always) last + + return makeSortDirection(std::greater(), std::bool_constant())( + lhs->second.cfgItem.lastRunStats.startTime, + rhs->second.cfgItem.lastRunStats.startTime); + //[!] ascending lastSync shows lowest "days past" first <=> highest lastSyncTime first + }; + + const auto lessSyncResult = [](CfgFileList::iterator lhs, CfgFileList::iterator rhs) + { + if (lhs->second.isLastRunCfg != rhs->second.isLastRunCfg) + return lhs->second.isLastRunCfg < rhs->second.isLastRunCfg; //"last session" label should be (always) last + + const bool haveResultL = !AFS::isNullPath(lhs->second.cfgItem.lastRunStats.logFilePath); + const bool haveResultR = !AFS::isNullPath(rhs->second.cfgItem.lastRunStats.logFilePath); + if (haveResultL != haveResultR) + return haveResultL > haveResultR; //move sync jobs that were never run to the back + + //primary sort order + if (haveResultL && lhs->second.cfgItem.lastRunStats.syncResult != rhs->second.cfgItem.lastRunStats.syncResult) + return makeSortDirection(std::greater(), std::bool_constant())(lhs->second.cfgItem.lastRunStats.syncResult, rhs->second.cfgItem.lastRunStats.syncResult); + + //secondary sort order + return LessNaturalSort()(lhs->second.name, rhs->second.name); + }; + + switch (sortColumn_) + { + case ColumnTypeCfg::name: + //pre-sort by name + std::sort(cfgListView_.begin(), cfgListView_.end(), lessCfgName); + + //aggregate groups by color (*almost* like a std::stable_sort) + for (auto it = cfgListView_.begin(); it != cfgListView_.end(); ) + if ((*it)->second.cfgItem.backColor.IsOk()) + it = std::stable_partition(it + 1, cfgListView_.end(), + [&groupCol = (*it)->second.cfgItem.backColor](CfgFileList::iterator item) { return item->second.cfgItem.backColor == groupCol; }); + else + ++it; + + //simplify aggregation logic by not having to consider "ascending/descending" + if (!ascending) + std::reverse(cfgListView_.begin(), cfgListView_.end()); + break; + + case ColumnTypeCfg::lastSync: + std::sort(cfgListView_.begin(), cfgListView_.end(), lessLastSync); + break; + + case ColumnTypeCfg::lastLog: + std::sort(cfgListView_.begin(), cfgListView_.end(), lessSyncResult); + break; + } +} + + +void ConfigView::sortListView() +{ + if (sortAscending_) + sortListViewImpl(); + else + sortListViewImpl(); +} + +//------------------------------------------------------------------------------------------- +//------------------------------------------------------------------------------------------- + +namespace +{ +class GridDataCfg : private wxEvtHandler, public GridData +{ +public: + GridDataCfg(Grid& grid) : grid_(grid) + { + grid.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onMouseLeft (event); }); + grid.Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onMouseLeftDouble(event); }); + } + + ConfigView& getDataView() { return cfgView_; } + + static int getRowDefaultHeight(const Grid& grid) + { + return std::max(dipToWxsize(getMenuIconDipSize()), + grid.getMainWin().GetCharHeight()) + dipToWxsize(1) /*extra space*/; + } + + int getSyncOverdueDays() const { return syncOverdueDays_; } + void setSyncOverdueDays(int syncOverdueDays) { syncOverdueDays_ = syncOverdueDays; } + +private: + size_t getRowCount() const override { return cfgView_.getRowCount(); } + + static int getDaysPast(time_t last) + { + time_t now = std::time(nullptr); + + const TimeComp tcNow = getLocalTime(now); + const TimeComp tcLast = getLocalTime(last); + if (tcNow == TimeComp() || tcLast == TimeComp()) + { + assert(false); + return 0; + } + + //truncate down to midnight => incorrect during DST switches, but doesn't matter due to rounding below + now -= tcNow .hour * 3600 + tcNow .minute * 60 + tcNow .second; + last -= tcLast.hour * 3600 + tcLast.minute * 60 + tcLast.second; + + return numeric::intDivRound(now - last, 24 * 3600); + } + + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (const ConfigView::Details* item = cfgView_.getItem(row)) + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + return utfTo(item->name); + + case ColumnTypeCfg::lastSync: + if (!item->isLastRunCfg && item->cfgItem.lastRunStats.startTime > 0) + { + //if (item->cfgItem.lastRunStats.startTime == 0) + // return std::wstring(1, EN_DASH); + + //return utfTo(formatTime(formatDateTimeTag, getLocalTime(item->cfgItem.lastRunStats.startTime))); + + const int daysPast = getDaysPast(item->cfgItem.lastRunStats.startTime); + return daysPast == 0 ? + utfTo(formatTime(Zstr("%R") /*equivalent to "%H:%M"*/, getLocalTime(item->cfgItem.lastRunStats.startTime))) : + //_("Today") : + _P("1 day", "%x days", daysPast); + } + break; + + case ColumnTypeCfg::lastLog: + if (!item->isLastRunCfg && !AFS::isNullPath(item->cfgItem.lastRunStats.logFilePath)) + return getSyncResultLabel(item->cfgItem.lastRunStats.syncResult); + break; + } + return std::wstring(); + } + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + if (selected) + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT)); + //else: wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW) already the default! + } + + enum class HoverAreaConfig + { + name, + link, + }; + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + wxDCTextColourChanger textColor(dc); //accessibility: always set both foreground AND background colors! + if (selected) + textColor.Set(wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHTTEXT)); + //else: wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT) already the default! + + if (const ConfigView::Details* item = cfgView_.getItem(row)) + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + { + wxColor backColor = item->cfgItem.backColor; + if (item->cfgItem.backColorPreview.IsOk()) + backColor = item->cfgItem.backColorPreview; + + if (backColor.IsOk()) + { + wxRect rectTmp = rect; + if (!selected || item->cfgItem.backColorPreview.IsOk()) + { + rectTmp.width = rect.width * 2 / 3; + clearArea(dc, rectTmp, backColor); //accessibility: always set both foreground AND background colors! + textColor.Set(relativeContrast(backColor, *wxWHITE) > + relativeContrast(backColor, *wxBLACK) ? *wxWHITE : *wxBLACK); // + + rectTmp.x += rectTmp.width; + rectTmp.width = rect.width - rectTmp.width; + dc.GradientFillLinear(rectTmp, backColor, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), wxEAST); + } + else //always show a glimpse of the background color + { + rectTmp.width = getColumnGapLeft() + dipToWxsize(getMenuIconDipSize()); + clearArea(dc, rectTmp, backColor); + + rectTmp.x += rectTmp.width; + rectTmp.width = getColumnGapLeft(); + dc.GradientFillLinear(rectTmp, backColor, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT), wxEAST); + } + } + if (!selected && static_cast(rowHover) == HoverAreaConfig::name) + drawRectangleBorder(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT), dipToWxsize(1)); + + //------------------------------------------------------------------------------------- + wxRect rectTmp = rect; + rectTmp.x += getColumnGapLeft(); + rectTmp.width -= getColumnGapLeft(); + + const wxImage cfgIcon = [&] + { + switch (item->cfgType) + { + case ConfigView::Details::CFG_TYPE_NONE: + return wxNullImage; + case ConfigView::Details::CFG_TYPE_GUI: + return loadImage("start_sync", dipToScreen(getMenuIconDipSize())); + case ConfigView::Details::CFG_TYPE_BATCH: + return loadImage("cfg_batch", dipToScreen(getMenuIconDipSize())); + } + assert(false); + return wxNullImage; + }(); + if (cfgIcon.IsOk()) + drawBitmapRtlNoMirror(dc, enabled ? cfgIcon : cfgIcon.ConvertToDisabled(), rectTmp, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + + rectTmp.x += dipToWxsize(getMenuIconDipSize()) + getColumnGapLeft(); + rectTmp.width -= dipToWxsize(getMenuIconDipSize()) + getColumnGapLeft(); + + if (!item->notes.empty()) + rectTmp.width -= dipToWxsize(getMenuIconDipSize()) + getColumnGapLeft(); + + drawCellText(dc, rectTmp, getValue(row, colType)); + + if (!item->notes.empty()) + { + rectTmp.x += rectTmp.width; + rectTmp.width = dipToWxsize(getMenuIconDipSize()); + + const wxImage notesIcon = loadImage("notes", dipToScreen(getMenuIconDipSize())); + drawBitmapRtlNoMirror(dc, enabled ? notesIcon : notesIcon.ConvertToDisabled(), rectTmp, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + } + } + break; + + case ColumnTypeCfg::lastSync: + { + wxDCTextColourChanger textColor2(dc); + if (syncOverdueDays_ > 0) + if (getDaysPast(item->cfgItem.lastRunStats.startTime) >= syncOverdueDays_) + textColor2.Set(*wxRED); //text barely readable when selected, for 4.5 contrast would need to be white :( + + drawCellText(dc, rect, getValue(row, colType), wxALIGN_CENTER); + } + break; + + case ColumnTypeCfg::lastLog: + if (!item->isLastRunCfg && !AFS::isNullPath(item->cfgItem.lastRunStats.logFilePath)) + { + const wxImage statusIcon = [&] + { + switch (item->cfgItem.lastRunStats.syncResult) + { + case TaskResult::success: + return loadImage("msg_success", dipToScreen(getMenuIconDipSize())); + case TaskResult::warning: + return loadImage("msg_warning", dipToScreen(getMenuIconDipSize())); + case TaskResult::error: + case TaskResult::cancelled: + return loadImage("msg_error", dipToScreen(getMenuIconDipSize())); + } + assert(false); + return wxNullImage; + }(); + drawBitmapRtlNoMirror(dc, enabled ? statusIcon : statusIcon.ConvertToDisabled(), rect, wxALIGN_CENTER); + } + if (static_cast(rowHover) == HoverAreaConfig::link) + drawBitmapRtlNoMirror(dc, loadImage("file_link_16"), rect, wxALIGN_CENTER); + break; + } + } + + int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override + { + // -> synchronize renderCell() <-> getBestSize() + + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + return getColumnGapLeft() + dipToWxsize(getMenuIconDipSize()) + getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth() + getColumnGapLeft(); + + case ColumnTypeCfg::lastSync: + return getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth() + getColumnGapLeft(); + + case ColumnTypeCfg::lastLog: + return dipToWxsize(getMenuIconDipSize()); + } + assert(false); + return 0; + } + + HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override + { + if (const ConfigView::Details* item = cfgView_.getItem(row)) + { + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + case ColumnTypeCfg::lastSync: + //if (!item->notes.empty() && cellRelativePosX >= cellWidth - (getColumnGapLeft() + dipToWxsize(getMenuIconDipSize()) + getColumnGapLeft())) + break; + case ColumnTypeCfg::lastLog: + if (!item->isLastRunCfg && !getNativeItemPath(item->cfgItem.lastRunStats.logFilePath).empty()) + return static_cast(HoverAreaConfig::link); + break; + } + return static_cast(HoverAreaConfig::name); + } + return HoverArea::none; + } + + void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override + { + const auto colTypeCfg = static_cast(colType); + + const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted); + wxRect rectRemain = rectInner; + + wxImage sortMarker; + if (const auto [sortCol, ascending] = cfgView_.getSortDirection(); + colTypeCfg == sortCol) + { + sortMarker = loadImage(ascending ? "sort_ascending" : "sort_descending"); + if (!enabled) + sortMarker = sortMarker.ConvertToDisabled(); + } + + switch (colTypeCfg) + { + case ColumnTypeCfg::name: + case ColumnTypeCfg::lastSync: + rectRemain.x += getColumnGapLeft(); + rectRemain.width -= getColumnGapLeft(); + drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); + + if (sortMarker.IsOk()) + drawBitmapRtlNoMirror(dc, sortMarker, rectInner, wxALIGN_CENTER_HORIZONTAL); + break; + + case ColumnTypeCfg::lastLog: + { + const wxImage logIcon = loadImage("log_file", dipToScreen(getMenuIconDipSize())); + drawBitmapRtlNoMirror(dc, enabled ? logIcon : logIcon.ConvertToDisabled(), rectInner, wxALIGN_CENTER); + + if (sortMarker.IsOk()) + { + const int gapLeft = (rectInner.width + logIcon.GetWidth()) / 2; + rectRemain.x += gapLeft; + rectRemain.width -= gapLeft; + + drawBitmapRtlNoMirror(dc, sortMarker, rectRemain, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + } + } + break; + } + } + + std::wstring getColumnLabel(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + return _("Name"); + case ColumnTypeCfg::lastSync: + return _("Last sync"); + case ColumnTypeCfg::lastLog: + return _("Log"); + } + return std::wstring(); + } + + std::wstring getToolTip(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + case ColumnTypeCfg::lastSync: + break; + case ColumnTypeCfg::lastLog: + return getColumnLabel(colType); + } + return std::wstring(); + } + + std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override + { + if (const ConfigView::Details* item = cfgView_.getItem(row)) + { + switch (static_cast(colType)) + { + case ColumnTypeCfg::name: + case ColumnTypeCfg::lastSync: + break; + + case ColumnTypeCfg::lastLog: + if (!item->isLastRunCfg && !AFS::isNullPath(item->cfgItem.lastRunStats.logFilePath)) + { + std::wstring tooltip = getSyncResultLabel(item->cfgItem.lastRunStats.syncResult) + L"\n"; + + if (item->cfgItem.lastRunStats.errors > 0) tooltip += TAB_SPACE + _("Errors:") + L' ' + formatNumber(item->cfgItem.lastRunStats.errors) + L"\n"; + if (item->cfgItem.lastRunStats.warnings > 0) tooltip += TAB_SPACE + _("Warnings:") + L' ' + formatNumber(item->cfgItem.lastRunStats.warnings) + L"\n"; + + tooltip += TAB_SPACE + _("Items processed:") + L' ' + formatNumber(item->cfgItem.lastRunStats.itemsProcessed) + + L" (" + formatFilesizeShort(item->cfgItem.lastRunStats.bytesProcessed) + L")\n"; + + const int64_t totalTimeSec = std::chrono::duration_cast(item->cfgItem.lastRunStats.totalTime).count(); + tooltip += TAB_SPACE + _("Total time:") + L' ' + utfTo(formatTimeSpan(totalTimeSec)); + + //non-native path won't be clickable => at least show in tooltip: + if (getNativeItemPath(item->cfgItem.lastRunStats.logFilePath).empty()) + tooltip += L"\n" + AFS::getDisplayPath(item->cfgItem.lastRunStats.logFilePath); + + return tooltip; + } + break; + } + return item->notes; + } + return std::wstring(); + } + + void onMouseLeft(GridClickEvent& event) + { + if (const ConfigView::Details* item = cfgView_.getItem(event.row_)) + switch (static_cast(event.hoverArea_)) + { + case HoverAreaConfig::name: + break; + case HoverAreaConfig::link: + try + { + if (const Zstring& nativePath = getNativeItemPath(item->cfgItem.lastRunStats.logFilePath); + !nativePath.empty()) + openWithDefaultApp(nativePath); //throw FileError + else + assert(false); //see getMouseHover() + } + catch (const FileError& e) { showNotificationDialog(&grid_, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } + return; + } + event.Skip(); + } + + void onMouseLeftDouble(GridClickEvent& event) + { + switch (static_cast(event.hoverArea_)) + { + case HoverAreaConfig::name: + break; + case HoverAreaConfig::link: + return; //swallow event here before MainDialog considers it as a request to start comparison + } + event.Skip(); + } + +private: + Grid& grid_; + ConfigView cfgView_; + int syncOverdueDays_ = 0; +}; +} + + +void cfggrid::init(Grid& grid) +{ + const int rowHeight = GridDataCfg::getRowDefaultHeight(grid); + + grid.setDataProvider(std::make_shared(grid)); + grid.showRowLabel(false); + grid.setRowHeight(rowHeight); + grid.setColumnLabelHeight(rowHeight + dipToWxsize(2)); +} + + +ConfigView& cfggrid::getDataView(Grid& grid) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->getDataView(); + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] cfggrid was not initialized."); +} + + +void cfggrid::addAndSelect(Grid& grid, const std::vector& filePaths, bool scrollToSelection) +{ + getDataView(grid).addCfgFiles(filePaths); + grid.Refresh(); //[!] let Grid know about changed row count *before* fiddling with selection!!! + + const std::set pathsSorted(filePaths.begin(), filePaths.end()); + std::vector rowsToSelect; + + for (size_t row = 0; row < grid.getRowCount(); ++row) + if (pathsSorted.contains(getDataView(grid).getItem(row)->cfgItem.cfgFilePath)) + rowsToSelect.push_back(row); + + if (scrollToSelection && !rowsToSelect.empty()) + grid.makeRowVisible(rowsToSelect[0]); //don't also set grid cursor: will confuse keyboard selection using shift and arrow keys + + grid.clearSelection(GridEventPolicy::deny); + + for (size_t row : rowsToSelect) + grid.selectRow(row, GridEventPolicy::deny); +} + + +int cfggrid::getSyncOverdueDays(Grid& grid) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->getSyncOverdueDays(); + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] cfggrid was not initialized."); +} + + +void cfggrid::setSyncOverdueDays(Grid& grid, int syncOverdueDays) +{ + auto* prov = dynamic_cast(grid.getDataProvider()); + if (!prov) + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] cfggrid was not initialized."); + + prov->setSyncOverdueDays(syncOverdueDays); + grid.Refresh(); +} diff --git a/FreeFileSync/Source/ui/cfg_grid.h b/FreeFileSync/Source/ui/cfg_grid.h new file mode 100644 index 0000000..4a75760 --- /dev/null +++ b/FreeFileSync/Source/ui/cfg_grid.h @@ -0,0 +1,172 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CONFIG_HISTORY_3248789479826359832 +#define CONFIG_HISTORY_3248789479826359832 + +#include +#include +#include +#include "../return_codes.h" +#include "../afs/concrete.h" + + +namespace fff +{ +struct LastRunStats +{ + time_t startTime = 0; //may be updated separately from log file, e.g. "nothing to sync" after comparison + //---------------------------------------------- + AbstractPath logFilePath = getNullPath(); //optional: available <=> sync took place + TaskResult syncResult = TaskResult::cancelled; + int itemsProcessed = -1; + int64_t bytesProcessed = -1; + std::chrono::milliseconds totalTime{}; + int errors = -1; + int warnings = -1; +}; + +struct ConfigFileItem +{ + ConfigFileItem() {} + ConfigFileItem(const Zstring& filePath, + const LastRunStats& runStats, + wxColor bcol) : + cfgFilePath(filePath), + lastRunStats(runStats), + backColor(bcol) {} + + Zstring cfgFilePath; + LastRunStats lastRunStats; + wxColor backColor; + wxColor backColorPreview; //while the folder picker is shown +}; + + +enum class ColumnTypeCfg +{ + name, + lastSync, + lastLog, +}; + +struct ColAttributesCfg +{ + ColumnTypeCfg type = ColumnTypeCfg::name; + int offset = 0; + int stretch = 0; + bool visible = false; +}; + +inline +std::vector getCfgGridDefaultColAttribs() +{ + using namespace zen; + return + { + {ColumnTypeCfg::name, -dipToWxsize(75) - dipToWxsize(42), 1, true}, + {ColumnTypeCfg::lastSync, dipToWxsize(75), 0, true}, + {ColumnTypeCfg::lastLog, dipToWxsize(42), 0, true}, //leave some room for the sort direction indicator + }; +} + +const ColumnTypeCfg cfgGridLastSortColumnDefault = ColumnTypeCfg::name; + +inline +bool getDefaultSortDirection(ColumnTypeCfg colType) +{ + switch (colType) + { + case ColumnTypeCfg::name: + return true; + case ColumnTypeCfg::lastSync: //actual sort order is "time since last sync" + return false; + case ColumnTypeCfg::lastLog: + return true; + } + assert(false); + return true; +} +//--------------------------------------------------------------------------------------------------------------------- +Zstring getLastRunConfigPath(); + + +class ConfigView +{ +public: + ConfigView() {} + + std::vector get() const; + void set(const std::vector& cfgItems); + + void addCfgFiles(const std::vector& filePaths); + void removeItems(const std::vector& filePaths); + void renameItem(const Zstring& pathFrom, const Zstring& pathTo); + + void setNotes(const Zstring& filePath, const std::wstring& notes); + void setLastRunStats(const std::vector& filePaths, const LastRunStats& lastRun); + void setLastInSyncTime(const std::vector& filePaths, time_t lastRunTime); + void setBackColor(const std::vector& filePaths, const wxColor& col, bool previewOnly = false); + + struct Details + { + ConfigFileItem cfgItem; + + Zstring name; + int lastUseIndex = 0; //support truncating the config list size via last usage, the higher the index the more recent the usage + bool isLastRunCfg = false; //LastRun.ffs_gui + std::wstring notes; + + enum ConfigType + { + CFG_TYPE_NONE, + CFG_TYPE_GUI, + CFG_TYPE_BATCH, + } cfgType = CFG_TYPE_NONE; + }; + + const Details* getItem(size_t row) const; + std::pair getItem(const Zstring& filePath) const; + + size_t getRowCount() const { assert(cfgList_.size() == cfgListView_.size()); return cfgListView_.size(); } + + void setSortDirection(ColumnTypeCfg colType, bool ascending); + std::pair getSortDirection() { return {sortColumn_, sortAscending_}; } + +private: + ConfigView (const ConfigView&) = delete; + ConfigView& operator=(const ConfigView&) = delete; + + void addCfgFilesImpl(const std::vector& filePaths); + + void sortListView(); + template void sortListViewImpl(); + + const Zstring lastRunConfigPath_ = getLastRunConfigPath(); //let's not use another static... + + using CfgFileList = std::map; + + CfgFileList cfgList_; + std::vector cfgListView_; //sorted view on cfgList_ + + ColumnTypeCfg sortColumn_ = cfgGridLastSortColumnDefault; + bool sortAscending_ = getDefaultSortDirection(cfgGridLastSortColumnDefault); +}; + + +namespace cfggrid +{ +void init(zen::Grid& grid); +ConfigView& getDataView(zen::Grid& grid); //grid.Refresh() after making changes! + +void addAndSelect(zen::Grid& grid, const std::vector& filePaths, bool scrollToSelection); + +int getSyncOverdueDays(zen::Grid& grid); +void setSyncOverdueDays(zen::Grid& grid, int syncOverdueDays); +} +} + +#endif //CONFIG_HISTORY_3248789479826359832 diff --git a/FreeFileSync/Source/ui/command_box.cpp b/FreeFileSync/Source/ui/command_box.cpp new file mode 100644 index 0000000..3768f2a --- /dev/null +++ b/FreeFileSync/Source/ui/command_box.cpp @@ -0,0 +1,202 @@ +// ***************************************************************************** +// * 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 "command_box.h" +#include +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +inline +wxString getSeparationLine() { return std::wstring(50, EM_DASH); } //no space between dashes! +} + + +CommandBox::CommandBox(wxWindow* parent, + wxWindowID id, + const wxString& value, + const wxPoint& pos, + const wxSize& size, + int n, + const wxString choices[], + long style, + const wxValidator& validator, + const wxString& name) : + wxComboBox(parent, id, value, pos, size, n, choices, style, validator, name) +{ + //#################################### + /*#*/ SetMinSize({dipToWxsize(150), -1}); //# workaround yet another wxWidgets bug: default minimum size is much too large for a wxComboBox + //#################################### + + Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyEvent (event); }); + Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& event) { onUpdateList(event); }); + Bind(wxEVT_COMMAND_COMBOBOX_SELECTED, [this](wxCommandEvent& event) { onSelection (event); }); + Bind(wxEVT_MOUSEWHEEL, [] (wxMouseEvent& event) {}); //swallow! this gives confusing UI feedback anyway +} + + +void CommandBox::addItemHistory() +{ + const Zstring newCommand = trimCpy(getValue()); + + if (newCommand == utfTo(getSeparationLine()) || //do not add sep. line + newCommand.empty()) + return; + + //do not add built-in commands to history + for (const auto& [description, cmd] : defaultCommands_) + if (newCommand == utfTo(description) || + equalNoCase(newCommand, cmd)) + return; + + std::erase_if(history_, [&](const Zstring& item) { return equalNoCase(newCommand, item); }); + + history_.insert(history_.begin(), newCommand); + + if (history_.size() > historyMax_) + history_.resize(historyMax_); +} + + +Zstring CommandBox::getValue() const +{ + return utfTo(trimCpy(GetValue())); +} + + +void CommandBox::setValue(const Zstring& value) +{ + setValueAndUpdateList(trimCpy(utfTo(value))); +} + + +//set value and update list are technically entangled: see potential bug description below +void CommandBox::setValueAndUpdateList(const wxString& value) +{ + //it may be a little lame to update the list on each mouse-button click, but it should be working and we dont't have to manipulate wxComboBox internals + + std::vector items; + + //1. built in commands + for (const auto& [description, cmd] : defaultCommands_) + items.push_back(description); + + //2. history elements + auto histSorted = history_; + std::sort(histSorted.begin(), histSorted.end(), LessNaturalSort() /*even on Linux*/); + + if (!items.empty() && !histSorted.empty()) + items.push_back(getSeparationLine()); + + for (const Zstring& hist : histSorted) + items.push_back(utfTo(hist)); + + //attention: if the target value is not part of the dropdown list, SetValue() will look for a string that *starts with* this value: + //e.g. if the dropdown list contains "222" SetValue("22") will erroneously set and select "222" instead, while "111" would be set correctly! + // -> by design on Windows! + if (std::find(items.begin(), items.end(), value) == items.end()) + { + if (!items.empty() && !value.empty()) + items.insert(items.begin(), {value, getSeparationLine()}); + else + items.insert(items.begin(), {value}); + } + + //this->Clear(); -> NO! emits yet another wxEVT_COMMAND_TEXT_UPDATED!!! + wxItemContainer::Clear(); //suffices to clear the selection items only! + this->Append(items); //expensive as fuck! => only call when absolutely needed! + + //this->SetSelection(wxNOT_FOUND); //don't select anything + ChangeValue(value); //preserve main text! +} + + +void CommandBox::onSelection(wxCommandEvent& event) +{ + //we cannot replace built-in commands at this position in call stack, so defer to a later time! + CallAfter([&] { onValidateSelection(); }); + + event.Skip(); +} + + +void CommandBox::onValidateSelection() +{ + const wxString value = GetValue(); + + if (value == getSeparationLine()) + return setValueAndUpdateList(wxString()); + + for (const auto& [description, cmd] : defaultCommands_) + if (description == value) + return setValueAndUpdateList(utfTo(cmd)); //replace GUI name by actual command string +} + + +void CommandBox::onUpdateList(wxEvent& event) +{ + setValue(getValue()); + event.Skip(); +} + + +void CommandBox::onKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + + switch (keyCode) + { + case WXK_DELETE: + case WXK_NUMPAD_DELETE: + { + //try to delete the currently selected config history item + int pos = this->GetCurrentSelection(); + if (0 <= pos && pos < static_cast(this->GetCount()) && + //what a mess...: + (GetValue() != GetString(pos) || //avoid problems when a character shall be deleted instead of list item + GetValue().empty())) //exception: always allow removing empty entry + { + const auto selValue = utfTo(GetString(pos)); + + if (std::find(history_.begin(), history_.end(), selValue) != history_.end()) //only history elements may be deleted + { + //save old (selected) value: deletion seems to have influence on this + const wxString currentVal = this->GetValue(); + //this->SetSelection(wxNOT_FOUND); + + //delete selected row + std::erase(history_, selValue); + + SetString(pos, wxString()); //in contrast to Delete(), this one does not kill the drop-down list and gives a nice visual feedback! + //Delete(pos); + + //(re-)set value + SetValue(currentVal); + } + return; //eat up key event + } + } + break; + + case WXK_UP: + case WXK_NUMPAD_UP: + case WXK_DOWN: + case WXK_NUMPAD_DOWN: + case WXK_PAGEUP: + case WXK_NUMPAD_PAGEUP: + case WXK_PAGEDOWN: + case WXK_NUMPAD_PAGEDOWN: + return; //swallow -> using these keys gives a weird effect due to this weird control + } + + + event.Skip(); +} diff --git a/FreeFileSync/Source/ui/command_box.h b/FreeFileSync/Source/ui/command_box.h new file mode 100644 index 0000000..c3431f8 --- /dev/null +++ b/FreeFileSync/Source/ui/command_box.h @@ -0,0 +1,56 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef COMMAND_BOX_H_18947773210473214 +#define COMMAND_BOX_H_18947773210473214 + +#include +#include +#include + + +//combobox with history function + functionality to delete items (DEL) +namespace fff +{ +class CommandBox : public wxComboBox +{ +public: + CommandBox(wxWindow* parent, + wxWindowID id, + const wxString& value = {}, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + int n = 0, + const wxString choices[] = nullptr, + long style = 0, + const wxValidator& validator = wxDefaultValidator, + const wxString& name = wxASCII_STR(wxComboBoxNameStr)); + + void setHistory(const std::vector& history, size_t historyMax) { history_ = history; historyMax_ = historyMax; } + std::vector getHistory() const { return history_; } + void addItemHistory(); //adds current item to history + + // use these two accessors instead of GetValue()/SetValue(): + Zstring getValue() const; + void setValue(const Zstring& value); + //required for setting value correctly + Linux to ensure the dropdown is shown as being populated + +private: + void onKeyEvent(wxKeyEvent& event); + void onSelection(wxCommandEvent& event); + void onValidateSelection(); + void onUpdateList(wxEvent& event); + + void setValueAndUpdateList(const wxString& value); + + std::vector history_; + size_t historyMax_ = 0; + + const std::vector> defaultCommands_; //(description/command) pairs +}; +} + +#endif //COMMAND_BOX_H_18947773210473214 diff --git a/FreeFileSync/Source/ui/file_grid.cpp b/FreeFileSync/Source/ui/file_grid.cpp new file mode 100644 index 0000000..4ac9ded --- /dev/null +++ b/FreeFileSync/Source/ui/file_grid.cpp @@ -0,0 +1,2309 @@ +// ***************************************************************************** +// * 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 "file_grid.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../base/file_hierarchy.h" + +using namespace zen; +using namespace fff; + + +namespace fff +{ +wxDEFINE_EVENT(EVENT_GRID_CHECK_ROWS, CheckRowsEvent); +wxDEFINE_EVENT(EVENT_GRID_SYNC_DIRECTION, SyncDirectionEvent); +} + + +namespace +{ +//let's NOT create wxWidgets objects statically: +wxColor getColorSyncBlue(bool faint) +{ + if (faint) return {0xed, 0xee, 0xff}; //faint blue + + return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x80, 0x94, 0xfe} /*medium blue*/ : + wxColor{0xb9, 0xbc, 0xff} /*light blue*/; +} + +wxColor getColorSyncGreen(bool faint) +{ + if (faint) + return {0xf1, 0xff, 0xed}; //faint green + + return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x6c, 0xfb, 0x53} /*medium green*/ : + wxColor{0xc4, 0xff, 0xb9} /*light green*/; +} + +wxColor getColorConflictBackground (bool faint) { if (faint) return {0xfe, 0xfe, 0xda}; return {247, 252, 62}; } //yellow +wxColor getColorDifferentBackground(bool faint) { if (faint) return {0xff, 0xed, 0xee}; return {255, 185, 187}; } //red + +wxColor getColorSymlinkBackground() { return {238, 201, 0}; } //orange + +wxColor getColorInactiveBack() { return wxSystemSettings::GetAppearance().IsDark() ? 0x6c6c6c /*medium grey*/ : 0xe4e4e4 /*light grey*/; } +wxColor getColorInactiveText() { return wxSystemSettings::GetAppearance().IsDark() ? 0xffffff /*white*/ : 0x404040 /*dark grey*/; } + +wxColor getColorGridLine() { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW); } + +const int FILE_GRID_GAP_SIZE_DIP = 2; +const int FILE_GRID_GAP_SIZE_WIDE_DIP = 6; + +/* class hierarchy: GridDataBase + /|\ + ___________|____________ + | | + GridDataRim | + /|\ | + ______|_______ | + | | | + GridDataLeft GridDataRight GridDataCenter */ + + +//accessibility, support high-contrast schemes => work with user-defined background color! +wxColor getGridAlternateBackgroundColor() +{ + const wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + /* CAVEAT: macOS uses partially-transparent colors! but probably not for this one: + wxSYS_COLOUR_WINDOW RGBA = #171717FF */ + + const bool isColorLight = relativeLuminance(backCol) > 0.5; + + //darken or brighten: only a faint gradient to avoid visual distraction + auto liftChannel = [diff = isColorLight ? -15 : 15](unsigned char c) { return static_cast(std::clamp(c + diff, 0, 255)); }; + + return wxColor(liftChannel(backCol.Red ()), + liftChannel(backCol.Green()), + liftChannel(backCol.Blue ())); +} + + +std::pair getCudAction(SyncOperation so) +{ + switch (so) + { + case SO_CREATE_LEFT: + case SO_MOVE_LEFT_TO: return {CudAction::create, SelectSide::left}; + + case SO_CREATE_RIGHT: + case SO_MOVE_RIGHT_TO: return {CudAction::create, SelectSide::right}; + + case SO_DELETE_LEFT: + case SO_MOVE_LEFT_FROM: return {CudAction::delete_, SelectSide::left}; + + case SO_DELETE_RIGHT: + case SO_MOVE_RIGHT_FROM: return {CudAction::delete_, SelectSide::right}; + + case SO_OVERWRITE_LEFT: + case SO_RENAME_LEFT: return {CudAction::update, SelectSide::left}; + + case SO_OVERWRITE_RIGHT: + case SO_RENAME_RIGHT: return {CudAction::update, SelectSide::right}; + + case SO_DO_NOTHING: + case SO_EQUAL: + case SO_UNRESOLVED_CONFLICT: return {CudAction::noChange, SelectSide::left}; + } + assert(false); + return {CudAction::noChange, SelectSide::left}; +} + + +wxColor getBackGroundColorSyncAction(SyncOperation so) +{ + switch (so) + { + case SO_CREATE_LEFT: + case SO_OVERWRITE_LEFT: + case SO_DELETE_LEFT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + case SO_RENAME_LEFT: + return getColorSyncBlue(false /*faint*/); + + case SO_CREATE_RIGHT: + case SO_OVERWRITE_RIGHT: + case SO_DELETE_RIGHT: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + case SO_RENAME_RIGHT: + return getColorSyncGreen(false /*faint*/); + + case SO_DO_NOTHING: + return getColorInactiveBack(); + case SO_EQUAL: + break; //usually white + case SO_UNRESOLVED_CONFLICT: + return getColorConflictBackground(false /*faint*/); + } + return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); +} + + +wxColor getBackGroundColorCmpDifference(CompareFileResult cmpResult) +{ + switch (cmpResult) + { + case FILE_EQUAL: + break; //usually white + case FILE_LEFT_ONLY: return getColorSyncBlue(false /*faint*/); + case FILE_LEFT_NEWER: return getColorSyncBlue(true /*faint*/); + + case FILE_RIGHT_ONLY: return getColorSyncGreen(false /*faint*/); + case FILE_RIGHT_NEWER: return getColorSyncGreen(true /*faint*/); + + case FILE_DIFFERENT_CONTENT: + return getColorDifferentBackground(false /*faint*/); + case FILE_RENAMED: //similar to both "equal" and "conflict": give hint via background color + case FILE_TIME_INVALID: + case FILE_CONFLICT: + return getColorConflictBackground(false /*faint*/); + } + return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); +} + + +class GridEventManager; +class GridDataLeft; +class GridDataRight; + +class IconUpdater : private wxEvtHandler //update file icons periodically: use SINGLE instance to coordinate left and right grids in parallel +{ +public: + IconUpdater(GridDataLeft& provLeft, GridDataRight& provRight, IconBuffer& iconBuffer) : provLeft_(provLeft), provRight_(provRight), iconBuffer_(iconBuffer) + { + timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { loadIconsAsynchronously(event); }); + } + + void start() { if (!timer_.IsRunning()) timer_.Start(100); } //timer interval in [ms] + //don't check too often! give worker thread some time to fetch data + +private: + void stop() { if (timer_.IsRunning()) timer_.Stop(); } + + void loadIconsAsynchronously(wxEvent& event); //loads all (not yet) drawn icons + + GridDataLeft& provLeft_; + GridDataRight& provRight_; + IconBuffer& iconBuffer_; + wxTimer timer_; +}; + + +struct IconManager +{ + IconManager() {} + + IconManager(GridDataLeft& provLeft, GridDataRight& provRight, IconBuffer::IconSize sz, bool showFileIcons) : + fileIcon_ (IconBuffer::genericFileIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), + dirIcon_ (IconBuffer::genericDirIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), + linkOverlayIcon_ (IconBuffer::linkOverlayIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), + plusOverlayIcon_ (IconBuffer::plusOverlayIcon (showFileIcons ? sz : IconBuffer::IconSize::small)), + minusOverlayIcon_(IconBuffer::minusOverlayIcon(showFileIcons ? sz : IconBuffer::IconSize::small)) + { + if (showFileIcons) + { + iconBuffer_ .emplace(sz); + iconUpdater_.emplace(provLeft, provRight, *iconBuffer_); + } + } + + int getIconWxsize() const { return screenToWxsize(iconBuffer_ ? iconBuffer_->getPixSize() : IconBuffer::getPixSize(IconBuffer::IconSize::small)); } + + IconBuffer* getIconBuffer() { return get(iconBuffer_); } + void startIconUpdater() { assert(iconUpdater_); if (iconUpdater_) iconUpdater_->start(); } + + const wxImage& getGenericFileIcon () const { return fileIcon_; } + const wxImage& getGenericDirIcon () const { return dirIcon_; } + const wxImage& getLinkOverlayIcon () const { return linkOverlayIcon_; } + const wxImage& getPlusOverlayIcon () const { return plusOverlayIcon_; } + const wxImage& getMinusOverlayIcon() const { return minusOverlayIcon_; } + +private: + const wxImage fileIcon_; + const wxImage dirIcon_; + const wxImage linkOverlayIcon_; + const wxImage plusOverlayIcon_; + const wxImage minusOverlayIcon_; + + std::optional iconBuffer_; + std::optional iconUpdater_; //bind ownership to GridDataRim<>! +}; + + +//mark rows selected on overview panel +class NavigationMarker +{ +public: + NavigationMarker() {} + + void set(std::unordered_set&& markedFilesAndLinks, + std::unordered_set&& markedContainer) + { + markedFilesAndLinks_.swap(markedFilesAndLinks); + markedContainer_ .swap(markedContainer); + } + + bool isMarked(const FileSystemObject& fsObj) const + { + if (markedFilesAndLinks_.contains(&fsObj)) //mark files/links directly + return true; + + if (auto folder = dynamic_cast(&fsObj)) + if (markedContainer_.contains(folder)) //mark folders which *are* the given ContainerObject* + return true; + + //also mark all items with any matching ancestors + for (const FileSystemObject* fsObj2 = &fsObj;;) + { + const ContainerObject& parent = fsObj2->parent(); + if (markedContainer_.contains(&parent)) + return true; + + fsObj2 = dynamic_cast(&parent); + if (!fsObj2) + return false; + } + } + +private: + std::unordered_set markedFilesAndLinks_; //mark files/symlinks directly within a container + std::unordered_set markedContainer_; //mark full container including all child-objects + //DO NOT DEREFERENCE!!!! NOT GUARANTEED TO BE VALID!!! +}; + + +struct SharedComponents //...between left, center, and right grids +{ + SharedRef gridDataView = makeSharedRef(); + SharedRef iconMgr = makeSharedRef(); + NavigationMarker navMarker; + std::unique_ptr evtMgr; + GridViewType gridViewType = GridViewType::action; + std::unordered_map compExtentsBuf_; //buffer expensive wxDC::GetTextExtent() calls! + //StringHash, StringEqual => heterogenous lookup by std::wstring_view +}; + +//######################################################################################################## + +class GridDataBase : public GridData +{ +public: + GridDataBase(Grid& grid, const SharedRef& sharedComp) : + grid_(grid), sharedComp_(sharedComp) {} + + void setData(FolderComparison& folderCmp) + { + sharedComp_.ref().gridDataView = makeSharedRef(); //clear old data view first! avoid memory peaks! + sharedComp_.ref().gridDataView = makeSharedRef(folderCmp); + sharedComp_.ref().compExtentsBuf_.clear(); //doesn't become stale! but still: re-calculate and save some memory... + } + + GridEventManager* getEventManager() { return sharedComp_.ref().evtMgr.get(); } + + /**/ FileView& getDataView() { return sharedComp_.ref().gridDataView.ref(); } + const FileView& getDataView() const { return sharedComp_.ref().gridDataView.ref(); } + + void setIconManager(const SharedRef& iconMgr) { sharedComp_.ref().iconMgr = iconMgr; } + + IconManager& getIconManager() { return sharedComp_.ref().iconMgr.ref(); } + + GridViewType getViewType() const { return sharedComp_.ref().gridViewType; } + void setViewType(GridViewType vt) { sharedComp_.ref().gridViewType = vt; } + + bool isNavMarked(const FileSystemObject& fsObj) const { return sharedComp_.ref().navMarker.isMarked(fsObj); } + + void setNavigationMarker(std::unordered_set&& markedFilesAndLinks, + std::unordered_set&& markedContainer) + { + sharedComp_.ref().navMarker.set(std::move(markedFilesAndLinks), std::move(markedContainer)); + } + + Grid& refGrid() { return grid_; } + const Grid& refGrid() const { return grid_; } + + const FileSystemObject* getFsObject(size_t row) const { return getDataView().getFsObject(row); } + + const wxSize& getTextExtentBuffered(const wxReadOnlyDC& dc, const std::wstring_view& text) + { + auto& compExtentsBuf = sharedComp_.ref().compExtentsBuf_; + //- only used for parent path names and file names on view => should not grow "too big" + //- cleaned up during GridDataBase::setData() + assert(!contains(text, L'\n')); + + auto it = compExtentsBuf.find(text); + if (it == compExtentsBuf.end()) + it = compExtentsBuf.emplace(text, dc.GetTextExtent(copyStringTo(text))).first; + //GetTextExtent() returns (0, 0) for empty string! + return it->second; + } + + //- trim while leaving path components intact + //- *always* returns at least one component, even if > maxWidth + size_t getPathTrimmedSize(const wxReadOnlyDC& dc, const std::wstring_view& itemPath, int maxWidth) + { + if (itemPath.size() <= 1) + return itemPath.size(); + + std::vector subComp; + + //split path by components, but skip slash at beginning or end + for (auto it = itemPath.begin() + 1; it != itemPath.end() - 1; ++it) + if (*it == L'/' || + *it == L'\\') + subComp.push_back(makeStringView(itemPath.begin(), it)); + + subComp.push_back(itemPath); + + if (maxWidth <= 0) + return subComp[0].size(); + + size_t low = 0; + size_t high = subComp.size(); + + for (;;) + { + if (high - low == 1) + return subComp[low].size(); + + const size_t middle = (low + high) / 2; //=> never 0 when "high - low > 1" + + if (getTextExtentBuffered(dc, subComp[middle]).GetWidth() <= maxWidth) + low = middle; + else + high = middle; + } + } + + //improve readability (while lacking cell borders) + const wxColor& getDefaultBackgroundColorAlternating(bool wantStandardColor) + { + return wantStandardColor ? gridBackgroundColor_ : gridBackgroundColorAlt_; + } + +private: + size_t getRowCount() const override { return getDataView().rowsOnView(); } + + const wxColor gridBackgroundColor_ = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + const wxColor gridBackgroundColorAlt_ = getGridAlternateBackgroundColor(); + + Grid& grid_; + SharedRef sharedComp_; +}; + +//######################################################################################################## + +template +class GridDataRim : public GridDataBase +{ +public: + GridDataRim(Grid& grid, const SharedRef& sharedComp) : GridDataBase(grid, sharedComp) {} + + void setItemPathForm(ItemPathFormat fmt) { itemPathFormat_ = fmt; groupItemNamesWidthBuf_.clear(); } + + void getUnbufferedIconsForPreload(std::vector>& newLoad) //return (priority, filepath) list + { + if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) + { + const auto& [rowFirst, rowLast] = refGrid().getVisibleRows(refGrid().getMainWin().GetClientSize()); + const ptrdiff_t visibleRowCount = rowLast - rowFirst; + + //preload icons not yet on screen: + const int preloadSize = 2 * std::max(20, visibleRowCount); //:= sum of lines above and below of visible range to preload + //=> use full visible height to handle "next page" command and a minimum of 20 for excessive mouse wheel scrolls + + for (ptrdiff_t i = 0; i < preloadSize; ++i) + { + const ptrdiff_t currentRow = rowFirst - (preloadSize + 1) / 2 + getAlternatingPos(i, visibleRowCount + preloadSize); //for odd preloadSize start one row earlier + + if (const FileSystemObject* fsObj = getFsObject(currentRow)) + if (getIconInfo(*fsObj).type == IconType::standard) + if (!iconBuf->readyForRetrieval(fsObj->template getAbstractPath())) + newLoad.emplace_back(i, fsObj->template getAbstractPath()); //insert least-important items on outer rim first + } + } + else assert(false); + } + + void updateNewAndGetUnbufferedIcons(std::vector& newLoad) //loads all not yet drawn icons + { + if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) + { + const auto& [rowFirst, rowLast] = refGrid().getVisibleRows(refGrid().getMainWin().GetClientSize()); + const ptrdiff_t visibleRowCount = rowLast - rowFirst; + + for (ptrdiff_t i = 0; i < visibleRowCount; ++i) + { + //alternate when adding rows: first, last, first + 1, last - 1 ... + const ptrdiff_t currentRow = rowFirst + getAlternatingPos(i, visibleRowCount); + + if (isFailedLoad(currentRow)) //find failed attempts to load icon + if (const FileSystemObject* fsObj = getFsObject(currentRow)) + if (getIconInfo(*fsObj).type == IconType::standard) + { + //test if they are already loaded in buffer: + if (iconBuf->readyForRetrieval(fsObj->template getAbstractPath())) + { + //do a *full* refresh for *every* failed load to update partial DC updates while scrolling + refGrid().refreshCell(currentRow, static_cast(ColumnTypeRim::path)); + setFailedLoad(currentRow, false); + } + else //not yet in buffer: mark for async. loading + newLoad.push_back(fsObj->template getAbstractPath()); + } + } + } + else assert(false); + } + +private: + bool isFailedLoad(size_t row) const { return row < failedLoads_.size() ? failedLoads_[row] != 0 : false; } + + void setFailedLoad(size_t row, bool failed = true) + { + if (failedLoads_.size() != refGrid().getRowCount()) + failedLoads_.resize(refGrid().getRowCount()); + + if (row < failedLoads_.size()) + failedLoads_[row] = failed; + } + + //icon buffer will load reversely, i.e. if we want to go from inside out, we need to start from outside in + static size_t getAlternatingPos(size_t pos, size_t total) + { + assert(pos < total); + return pos % 2 == 0 ? pos / 2 : total - 1 - pos / 2; + } + +private: + enum class DisplayType + { + inactive, + normal, + symlink, + }; + static DisplayType getObjectDisplayType(const FileSystemObject& fsObj) + { + if (!fsObj.isActive()) + return DisplayType::inactive; + + DisplayType output = DisplayType::normal; + + visitFSObject(fsObj, [](const FolderPair& folder) {}, + [](const FilePair& file) {}, + [&](const SymlinkPair& symlink) { output = DisplayType::symlink; }); + + return output; + } + + + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (const FileSystemObject* fsObj = getFsObject(row)) + if (!fsObj->isEmpty()) + { + if (static_cast(colType) == ColumnTypeRim::path) + switch (itemPathFormat_) + { + case ItemPathFormat::name: + return utfTo(fsObj->getItemName()); + case ItemPathFormat::relative: + return utfTo(fsObj->getRelativePath()); + case ItemPathFormat::full: + return AFS::getDisplayPath(fsObj->getAbstractPath()); + } + + std::wstring value; //dynamically allocates 16 byte memory! but why? shouldn't SSO make this superfluous?! or is it only in debug? + switch (static_cast(colType)) + { + case ColumnTypeRim::path: + assert(false); + break; + + case ColumnTypeRim::size: + visitFSObject(*fsObj, [&](const FolderPair& folder) { /*value = L'<' + _("Folder") + L'>'; -> redundant!? */ }, + [&](const FilePair& file) { value = formatNumber(file.getFileSize()); }, + //[&](const FilePair& file) { value = numberTo(file.getFilePrint()); }, // -> test file id + [&](const SymlinkPair& symlink) { value = L'<' + _("Symlink") + L'>'; }); + break; + + case ColumnTypeRim::date: + visitFSObject(*fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) { value = formatUtcToLocalTime(file .getLastWriteTime()); }, + [&](const SymlinkPair& symlink) { value = formatUtcToLocalTime(symlink.getLastWriteTime()); }); + break; + + case ColumnTypeRim::extension: + visitFSObject(*fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) { value = utfTo(getFileExtension(file .getItemName())); }, + [&](const SymlinkPair& symlink) { value = utfTo(getFileExtension(symlink.getItemName())); }); + break; + } + return value; + } + return {}; + } + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + + if (!enabled || !selected) + { + const wxColor backCol = [&] + { + if (pdi.fsObj && !pdi.fsObj->isEmpty()) //do we need color indication for *inactive* empty rows? probably not... + switch (getObjectDisplayType(*pdi.fsObj)) + { + case DisplayType::normal: break; + case DisplayType::symlink: return getColorSymlinkBackground(); + case DisplayType::inactive: return getColorInactiveBack(); + } + return getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); + }(); + if (backCol != wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW) /*already the default!*/) + clearArea(dc, rect, backCol); + } + else + GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); + + //---------------------------------------------------------------------------------- + const wxRect rectLine(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)); + clearArea(dc, rectLine, row == pdi.groupLastRow - 1 || //last group item + (pdi.fsObj == pdi.folderGroupObj && //folder item => distinctive separation color against subsequent file items + itemPathFormat_ != ItemPathFormat::name) ? + getColorGridLine() : getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 != 0)); + } + + + int getGroupItemNamesWidth(const wxReadOnlyDC& dc, const FileView::PathDrawInfo& pdi) + { + //FileView::updateView() called? => invalidates group item render buffer + if (pdi.viewUpdateId != viewUpdateIdLast_) + { + viewUpdateIdLast_ = pdi.viewUpdateId; + groupItemNamesWidthBuf_.clear(); + } + + auto& widthBuf = groupItemNamesWidthBuf_; + if (pdi.groupIdx >= widthBuf.size()) + widthBuf.resize(pdi.groupIdx + 1, -1 /*sentinel value*/); + + int& itemNamesWidth = widthBuf[pdi.groupIdx]; + if (itemNamesWidth < 0) + { + itemNamesWidth = 0; + //const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; + + std::vector itemWidths; + for (size_t row2 = pdi.groupFirstRow; row2 < pdi.groupLastRow; ++row2) + if (const FileSystemObject* fsObj = getDataView().getFsObject(row2)) + if (itemPathFormat_ == ItemPathFormat::name || fsObj != pdi.folderGroupObj) +#if 0 //render same layout even when items don't exist + if (fsObj->isEmpty()) + itemNamesWidth = ellipsisWidth; + else +#endif + itemWidths.push_back(getTextExtentBuffered(dc, utfTo(fsObj->getItemName())).x); + + if (!itemWidths.empty()) + { + //ignore (small number of) excessive file name widths: + auto itPercentile = itemWidths.begin() + itemWidths.size() * 8 / 10; //80th percentile + std::nth_element(itemWidths.begin(), itPercentile, itemWidths.end()); //complexity: O(n) + itemNamesWidth = std::max(itemNamesWidth, *itPercentile); + } + assert(itemNamesWidth >= 0); + + //Note: A better/faster solution would be to get 80th percentile of all std::wstring::size(), then do a *single* getTextExtentBuffered()! + // However, we need all the getTextExtentBuffered(itemName) later anyway, so above is fine. + } + return itemNamesWidth; + } + + + struct GroupRowLayout + { + std::wstring groupParentPart; //... if distributed over multiple rows, otherwise full group parent folder + std::wstring groupName; //only filled for first row of a group + std::wstring itemName; + int groupParentWidth; + int groupNameWidth; + }; + GroupRowLayout getGroupRowLayout(const wxReadOnlyDC& dc, size_t row, const FileView::PathDrawInfo& pdi, int maxWidth) + { + assert(pdi.fsObj); + + const bool drawFileIcons = getIconManager().getIconBuffer(); + const int iconSize = getIconManager().getIconWxsize(); + + //-------------------------------------------------------------------- + const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; + const int arrowRightDownWidth = getTextExtentBuffered(dc, rightArrowDown_).x; + const int groupItemNamesWidth = getGroupItemNamesWidth(dc, pdi); + //-------------------------------------------------------------------- + + //exception for readability: top row is always group start! + const size_t groupFirstRow = std::max(pdi.groupFirstRow, refGrid().getRowAtWinPos(0)); + + const size_t groupRowCount = pdi.groupLastRow - groupFirstRow; + + std::wstring itemName; + if (itemPathFormat_ == ItemPathFormat::name || //hack: show folder name in item colum since groupName/groupParentFolder are unused! + pdi.fsObj != pdi.folderGroupObj) //=> consider groupItemNamesWidth! + itemName = utfTo(pdi.fsObj->getItemName()); + //=> doesn't matter if isEmpty()! => only indicates if component should be drawn + + std::wstring groupName; + std::wstring groupParentFolder; + switch (itemPathFormat_) + { + case ItemPathFormat::name: + break; + + case ItemPathFormat::relative: + if (pdi.folderGroupObj) + { + groupName = utfTo(pdi.folderGroupObj ->template getItemName ()); + groupParentFolder = utfTo(pdi.folderGroupObj->parent().template getRelativePath()); + } + break; + + case ItemPathFormat::full: + if (pdi.folderGroupObj) + { + groupName = utfTo(pdi.folderGroupObj ->template getItemName ()); + groupParentFolder = AFS::getDisplayPath(pdi.folderGroupObj->parent().template getAbstractPath()); + } + else //=> BaseFolderPair + groupParentFolder = AFS::getDisplayPath(pdi.fsObj->base().getAbstractPath()); + break; + } + + if (!groupParentFolder.empty()) + { + const wchar_t pathSep = [&] + { + for (auto it = groupParentFolder.end(); it != groupParentFolder.begin();) //reverse iteration: 1. check 2. decrement 3. evaluate + { + --it; // + + if (*it == L'/' || + *it == L'\\') + return *it; + } + return static_cast(FILE_NAME_SEPARATOR); + }(); + if (!endsWith(groupParentFolder, pathSep)) //visual hint that this is a parent folder only + groupParentFolder += pathSep; // + } + + /* group details: single row + ________________________ ___________________________________ _____________________________________________________ + | (gap | group parent) | | (gap | icon | gap | group name) | | (2x gap | vline) | (gap | icon) | gap | item name | + ------------------------ ----------------------------------- ----------------------------------------------------- + + group details: stacked + __________________________________ ___________________________________ ___________________________________________________ + | gap | group parent, part 1 | ⤵️ | | (gap | icon | gap | group name) | | | (gap | icon) | gap | item name | + |-------------------------------------------------------------------------------------| | 2x gap | vline |--------------------------------| + | gap | group parent, part n | | | (gap | icon) | gap | item name | + --------------------------------------------------------------------------------------- --------------------------------------------------- + + -> group name on first row + -> parent name distributed over multiple rows, if needed */ + + int groupParentWidth = groupParentFolder.empty() ? 0 : (gapSize_ + getTextExtentBuffered(dc, groupParentFolder).x); + + int groupNameWidth = groupName.empty() ? 0 : (gapSize_ + iconSize + gapSize_ + getTextExtentBuffered(dc, groupName).x); + const int groupNameMinWidth = groupName.empty() ? 0 : (gapSize_ + iconSize + gapSize_ + ellipsisWidth); + + const int groupSepWidth = (groupParentFolder.empty() && groupName.empty()) ? 0 : (2 * gapSize_ + dipToWxsize(1)); + + int groupItemsWidth = groupSepWidth + (drawFileIcons ? gapSize_ + iconSize : 0) + gapSize_ + groupItemNamesWidth; + const int groupItemsMinWidth = groupSepWidth + (drawFileIcons ? gapSize_ + iconSize : 0) + gapSize_ + ellipsisWidth; + + std::wstring groupParentPart; + + //not enough space? => trim or render on multiple rows + if (int excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; + excessWidth > 0) + { + //1. shrink group parent + if (!groupParentFolder.empty()) + { + const int groupParentMinWidth = !groupName.empty() && groupRowCount > 1 ? //group parent details (possibly) on multiple rows + 0 : gapSize_ + ellipsisWidth; + + groupParentWidth = std::max(groupParentWidth - excessWidth, groupParentMinWidth); + excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; + } + + if (excessWidth > 0) + { + //2. shrink item rendering + groupItemsWidth = std::max(groupItemsWidth - excessWidth, groupItemsMinWidth); + excessWidth = groupParentWidth + groupNameWidth + groupItemsWidth - maxWidth; + + if (excessWidth > 0) + //3. shrink group name + if (!groupName.empty()) + groupNameWidth = std::max(groupNameWidth - excessWidth, groupNameMinWidth); + } + + //group parent details on multiple lines + if (!groupParentFolder.empty()) + { + //let's not waste empty row space for medium + large icon sizes: print multiple lines per row! + const int linesPerRow = std::max(refGrid().getRowHeight() / charHeight_, 1); + + size_t compPos = 0; + for (size_t i = groupFirstRow; i <= row; ++i) + for (int l = 0; l < linesPerRow; ++l) + { + const size_t compLen = i == pdi.groupLastRow - 1 && l == linesPerRow - 1 ? //not enough rows to show remaining parent folder components? + groupParentFolder.size() - compPos : //=> append the rest: will be truncated with ellipsis + getPathTrimmedSize(dc, makeStringView(groupParentFolder.begin() + compPos, groupParentFolder.end()), + groupParentWidth + (i == groupFirstRow ? 0 : groupNameWidth) - gapSize_ - arrowRightDownWidth); + + if (i == groupFirstRow && !groupName.empty() && groupRowCount > 1 && + getTextExtentBuffered(dc, makeStringView(groupParentFolder.begin() + compPos, compLen)).x > groupParentWidth - gapSize_ - arrowRightDownWidth) + { + if (i == row && l != 0) + groupParentPart.insert(groupParentPart.begin(), linesPerRow - l, L'\n'); //effectively: "align bottom" for first row + break; //exception: never truncate parent component on first row, but continue on second row instead + } + + if (i == row) + groupParentPart += compPos + compLen == groupParentFolder.size() ? + groupParentFolder.substr(compPos) : + groupParentFolder.substr(compPos, compLen) + rightArrowDown_ + L'\n'; + compPos += compLen; + + if (compPos == groupParentFolder.size()) + goto break2; + } + break2: + if (endsWith(groupParentPart, L'\n')) + groupParentPart.pop_back(); + } + } + else + { + if (row == groupFirstRow) + groupParentPart = groupParentFolder; + } + + //path components should follow the app layout direction and are NOT a single piece of text! + //caveat: - add Bidi support only during rendering and not in getValue() or AFS::getDisplayPath(): e.g. support "open file in Explorer" + // - add *after* getPathTrimmedSize(), otherwise LTR-mark can be confused for path component, e.g. "/home" would be two components! + assert(!contains(groupParentPart, slashBidi_) && !contains(groupParentPart, bslashBidi_)); + replace(groupParentPart, L'/', slashBidi_); + replace(groupParentPart, L'\\', bslashBidi_); + + return + { + std::move(groupParentPart), + row == groupFirstRow ? std::move(groupName) : std::wstring{}, + std::move(itemName), + row == groupFirstRow ? groupParentWidth : groupParentWidth + groupNameWidth, + row == groupFirstRow ? groupNameWidth : 0, + }; + } + + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + //----------------------------------------------- + //don't forget: harmonize with getBestSize()!!! + //----------------------------------------------- + + if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + pdi.fsObj) + { + //accessibility: always set both foreground AND background colors! + wxDCTextColourChanger textColor(dc); + if (enabled && selected) //=> coordinate with renderRowBackgound() + textColor.Set(*wxBLACK); + else if (!pdi.fsObj->isEmpty()) + switch (getObjectDisplayType(*pdi.fsObj)) + { + case DisplayType::normal: break; + case DisplayType::symlink: textColor.Set(*wxBLACK); break; + case DisplayType::inactive: textColor.Set(getColorInactiveText()); break; + } + + wxRect rectTmp = rect; + + switch (static_cast(colType)) + { + case ColumnTypeRim::path: + { + auto drawCudHighlight = [&](wxRect rectCud, SyncOperation syncOp) + { + if (getViewType() == GridViewType::action) + if (!enabled || !selected) + if (const auto& [cudAction, cudSide] = getCudAction(syncOp); + cudAction != CudAction::noChange && side == cudSide) + { + rectCud.width = gapSize_ + screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small)); + //fixed-size looks fine for all icon sizes! use same width even if file icons are disabled! + clearArea(dc, rectCud, getBackGroundColorSyncAction(syncOp)); + + rectCud.x += rectCud.width; + rectCud.width = gapSize_ + dipToWxsize(2); + +#if 0 //wxDC::GetPixel() is broken in GTK3! https://github.com/wxWidgets/wxWidgets/issues/14067 + wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + dc.GetPixel(rectCud.GetTopRight(), &backCol); +#else + const wxColor backCol = getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); +#endif + dc.GradientFillLinear(rectCud, getBackGroundColorSyncAction(syncOp), backCol, wxEAST); + } + }; + + bool navMarkerDrawn = false; + auto tryDrawNavMarker = [&](wxRect rectNav) + { + if (!navMarkerDrawn && + rectNav.x == rect.x && //draw marker *only* if current render group (group parent, group name, item name) is at beginning of a row! + isNavMarked(*pdi.fsObj) && + (!enabled || !selected)) + { + rectNav.width = std::min(rectNav.width, dipToWxsize(10)); + + if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! + rectNav.height -= dipToWxsize(1); + + dc.GradientFillLinear(rectNav, getColorSelectionGradientFrom(), getColorSelectionGradientTo(), wxEAST); + navMarkerDrawn = true; + } + }; + + auto drawIcon = [&](wxImage icon, wxRect rectIcon, bool drawActive) + { + if (!drawActive) + icon = icon.ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3); //treat all channels equally! + + if (!enabled) + icon = icon.ConvertToDisabled(); + + rectIcon.x += gapSize_; + rectIcon.width = getIconManager().getIconWxsize(); //center smaller-than-default icons + drawBitmapRtlNoMirror(dc, icon, rectIcon, wxALIGN_CENTER); + }; + + auto drawFileIcon = [this, &drawIcon](const wxImage& fileIcon, bool drawAsLink, const wxRect& rectIcon, const FileSystemObject& fsObj) + { + if (fileIcon.IsOk()) + drawIcon(fileIcon, rectIcon, fsObj.isActive()); + + if (drawAsLink) + drawIcon(getIconManager().getLinkOverlayIcon(), rectIcon, fsObj.isActive()); + + if (getViewType() == GridViewType::action) + if (const auto& [cudAction, cudSide] = getCudAction(fsObj.getSyncOperation()); + side == cudSide) + switch (cudAction) + { + case CudAction::create: + assert(!fileIcon.IsOk() && !drawAsLink); + if (const bool isFolder = dynamic_cast(&fsObj) != nullptr) + drawIcon(getIconManager().getGenericDirIcon().ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3). //treat all channels equally! + ConvertToDisabled(), rectIcon, true /*drawActive: [!]*/); //visual hint to distinguish file/folder creation + + //too much clutter? => drawIcon(getIconManager().getPlusOverlayIcon(), rectIcon, + // true /*drawActive: [!] e.g. disabled folder, exists left only, where child item is copied*/); + break; + case CudAction::delete_: + drawIcon(getIconManager().getMinusOverlayIcon(), rectIcon, true /*drawActive: [!]*/); + break; + case CudAction::noChange: + case CudAction::update: + break; + }; + }; + //------------------------------------------------------------------------- + + const auto& [groupParentPart, + groupName, + itemName, + groupParentWidth, + groupNameWidth] = getGroupRowLayout(dc, row, pdi, rectTmp.width); + + wxRect rectGroup, rectGroupParent, rectGroupName; + rectGroup = rectGroupParent = rectGroupName = rectTmp; + + rectGroup .width = groupParentWidth + groupNameWidth; + rectGroupParent.width = groupParentWidth; + rectGroupName .width = groupNameWidth; + rectGroupName.x += groupParentWidth; + + rectTmp.x += rectGroup.width; + rectTmp.width -= rectGroup.width; + + wxRect rectGroupItems = rectTmp; + + if (itemName.empty()) //expand group name to include unused item area (e.g. bigger selection border) + { + rectGroupName.width += rectGroupItems.width; + rectGroupItems.width = 0; + } + + //------------------------------------------------------------------------- + { + //clear background below parent path => harmonize with renderRowBackgound() + wxDCTextColourChanger textColorGroup(dc); + if (rectGroup.width > 0 && + (!enabled || !selected)) + { + wxRect rectGroupBack = rectGroup; + rectGroupBack.width += 2 * gapSize_; //include gap before vline + + if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! + rectGroupBack.height -= dipToWxsize(1); + + clearArea(dc, rectGroupBack, getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0)); + //clearArea() is surprisingly expensive => call just once! + textColorGroup.Set(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + //accessibility: always set *both* foreground AND background colors! + } + + if (!groupParentPart.empty() && + (!pdi.folderGroupObj || !pdi.folderGroupObj->isEmpty())) //don't show for missing folders + { + tryDrawNavMarker(rectGroupParent); + + wxRect rectGroupParentText = rectGroupParent; + rectGroupParentText.x += gapSize_; + rectGroupParentText.width -= gapSize_; + + //let's not waste empty row space for medium + large icon sizes: print multiple lines per row! + split(groupParentPart, L'\n', [&, linesPerRow = std::max(refGrid().getRowHeight() / charHeight_, 1), + lineNo = 0](const std::wstring_view line) mutable + { + drawCellText(dc, { + rectGroupParentText.x, //distribute lines evenly across multiple rows: + rectGroupParentText.y + (rectGroupParentText.height * (1 + lineNo++ * 2) - linesPerRow * charHeight_) / (linesPerRow * 2), + rectGroupParentText.width, charHeight_ + }, line, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, line)); + }); +#if 0 + drawCellText(dc, rectGroupParentText, groupParentPart, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, groupParentPart)); +#endif + } + + if (!groupName.empty()) + { + wxRect rectGroupNameBack = rectGroupName; + + if (!itemName.empty()) + rectGroupNameBack.width += 2 * gapSize_; //include gap left of item vline + rectGroupNameBack.height -= dipToWxsize(1); //harmonize with item separation lines + + wxDCTextColourChanger textColorGroupName(dc); + //folder background: coordinate with renderRowBackgound() + if (!enabled || !selected) + if (!pdi.folderGroupObj->isEmpty() && + !pdi.folderGroupObj->isActive()) + { + clearArea(dc, rectGroupNameBack, getColorInactiveBack()); + textColorGroupName.Set(getColorInactiveText()); + } + drawCudHighlight(rectGroupNameBack, pdi.folderGroupObj->getSyncOperation()); + tryDrawNavMarker(rectGroupName); + + wxImage folderIcon; + bool drawAsLink = false; + if (!pdi.folderGroupObj->isEmpty()) + { + folderIcon = getIconManager().getGenericDirIcon(); + drawAsLink = pdi.folderGroupObj->isFollowedSymlink(); + } + drawFileIcon(folderIcon, drawAsLink, rectGroupName, *pdi.folderGroupObj); + rectGroupName.x += gapSize_ + getIconManager().getIconWxsize() + gapSize_; + rectGroupName.width -= gapSize_ + getIconManager().getIconWxsize() + gapSize_; + + //mouse highlight: group name + if (static_cast(rowHover) == HoverAreaGroup::groupName || + (static_cast(rowHover) == HoverAreaGroup::item && pdi.fsObj == pdi.folderGroupObj /*exception: extend highlight*/)) + drawRectangleBorder(dc, rectGroupNameBack, mouseHighlightColor_, dipToWxsize(1)); + + if (!pdi.folderGroupObj->isEmpty()) + drawCellText(dc, rectGroupName, groupName, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, groupName)); + } + } + + //------------------------------------------------------------------------- + if (!itemName.empty()) + { + //draw group/items separation line + if (rectGroup.width > 0) + { + rectGroupItems.x += 2 * gapSize_; + rectGroupItems.width -= 2 * gapSize_; + + wxRect rectLine = rectGroupItems; + rectLine.width = dipToWxsize(1); + clearArea(dc, rectLine, getColorGridLine()); + + rectGroupItems.x += dipToWxsize(1); + rectGroupItems.width -= dipToWxsize(1); + } + //------------------------------------------------------------------------- + + wxRect rectItemsBack = rectGroupItems; + rectItemsBack.height -= dipToWxsize(1); //preserve item separation lines! + + drawCudHighlight(rectItemsBack, pdi.fsObj->getSyncOperation()); + tryDrawNavMarker(rectGroupItems); + + if (IconBuffer* iconBuf = getIconManager().getIconBuffer()) //=> draw file icons + { + /* whenever there's something new to render on screen, start up watching for failed icon drawing: + => ideally it would suffice to start watching only when scrolling grid or showing new grid content, but this solution is more robust + and the icon updater will stop automatically when finished anyway + Note: it's not sufficient to start up on failed icon loads only, since we support prefetching of not yet visible rows!!! */ + getIconManager().startIconUpdater(); + + wxImage fileIcon; + const IconInfo ii = getIconInfo(*pdi.fsObj); + switch (ii.type) + { + case IconType::folder: + fileIcon = getIconManager().getGenericDirIcon(); + break; + + case IconType::standard: + if (std::optional tmpIco = iconBuf->retrieveFileIcon(pdi.fsObj->template getAbstractPath())) + fileIcon = *tmpIco; + else + { + setFailedLoad(row); //save status of failed icon load -> used for async. icon loading + //falsify only! avoid writing incorrect success status when only partially updating the DC, e.g. during scrolling, + //see repaint behavior of ::ScrollWindow() function! + fileIcon = iconBuf->getIconByExtension(pdi.fsObj->template getItemName()); //better than nothing + } + break; + + case IconType::none: + break; + } + drawFileIcon(fileIcon, ii.drawAsLink, rectGroupItems, *pdi.fsObj); + rectGroupItems.x += gapSize_ + getIconManager().getIconWxsize(); + rectGroupItems.width -= gapSize_ + getIconManager().getIconWxsize(); + } + + rectGroupItems.x += gapSize_; + rectGroupItems.width -= gapSize_; + + //mouse highlight: item name + if (static_cast(rowHover) == HoverAreaGroup::item) + drawRectangleBorder(dc, rectItemsBack, mouseHighlightColor_, dipToWxsize(1)); + + if (!pdi.fsObj->isEmpty()) + drawCellText(dc, rectGroupItems, itemName, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &getTextExtentBuffered(dc, itemName)); + } + + //if not done yet: + tryDrawNavMarker(rect); + } + break; + + case ColumnTypeRim::size: + case ColumnTypeRim::date: + case ColumnTypeRim::extension: + { + if (refGrid().GetLayoutDirection() == wxLayout_RightToLeft || //remain left-justified for RTL languages + static_cast(colType) == ColumnTypeRim::extension) + { + rectTmp.x += gapSize_; + rectTmp.width -= gapSize_; + drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + } + else + { + rectTmp.width -= gapSize_; + drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL); + //macOS: wxALIGN_RIGHT also helps mitigate NSDateFormatter not zero-padding dates! + } + } + break; + } + } + } + + + HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override + { + if (static_cast(colType) == ColumnTypeRim::path) + if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + pdi.fsObj) + { + const auto& [groupParentPart, + groupName, + itemName, + groupParentWidth, + groupNameWidth] = getGroupRowLayout(dc, row, pdi, cellWidth); + + if (!groupName.empty() && pdi.fsObj != pdi.folderGroupObj) + { + const int groupNameCellBeginX = groupParentWidth; + + if (groupNameCellBeginX <= cellRelativePosX && cellRelativePosX < groupNameCellBeginX + groupNameWidth + 2 * gapSize_ /*include gap before vline*/) + return static_cast(HoverAreaGroup::groupName); + } + } + return static_cast(HoverAreaGroup::item); + } + + + int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override + { + if (static_cast(colType) == ColumnTypeRim::path) + { + int bestSize = 0; + + if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + pdi.fsObj) + { + const int insanelyHugeWidth = 1000'000'000; //(hopefully) still small enough to avoid integer overflows + /* ________________________ ___________________________________ _____________________________________________________ + | (gap | group parent) | | (gap | icon | gap | group name) | | (2x gap | vline) | (gap | icon) | gap | item name | + ------------------------ ----------------------------------- ----------------------------------------------------- */ + const auto& [groupParentPart, + groupName, + itemName, + groupParentWidth, + groupNameWidth] = getGroupRowLayout(dc, row, pdi, insanelyHugeWidth); + + const int groupSepWidth = groupParentWidth + groupNameWidth <= 0 ? 0 : (2 * gapSize_ + dipToWxsize(1)); + const int fileIconWidth = getIconManager().getIconBuffer() ? gapSize_ + getIconManager().getIconWxsize() : 0; + const int ellipsisWidth = getTextExtentBuffered(dc, ELLIPSIS).x; + const int itemWidth = itemName.empty() ? 0 : + (groupSepWidth + fileIconWidth + gapSize_ + + (pdi.fsObj->isEmpty() ? ellipsisWidth : getTextExtentBuffered(dc, itemName).x)); + + bestSize += groupParentWidth + groupNameWidth + itemWidth + gapSize_ /*[!]*/; + } + return bestSize; + } + else + { + const wxReadOnlyDC& infoDc = dc; + const std::wstring cellValue = getValue(row, colType); + return gapSize_ + infoDc.GetTextExtent(cellValue).GetWidth() + gapSize_; + } + } + + + std::wstring getColumnLabel(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeRim::path: + switch (itemPathFormat_) + { + case ItemPathFormat::name: return _("Item name"); + case ItemPathFormat::relative: return _("Relative path"); + case ItemPathFormat::full: return _("Full path"); + } + assert(false); + break; + case ColumnTypeRim::size: return _("Size"); + case ColumnTypeRim::date: return _("Date"); + case ColumnTypeRim::extension: return _("Extension"); + } + //assert(false); may be ColumnType::none + return std::wstring(); + } + + void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override + { + const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted); + wxRect rectRemain = rectInner; + + rectRemain.x += getColumnGapLeft(); + rectRemain.width -= getColumnGapLeft(); + drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); + + //draw sort marker + if (auto sortInfo = getDataView().getSortConfig()) + if (const ColumnTypeRim* sortType = std::get_if(&sortInfo->sortCol)) + if (*sortType == static_cast(colType) && sortInfo->onLeft == (side == SelectSide::left)) + { + bool ascending = sortInfo->ascending; //work around MSVC 17.4 compiler bug :( "error C2039: 'sortCol': is not a member of 'fff::FileView::SortInfo'" + + const wxImage sortMarker = loadImage(ascending ? "sort_ascending" : "sort_descending"); + drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectInner, wxALIGN_CENTER_HORIZONTAL); + } + } + + std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override + { + const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + + std::wstring toolTip; + + if (const FileSystemObject* tipObj = static_cast(rowHover) == HoverAreaGroup::groupName ? pdi.folderGroupObj : pdi.fsObj) + { + if (getDataView().getEffectiveFolderPairCount() > 1) + toolTip += AFS::getDisplayPath(tipObj->base().getAbstractPath()) + rightArrowDown_ + L"\n\n"; + + toolTip += utfTo(tipObj->getRelativePath()); + + //path components should follow the app layout direction and are NOT a single piece of text! + //caveat: add Bidi support only during rendering and not in getValue() or AFS::getDisplayPath(): e.g. support "open file in Explorer" + assert(!contains(toolTip, slashBidi_) && !contains(toolTip, bslashBidi_)); + replace(toolTip, L'/', slashBidi_); + replace(toolTip, L'\\', bslashBidi_); + + if (tipObj->isEmpty()) + toolTip += std::wstring(L"\n") + TAB_SPACE + L'<' + _("Item not existing") + L'>'; + else + visitFSObject(*tipObj, [&](const FolderPair& folder) + { + //toolTip += std::wstring(L"\n") + TAB_SPACE + '<' + _("Folder") + L'>'; -> redundant!? + }, + [&](const FilePair& file) + { + toolTip += std::wstring(L"\n") + TAB_SPACE + _("Size:") + L' ' + formatFilesizeShort (file.getFileSize ()) + + /**/ L'\n' + TAB_SPACE + _("Date:") + L' ' + formatUtcToLocalTime(file.getLastWriteTime()); + }, + [&](const SymlinkPair& symlink) + { + toolTip += std::wstring(L"\n") + TAB_SPACE + L'<' + _("Symlink") + L'>' + + /**/ L'\n' + TAB_SPACE + _("Date:") + L' ' + formatUtcToLocalTime(symlink.getLastWriteTime()); + }); + } + return toolTip; + } + + + enum class IconType + { + none, + folder, + standard, + }; + struct IconInfo + { + IconType type = IconType::none; + bool drawAsLink = false; + }; + static IconInfo getIconInfo(const FileSystemObject& fsObj) + { + IconInfo out; + + if (!fsObj.isEmpty()) + visitFSObject(fsObj, [&](const FolderPair& folder) + { + out.type = IconType::folder; + out.drawAsLink = folder.isFollowedSymlink(); + }, + + [&](const FilePair& file) + { + out.type = IconType::standard; + out.drawAsLink = file.isFollowedSymlink() || hasLinkExtension(file.getItemName()); + }, + + [&](const SymlinkPair& symlink) + { + out.type = IconType::standard; + out.drawAsLink = true; + }); + return out; + } + + const int gapSize_ = dipToWxsize(FILE_GRID_GAP_SIZE_DIP); + const int gapSizeWide_ = dipToWxsize(FILE_GRID_GAP_SIZE_WIDE_DIP); + + const wxColor mouseHighlightColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode! + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 + + const int charHeight_ = refGrid().getMainWin().GetCharHeight(); + + + ItemPathFormat itemPathFormat_ = ItemPathFormat::full; + + std::vector failedLoads_; //effectively a vector of size "number of rows" + + const std::wstring slashBidi_ = (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? RTL_MARK : LTR_MARK) + std::wstring(L"/"); + const std::wstring bslashBidi_ = (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? RTL_MARK : LTR_MARK) + std::wstring(L"\\"); + //no need for LTR/RTL marks on both sides: text follows main direction if slash is between two strong characters with different directions + + const std::wstring rightArrowDown_ = wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? + std::wstring() + RTL_MARK + LEFT_ARROW_ANTICLOCK : + std::wstring() + LTR_MARK + RIGHT_ARROW_CURV_DOWN; + //Windows bug: RIGHT_ARROW_CURV_DOWN rendering and extent calculation is buggy (see wx+\tooltip.cpp) => need LTR mark! + + std::vector groupItemNamesWidthBuf_; //buffer! groupItemNamesWidths essentially only depends on (groupIdx, side) + uint64_t viewUpdateIdLast_ = 0; // +}; + + +class GridDataLeft : public GridDataRim +{ +public: + GridDataLeft(Grid& grid, const SharedRef& sharedComp) : GridDataRim(grid, sharedComp) {} +}; + +class GridDataRight : public GridDataRim +{ +public: + GridDataRight(Grid& grid, const SharedRef& sharedComp) : GridDataRim(grid, sharedComp) {} +}; + +//######################################################################################################## + +class GridDataCenter : public GridDataBase +{ +public: + GridDataCenter(Grid& grid, const SharedRef& sharedComp) : GridDataBase(grid, sharedComp), + toolTip_(grid) {} //tool tip must not live longer than grid! + + void onSelectBegin() + { + selectionInProgress_ = true; + refGrid().clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! + toolTip_.hide(); //handle custom tooltip + } + + void onSelectEnd(size_t rowFirst, size_t rowLast, HoverArea rowHover, ptrdiff_t clickInitRow) + { + refGrid().clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! + + //issue custom event + if (selectionInProgress_) //don't process selections initiated by right-click + if (rowFirst < rowLast && rowLast <= refGrid().getRowCount()) //empty? probably not in this context + switch (static_cast(rowHover)) + { + case HoverAreaCenter::checkbox: + if (const FileSystemObject* fsObj = getFsObject(clickInitRow)) + { + const bool setIncluded = !fsObj->isActive(); + CheckRowsEvent evt(rowFirst, rowLast, setIncluded); + refGrid().GetEventHandler()->ProcessEvent(evt); + } + break; + case HoverAreaCenter::dirLeft: + { + SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::left); + refGrid().GetEventHandler()->ProcessEvent(evt); + } + break; + case HoverAreaCenter::dirNone: + { + SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::none); + refGrid().GetEventHandler()->ProcessEvent(evt); + } + break; + case HoverAreaCenter::dirRight: + { + SyncDirectionEvent evt(rowFirst, rowLast, SyncDirection::right); + refGrid().GetEventHandler()->ProcessEvent(evt); + } + break; + } + selectionInProgress_ = false; + + //update highlight_ and tooltip: on OS X no mouse movement event is generated after a mouse button click (unlike on Windows) + wxPoint clientPos = refGrid().getMainWin().ScreenToClient(wxGetMousePosition()); + evalMouseMovement(clientPos); + } + + void evalMouseMovement(const wxPoint& clientPos) + { + //manage block highlighting and custom tooltip + if (!selectionInProgress_) + { + const size_t row = refGrid().getRowAtWinPos (clientPos.y); //return -1 for invalid position, rowCount if past the end + const Grid::ColumnPosInfo cpi = refGrid().getColumnAtWinPos(clientPos.x); //returns ColumnType::none if no column at x position! + + if (row < refGrid().getRowCount() && cpi.colType != ColumnType::none && + refGrid().getMainWin().GetClientRect().Contains(clientPos)) //cursor might have moved outside visible client area + showToolTip(row, static_cast(cpi.colType), refGrid().getMainWin().ClientToScreen(clientPos)); + else + toolTip_.hide(); + } + } + + void onMouseLeave() //wxEVT_LEAVE_WINDOW does not respect mouse capture! + { + toolTip_.hide(); //handle custom tooltip + } + +private: + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (const FileSystemObject* fsObj = getFsObject(row)) + switch (static_cast(colType)) + { + case ColumnTypeCenter::checkbox: + break; + case ColumnTypeCenter::difference: + return getSymbol(fsObj->getCategory()); + case ColumnTypeCenter::action: + return getSymbol(fsObj->getSyncOperation()); + } + return std::wstring(); + } + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + + if (!enabled || !selected) + { + const wxColor backCol = [&] + { + if (!pdi.fsObj) + return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + + if (!pdi.fsObj->isActive()) + return getColorInactiveBack(); + + return getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 == 0); + }(); + if (backCol != wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW) /*already the default!*/) + clearArea(dc, rect, backCol); + } + else + GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); + + //---------------------------------------------------------------------------------- + const wxRect rectLine(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)); + clearArea(dc, rectLine, row == pdi.groupLastRow - 1 /*last group item*/ ? + getColorGridLine() : getDefaultBackgroundColorAlternating(pdi.groupIdx % 2 != 0)); + } + + enum class HoverAreaCenter //each cell can be divided into four blocks concerning mouse selections + { + checkbox, + dirLeft, + dirNone, + dirRight + }; + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + if (const FileView::PathDrawInfo pdi = getDataView().getDrawInfo(row); + pdi.fsObj) + { + auto drawHighlightBackground = [&](const wxColor& col) + { + if ((!enabled || !selected) && pdi.fsObj->isActive()) //coordinate with renderRowBackgound()! + { + wxRect rectBack = rect; + if (row == pdi.groupLastRow - 1 /*last group item*/) //preserve the group separation line! + rectBack.height -= dipToWxsize(1); + + clearArea(dc, rectBack, col); + } + }; + + switch (static_cast(colType)) + { + case ColumnTypeCenter::checkbox: + { + const bool drawMouseHover = static_cast(rowHover) == HoverAreaCenter::checkbox; + + wxImage icon = loadImage(pdi.fsObj->isActive() ? + (drawMouseHover ? "checkbox_true_hover" : "checkbox_true") : + (drawMouseHover ? "checkbox_false_hover" : "checkbox_false")); + if (!enabled) + icon = icon.ConvertToDisabled(); + + drawBitmapRtlNoMirror(dc, icon, rect, wxALIGN_CENTER); + } + break; + + case ColumnTypeCenter::difference: + { + if (getViewType() == GridViewType::difference) + drawHighlightBackground(getBackGroundColorCmpDifference(pdi.fsObj->getCategory())); + + wxRect rectTmp = rect; + { + //draw notch on left side + if (notch_.GetHeight() != wxsizeToScreen(rectTmp.height)) + notch_ = notch_.Scale(notch_.GetWidth(), wxsizeToScreen(rectTmp.height)); + + //wxWidgets screws up again and has wxALIGN_RIGHT off by one pixel! -> use wxALIGN_LEFT instead + const wxRect rectNotch(rectTmp.x + rectTmp.width - screenToWxsize(notch_.GetWidth()), rectTmp.y, + screenToWxsize(notch_.GetWidth()), rectTmp.height); + drawBitmapRtlNoMirror(dc, notch_, rectNotch, wxALIGN_LEFT); + rectTmp.width -= screenToWxsize(notch_.GetWidth()); + } + + auto drawIcon = [&](wxImage icon, int alignment) + { + if (!enabled) + icon = icon.ConvertToDisabled(); + + drawBitmapRtlMirror(dc, icon, rectTmp, alignment, renderBufCmp_); + }; + + if (getViewType() == GridViewType::difference) + drawIcon(getCmpResultImage(pdi.fsObj->getCategory()), wxALIGN_CENTER); + else if (pdi.fsObj->getCategory() != FILE_EQUAL) //don't show = in both middle columns + drawIcon(greyScale(getCmpResultImage(pdi.fsObj->getCategory())), wxALIGN_CENTER); + } + break; + + case ColumnTypeCenter::action: + { + if (getViewType() == GridViewType::action) + drawHighlightBackground(getBackGroundColorSyncAction(pdi.fsObj->getSyncOperation())); + + auto drawIcon = [&](wxImage icon, int alignment) + { + if (!enabled) + icon = icon.ConvertToDisabled(); + + drawBitmapRtlMirror(dc, icon, rect, alignment, renderBufSync_); + }; + + //synchronization preview + const auto rowHoverCenter = rowHover == HoverArea::none ? HoverAreaCenter::checkbox : static_cast(rowHover); + switch (rowHoverCenter) + { + case HoverAreaCenter::dirLeft: + drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::left)), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + break; + case HoverAreaCenter::dirNone: + drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::none)), wxALIGN_CENTER); + break; + case HoverAreaCenter::dirRight: + drawIcon(getSyncOpImage(pdi.fsObj->testSyncOperation(SyncDirection::right)), wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL); + break; + case HoverAreaCenter::checkbox: + if (getViewType() == GridViewType::action) + drawIcon(getSyncOpImage(pdi.fsObj->getSyncOperation()), wxALIGN_CENTER); + else if (pdi.fsObj->getSyncOperation() != SO_EQUAL) //don't show = in both middle columns + drawIcon(greyScale(getSyncOpImage(pdi.fsObj->getSyncOperation())), wxALIGN_CENTER); + break; + } + } + break; + } + } + } + + HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override + { + if (const FileSystemObject* const fsObj = getFsObject(row)) + switch (static_cast(colType)) + { + case ColumnTypeCenter::checkbox: + case ColumnTypeCenter::difference: + return static_cast(HoverAreaCenter::checkbox); + + case ColumnTypeCenter::action: + if (fsObj->getSyncOperation() == SO_EQUAL) //in sync-preview equal files shall be treated like a checkbox + return static_cast(HoverAreaCenter::checkbox); + /* cell: ------------------------ + | left | middle | right| + ------------------------ */ + if (0 <= cellRelativePosX) + { + if (cellRelativePosX < cellWidth / 3) + return static_cast(HoverAreaCenter::dirLeft); + else if (cellRelativePosX < 2 * cellWidth / 3) + return static_cast(HoverAreaCenter::dirNone); + else if (cellRelativePosX < cellWidth) + return static_cast(HoverAreaCenter::dirRight); + } + break; + } + return HoverArea::none; + } + + std::wstring getColumnLabel(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeCenter::checkbox: + break; + case ColumnTypeCenter::difference: + return _("Difference"); + case ColumnTypeCenter::action: + return _("Action"); + } + return std::wstring(); + } + + std::wstring getToolTip(ColumnType colType) const override { return getColumnLabel(colType) + L" (F11)"; } + + void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override + { + const auto colTypeCenter = static_cast(colType); + + const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted && colTypeCenter != ColumnTypeCenter::checkbox); + + wxImage colIcon; + switch (colTypeCenter) + { + case ColumnTypeCenter::checkbox: + break; + + case ColumnTypeCenter::difference: + colIcon = greyScaleIfDisabled(loadImage("compare", dipToScreen(getMenuIconDipSize())), getViewType() == GridViewType::difference); + break; + + case ColumnTypeCenter::action: + colIcon = greyScaleIfDisabled(loadImage("start_sync", dipToScreen(getMenuIconDipSize())), getViewType() == GridViewType::action); + break; + } + + if (colIcon.IsOk()) + drawBitmapRtlNoMirror(dc, enabled ? colIcon : colIcon.ConvertToDisabled(), rectInner, wxALIGN_CENTER); + + //draw sort marker + if (auto sortInfo = getDataView().getSortConfig()) + if (const ColumnTypeCenter* sortType = std::get_if(&sortInfo->sortCol)) + if (*sortType == colTypeCenter) + { + const int gapLeft = (rectInner.width + screenToWxsize(colIcon.GetWidth())) / 2; + wxRect rectRemain = rectInner; + rectRemain.x += gapLeft; + rectRemain.width -= gapLeft; + + const wxImage sortMarker = loadImage(sortInfo->ascending ? "sort_ascending" : "sort_descending"); + drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectRemain, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + } + } + + void showToolTip(size_t row, ColumnTypeCenter colType, wxPoint posScreen) + { + if (const FileSystemObject* fsObj = getFsObject(row)) + { + switch (colType) + { + case ColumnTypeCenter::checkbox: + case ColumnTypeCenter::difference: + { + const char* imageName = [&] + { + switch (fsObj->getCategory()) + { + case FILE_RENAMED: //similar to both "equal" and "conflict" + case FILE_EQUAL: return "cat_equal"; + case FILE_LEFT_ONLY: return "cat_left_only"; + case FILE_RIGHT_ONLY: return "cat_right_only"; + case FILE_LEFT_NEWER: return "cat_left_newer"; + case FILE_RIGHT_NEWER: return "cat_right_newer"; + case FILE_DIFFERENT_CONTENT: return "cat_different"; + case FILE_TIME_INVALID: + case FILE_CONFLICT: return "cat_conflict"; + } + assert(false); + return ""; + }(); + const auto& img = mirrorIfRtl(loadImage(imageName)); + toolTip_.show(getCategoryDescription(*fsObj), posScreen, &img); + } + break; + + case ColumnTypeCenter::action: + { + const char* imageName = [&] + { + switch (fsObj->getSyncOperation()) + { + case SO_CREATE_LEFT: return "so_create_left"; + case SO_CREATE_RIGHT: return "so_create_right"; + case SO_DELETE_LEFT: return "so_delete_left"; + case SO_DELETE_RIGHT: return "so_delete_right"; + case SO_MOVE_LEFT_FROM: return "so_move_left_source"; + case SO_MOVE_LEFT_TO: return "so_move_left_target"; + case SO_MOVE_RIGHT_FROM: return "so_move_right_source"; + case SO_MOVE_RIGHT_TO: return "so_move_right_target"; + case SO_OVERWRITE_LEFT: return "so_update_left"; + case SO_OVERWRITE_RIGHT: return "so_update_right"; + case SO_RENAME_LEFT: return "so_move_left"; + case SO_RENAME_RIGHT: return "so_move_right"; + case SO_DO_NOTHING: return "so_none"; + case SO_EQUAL: return "cat_equal"; + case SO_UNRESOLVED_CONFLICT: return "cat_conflict"; + }; + assert(false); + return ""; + }(); + const auto& img = mirrorIfRtl(loadImage(imageName)); + toolTip_.show(getSyncOpDescription(*fsObj), posScreen, &img); + } + break; + } + } + else + toolTip_.hide(); //if invalid row... + } + + bool selectionInProgress_ = false; + + std::optional renderBufCmp_; //avoid costs of recreating this temporary variable + std::optional renderBufSync_; + Tooltip toolTip_; + wxImage notch_ = loadImage("notch"); +}; + +//######################################################################################################## + +class GridEventManager : private wxEvtHandler +{ +public: + GridEventManager(Grid& gridL, + Grid& gridC, + Grid& gridR, + GridDataCenter& provCenter) : + gridL_(gridL), gridC_(gridC), gridR_(gridR), + provCenter_(provCenter) + { + gridL_.Bind(EVENT_GRID_COL_RESIZE, [this](GridColumnResizeEvent& event) { onResizeColumn(event, gridL_, gridR_); }); + gridR_.Bind(EVENT_GRID_COL_RESIZE, [this](GridColumnResizeEvent& event) { onResizeColumn(event, gridR_, gridL_); }); + + gridL_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridL_); }); + gridC_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridC_); }); + gridR_.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event, gridR_); }); + + gridC_.getMainWin().Bind(wxEVT_MOTION, [this](wxMouseEvent& event) { onCenterMouseMovement(event); }); + gridC_.getMainWin().Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& event) { onCenterMouseLeave (event); }); + + gridC_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onCenterSelectBegin(event); }); + gridC_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onCenterSelectEnd (event); }); + + gridL_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onGridClickRim(event, gridL_); }); + gridR_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onGridClickRim(event, gridR_); }); + + //clear selection of other grid when selecting on + gridL_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this]( GridClickEvent& event) { onGridLeftClick(event, gridR_); }); //clear immediately, + gridL_.Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this]( GridClickEvent& event) { onGridRightClick(event, gridR_, gridL_); }); //don't wait for GridSelectEvent + gridL_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onGridSelection(event, gridR_); }); + + gridR_.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this]( GridClickEvent& event) { onGridLeftClick(event, gridL_); }); + gridR_.Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this]( GridClickEvent& event) { onGridRightClick(event, gridL_, gridR_); }); + gridR_.Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onGridSelection(event, gridL_); }); + + //parallel grid scrolling: do NOT use DoPrepareDC() to align grids! GDI resource leak! Use regular paint event instead: + gridL_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridL_); event.Skip(); }); + gridC_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridC_); event.Skip(); }); + gridR_.getMainWin().Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintGrid(gridR_); event.Skip(); }); + + + //----------------------------------------------------------------------------------------------------- + //scroll master event handling: connect LAST, so that scrollMaster_ is set BEFORE other event handling! + //----------------------------------------------------------------------------------------------------- + auto connectGridAccess = [&](Grid& grid, std::function handler) + { + grid.Bind(wxEVT_SCROLLWIN_TOP, handler); + grid.Bind(wxEVT_SCROLLWIN_BOTTOM, handler); + grid.Bind(wxEVT_SCROLLWIN_LINEUP, handler); + grid.Bind(wxEVT_SCROLLWIN_LINEDOWN, handler); + grid.Bind(wxEVT_SCROLLWIN_PAGEUP, handler); + grid.Bind(wxEVT_SCROLLWIN_PAGEDOWN, handler); + grid.Bind(wxEVT_SCROLLWIN_THUMBTRACK, handler); + //wxEVT_KILL_FOCUS -> there's no need to reset "scrollMaster" + //wxEVT_SET_FOCUS -> not good enough: + //e.g.: left grid has input, right grid is "scrollMaster" due to dragging scroll thumb via mouse. + //=> Next keyboard input on left does *not* emit focus change event, but still "scrollMaster" needs to change + //=> hook keyboard input instead of focus event: + grid.getMainWin().Bind(wxEVT_CHAR, handler); + grid.Bind(wxEVT_KEY_DOWN, handler); + //grid.getMainWin().Bind(wxEVT_KEY_UP, handler); -> superfluous? + + grid.getMainWin().Bind(wxEVT_LEFT_DOWN, handler); + grid.getMainWin().Bind(wxEVT_LEFT_DCLICK, handler); + grid.getMainWin().Bind(wxEVT_RIGHT_DOWN, handler); + grid.getMainWin().Bind(wxEVT_MOUSEWHEEL, handler); + }; + connectGridAccess(gridL_, [this](wxEvent& event) { setScrollMaster(gridL_); event.Skip(); }); // + connectGridAccess(gridC_, [this](wxEvent& event) { setScrollMaster(gridC_); event.Skip(); }); //connect *after* onKeyDown() in order to receive callback *before*!!! + connectGridAccess(gridR_, [this](wxEvent& event) { setScrollMaster(gridR_); event.Skip(); }); // + } + + ~GridEventManager() + { + //assert(!scrollbarAlignPending_); => false-positives: e.g. start ffs, right-click on grid, close dialog by clicking X + } + + void setScrollMaster(const Grid& grid) + { + if (&grid != &gridC_ && + &grid != &gridL_ && + &grid != &gridR_) + { + assert(false); //does this ever happen? + return ; + } +#if 0 + if (const std::string& logtext = "new scroll master: " + printNumber("%llx", reinterpret_cast(&grid)) + "\n"; + scrollMaster_ != &grid) + + std::cerr << logtext; +#endif + scrollMaster_ = &grid; + } + +private: + void onCenterSelectBegin(GridClickEvent& event) + { + provCenter_.onSelectBegin(); + event.Skip(); + } + + void onCenterSelectEnd(GridSelectEvent& event) + { + if (event.positive_) + { + if (event.mouseClick_) + provCenter_.onSelectEnd(event.rowFirst_, event.rowLast_, event.mouseClick_->hoverArea_, event.mouseClick_->row_); + else + provCenter_.onSelectEnd(event.rowFirst_, event.rowLast_, HoverArea::none, -1); + } + event.Skip(); + } + + void onCenterMouseMovement(wxMouseEvent& event) + { + provCenter_.evalMouseMovement(event.GetPosition()); + event.Skip(); + } + + void onCenterMouseLeave(wxMouseEvent& event) + { + provCenter_.onMouseLeave(); + event.Skip(); + } + + void onGridClickRim(GridClickEvent& event, Grid& grid) + { + if (static_cast(event.hoverArea_) == HoverAreaGroup::groupName) + if (const FileView::PathDrawInfo pdi = provCenter_.getDataView().getDrawInfo(event.row_); + pdi.fsObj) + { + const ptrdiff_t topRowOld = grid.getRowAtWinPos(0); + grid.makeRowVisible(pdi.groupFirstRow); + const ptrdiff_t topRowNew = grid.getRowAtWinPos(0); + + if (topRowNew != topRowOld) //=> grid was scrolled: prevent AddPendingEvent() recursion! + { + assert(topRowNew == makeSigned(pdi.groupFirstRow)); + assert(topRowNew == grid.getRowAtWinPos((event.mousePos_ - grid.getMainWin().GetPosition()).y)); + //don't waste a click: simulate start of new selection at Grid::MainWin-relative position (0/0): + grid.getMainWin().GetEventHandler()->AddPendingEvent(wxMouseEvent(wxEVT_LEFT_DOWN)); + return; + } + } + event.Skip(); + } + + void onGridLeftClick(GridClickEvent& event, Grid& gridOther) + { + //see grid.cpp Grid::MainWin::onMouseDown(): + if (!wxGetKeyState(WXK_CONTROL) && !wxGetKeyState(WXK_SHIFT)) //clear other grid unless user is holding CTRL, or SHIFT + gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! + event.Skip(); + } + + void onGridRightClick(GridClickEvent& event, Grid& gridOther, Grid& gridThis) + { + const std::vector& selectedRows = gridThis.getSelectedRows(); + const bool rowSelected = std::find(selectedRows.begin(), selectedRows.end(), makeUnsigned(event.row_)) != selectedRows.end(); + + //clear other grid unless GridContextMenuEvent is about to happen, or user is holding CTRL, or SHIFT + if (!rowSelected && !wxGetKeyState(WXK_CONTROL) && !wxGetKeyState(WXK_SHIFT)) + gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! + event.Skip(); + } + + void onGridSelection(GridSelectEvent& event, Grid& gridOther) + { + if (!event.mouseClick_ && !wxGetKeyState(WXK_SHIFT)) //clear other grid during keyboard selection, unless user is holding SHIFT + gridOther.clearSelection(GridEventPolicy::deny); //don't emit event, prevent recursion! + event.Skip(); + } + + void onKeyDown(wxKeyEvent& event, const Grid& grid) + { + int keyCode = event.GetKeyCode(); + if (grid.GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + + //skip middle component when navigating via keyboard + const size_t row = grid.getGridCursor(); + + if (event.ShiftDown()) + ; + else if (event.ControlDown()) + ; + else + switch (keyCode) + { + case WXK_LEFT: + case WXK_NUMPAD_LEFT: + gridL_.setGridCursor(row, GridEventPolicy::allow); + gridL_.SetFocus(); + //since key event is likely originating from right grid, we need to set scrollMaster manually! + setScrollMaster(gridL_); //onKeyDown is called *after* onGridAccessL()! + return; //swallow event + + case WXK_RIGHT: + case WXK_NUMPAD_RIGHT: + gridR_.setGridCursor(row, GridEventPolicy::allow); + gridR_.SetFocus(); + setScrollMaster(gridR_); + return; //swallow event + } + + event.Skip(); + } + + void onResizeColumn(GridColumnResizeEvent& event, const Grid& grid, Grid& gridOther) + { + //find stretch factor of resized column: type is unique due to makeConsistent()! + std::vector cfgSrc = grid.getColumnConfig(); + auto it = std::find_if(cfgSrc.begin(), cfgSrc.end(), [&](Grid::ColAttributes& ca) { return ca.type == event.colType_; }); + if (it == cfgSrc.end()) + return; + const int stretchSrc = it->stretch; + + //we do not propagate resizings on stretched columns to the other side: awkward user experience + if (stretchSrc > 0) + return; + + //apply resized offset to other side, but only if stretch factors match! + std::vector cfgTrg = gridOther.getColumnConfig(); + for (Grid::ColAttributes& ca : cfgTrg) + if (ca.type == event.colType_ && ca.stretch == stretchSrc) + ca.offset = event.offset_; + gridOther.setColumnConfig(cfgTrg); + } + + void onPaintGrid(const Grid& grid) + { +#if 0 + const std::string& logtext = "wxEVT_PAINT: " + printNumber("%llx", reinterpret_cast(&grid)) + "\n"; + std::cerr << logtext; +#endif + /* keep scroll positions of all three grids in sync + + wxGrid::Scroll() *during* vs *after* paint event: + ------------------------------------------------ + macOS: doesn't matter; 3 paint events per mouse scroll + + Linux: no visible perf issue, but + a) *during* paint event: 6 paint events + b) *after* paint event: 4 paint events + + Windows: a) *during* paint event: + 1. double-buffering(WS_EX_COMPOSITED) => excessive amount of additional paint events and accidental RECURSION!!! + Apparently multiple paint events sent (with clipped DC), then during wxGrid::Scroll() -> wxWindow::Update() -> onPaintGrid() for *SAME* grid! + 2. no double buffering => 4 paint events per mouse scroll + b) *after* paint event: + 1. double-buffering(WS_EX_COMPOSITED) => 6 paint events per mouse scroll + => no visible perf-difference compared to 2. but 60% higher CPU time during excessive scrolling + 2. no double buffering => 4 paint events per mouse scroll */ + if (&grid == scrollMaster_ && !scrollPosAlignPending_) + { + scrollPosAlignPending_ = true; + + CallAfter([this] + { + auto scroll = [this](Grid& target, int y) //support polling + { + if (&target != scrollMaster_) + { + //scroll vertically only - scrolling horizontally becomes annoying if left and right sides have different widths; + //e.g. h-scroll on left would be undone when scrolling vertically on right which doesn't have a h-scrollbar + int yOld = 0; + target.GetViewStart(nullptr, &yOld); + if (yOld != y) + target.Scroll(-1, y); //empirical test Windows/Ubuntu: this call does NOT trigger a wxEVT_SCROLLWIN event, + // which would incorrectly set "scrollMaster" to "&target"! + //CAVEAT: wxScrolledWindow::Scroll() internally calls wxWindow::Update(), leading to immediate WM_PAINT handling in the target grid! + // and this while we're still in our WM_PAINT handler! => no recursion, thanks to scrollMaster_ (hopefully) + } + }; + int y = 0; + scrollMaster_->GetViewStart(nullptr, &y); + scroll(gridC_, y); + scroll(gridL_, y); + scroll(gridR_, y); + + assert(scrollPosAlignPending_); + scrollPosAlignPending_ = false; + }); + } + + //harmonize placement of horizontal scrollbar to avoid grids getting out of sync! + //since this affects the grid that is currently repainted, run asynchronously! + if (!scrollbarAlignPending_) //send one async event at most, else they may accumulate and create perf issues, see grid.cpp + { + scrollbarAlignPending_ = true; + + CallAfter([this] //update *outside* of wxPaint event + { + auto needsHorizontalScrollbars = [](const Grid& target) + { + const wxWindow& mainWin = target.getMainWin(); + return mainWin.GetVirtualSize().GetWidth() > mainWin.GetClientSize().GetWidth(); + //assuming Grid::updateWindowSizes() does its job well, this should suffice! + //CAVEAT: if horizontal and vertical scrollbar are circular dependent from each other + //(h-scrollbar is shown due to v-scrollbar consuming horizontal width, etc...) + //while in fact both are NOT needed, this special case results in a bogus need for scrollbars! + //see https://sourceforge.net/tracker/?func=detail&aid=3514183&group_id=234430&atid=1093083 + // => since we're outside the Grid abstraction, we should not duplicate code to handle this special case as it seems to be insignificant + }; + + Grid::ScrollBarStatus sbStatusX = needsHorizontalScrollbars(gridL_) || + needsHorizontalScrollbars(gridR_) ? + Grid::SB_SHOW_ALWAYS : Grid::SB_SHOW_NEVER; + gridL_.showScrollBars(sbStatusX, Grid::SB_SHOW_NEVER); + gridC_.showScrollBars(sbStatusX, Grid::SB_SHOW_NEVER); + gridR_.showScrollBars(sbStatusX, Grid::SB_SHOW_AUTOMATIC); + + assert(scrollbarAlignPending_); + scrollbarAlignPending_ = false; + }); + } + } + + Grid& gridL_; + Grid& gridC_; + Grid& gridR_; + + const Grid* scrollMaster_ = &gridL_; //for address check only; this needn't be the grid having focus! + //e.g. mouse wheel events should set window under cursor as scrollMaster, but *not* change focus + + GridDataCenter& provCenter_; + bool scrollbarAlignPending_ = false; + bool scrollPosAlignPending_ = false; +}; +} + +//######################################################################################################## + +void filegrid::init(Grid& gridLeft, Grid& gridCenter, Grid& gridRight) +{ + auto sharedComp = makeSharedRef(); + + auto provLeft_ = std::make_shared(gridLeft, sharedComp); + auto provCenter_ = std::make_shared(gridCenter, sharedComp); + auto provRight_ = std::make_shared(gridRight, sharedComp); + + sharedComp.ref().evtMgr = std::make_unique(gridLeft, gridCenter, gridRight, *provCenter_); + + gridLeft .setDataProvider(provLeft_); //data providers reference grid => + gridCenter.setDataProvider(provCenter_); //ownership must belong *exclusively* to grid! + gridRight .setDataProvider(provRight_); + + gridCenter.enableColumnMove (false); + gridCenter.enableColumnResize(false); + + gridCenter.showRowLabel(false); + gridRight .showRowLabel(false); + + //gridLeft .showScrollBars(Grid::SB_SHOW_AUTOMATIC, Grid::SB_SHOW_NEVER); -> redundant: configuration happens in GridEventManager::onPaintGrid() + //gridCenter.showScrollBars(Grid::SB_SHOW_NEVER, Grid::SB_SHOW_NEVER); + + const int widthCheckbox = screenToWxsize( loadImage("checkbox_true").GetWidth() + dipToScreen(3)); + const int widthDifference = screenToWxsize(2 * loadImage("sort_ascending").GetWidth() + loadImage("cat_left_only_sicon").GetWidth() + loadImage("notch").GetWidth()); + const int widthAction = screenToWxsize(3 * loadImage("so_create_left_sicon").GetWidth()); + gridCenter.SetSize(widthDifference + widthCheckbox + widthAction, -1); + + gridCenter.setColumnConfig( + { + {static_cast(ColumnTypeCenter::checkbox), widthCheckbox, 0, true}, + {static_cast(ColumnTypeCenter::difference), widthDifference, 0, true}, + {static_cast(ColumnTypeCenter::action), widthAction, 0, true}, + }); +} + + +void filegrid::setData(Grid& grid, FolderComparison& folderCmp) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->setData(folderCmp); + + throw std::runtime_error("filegrid was not initialized! " + std::string(__FILE__) + ':' + numberTo(__LINE__)); +} + + +FileView& filegrid::getDataView(Grid& grid) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->getDataView(); + + throw std::runtime_error("filegrid was not initialized! " + std::string(__FILE__) + ':' + numberTo(__LINE__)); +} + + +namespace +{ +//resolve circular linker dependencies +void IconUpdater::loadIconsAsynchronously(wxEvent& event) //loads all (not yet) drawn icons +{ + std::vector> prefetchLoad; + provLeft_ .getUnbufferedIconsForPreload(prefetchLoad); + provRight_.getUnbufferedIconsForPreload(prefetchLoad); + + //make sure least-important prefetch rows are inserted first into workload (=> processed last) + //priority index nicely considers both grids at the same time! + std::sort(prefetchLoad.begin(), prefetchLoad.end(), [](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; }); + + //last inserted items are processed first in icon buffer: + std::vector newLoad; + for (const auto& [priority, filePath] : prefetchLoad) + newLoad.push_back(filePath); + + provRight_.updateNewAndGetUnbufferedIcons(newLoad); + provLeft_ .updateNewAndGetUnbufferedIcons(newLoad); + + iconBuffer_.setWorkload(newLoad); + + if (newLoad.empty()) //let's only pay for IconUpdater while needed + stop(); +} +} + + +void filegrid::setupIcons(Grid& gridLeft, Grid& gridCenter, Grid& gridRight, bool showFileIcons, IconBuffer::IconSize sz) +{ + auto* provLeft = dynamic_cast(gridLeft .getDataProvider()); + auto* provRight = dynamic_cast(gridRight.getDataProvider()); + + if (provLeft && provRight) + { + auto iconMgr = makeSharedRef(*provLeft, *provRight, sz, showFileIcons); + provLeft ->setIconManager(iconMgr); + + const int newRowHeight = std::max(iconMgr.ref().getIconWxsize(), gridLeft.getMainWin().GetCharHeight()) + dipToWxsize(1); //add some space + + gridLeft .setRowHeight(newRowHeight); + gridCenter.setRowHeight(newRowHeight); + gridRight .setRowHeight(newRowHeight); + } + else + assert(false); +} + + +void filegrid::setItemPathForm(Grid& grid, ItemPathFormat fmt) +{ + if (auto* provLeft = dynamic_cast(grid.getDataProvider())) + provLeft->setItemPathForm(fmt); + else if (auto* provRight = dynamic_cast(grid.getDataProvider())) + provRight->setItemPathForm(fmt); + else + assert(false); + grid.Refresh(); +} + + +void filegrid::refresh(Grid& gridLeft, Grid& gridCenter, Grid& gridRight) +{ + gridLeft .Refresh(); + gridCenter.Refresh(); + gridRight .Refresh(); +} + + +void filegrid::setScrollMaster(Grid& grid) +{ + if (auto prov = dynamic_cast(grid.getDataProvider())) + if (auto evtMgr = prov->getEventManager()) + { + evtMgr->setScrollMaster(grid); + return; + } + assert(false); +} + + +void filegrid::setNavigationMarker(Grid& gridLeft, + zen::Grid& gridRight, + std::unordered_set&& markedFilesAndLinks, + std::unordered_set&& markedContainer) +{ + if (auto grid = dynamic_cast(gridLeft.getDataProvider())) + grid->setNavigationMarker(std::move(markedFilesAndLinks), std::move(markedContainer)); + else + assert(false); + gridLeft .Refresh(); + gridRight.Refresh(); +} + + +void filegrid::setViewType(Grid& gridCenter, GridViewType vt) +{ + if (auto prov = dynamic_cast(gridCenter.getDataProvider())) + prov->setViewType(vt); + else + assert(false); + gridCenter.Refresh(); +} + + +wxImage fff::getSyncOpImage(SyncOperation syncOp) +{ + switch (syncOp) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: return loadImage("so_create_left_sicon"); + case SO_CREATE_RIGHT: return loadImage("so_create_right_sicon"); + case SO_DELETE_LEFT: return loadImage("so_delete_left_sicon"); + case SO_DELETE_RIGHT: return loadImage("so_delete_right_sicon"); + case SO_MOVE_LEFT_FROM: return loadImage("so_move_left_source_sicon"); + case SO_MOVE_LEFT_TO: return loadImage("so_move_left_target_sicon"); + case SO_MOVE_RIGHT_FROM: return loadImage("so_move_right_source_sicon"); + case SO_MOVE_RIGHT_TO: return loadImage("so_move_right_target_sicon"); + case SO_OVERWRITE_LEFT: return loadImage("so_update_left_sicon"); + case SO_OVERWRITE_RIGHT: return loadImage("so_update_right_sicon"); + case SO_RENAME_LEFT: return loadImage("so_move_left_sicon"); + case SO_RENAME_RIGHT: return loadImage("so_move_right_sicon"); + case SO_DO_NOTHING: return loadImage("so_none_sicon"); + case SO_EQUAL: return loadImage("cat_equal_sicon"); + case SO_UNRESOLVED_CONFLICT: return loadImage("cat_conflict_small"); + } + assert(false); + return wxNullImage; +} + + +wxImage fff::getCmpResultImage(CompareFileResult cmpResult) +{ + switch (cmpResult) + { + case FILE_RENAMED: //similar to both "equal" and "conflict" + case FILE_EQUAL: return loadImage("cat_equal_sicon"); + case FILE_LEFT_ONLY: return loadImage("cat_left_only_sicon"); + case FILE_RIGHT_ONLY: return loadImage("cat_right_only_sicon"); + case FILE_LEFT_NEWER: return loadImage("cat_left_newer_sicon"); + case FILE_RIGHT_NEWER: return loadImage("cat_right_newer_sicon"); + case FILE_DIFFERENT_CONTENT: return loadImage("cat_different_sicon"); + case FILE_TIME_INVALID: + case FILE_CONFLICT: return loadImage("cat_conflict_small"); + } + assert(false); + return wxNullImage; +} diff --git a/FreeFileSync/Source/ui/file_grid.h b/FreeFileSync/Source/ui/file_grid.h new file mode 100644 index 0000000..355c900 --- /dev/null +++ b/FreeFileSync/Source/ui/file_grid.h @@ -0,0 +1,81 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CUSTOM_GRID_H_8405817408327894 +#define CUSTOM_GRID_H_8405817408327894 + +#include +#include "file_view.h" +#include "../icon_buffer.h" + + +namespace fff +{ +//setup grid to show grid view within three components: +namespace filegrid +{ +void init(zen::Grid& gridLeft, zen::Grid& gridCenter, zen::Grid& gridRight); +FileView& getDataView(zen::Grid& grid); + +void setData(zen::Grid& grid, FolderComparison& folderCmp); //takes (shared) ownership + +void setViewType(zen::Grid& gridCenter, GridViewType vt); + +void setupIcons(zen::Grid& gridLeft, zen::Grid& gridCenter, zen::Grid& gridRight, bool showFileIcons, IconBuffer::IconSize sz); + +void setItemPathForm(zen::Grid& grid, ItemPathFormat fmt); //only for left/right grid + +void refresh(zen::Grid& gridLeft, zen::Grid& gridCenter, zen::Grid& gridRight); + +void setScrollMaster(zen::Grid& grid); + +//mark rows selected in overview panel and navigate to leading object +void setNavigationMarker(zen::Grid& gridLeft, zen::Grid& gridRight, + std::unordered_set&& markedFilesAndLinks,//mark files/symlinks directly within a container + std::unordered_set&& markedContainer); //mark full container including child-objects +} + +wxImage getSyncOpImage(SyncOperation syncOp); +wxImage getCmpResultImage(CompareFileResult cmpResult); + + +//grid hover area for file group rendering +enum class HoverAreaGroup +{ + groupName, + item +}; + +//---------- custom events for middle grid ---------- +struct CheckRowsEvent; +struct SyncDirectionEvent; +wxDECLARE_EVENT(EVENT_GRID_CHECK_ROWS, CheckRowsEvent); +wxDECLARE_EVENT(EVENT_GRID_SYNC_DIRECTION, SyncDirectionEvent); + + +struct CheckRowsEvent : public wxEvent +{ + CheckRowsEvent(size_t rowFirst, size_t rowLast, bool setIncluded) : wxEvent(0 /*winid*/, EVENT_GRID_CHECK_ROWS), rowFirst_(rowFirst), rowLast_(rowLast), setActive_(setIncluded) { assert(rowFirst <= rowLast); } + CheckRowsEvent* Clone() const override { return new CheckRowsEvent(*this); } + + const size_t rowFirst_; //selected range: [rowFirst_, rowLast_) + const size_t rowLast_; //range is empty when clearing selection + const bool setActive_; +}; + + +struct SyncDirectionEvent : public wxEvent +{ + SyncDirectionEvent(size_t rowFirst, size_t rowLast, SyncDirection direction) : wxEvent(0 /*winid*/, EVENT_GRID_SYNC_DIRECTION), rowFirst_(rowFirst), rowLast_(rowLast), direction_(direction) { assert(rowFirst <= rowLast); } + SyncDirectionEvent* Clone() const override { return new SyncDirectionEvent(*this); } + + const size_t rowFirst_; //see CheckRowsEvent + const size_t rowLast_; // + const SyncDirection direction_; +}; +} + +#endif //CUSTOM_GRID_H_8405817408327894 diff --git a/FreeFileSync/Source/ui/file_grid_attr.h b/FreeFileSync/Source/ui/file_grid_attr.h new file mode 100644 index 0000000..fbf8341 --- /dev/null +++ b/FreeFileSync/Source/ui/file_grid_attr.h @@ -0,0 +1,105 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef COLUMN_ATTR_H_189467891346732143213 +#define COLUMN_ATTR_H_189467891346732143213 + +#include +#include +#include + + +namespace fff +{ +enum class GridViewType +{ + difference, + action, +}; + +enum class ColumnTypeRim +{ + path, + size, + date, + extension, +}; + +struct ColAttributesRim +{ + ColumnTypeRim type = ColumnTypeRim::path; + int offset = 0; + int stretch = 0; + bool visible = false; +}; + +inline +std::vector getFileGridDefaultColAttribsLeft() +{ + using namespace zen; + return //harmonize with main_dlg.cpp::onGridLabelContextRim() => expects stretched path and non-stretched other columns! + { + {ColumnTypeRim::path, -dipToWxsize(100), 1, true }, + {ColumnTypeRim::extension, dipToWxsize( 60), 0, false}, + {ColumnTypeRim::date, dipToWxsize(140), 0, false}, //optimal: Ubuntu: 138, macOS: 121 + {ColumnTypeRim::size, dipToWxsize(100), 0, true }, //optimal: Ubuntu: 96, macOS: 94 for 2GB size + }; +} + +inline +std::vector getFileGridDefaultColAttribsRight() +{ + return getFileGridDefaultColAttribsLeft(); //*currently* same default +} + + +inline +bool getDefaultSortDirection(ColumnTypeRim type) //true: ascending; false: descending +{ + switch (type) + { + case ColumnTypeRim::size: + case ColumnTypeRim::date: + return false; + + case ColumnTypeRim::path: + case ColumnTypeRim::extension: + return true; + } + assert(false); + return true; +} + + +enum class ItemPathFormat +{ + name, + relative, + full, +}; + +const ItemPathFormat defaultItemPathFormatLeftGrid = ItemPathFormat::relative; +const ItemPathFormat defaultItemPathFormatRightGrid = ItemPathFormat::relative; + +//------------------------------------------------------------------ +enum class ColumnTypeCenter +{ + checkbox, + difference, + action, +}; + + +inline +bool getDefaultSortDirection(ColumnTypeCenter type) //true: ascending; false: descending +{ + assert(type != ColumnTypeCenter::checkbox); + return true; +} +//------------------------------------------------------------------ +} + +#endif //COLUMN_ATTR_H_189467891346732143213 diff --git a/FreeFileSync/Source/ui/file_view.cpp b/FreeFileSync/Source/ui/file_view.cpp new file mode 100644 index 0000000..33acb70 --- /dev/null +++ b/FreeFileSync/Source/ui/file_view.cpp @@ -0,0 +1,857 @@ +// ***************************************************************************** +// * 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 "file_view.h" +#include +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +void serializeHierarchy(ContainerObject& conObj, std::vector>& output) +{ + for (FilePair& file : conObj.files()) + output.push_back(file.weak_from_this()); + + for (SymlinkPair& symlink : conObj.symlinks()) + output.push_back(symlink.weak_from_this()); + + for (FolderPair& folder : conObj.subfolders()) + { + output.push_back(folder.weak_from_this()); + serializeHierarchy(folder, output); //add recursion here to list sub-objects directly below parent! + } + +#if 0 + /* Spend additional CPU cycles to sort the standard file list? + + Test case: 690.000 item pairs, Windows 7 x64 (C:\ vs I:\) + ---------------------- + CmpNaturalSort: 850 ms + CmpLocalPath: 233 ms + CmpAsciiNoCase: 189 ms + No sorting: 30 ms */ + + template + static std::vector getItemsSorted(std::list& itemList) + { + std::vector output; + for (ItemPair& item : itemList) + output.push_back(&item); + + std::sort(output.begin(), output.end(), [](const ItemPair* lhs, const ItemPair* rhs) { return LessNaturalSort()(lhs->getItemNameAny(), rhs->getItemNameAny()); }); + return output; + } +#endif +} +} + + +FileView::FileView(FolderComparison& folderCmp) +{ + for (BaseFolderPair& baseObj : asRange(folderCmp)) + //remove truly empty folder pairs as early as this: we want to distinguish single/multiple folder pair cases by looking at "folderPairs_" + if (!AFS::isNullPath(baseObj.getAbstractPath()) || + !AFS::isNullPath(baseObj.getAbstractPath())) + { + serializeHierarchy(baseObj, sortedRef_); + + folderPairs_.emplace_back(&baseObj, + baseObj.getAbstractPath(), + baseObj.getAbstractPath()); + } +} + + +template +void FileView::updateView(Predicate pred) +{ + viewRef_ .clear(); + groupDetails_ .clear(); + rowPositions_ .clear(); + rowPositionsFirstChild_.clear(); + + static uint64_t globalViewUpdateId; + viewUpdateId_ = ++globalViewUpdateId; + assert(runningOnMainThread()); + + std::vector parentsBuf; //from bottom to top of hierarchy + const ContainerObject* groupStartObj = nullptr; + + for (const std::weak_ptr& objRef : sortedRef_) + if (const FileSystemObject* fsObj = objRef.lock().get()) + if (pred(*fsObj)) + { + const size_t row = viewRef_.size(); + + //save row position for direct random access to FilePair or FolderPair + rowPositions_.emplace(fsObj, row); //costs: 0.28 µs per call - MSVC based on std::set + + parentsBuf.clear(); + for (const FileSystemObject* fsObj2 = fsObj;;) + { + const ContainerObject& parent = fsObj2->parent(); + parentsBuf.push_back(&parent); + + fsObj2 = dynamic_cast(&parent); + if (!fsObj2) + break; + } + + //save row position to identify first child *on sorted subview* of FolderPair or BaseFolderPair in case latter are filtered out + for (const ContainerObject* parent : parentsBuf) + if (const auto [it, inserted] = this->rowPositionsFirstChild_.emplace(parent, row); + !inserted) //=> parents further up in hierarchy already inserted! + break; + + //------ save info to aggregate rows by parent folders ------ + if (const auto folder = dynamic_cast(fsObj)) + { + groupStartObj = folder; + groupDetails_.push_back({row}); + } + else if (&fsObj->parent() != groupStartObj) + { + groupStartObj = &fsObj->parent(); + groupDetails_.push_back({row}); + } + assert(!groupDetails_.empty()); + const size_t groupIdx = groupDetails_.size() - 1; + //----------------------------------------------------------- + viewRef_.push_back({objRef, groupIdx}); + } +} + + +ptrdiff_t FileView::findRowDirect(const FileSystemObject* fsObj) const +{ + auto it = rowPositions_.find(fsObj); + return it != rowPositions_.end() ? it->second : -1; +} + + +ptrdiff_t FileView::findRowFirstChild(const ContainerObject* conObj) const +{ + auto it = rowPositionsFirstChild_.find(conObj); + return it != rowPositionsFirstChild_.end() ? it->second : -1; +} + + +namespace +{ +template +void addNumbers(const FileSystemObject& fsObj, ViewStats& stats) +{ + visitFSObject(fsObj, [&](const FolderPair& folder) + { + if (!folder.isEmpty()) + ++stats.fileStatsLeft.folderCount; + + if (!folder.isEmpty()) + ++stats.fileStatsRight.folderCount; + }, + + [&](const FilePair& file) + { + if (!file.isEmpty()) + { + stats.fileStatsLeft.bytes += file.getFileSize(); + ++stats.fileStatsLeft.fileCount; + } + if (!file.isEmpty()) + { + stats.fileStatsRight.bytes += file.getFileSize(); + ++stats.fileStatsRight.fileCount; + } + }, + + [&](const SymlinkPair& symlink) + { + if (!symlink.isEmpty()) + ++stats.fileStatsLeft.fileCount; + + if (!symlink.isEmpty()) + ++stats.fileStatsRight.fileCount; + }); +} +} + + +FileView::DifferenceViewStats FileView::applyDifferenceFilter(bool showExcluded, //maps sortedRef to viewRef + bool showLeftOnly, + bool showRightOnly, + bool showLeftNewer, + bool showRightNewer, + bool showDifferent, + bool showEqual, + bool showConflict) +{ + DifferenceViewStats stats; + + updateView([&](const FileSystemObject& fsObj) + { + auto categorize = [&](bool showCategory, int& categoryCount) + { + if (!fsObj.isActive()) + { + ++stats.excluded; + if (!showExcluded) + return false; + } + ++categoryCount; + if (!showCategory) + return false; + + addNumbers(fsObj, stats); //calculate total number of bytes for each side + return true; + }; + + switch (fsObj.getCategory()) + { + case FILE_LEFT_ONLY: + return categorize(showLeftOnly, stats.leftOnly); + case FILE_RIGHT_ONLY: + return categorize(showRightOnly, stats.rightOnly); + case FILE_LEFT_NEWER: + return categorize(showLeftNewer, stats.leftNewer); + case FILE_RIGHT_NEWER: + return categorize(showRightNewer, stats.rightNewer); + case FILE_DIFFERENT_CONTENT: + return categorize(showDifferent, stats.different); + case FILE_EQUAL: + return categorize(showEqual, stats.equal); + case FILE_RENAMED: + case FILE_CONFLICT: + case FILE_TIME_INVALID: + return categorize(showConflict, stats.conflict); + } + assert(false); + return true; + }); + + return stats; +} + + +FileView::ActionViewStats FileView::applyActionFilter(bool showExcluded, //maps sortedRef to viewRef + bool showCreateLeft, + bool showCreateRight, + bool showDeleteLeft, + bool showDeleteRight, + bool showUpdateLeft, + bool showUpdateRight, + bool showDoNothing, + bool showEqual, + bool showConflict) +{ + ActionViewStats stats; + + int moveLeft = 0; + int moveRight = 0; + + updateView([&](const FileSystemObject& fsObj) + { + auto categorize = [&](bool showCategory, int& categoryCount) + { + if (!fsObj.isActive()) + { + ++stats.excluded; + if (!showExcluded) + return false; + } + ++categoryCount; + if (!showCategory) + return false; + + addNumbers(fsObj, stats); //calculate total number of bytes for each side + return true; + }; + + switch (fsObj.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_CREATE_LEFT: + return categorize(showCreateLeft, stats.createLeft); + case SO_CREATE_RIGHT: + return categorize(showCreateRight, stats.createRight); + case SO_DELETE_LEFT: + return categorize(showDeleteLeft, stats.deleteLeft); + case SO_DELETE_RIGHT: + return categorize(showDeleteRight, stats.deleteRight); + case SO_OVERWRITE_LEFT: + case SO_RENAME_LEFT: + return categorize(showUpdateLeft, stats.updateLeft); + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + return categorize(showUpdateLeft, moveLeft); + case SO_OVERWRITE_RIGHT: + case SO_RENAME_RIGHT: + return categorize(showUpdateRight, stats.updateRight); + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + return categorize(showUpdateRight, moveRight); + case SO_DO_NOTHING: + return categorize(showDoNothing, stats.updateNone); + case SO_EQUAL: + return categorize(showEqual, stats.equal); + case SO_UNRESOLVED_CONFLICT: + return categorize(showConflict, stats.conflict); + } + assert(false); + return true; + }); + + assert(moveLeft % 2 == 0 && moveRight % 2 == 0); + stats.updateLeft += moveLeft / 2; //count move operations as single update + stats.updateRight += moveRight / 2; //=> harmonize with SyncStatistics::processFile() + + return stats; +} + + +std::vector FileView::getAllFileRef(const std::vector& rows) +{ + const size_t viewSize = viewRef_.size(); + + std::vector output; + + for (size_t pos : rows) + if (pos < viewSize) + if (const std::shared_ptr fsObj = viewRef_[pos].objRef.lock()) + output.push_back(fsObj.get()); + + return output; +} + + +FileView::PathDrawInfo FileView::getDrawInfo(size_t row) +{ + if (row < viewRef_.size()) + { + const size_t groupIdx = viewRef_[row].groupIdx; + assert(groupIdx < groupDetails_.size()); + + const size_t groupFirstRow = groupDetails_[groupIdx].groupFirstRow; + + const size_t groupLastRow = groupIdx + 1 < groupDetails_.size() ? + groupDetails_[groupIdx + 1].groupFirstRow : + viewRef_.size(); + FileSystemObject* fsObj = viewRef_[row].objRef.lock().get(); + + FolderPair* folderGroupObj = dynamic_cast(fsObj); + if (fsObj && !folderGroupObj) + folderGroupObj = dynamic_cast(&fsObj->parent()); + + return {groupFirstRow, groupLastRow, groupIdx, viewUpdateId_, folderGroupObj, fsObj}; + } + assert(false); //unexpected: check rowsOnView()! + return {}; +} + + +void FileView::removeInvalidRows() +{ + //remove rows that have been deleted meanwhile + std::erase_if(sortedRef_, [&](const std::weak_ptr& objRef) { return objRef.expired(); }); + + viewRef_ .clear(); + groupDetails_ .clear(); + rowPositions_ .clear(); + rowPositionsFirstChild_.clear(); +} + + +//------------------------------------ SORTING ----------------------------------------- +namespace +{ +struct CompileTimeReminder : public FSObjectVisitor +{ + void visit(const FilePair& file ) override {} + void visit(const SymlinkPair& symlink) override {} + void visit(const FolderPair& folder ) override {} +} checkDymanicCasts; //just a compile-time reminder to manually check dynamic casts in this file if ever needed + + +inline +bool isDirectoryPair(const FileSystemObject& fsObj) +{ + return dynamic_cast(&fsObj) != nullptr; +} + + +template inline +bool lessFileName(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + //sort order: first files/symlinks, then directories then empty rows + + //empty rows always last + if (lhs.isEmpty()) + return false; + else if (rhs.isEmpty()) + return true; + + //directories after files/symlinks: + if (isDirectoryPair(lhs)) + { + if (!isDirectoryPair(rhs)) + return false; + } + else if (isDirectoryPair(rhs)) + return true; + + return zen::makeSortDirection(LessNaturalSort() /*even on Linux*/, std::bool_constant())(lhs.getItemName(), rhs.getItemName()); +} + + +template inline +bool lessFilePath(const std::weak_ptr& lhs, const std::weak_ptr& rhs, + const std::unordered_map& sortedPos, + std::vector& tempBuf) +{ + const FileSystemObject* fsObjL = lhs.lock().get(); + const FileSystemObject* fsObjR = rhs.lock().get(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + //------- presort by folder pair ---------- + { + auto itL = sortedPos.find(&fsObjL->base()); + auto itR = sortedPos.find(&fsObjR->base()); + assert(itL != sortedPos.end() && itR != sortedPos.end()); + if (itL == sortedPos.end()) //invalid rows shall appear at the end + return false; + else if (itR == sortedPos.end()) + return true; + + const size_t basePosL = itL->second; + const size_t basePosR = itR->second; + + if (basePosL != basePosR) + return zen::makeSortDirection(std::less(), std::bool_constant())(basePosL, basePosR); + } + + //------- sort component-wise ---------- + const auto folderL = dynamic_cast(fsObjL); + const auto folderR = dynamic_cast(fsObjR); + + std::vector& parentsBuf = tempBuf; //from bottom to top of hierarchy, excluding base + parentsBuf.clear(); + + const auto collectParents = [&](const FileSystemObject* fsObj) + { + for (;;) + if (const auto folder = dynamic_cast(&fsObj->parent())) //perf: most expensive part of this function! + { + parentsBuf.push_back(folder); + fsObj = folder; + } + else + break; + }; + if (folderL) + parentsBuf.push_back(folderL); + collectParents(fsObjL); + const size_t parentsSizeL = parentsBuf.size(); + + if (folderR) + parentsBuf.push_back(folderR); + collectParents(fsObjR); + + const std::span parentsL(parentsBuf.data(), parentsSizeL); //no construction via iterator (yet): https://github.com/cplusplus/draft/pull/3456 + const std::span parentsR(parentsBuf.data() + parentsSizeL, parentsBuf.size() - parentsSizeL); + + const auto& [itL, itR] = std::mismatch(parentsL.rbegin(), parentsL.rend(), + parentsR.rbegin(), parentsR.rend()); + if (itL == parentsL.rend()) + { + if (itR == parentsR.rend()) + { + //make folders always appear before contained files + if (folderR) + return false; + else if (folderL) + return true; + + return zen::makeSortDirection(LessNaturalSort(), std::bool_constant())(fsObjL->getItemName(), fsObjR->getItemName()); + } + else + return true; + } + else if (itR == parentsR.rend()) + return false; + + //different components... + if (const std::weak_ordering cmp = compareNatural((*itL)->getItemName(), (*itR)->getItemName()); + cmp != std::weak_ordering::equivalent) + { + if constexpr (ascending) + return std::is_lt(cmp); + else + return std::is_gt(cmp); + } + //return zen::makeSortDirection(std::less(), std::bool_constant())(rv, 0); + + /*...with equivalent names: + 1. functional correctness => must not compare equal! e.g. a/a/x and a/A/y + 2. ensure stable sort order */ + return *itL < *itR; +} + + +template inline +bool lessFilesize(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + //empty rows always last + if (lhs.isEmpty()) + return false; + else if (rhs.isEmpty()) + return true; + + //directories second last + if (isDirectoryPair(lhs)) + return false; + else if (isDirectoryPair(rhs)) + return true; + + const FilePair* fileL = dynamic_cast(&lhs); + const FilePair* fileR = dynamic_cast(&rhs); + + //then symlinks + if (!fileL) + return false; + else if (!fileR) + return true; + + //return list beginning with largest files first + return zen::makeSortDirection(std::less(), std::bool_constant())(fileL->getFileSize(), fileR->getFileSize()); +} + + +template inline +bool lessFiletime(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + if (lhs.isEmpty()) + return false; //empty rows always last + else if (rhs.isEmpty()) + return true; //empty rows always last + + const FilePair* fileL = dynamic_cast(&lhs); + const FilePair* fileR = dynamic_cast(&rhs); + + const SymlinkPair* symlinkL = dynamic_cast(&lhs); + const SymlinkPair* symlinkR = dynamic_cast(&rhs); + + if (!fileL && !symlinkL) + return false; //directories last + else if (!fileR && !symlinkR) + return true; //directories last + + const int64_t dateL = fileL ? fileL->getLastWriteTime() : symlinkL->getLastWriteTime(); + const int64_t dateR = fileR ? fileR->getLastWriteTime() : symlinkR->getLastWriteTime(); + + //return list beginning with newest files first + return zen::makeSortDirection(std::less(), std::bool_constant())(dateL, dateR); +} + + +template inline +bool lessExtension(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + if (lhs.isEmpty()) + return false; //empty rows always last + else if (rhs.isEmpty()) + return true; //empty rows always last + + if (dynamic_cast(&lhs)) + return false; //directories last + else if (dynamic_cast(&rhs)) + return true; //directories last + + auto getExtension = [](const FileSystemObject& fsObj) + { + return afterLast(fsObj.getItemName(), Zstr('.'), zen::IfNotFoundReturn::none); + }; + + return zen::makeSortDirection(LessNaturalSort() /*even on Linux*/, std::bool_constant())(getExtension(lhs), getExtension(rhs)); +} + + +template inline +bool lessCmpResult(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + return zen::makeSortDirection([](CompareFileResult lhs2, CompareFileResult rhs2) + { + //presort: equal shall appear at end of list + if (lhs2 == FILE_EQUAL) + return false; + if (rhs2 == FILE_EQUAL) + return true; + return lhs2 < rhs2; + }, + std::bool_constant())(lhs.getCategory(), rhs.getCategory()); +} + + +template inline +bool lessSyncDirection(const FileSystemObject& lhs, const FileSystemObject& rhs) +{ + return zen::makeSortDirection(std::less(), std::bool_constant())(lhs.getSyncOperation(), rhs.getSyncOperation()); +} + + +template +struct LessFullPath +{ + LessFullPath(std::vector> folderPairs) + { + //calculate positions of base folders sorted by name + std::sort(folderPairs.begin(), folderPairs.end(), [](const auto& a, const auto& b) + { + const auto& [baseObjA, basePathLA, basePathRA] = a; + const auto& [baseObjB, basePathLB, basePathRB] = b; + + const AbstractPath& basePathA = selectParam(basePathLA, basePathRA); + const AbstractPath& basePathB = selectParam(basePathLB, basePathRB); + + return LessNaturalSort()/*even on Linux*/(utfTo(AFS::getDisplayPath(basePathA)), + utfTo(AFS::getDisplayPath(basePathB))); + }); + + size_t pos = 0; + for (const auto& [baseObj, basePathL, basePathR] : folderPairs) + shared_.ref().sortedPos.emplace(baseObj, pos++); + } + + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + return lessFilePath(lhs, rhs, shared_.ref().sortedPos, shared_.ref().tempBuf); + } + +private: + struct Shared + { + std::unordered_map sortedPos; + mutable std::vector tempBuf; //avoid repeated memory allocation in lessFilePath() + }; + SharedRef shared_ = makeSharedRef(); //std::sort makes lots of predicate copies during its "divide and conquer" +}; + + +template +struct LessRelativeFolder +{ + LessRelativeFolder(const std::vector>& folderPairs) + { + size_t pos = 0; //take over positions of base folders as set up by user + for (const auto& [baseObj, basePathL, basePathR] : folderPairs) + shared_.ref().sortedPos.emplace(baseObj, pos++); + } + + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + return lessFilePath(lhs, rhs, shared_.ref().sortedPos, shared_.ref().tempBuf); + } + +private: + struct Shared + { + std::unordered_map sortedPos; + mutable std::vector tempBuf; //avoid repeated memory allocation in lessFilePath() + }; + SharedRef shared_ = makeSharedRef(); //std::sort makes lots of predicate copies during its "divide and conquer" +}; + + +template +struct LessFileName +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessFileName(*fsObjL, *fsObjR); + } +}; + + +template +struct LessFilesize +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessFilesize(*fsObjL, *fsObjR); + } +}; + + +template +struct LessFiletime +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessFiletime(*fsObjL, *fsObjR); + } +}; + + +template +struct LessExtension +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessExtension(*fsObjL, *fsObjR); + } +}; + + +template +struct LessCmpResult +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessCmpResult(*fsObjL, *fsObjR); + } +}; + + +template +struct LessSyncDirection +{ + bool operator()(const std::weak_ptr& lhs, const std::weak_ptr& rhs) const + { + const std::shared_ptr fsObjL = lhs.lock(); + const std::shared_ptr fsObjR = rhs.lock(); + if (!fsObjL) //invalid rows shall appear at the end + return false; + else if (!fsObjR) + return true; + + return lessSyncDirection(*fsObjL, *fsObjR); + } +}; +} + +//------------------------------------------------------------------------------------------------------- + +void FileView::sortView(ColumnTypeRim type, ItemPathFormat pathFmt, bool onLeft, bool ascending) +{ + viewRef_ .clear(); + groupDetails_ .clear(); + rowPositions_ .clear(); + rowPositionsFirstChild_.clear(); + currentSort_ = SortInfo({type, onLeft, ascending}); + + switch (type) + { + case ColumnTypeRim::path: + switch (pathFmt) + { + case ItemPathFormat::name: + if ( ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFileName()); + else if ( ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFileName()); + else if (!ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFileName()); + else if (!ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFileName()); + break; + + case ItemPathFormat::relative: + if ( ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessRelativeFolder(folderPairs_)); + else if ( ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessRelativeFolder(folderPairs_)); + else if (!ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessRelativeFolder(folderPairs_)); + else if (!ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessRelativeFolder(folderPairs_)); + break; + + case ItemPathFormat::full: + if ( ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFullPath(folderPairs_)); + else if ( ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFullPath(folderPairs_)); + else if (!ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFullPath(folderPairs_)); + else if (!ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFullPath(folderPairs_)); + break; + } + break; + + case ColumnTypeRim::size: + if ( ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFilesize()); + else if ( ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFilesize()); + else if (!ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFilesize()); + else if (!ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFilesize()); + break; + case ColumnTypeRim::date: + if ( ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFiletime()); + else if ( ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFiletime()); + else if (!ascending && onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFiletime()); + else if (!ascending && !onLeft) std::sort(sortedRef_.begin(), sortedRef_.end(), LessFiletime()); + break; + case ColumnTypeRim::extension: + if ( ascending && onLeft) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessExtension()); + else if ( ascending && !onLeft) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessExtension()); + else if (!ascending && onLeft) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessExtension()); + else if (!ascending && !onLeft) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessExtension()); + break; + } +} + + +void FileView::sortView(ColumnTypeCenter type, bool ascending) +{ + viewRef_ .clear(); + groupDetails_ .clear(); + rowPositions_ .clear(); + rowPositionsFirstChild_.clear(); + currentSort_ = SortInfo({type, false, ascending}); + + switch (type) + { + case ColumnTypeCenter::checkbox: + assert(false); + break; + case ColumnTypeCenter::difference: + if ( ascending) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessCmpResult()); + else if (!ascending) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessCmpResult()); + break; + case ColumnTypeCenter::action: + if ( ascending) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessSyncDirection()); + else if (!ascending) std::stable_sort(sortedRef_.begin(), sortedRef_.end(), LessSyncDirection()); + break; + } +} diff --git a/FreeFileSync/Source/ui/file_view.h b/FreeFileSync/Source/ui/file_view.h new file mode 100644 index 0000000..fd05c33 --- /dev/null +++ b/FreeFileSync/Source/ui/file_view.h @@ -0,0 +1,163 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GRID_VIEW_H_9285028345703475842569 +#define GRID_VIEW_H_9285028345703475842569 + +#include +#include +#include +#include +#include "file_grid_attr.h" +#include "../base/file_hierarchy.h" + + +namespace fff +{ +class FileView //grid view of FolderComparison +{ +public: + FileView() {} + explicit FileView(FolderComparison& folderCmp); //takes weak (non-owning) references + + size_t rowsOnView() const { return viewRef_ .size(); } //only visible elements + size_t rowsTotal () const { return sortedRef_.size(); } //total rows available + + //returns nullptr if object is not found; complexity: constant! + const FileSystemObject* getFsObject(size_t row) const { return row < viewRef_.size() ? viewRef_[row].objRef.lock().get() : nullptr; } + /**/ FileSystemObject* getFsObject(size_t row) { return const_cast(static_cast(*this).getFsObject(row)); } //see Meyers Effective C++ + + //references to FileSystemObject: no nullptr-check needed! everything is bound + std::vector getAllFileRef(const std::vector& rows); + + struct PathDrawInfo + { + size_t groupFirstRow = 0; //half-open range + size_t groupLastRow = 0; // + const size_t groupIdx = 0; + uint64_t viewUpdateId = 0; //help detect invalid buffers after updateView() + FolderPair* folderGroupObj = nullptr; //nullptr if group is BaseFolderPair (or fsObj not found) + FileSystemObject* fsObj = nullptr; //nullptr if object is not found + }; + PathDrawInfo getDrawInfo(size_t row); //complexity: constant! + + struct FileStats + { + int fileCount = 0; + int folderCount = 0; + uint64_t bytes = 0; + }; + + struct DifferenceViewStats + { + int excluded = 0; + int equal = 0; + int conflict = 0; + + int leftOnly = 0; + int rightOnly = 0; + int leftNewer = 0; + int rightNewer = 0; + int different = 0; + + FileStats fileStatsLeft; + FileStats fileStatsRight; + }; + DifferenceViewStats applyDifferenceFilter(bool showExcluded, + bool showLeftOnly, + bool showRightOnly, + bool showLeftNewer, + bool showRightNewer, + bool showDifferent, + bool showEqual, + bool showConflict); + struct ActionViewStats + { + int excluded = 0; + int equal = 0; + int conflict = 0; + + int createLeft = 0; + int createRight = 0; + int deleteLeft = 0; + int deleteRight = 0; + int updateLeft = 0; + int updateRight = 0; + int updateNone = 0; + + FileStats fileStatsLeft; + FileStats fileStatsRight; + }; + ActionViewStats applyActionFilter(bool showExcluded, + bool showCreateLeft, + bool showCreateRight, + bool showDeleteLeft, + bool showDeleteRight, + bool showUpdateLeft, + bool showUpdateRight, + bool showDoNothing, + bool showEqual, + bool showConflict); + + void removeInvalidRows(); //remove references to rows that have been deleted meanwhile: call after manual deletion and synchronization! + + //sorting... + void sortView(ColumnTypeRim type, ItemPathFormat pathFmt, bool onLeft, bool ascending); //always call these; never sort externally! + void sortView(ColumnTypeCenter type, bool ascending); // + + struct SortInfo + { + std::variant sortCol; + bool onLeft = false; //only use if sortCol is ColumnTypeRim + bool ascending = false; + }; + const SortInfo* getSortConfig() const { return zen::get(currentSort_); } //return nullptr if currently not sorted + + ptrdiff_t findRowDirect (const FileSystemObject* fsObj) const; //find an object's row position on view list directly, return < 0 if not found + ptrdiff_t findRowFirstChild(const ContainerObject* conObj) const; //find first child of FolderPair or BaseFolderPair *on sorted sub view* + //"conObj" may be invalid, it is NOT dereferenced, return < 0 if not found + + //count non-empty pairs to distinguish single/multiple folder pair cases + size_t getEffectiveFolderPairCount() const { return folderPairs_.size(); } + +private: + FileView (const FileView&) = delete; + FileView& operator=(const FileView&) = delete; + + template void updateView(Predicate pred); + + + std::unordered_map rowPositions_; //find row positions on viewRef_ directly + std::unordered_map rowPositionsFirstChild_; //find first child on sortedRef of a container object + //void* instead of ContainerObject*: these pointers should *never be dereferenced*! + + struct GroupDetail + { + size_t groupFirstRow = 0; + }; + std::vector groupDetails_; + + uint64_t viewUpdateId_ = 0; //help clients detect invalid buffers after updateView() + + struct ViewRow + { + std::weak_ptr objRef; + size_t groupIdx = 0; //...into groupDetails_ + }; + std::vector viewRef_; //partial view on sortedRef_ + /* /|\ + | (applyFilterBy...) */ + std::vector> sortedRef_; //flat view of weak pointers on folderCmp; may be sorted + /* /|\ + | (constructor) + FolderComparison folderCmp */ + std::vector> folderPairs_; + + std::optional currentSort_; +}; +} + +#endif //GRID_VIEW_H_9285028345703475842569 diff --git a/FreeFileSync/Source/ui/folder_history_box.cpp b/FreeFileSync/Source/ui/folder_history_box.cpp new file mode 100644 index 0000000..2508f45 --- /dev/null +++ b/FreeFileSync/Source/ui/folder_history_box.cpp @@ -0,0 +1,139 @@ +// ***************************************************************************** +// * 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 "folder_history_box.h" +#include + #include +#include "../afs/concrete.h" + +using namespace zen; +using namespace fff; +using AFS = AbstractFileSystem; + + +FolderHistoryBox::FolderHistoryBox(wxWindow* parent, + wxWindowID id, + const wxString& value, + const wxPoint& pos, + const wxSize& size, + int n, + const wxString choices[], + long style, + const wxValidator& validator, + const wxString& name) : + wxComboBox(parent, id, value, pos, size, n, choices, style, validator, name) +{ + //##################################### + /*##*/ SetMinSize({dipToWxsize(150), -1}); //## workaround yet another wxWidgets bug: default minimum size is much too large for a wxComboBox + //##################################### + + Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyEvent(event); }); + + /* + we can't attach to wxEVT_COMMAND_TEXT_UPDATED, since setValueAndUpdateList() will implicitly emit wxEVT_COMMAND_TEXT_UPDATED again when calling Clear()! + => Crash on Suse/X11/wxWidgets 2.9.4 on startup (setting a flag to guard against recursion does not work, still crash) + + On OS X attaching to wxEVT_LEFT_DOWN leads to occasional crashes, especially when double-clicking + */ + + //file drag and drop directly into the text control unhelpfully inserts in format "file://.." + //1. this format's implementation is a mess: http://www.lephpfacile.com/manuel-php-gtk/tutorials.filednd.urilist.php + //2. even if we handle "drag-data-received" for "text/uri-list" this doesn't consider logic in dirname.cpp + //=> disable all drop events on the text control (disables text drop, too, but not a big loss) + //=> all drops are nicely propagated as regular file drop events like they should have been in the first place! + if (GtkWidget* widget = GetConnectWidget()) + ::gtk_drag_dest_unset(widget); +} + + +void FolderHistoryBox::onRequireHistoryUpdate(wxEvent& event) +{ + setValueAndUpdateList(GetValue()); + event.Skip(); +} + + +//set value and update list are technically entangled: see potential bug description below +void FolderHistoryBox::setValueAndUpdateList(const wxString& folderPathPhrase) +{ + //populate selection list.... + std::vector items; + { + auto trimTrailingSep = [](Zstring path) + { + if (endsWith(path, Zstr('/')) || + endsWith(path, Zstr('\\'))) + path.pop_back(); + return path; + }; + + const Zstring& folderPathPhraseTrimmed = trimTrailingSep(trimCpy(utfTo(folderPathPhrase))); + + //path phrase aliases: allow user changing to volume name and back + for (const Zstring& aliasPhrase : AFS::getPathPhraseAliases(createAbstractPath(utfTo(folderPathPhrase)))) //may block when resolving [] + if (!equalNoCase(folderPathPhraseTrimmed, + trimTrailingSep(aliasPhrase))) //don't add redundant aliases + items.push_back(utfTo(aliasPhrase)); + } + + if (sharedHistory_.get()) + { + std::vector tmp = sharedHistory_->getList(); + std::sort(tmp.begin(), tmp.end(), LessNaturalSort() /*even on Linux*/); + + if (!items.empty() && !tmp.empty()) + items.push_back(HistoryList::separationLine()); + + for (const Zstring& str : tmp) + items.push_back(utfTo(str)); + } + + //########################################################################################### + + //attention: if the target value is not part of the dropdown list, SetValue() will look for a string that *starts with* this value: + //e.g. if the dropdown list contains "222" SetValue("22") will erroneously set and select "222" instead, while "111" would be set correctly! + // -> by design on Windows! + if (std::find(items.begin(), items.end(), folderPathPhrase) == items.end()) + items.insert(items.begin(), folderPathPhrase); + + //this->Clear(); -> NO! emits yet another wxEVT_COMMAND_TEXT_UPDATED!!! + wxItemContainer::Clear(); //suffices to clear the selection items only! + this->Append(items); //expensive as fuck! => only call when absolutely needed! + + //this->SetSelection(wxNOT_FOUND); //don't select anything + ChangeValue(folderPathPhrase); //preserve main text! +} + + +void FolderHistoryBox::onKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + + if (keyCode == WXK_DELETE || + keyCode == WXK_NUMPAD_DELETE) + //try to delete the currently selected config history item + if (const int pos = this->GetCurrentSelection(); + 0 <= pos && pos < static_cast(this->GetCount()) && + //what a mess...: + (GetValue() != GetString(pos) || //avoid problems when a character shall be deleted instead of list item + GetValue().empty())) //exception: always allow removing empty entry + { + //save old (selected) value: deletion seems to have influence on this + const wxString currentVal = this->GetValue(); + //this->SetSelection(wxNOT_FOUND); + + //delete selected row + if (sharedHistory_.get()) + sharedHistory_->delItem(utfTo(GetString(pos))); + SetString(pos, wxString()); //in contrast to "Delete(pos)", this one does not kill the drop-down list and gives a nice visual feedback! + + this->SetValue(currentVal); + return; //eat up key event + } + + + event.Skip(); +} diff --git a/FreeFileSync/Source/ui/folder_history_box.h b/FreeFileSync/Source/ui/folder_history_box.h new file mode 100644 index 0000000..a47c70c --- /dev/null +++ b/FreeFileSync/Source/ui/folder_history_box.h @@ -0,0 +1,91 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FOLDER_HISTORY_BOX_H_08170517045945 +#define FOLDER_HISTORY_BOX_H_08170517045945 + +#include +#include +#include +#include + + +namespace fff +{ +class HistoryList +{ +public: + HistoryList(const std::vector& folderPathPhrases, size_t maxSize) : + maxSize_(maxSize), + folderPathPhrases_(folderPathPhrases) { truncate(); } + + const std::vector& getList() const { return folderPathPhrases_; } + + static const wxString separationLine() { return wxString(50, EM_DASH); } + + void addItem(Zstring folderPathPhrase) + { + zen::trim(folderPathPhrase); + + if (folderPathPhrase.empty() || folderPathPhrase == zen::utfTo(separationLine())) + return; + + //insert new folder or put it to the front if already existing + std::erase_if(folderPathPhrases_, [&](const Zstring& item) { return equalNoCase(item, folderPathPhrase); }); + + folderPathPhrases_.insert(folderPathPhrases_.begin(), folderPathPhrase); + truncate(); + } + + void delItem(const Zstring& folderPathPhrase) { std::erase_if(folderPathPhrases_, [&](const Zstring& item) { return equalNoCase(item, folderPathPhrase); }); } + +private: + void truncate() + { + if (folderPathPhrases_.size() > maxSize_) //keep maximal size of history list + folderPathPhrases_.resize(maxSize_); + } + + const size_t maxSize_ = 0; + std::vector folderPathPhrases_; +}; + + +//combobox with history function + functionality to delete items (DEL) +class FolderHistoryBox : public wxComboBox +{ +public: + FolderHistoryBox(wxWindow* parent, + wxWindowID id, + const wxString& value = {}, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + int n = 0, + const wxString choices[] = nullptr, + long style = 0, + const wxValidator& validator = wxDefaultValidator, + const wxString& name = wxASCII_STR(wxComboBoxNameStr)); + + void setHistory(std::shared_ptr sharedHistory) { sharedHistory_ = std::move(sharedHistory); } + std::shared_ptr getHistory() { return sharedHistory_; } + + void setValue(const wxString& folderPathPhrase) + { + setValueAndUpdateList(folderPathPhrase); //required for setting value correctly; Linux: ensure the dropdown is shown as being populated + } + + //wxString wxComboBox::GetValue() const; + +private: + void onKeyEvent(wxKeyEvent& event); + void onRequireHistoryUpdate(wxEvent& event); + void setValueAndUpdateList(const wxString& folderPathPhrase); + + std::shared_ptr sharedHistory_; +}; +} + +#endif //FOLDER_HISTORY_BOX_H_08170517045945 diff --git a/FreeFileSync/Source/ui/folder_pair.h b/FreeFileSync/Source/ui/folder_pair.h new file mode 100644 index 0000000..4f2a46a --- /dev/null +++ b/FreeFileSync/Source/ui/folder_pair.h @@ -0,0 +1,240 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FOLDER_PAIR_H_89341750847252345 +#define FOLDER_PAIR_H_89341750847252345 + +#include +#include +#include +#include +#include "../base/norm_filter.h" + + +namespace fff +{ +//basic functionality for handling alternate folder pair configuration: change sync-cfg/filter cfg, right-click context menu, button icons... +std::wstring getFilterSummaryForTooltip(const FilterConfig& filterCfg); + + +template +class FolderPairPanelBasic : private wxEvtHandler +{ +public: + explicit FolderPairPanelBasic(GuiPanel& basicPanel) : //takes reference on basic panel to be enhanced + basicPanel_(basicPanel) + { + using namespace zen; + + //register events for removal of alternate configuration + basicPanel_.m_bpButtonLocalCompCfg ->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onLocalCompCfgContext (event); }); + basicPanel_.m_bpButtonLocalSyncCfg ->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onLocalSyncCfgContext (event); }); + basicPanel_.m_bpButtonLocalFilter ->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onLocalFilterCfgContext(event); }); + + setImage(*basicPanel_.m_bpButtonRemovePair, loadImage("item_remove")); + } + + + void setConfig(const std::optional& compConfig, const std::optional& syncCfg, const FilterConfig& filter) + { + localCmpCfg_ = compConfig; + localSyncCfg_ = syncCfg; + localFilter_ = filter; + refreshButtons(); + } + + std::optional getCompConfig () const { return localCmpCfg_; } + std::optional getSyncConfig () const { return localSyncCfg_; } + FilterConfig getFilterConfig() const { return localFilter_; } + +private: + void refreshButtons() + { + using namespace zen; + + setImage(*basicPanel_.m_bpButtonLocalCompCfg, greyScaleIfDisabled(imgCmp_, !!localCmpCfg_)); + basicPanel_.m_bpButtonLocalCompCfg->SetToolTip(localCmpCfg_ ? + _("Local comparison settings") + L"\n(" + getVariantName(localCmpCfg_->compareVar) + L')' : + _("Local comparison settings")); + + setImage(*basicPanel_.m_bpButtonLocalSyncCfg, greyScaleIfDisabled(imgSync_, !!localSyncCfg_)); + basicPanel_.m_bpButtonLocalSyncCfg->SetToolTip(localSyncCfg_ ? + _("Local synchronization settings") + L"\n(" + getVariantName(getSyncVariant(localSyncCfg_->directionCfg)) + L')' : + _("Local synchronization settings")); + + setImage(*basicPanel_.m_bpButtonLocalFilter, greyScaleIfDisabled(imgFilter_, !isNullFilter(localFilter_))); + basicPanel_.m_bpButtonLocalFilter->SetToolTip(_("Local filter") + getFilterSummaryForTooltip(localFilter_)); + } + + void onLocalCompCfgContext(wxEvent& event) + { + using namespace zen; + + ContextMenu menu; + + auto setVariant = [&](CompareVariant var) + { + if (!this->localCmpCfg_) + this->localCmpCfg_ = CompConfig(); + this->localCmpCfg_->compareVar = var; + + this->refreshButtons(); + this->onLocalCompCfgChange(); + }; + + auto addVariantItem = [&](CompareVariant cmpVar, const char* iconName) + { + const wxImage imgSel = loadImage(iconName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize())); + + menu.addItem(getVariantName(cmpVar), [&setVariant, cmpVar] { setVariant(cmpVar); }, + greyScaleIfDisabled(imgSel, this->localCmpCfg_ && this->localCmpCfg_->compareVar == cmpVar)); + }; + addVariantItem(CompareVariant::timeSize, "cmp_time"); + addVariantItem(CompareVariant::content, "cmp_content"); + addVariantItem(CompareVariant::size, "cmp_size"); + + //---------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto removeLocalCompCfg = [&] + { + this->localCmpCfg_ = {}; //"this->" galore: workaround GCC compiler bugs + this->refreshButtons(); + this->onLocalCompCfgChange(); + }; + menu.addItem(_("Remove local settings"), removeLocalCompCfg, wxNullImage, static_cast(localCmpCfg_)); + menu.popup(*basicPanel_.m_bpButtonLocalCompCfg, {basicPanel_.m_bpButtonLocalCompCfg->GetSize().x, 0}); + } + + void onLocalSyncCfgContext(wxEvent& event) + { + using namespace zen; + + ContextMenu menu; + + auto setVariant = [&](SyncVariant var) + { + if (!this->localSyncCfg_) + this->localSyncCfg_ = SyncConfig(); + this->localSyncCfg_->directionCfg = getDefaultSyncCfg(var); + + this->refreshButtons(); + this->onLocalSyncCfgChange(); + }; + + auto addVariantItem = [&](SyncVariant syncVar, const char* iconName) + { + const wxImage imgSel = mirrorIfRtl(loadImage(iconName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize()))); + + menu.addItem(getVariantName(syncVar), [&setVariant, syncVar] { setVariant(syncVar); }, + greyScaleIfDisabled(imgSel, this->localSyncCfg_ && getSyncVariant(this->localSyncCfg_->directionCfg) == syncVar)); + }; + addVariantItem(SyncVariant::twoWay, "sync_twoway"); + addVariantItem(SyncVariant::mirror, "sync_mirror"); + addVariantItem(SyncVariant::update, "sync_update"); + //addVariantItem(SyncVariant::custom, "sync_custom"); -> doesn't make sense, does it? + + //---------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto removeLocalSyncCfg = [&] + { + this->localSyncCfg_ = {}; + this->refreshButtons(); + this->onLocalSyncCfgChange(); + }; + menu.addItem(_("Remove local settings"), removeLocalSyncCfg, wxNullImage, static_cast(localSyncCfg_)); + menu.popup(*basicPanel_.m_bpButtonLocalSyncCfg, {basicPanel_.m_bpButtonLocalSyncCfg->GetSize().x, 0}); + } + + void onLocalFilterCfgContext(wxEvent& event) + { + using namespace zen; + + std::optional filterCfgOnClipboard; + if (std::optional clipTxt = getClipboardText()) + filterCfgOnClipboard = parseFilterBuf(utfTo(*clipTxt)); + + auto cutFilter = [&] + { + setClipboardText(utfTo(serializeFilter(this->localFilter_))); + this->localFilter_ = FilterConfig(); + this->refreshButtons(); + this->onLocalFilterCfgChange(); + }; + + auto copyFilter = [&] { setClipboardText(utfTo(serializeFilter(this->localFilter_))); }; + + auto pasteFilter = [&] + { + this->localFilter_ = *filterCfgOnClipboard; + this->refreshButtons(); + this->onLocalFilterCfgChange(); + }; + + zen::ContextMenu menu; + menu.addItem( _("&Copy"), copyFilter, loadImage("item_copy_sicon"), !isNullFilter(localFilter_)); + menu.addItem( _("&Paste"), pasteFilter, loadImage("item_paste_sicon"), filterCfgOnClipboard.has_value()); + menu.addSeparator(); + menu.addItem( _("Cu&t"), cutFilter, loadImage("item_cut_sicon"), !isNullFilter(localFilter_)); + + menu.popup(*basicPanel_.m_bpButtonLocalFilter, {basicPanel_.m_bpButtonLocalFilter->GetSize().x, 0}); + } + + + virtual MainConfiguration getMainConfig() const = 0; + virtual wxWindow* getParentWindow() = 0; + + virtual void onLocalCompCfgChange () = 0; + virtual void onLocalSyncCfgChange () = 0; + virtual void onLocalFilterCfgChange() = 0; + + GuiPanel& basicPanel_; //panel to be enhanced by this template + + //alternate configuration attached to it + std::optional localCmpCfg_; + std::optional localSyncCfg_; + FilterConfig localFilter_; + + const wxImage imgCmp_ = zen::loadImage("options_compare", zen::dipToScreen(20)); + const wxImage imgSync_ = zen::loadImage("options_sync", zen::dipToScreen(20)); + const wxImage imgFilter_ = zen::loadImage("options_filter", zen::dipToScreen(20)); +}; + + +inline +std::wstring getFilterSummaryForTooltip(const FilterConfig& filterCfg) +{ + using namespace zen; + + auto indentLines = [](Zstring str) + { + std::wstring out; + split(str, Zstr('\n'), [&out](ZstringView block) + { + block = trimCpy(block); + if (!block.empty()) + { + out += L'\n'; + out += TAB_SPACE; + out += utfTo(block); + } + }); + return out; + }; + + std::wstring filterSummary; + if (trimCpy(filterCfg.includeFilter) != Zstr("*")) //harmonize with base/path_filter.cpp NameFilter::isNull + filterSummary += L"\n\n" + _("Include:") + indentLines(filterCfg.includeFilter); + + if (!trimCpy(filterCfg.excludeFilter).empty()) + filterSummary += L"\n\n" + _("Exclude:") + indentLines(filterCfg.excludeFilter); + + return filterSummary; +} +} + +#endif //FOLDER_PAIR_H_89341750847252345 diff --git a/FreeFileSync/Source/ui/folder_selector.cpp b/FreeFileSync/Source/ui/folder_selector.cpp new file mode 100644 index 0000000..69319d1 --- /dev/null +++ b/FreeFileSync/Source/ui/folder_selector.cpp @@ -0,0 +1,304 @@ +// ***************************************************************************** +// * 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 "folder_selector.h" +#include +#include +#include +#include +#include +#include +#include "small_dlgs.h" //includes structures.h, which defines "AFS" +#include "../afs/concrete.h" +#include "../afs/native.h" +#include "../afs/gdrive.h" + + #include + + +using namespace zen; +using namespace fff; + + +namespace +{ +constexpr std::chrono::milliseconds FOLDER_SELECTED_EXISTENCE_CHECK_TIME_MAX(200); + + +void setFolderPathPhrase(const Zstring& folderPathPhrase, FolderHistoryBox* comboBox, wxWindow& tooltipWnd, wxStaticText* staticText) //pointers are optional +{ + if (comboBox) + comboBox->setValue(utfTo(folderPathPhrase)); + + const Zstring folderPathPhraseFmt = AFS::getInitPathPhrase(createAbstractPath(folderPathPhrase)); //noexcept + //may block when resolving [] + + if (folderPathPhraseFmt.empty()) + tooltipWnd.UnsetToolTip(); //wxGTK doesn't allow wxToolTip with empty text! + else + tooltipWnd.SetToolTip(utfTo(folderPathPhraseFmt)); + + auto trimTrailingSep = [](Zstring path) + { + if (endsWith(path, Zstr('/')) || + endsWith(path, Zstr('\\'))) + path.pop_back(); + return path; + }; + + if (staticText) //change static box label only if there is a real difference to what is shown in wxTextCtrl anyway + staticText->SetLabel(equalNoCase(trimTrailingSep(trimCpy(folderPathPhrase)), + trimTrailingSep(folderPathPhraseFmt)) ? + wxString(_("Drag && drop")) : utfTo(folderPathPhraseFmt)); +} + + +} + +//############################################################################################################## + +namespace fff +{ +wxDEFINE_EVENT(EVENT_ON_FOLDER_SELECTED, wxCommandEvent); +} + + +FolderSelector::FolderSelector(wxWindow* parent, + wxWindow& dropWindow, + wxButton& selectFolderButton, + wxButton& selectAltFolderButton, + FolderHistoryBox& folderComboBox, + Zstring& folderLastSelected, Zstring& sftpKeyFileLastSelected, + wxStaticText* staticText, + wxWindow* dropWindow2, + const std::function& shellItemPaths)>& droppedPathsFilter, + const std::function& getDeviceParallelOps, + const std::function& setDeviceParallelOps) : + droppedPathsFilter_ (droppedPathsFilter), + getDeviceParallelOps_(getDeviceParallelOps), + setDeviceParallelOps_(setDeviceParallelOps), + parent_(parent), + dropWindow_(dropWindow), + dropWindow2_(dropWindow2), + selectFolderButton_(selectFolderButton), + selectAltFolderButton_(selectAltFolderButton), + folderComboBox_(folderComboBox), + folderLastSelected_(folderLastSelected), + sftpKeyFileLastSelected_(sftpKeyFileLastSelected), + staticText_(staticText) +{ + assert(getDeviceParallelOps_); + + auto setupDragDrop = [&](wxWindow& dropWin) + { + setupFileDrop(dropWin); + dropWin.Bind(EVENT_DROP_FILE, &FolderSelector::onItemPathDropped, this); + }; + + setupDragDrop(dropWindow_); + if (dropWindow2_) + setupDragDrop(*dropWindow2_); + + setImage(selectAltFolderButton_, loadImage("cloud_small")); + + //keep folderSelector and dirpath synchronous + folderComboBox_ .Bind(wxEVT_MOUSEWHEEL, &FolderSelector::onMouseWheel, this); + folderComboBox_ .Bind(wxEVT_COMMAND_TEXT_UPDATED, &FolderSelector::onEditFolderPath, this); + //folderComboBox_.Bind(wxEVT_COMMAND_COMBOBOX_SELECTED, &FolderSelector::onHistoryPathSelected, this); + // => wxEVT_COMMAND_COMBOBOX_SELECTED implies wxEVT_COMMAND_TEXT_UPDATED + selectFolderButton_ .Bind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector::onSelectFolder, this); + selectAltFolderButton_.Bind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector::onSelectAltFolder, this); +} + + +FolderSelector::~FolderSelector() +{ + [[maybe_unused]] bool ubOk1 = dropWindow_.Unbind(EVENT_DROP_FILE, &FolderSelector::onItemPathDropped, this); + [[maybe_unused]] bool ubOk2 = true; + if (dropWindow2_) + ubOk2 = dropWindow2_->Unbind(EVENT_DROP_FILE, &FolderSelector::onItemPathDropped, this); + + [[maybe_unused]] bool ubOk3 = folderComboBox_ .Unbind(wxEVT_MOUSEWHEEL, &FolderSelector::onMouseWheel, this); + [[maybe_unused]] bool ubOk4 = folderComboBox_ .Unbind(wxEVT_COMMAND_TEXT_UPDATED, &FolderSelector::onEditFolderPath, this); + //[[maybe_unused]] bool ubOk5 = folderComboBox_ .Unbind(wxEVT_COMMAND_COMBOBOX_SELECTED, &FolderSelector::onHistoryPathSelected, this); + // => wxEVT_COMMAND_COMBOBOX_SELECTED implies wxEVT_COMMAND_TEXT_UPDATED + [[maybe_unused]] bool ubOk6 = selectFolderButton_ .Unbind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector::onSelectFolder, this); + [[maybe_unused]] bool ubOk7 = selectAltFolderButton_.Unbind(wxEVT_COMMAND_BUTTON_CLICKED, &FolderSelector::onSelectAltFolder, this); + assert(ubOk1 && ubOk2 && ubOk3 && ubOk4 && /*ubOk5 &&*/ ubOk6 && ubOk7); +} + + +void FolderSelector::onMouseWheel(wxMouseEvent& event) +{ + //for combobox: although switching through available items is wxWidgets default, this is NOT Windows default, e.g. Explorer + //additionally this will delete manual entries, although all the users wanted is scroll the parent window! + + //redirect to parent scrolled window! + for (wxWindow* wnd = folderComboBox_.GetParent(); wnd; wnd = wnd->GetParent()) + if (dynamic_cast(wnd)) + if (wxEvtHandler* evtHandler = wnd->GetEventHandler()) + return evtHandler->AddPendingEvent(event); + assert(false); //get here when attempting to scroll first folder pair (which is not inside a wxScrolledWindow) + //event.Skip(); +} + + +void FolderSelector::onItemPathDropped(FileDropEvent& event) +{ + if (event.itemPaths_.empty()) + return; + + if (!droppedPathsFilter_ || droppedPathsFilter_(event.itemPaths_)) + { + auto fmtShellPath = [](Zstring shellItemPath) + { + if (endsWith(shellItemPath, Zstr(' '))) //prevent createAbstractPath() from trimming legit trailing blank! + shellItemPath += FILE_NAME_SEPARATOR; + + const AbstractPath itemPath = createAbstractPath(shellItemPath); + try + { + if (AFS::getItemType(itemPath) == AFS::ItemType::file) //throw FileError + if (const std::optional parentPath = AFS::getParentPath(itemPath)) + return AFS::getInitPathPhrase(*parentPath); + } + catch (FileError&) {} //e.g. good for inactive mapped network shares, not so nice for C:\pagefile.sys + //make sure FFS-specific explicit MTP-syntax is applied! + return AFS::getInitPathPhrase(itemPath); + }; + + setPath(fmtShellPath(event.itemPaths_[0])); + //drop two folder paths at once: + if (siblingSelector_ && event.itemPaths_.size() >= 2) + siblingSelector_->setPath(fmtShellPath(event.itemPaths_[1])); + + //notify action invoked by user + wxCommandEvent dummy(EVENT_ON_FOLDER_SELECTED); + ProcessEvent(dummy); + } + + //event.Skip(); //let other handlers try -> are there any?? +} + + +void FolderSelector::onEditFolderPath(wxCommandEvent& event) +{ + setFolderPathPhrase(utfTo(event.GetString()), nullptr, folderComboBox_, staticText_); + + wxCommandEvent dummy(EVENT_ON_FOLDER_SELECTED); + ProcessEvent(dummy); + event.Skip(); +} + + +void FolderSelector::onSelectFolder(wxCommandEvent& event) +{ + Zstring defaultFolderNative; + { + //make sure default folder exists: don't let folder picker hang on non-existing network share! + auto folderAccessible = [stopTime = std::chrono::steady_clock::now() + FOLDER_SELECTED_EXISTENCE_CHECK_TIME_MAX](const AbstractPath& folderPath) + { + if (AFS::isNullPath(folderPath)) + return false; + + auto ft = runAsync([folderPath] + { + try + { + return AFS::getItemType(folderPath) != AFS::ItemType::file; //throw FileError + } + catch (FileError&) { return false; } + }); + return ft.wait_until(stopTime) == std::future_status::ready && ft.get(); //potentially slow network access: wait 200ms at most + }; + + auto trySetDefaultPath = [&](const Zstring& folderPathPhrase) + { + if (acceptsItemPathPhraseNative(folderPathPhrase)) //noexcept + { + const AbstractPath folderPath = createItemPathNative(folderPathPhrase); + if (folderAccessible(folderPath)) + if (const Zstring& nativePath = getNativeItemPath(folderPath); + !nativePath.empty()) + defaultFolderNative = nativePath; + } + }; + const Zstring& currentFolderPath = getPath(); + trySetDefaultPath(currentFolderPath); + + if (defaultFolderNative.empty() && //=> fallback: use last user-selected path + trimCpy(folderLastSelected_) != trimCpy(currentFolderPath) /*case-sensitive comp for path phrase!*/) + trySetDefaultPath(folderLastSelected_); + } + + Zstring shellItemPath; + //default size? Windows: not implemented, Linux(GTK2): not implemented, macOS: not implemented => wxWidgets, what is this shit!? + wxDirDialog folderSelector(parent_, _("Select a folder"), utfTo(defaultFolderNative), wxDD_DEFAULT_STYLE | wxDD_SHOW_HIDDEN); + //GTK2: "Show hidden" is also available as a context menu option in the folder picker! + //It looks like wxDD_SHOW_HIDDEN only sets the default when opening for the first time!? + if (folderSelector.ShowModal() != wxID_OK) + return; + shellItemPath = utfTo(folderSelector.GetPath()); + if (endsWith(shellItemPath, Zstr(' '))) //prevent createAbstractPath() from trimming legit trailing blank! + shellItemPath += FILE_NAME_SEPARATOR; + + //make sure FFS-specific explicit MTP-syntax is applied! + const Zstring newFolderPathPhrase = AFS::getInitPathPhrase(createAbstractPath(shellItemPath)); //noexcept + + setPath(newFolderPathPhrase); + folderLastSelected_ = newFolderPathPhrase; + + //notify action invoked by user + wxCommandEvent dummy(EVENT_ON_FOLDER_SELECTED); + ProcessEvent(dummy); +} + + +void FolderSelector::onSelectAltFolder(wxCommandEvent& event) +{ + Zstring folderPathPhrase = getPath(); + size_t parallelOps = getDeviceParallelOps_ ? getDeviceParallelOps_(folderPathPhrase) : 1; + + const AbstractPath oldPath = createAbstractPath(folderPathPhrase); + + if (showCloudSetupDialog(parent_, folderPathPhrase, sftpKeyFileLastSelected_, parallelOps, static_cast(setDeviceParallelOps_)) != ConfirmationButton::accept) + return; + + setPath(folderPathPhrase); + + if (setDeviceParallelOps_) + setDeviceParallelOps_(folderPathPhrase, parallelOps); + + //notify action invoked by user + if (createAbstractPath(folderPathPhrase) != oldPath) + { + wxCommandEvent dummy(EVENT_ON_FOLDER_SELECTED); + ProcessEvent(dummy); + } + //else: don't notify if user only changed connection settings, e.g. parallel Ops +} + + +Zstring FolderSelector::getPath() const +{ + return utfTo(folderComboBox_.GetValue()); +} + + +void FolderSelector::setPath(const Zstring& folderPathPhrase) +{ + setFolderPathPhrase(folderPathPhrase, &folderComboBox_, folderComboBox_, staticText_); +} + + +void fff::openFolderInFileBrowser(const AbstractPath& folderPath) //throw FileError +{ + if (const Zstring& gdriveUrl = getGoogleDriveFolderUrl(folderPath); //throw FileError + !gdriveUrl.empty()) + return openWithDefaultApp(gdriveUrl); //throw FileError + else + openWithDefaultApp(utfTo(AFS::getDisplayPath(folderPath))); //throw FileError +} diff --git a/FreeFileSync/Source/ui/folder_selector.h b/FreeFileSync/Source/ui/folder_selector.h new file mode 100644 index 0000000..1700ca6 --- /dev/null +++ b/FreeFileSync/Source/ui/folder_selector.h @@ -0,0 +1,82 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FOLDER_SELECTOR_H_24857842375234523463425 +#define FOLDER_SELECTOR_H_24857842375234523463425 + +#include +#include +#include +#include +#include "folder_history_box.h" +#include "../afs/abstract.h" + + +namespace fff +{ +/* handle drag and drop, tooltip, label and manual input, coordinating a wxWindow, wxButton, and wxComboBox/wxTextCtrl + + Reasons NOT to use wxDirPickerCtrl, but wxButton instead: + - Crash on GTK 2: https://favapps.wordpress.com/2012/06/11/freefilesync-crash-in-linux-when-syncing-solved/ + - still uses outdated ::SHBrowseForFolder() (even on Windows 7) + - selection dialog remembers size, but NOT position => if user enlarges window, the next time he opens the dialog it may leap out of visible screen + - hard-codes "Browse" button label */ + +wxDECLARE_EVENT(EVENT_ON_FOLDER_SELECTED, wxCommandEvent); //directory is changed by the user, including manual type-in +//example: wnd.Bind(EVENT_ON_FOLDER_SELECTED, [this](wxCommandEvent& event) { onDirSelected(event); }); + +class FolderSelector: public wxEvtHandler +{ +public: + FolderSelector(wxWindow* parent, + wxWindow& dropWindow, + wxButton& selectFolderButton, + wxButton& selectAltFolderButton, + FolderHistoryBox& folderComboBox, + Zstring& folderLastSelected, Zstring& sftpKeyFileLastSelected, + wxStaticText* staticText, //optional + wxWindow* dropWindow2, // + const std::function& shellItemPaths)>& droppedPathsFilter, //optional + const std::function& getDeviceParallelOps, //mandatory + const std::function& setDeviceParallelOps); //optional + + ~FolderSelector(); + + void setSiblingSelector(FolderSelector* selector) { siblingSelector_ = selector; } + + void setPath(const Zstring& folderPathPhrase); + Zstring getPath() const; + +private: + void onMouseWheel (wxMouseEvent& event); + void onItemPathDropped(zen::FileDropEvent& event); + void onEditFolderPath (wxCommandEvent& event); + void onSelectFolder (wxCommandEvent& event); + void onSelectAltFolder(wxCommandEvent& event); + + const std::function& shellItemPaths)> droppedPathsFilter_; + + const std::function getDeviceParallelOps_; + const std::function setDeviceParallelOps_; + + wxWindow* parent_; + wxWindow& dropWindow_; + wxWindow* dropWindow2_ = nullptr; // + wxButton& selectFolderButton_; + wxButton& selectAltFolderButton_; + FolderHistoryBox& folderComboBox_; + Zstring& folderLastSelected_; + Zstring& sftpKeyFileLastSelected_; + wxStaticText* staticText_ = nullptr; //optional + FolderSelector* siblingSelector_ = nullptr; // +}; + + +//abstract version of openWithDefaultApp() +void openFolderInFileBrowser(const AbstractPath& folderPath); //throw FileError +} + +#endif //FOLDER_SELECTOR_H_24857842375234523463425 diff --git a/FreeFileSync/Source/ui/gui_generated.cpp b/FreeFileSync/Source/ui/gui_generated.cpp new file mode 100644 index 0000000..01d35ad --- /dev/null +++ b/FreeFileSync/Source/ui/gui_generated.cpp @@ -0,0 +1,6232 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#include "gui_generated.h" + +/////////////////////////////////////////////////////////////////////////// + +MainDialogGenerated::MainDialogGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxFrame( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + + m_menubar = new wxMenuBar( 0 ); + m_menuFile = new wxMenu(); + m_menuItemNew = new wxMenuItem( m_menuFile, wxID_NEW, wxString( _("&New") ) + wxT('\t') + wxT("Ctrl+N"), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemNew ); + + m_menuItemLoad = new wxMenuItem( m_menuFile, wxID_OPEN, wxString( _("&Open...") ) + wxT('\t') + wxT("Ctrl+O"), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemLoad ); + + m_menuFile->AppendSeparator(); + + m_menuItemSave = new wxMenuItem( m_menuFile, wxID_SAVE, wxString( _("&Save") ) + wxT('\t') + wxT("Ctrl+S"), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemSave ); + + m_menuItemSaveAs = new wxMenuItem( m_menuFile, wxID_SAVEAS, wxString( _("Save &as...") ), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemSaveAs ); + + m_menuItemSaveAsBatch = new wxMenuItem( m_menuFile, wxID_ANY, wxString( _("Save as &batch job...") ), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemSaveAsBatch ); + + m_menuFile->AppendSeparator(); + + m_menuItemQuit = new wxMenuItem( m_menuFile, wxID_EXIT, wxString( _("E&xit") ), wxEmptyString, wxITEM_NORMAL ); + m_menuFile->Append( m_menuItemQuit ); + + m_menubar->Append( m_menuFile, _("&File") ); + + m_menuActions = new wxMenu(); + m_menuItemShowLog = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("Show &log") ) + wxT('\t') + wxT("F4"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemShowLog ); + + m_menuActions->AppendSeparator(); + + m_menuItemCompare = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("Start &comparison") ) + wxT('\t') + wxT("F5"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemCompare ); + + m_menuActions->AppendSeparator(); + + m_menuItemCompSettings = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("C&omparison settings") ) + wxT('\t') + wxT("F6"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemCompSettings ); + + m_menuItemFilter = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("&Filter settings") ) + wxT('\t') + wxT("F7"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemFilter ); + + m_menuItemSyncSettings = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("S&ynchronization settings") ) + wxT('\t') + wxT("F8"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemSyncSettings ); + + m_menuActions->AppendSeparator(); + + m_menuItemSynchronize = new wxMenuItem( m_menuActions, wxID_ANY, wxString( _("Start &synchronization") ) + wxT('\t') + wxT("F9"), wxEmptyString, wxITEM_NORMAL ); + m_menuActions->Append( m_menuItemSynchronize ); + + m_menubar->Append( m_menuActions, _("&Actions") ); + + m_menuTools = new wxMenu(); + m_menuItemOptions = new wxMenuItem( m_menuTools, wxID_PREFERENCES, wxString( _("&Preferences") ) + wxT('\t') + wxT("Ctrl+,"), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemOptions ); + + m_menuLanguages = new wxMenu(); + wxMenuItem* m_menuLanguagesItem = new wxMenuItem( m_menuTools, wxID_ANY, _("&Language"), wxEmptyString, wxITEM_NORMAL, m_menuLanguages ); + m_menuTools->Append( m_menuLanguagesItem ); + + m_menuTools->AppendSeparator(); + + m_menuItemFind = new wxMenuItem( m_menuTools, wxID_FIND, wxString( _("&Find...") ) + wxT('\t') + wxT("Ctrl+F"), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemFind ); + + m_menuItemExportList = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("&Export file list") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemExportList ); + + m_menuTools->AppendSeparator(); + + m_menuItemResetLayout = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("&Reset layout") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemResetLayout ); + + m_menuItemShowMain = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("dummy") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemShowMain ); + + m_menuItemShowFolders = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("dummy") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemShowFolders ); + + m_menuItemShowViewFilter = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("dummy") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemShowViewFilter ); + + m_menuItemShowConfig = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("dummy") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemShowConfig ); + + m_menuItemShowOverview = new wxMenuItem( m_menuTools, wxID_ANY, wxString( _("dummy") ), wxEmptyString, wxITEM_NORMAL ); + m_menuTools->Append( m_menuItemShowOverview ); + + m_menubar->Append( m_menuTools, _("&Tools") ); + + m_menuHelp = new wxMenu(); + m_menuItemHelp = new wxMenuItem( m_menuHelp, wxID_HELP, wxString( _("&View help") ) + wxT('\t') + wxT("F1"), wxEmptyString, wxITEM_NORMAL ); + m_menuHelp->Append( m_menuItemHelp ); + + m_menuHelp->AppendSeparator(); + + m_menuItemCheckVersionNow = new wxMenuItem( m_menuHelp, wxID_ANY, wxString( _("&Check for updates now") ), wxEmptyString, wxITEM_NORMAL ); + m_menuHelp->Append( m_menuItemCheckVersionNow ); + + m_menuHelp->AppendSeparator(); + + m_menuItemAbout = new wxMenuItem( m_menuHelp, wxID_ABOUT, wxString( _("&About") ) + wxT('\t') + wxT("Shift+F1"), wxEmptyString, wxITEM_NORMAL ); + m_menuHelp->Append( m_menuItemAbout ); + + m_menubar->Append( m_menuHelp, _("&Help") ); + + this->SetMenuBar( m_menubar ); + + bSizerPanelHolder = new wxBoxSizer( wxVERTICAL ); + + m_panelTopButtons = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelTopButtons->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1791; + bSizer1791 = new wxBoxSizer( wxHORIZONTAL ); + + bSizerTopButtons = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer261; + bSizer261 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer261->Add( 0, 0, 1, 0, 5 ); + + m_buttonCancel = new wxButton( m_panelTopButtons, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonCancel->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonCancel->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNTEXT ) ); + m_buttonCancel->Enable( false ); + m_buttonCancel->Hide(); + + bSizer261->Add( m_buttonCancel, 0, wxEXPAND, 5 ); + + m_buttonCompare = new zen::BitmapTextButton( m_panelTopButtons, wxID_ANY, _("Compare"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonCompare->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonCompare->SetToolTip( _("dummy") ); + + bSizer261->Add( m_buttonCompare, 0, wxEXPAND, 5 ); + + + bSizerTopButtons->Add( bSizer261, 1, wxEXPAND, 5 ); + + + bSizerTopButtons->Add( 8, 8, 0, 0, 5 ); + + wxBoxSizer* bSizer2942; + bSizer2942 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonCmpConfig = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonCmpConfig->SetToolTip( _("dummy") ); + + bSizer2942->Add( m_bpButtonCmpConfig, 0, wxEXPAND, 5 ); + + m_bpButtonCmpContext = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer2942->Add( m_bpButtonCmpContext, 0, wxEXPAND, 5 ); + + + bSizer2942->Add( 0, 0, 1, 0, 5 ); + + + bSizer2942->Add( 8, 0, 0, 0, 5 ); + + m_bpButtonFilter = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonFilter->SetToolTip( _("dummy") ); + + bSizer2942->Add( m_bpButtonFilter, 0, wxEXPAND, 5 ); + + m_bpButtonFilterContext = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer2942->Add( m_bpButtonFilterContext, 0, wxEXPAND, 5 ); + + + bSizer2942->Add( 8, 0, 0, 0, 5 ); + + + bSizer2942->Add( 0, 0, 1, 0, 5 ); + + m_bpButtonSyncConfig = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSyncConfig->SetToolTip( _("dummy") ); + + bSizer2942->Add( m_bpButtonSyncConfig, 0, wxEXPAND, 5 ); + + m_bpButtonSyncContext = new wxBitmapButton( m_panelTopButtons, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer2942->Add( m_bpButtonSyncContext, 0, wxEXPAND, 5 ); + + + bSizerTopButtons->Add( bSizer2942, 1, wxEXPAND, 5 ); + + + bSizerTopButtons->Add( 8, 8, 0, 0, 5 ); + + wxBoxSizer* bSizer262; + bSizer262 = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonSync = new zen::BitmapTextButton( m_panelTopButtons, wxID_ANY, _("Synchronize"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonSync->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonSync->SetToolTip( _("dummy") ); + + bSizer262->Add( m_buttonSync, 0, wxEXPAND, 5 ); + + + bSizerTopButtons->Add( bSizer262, 1, wxEXPAND, 5 ); + + + bSizer1791->Add( bSizerTopButtons, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelTopButtons->SetSizer( bSizer1791 ); + m_panelTopButtons->Layout(); + bSizer1791->Fit( m_panelTopButtons ); + bSizerPanelHolder->Add( m_panelTopButtons, 0, wxEXPAND, 5 ); + + m_panelDirectoryPairs = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL|wxBORDER_STATIC ); + wxBoxSizer* bSizer1601; + bSizer1601 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer91; + bSizer91 = new wxBoxSizer( wxHORIZONTAL ); + + m_panelTopLeft = new wxPanel( m_panelDirectoryPairs, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelTopLeft->SetMinSize( wxSize( 1, -1 ) ); + + wxFlexGridSizer* fgSizer8; + fgSizer8 = new wxFlexGridSizer( 0, 2, 0, 0 ); + fgSizer8->AddGrowableCol( 1 ); + fgSizer8->SetFlexibleDirection( wxBOTH ); + fgSizer8->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_ALL ); + + + fgSizer8->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextResolvedPathL = new wxStaticText( m_panelTopLeft, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextResolvedPathL->Wrap( -1 ); + fgSizer8->Add( m_staticTextResolvedPathL, 0, wxALIGN_CENTER_VERTICAL|wxALL, 2 ); + + wxBoxSizer* bSizer159; + bSizer159 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonAddPair = new wxBitmapButton( m_panelTopLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonAddPair->SetToolTip( _("Add folder pair") ); + + bSizer159->Add( m_bpButtonAddPair, 0, wxEXPAND, 5 ); + + m_bpButtonRemovePair = new wxBitmapButton( m_panelTopLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonRemovePair->SetToolTip( _("Remove folder pair") ); + + bSizer159->Add( m_bpButtonRemovePair, 0, wxEXPAND, 5 ); + + + fgSizer8->Add( bSizer159, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer182; + bSizer182 = new wxBoxSizer( wxHORIZONTAL ); + + m_folderPathLeft = new fff::FolderHistoryBox( m_panelTopLeft, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer182->Add( m_folderPathLeft, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolderLeft = new wxButton( m_panelTopLeft, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolderLeft->SetToolTip( _("Select a folder") ); + + bSizer182->Add( m_buttonSelectFolderLeft, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltFolderLeft = new wxBitmapButton( m_panelTopLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltFolderLeft->SetToolTip( _("Access online storage") ); + + bSizer182->Add( m_bpButtonSelectAltFolderLeft, 0, wxEXPAND, 5 ); + + + fgSizer8->Add( bSizer182, 0, wxEXPAND, 5 ); + + + m_panelTopLeft->SetSizer( fgSizer8 ); + m_panelTopLeft->Layout(); + fgSizer8->Fit( m_panelTopLeft ); + bSizer91->Add( m_panelTopLeft, 1, wxLEFT|wxALIGN_BOTTOM, 5 ); + + m_panelTopCenter = new wxPanel( m_panelDirectoryPairs, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + wxBoxSizer* bSizer1771; + bSizer1771 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonSwapSides = new wxBitmapButton( m_panelTopCenter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSwapSides->SetToolTip( _("dummy") ); + + bSizer1771->Add( m_bpButtonSwapSides, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer160; + bSizer160 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonLocalCompCfg = new wxBitmapButton( m_panelTopCenter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalCompCfg->SetToolTip( _("dummy") ); + + bSizer160->Add( m_bpButtonLocalCompCfg, 0, wxEXPAND, 5 ); + + m_bpButtonLocalFilter = new wxBitmapButton( m_panelTopCenter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalFilter->SetToolTip( _("dummy") ); + + bSizer160->Add( m_bpButtonLocalFilter, 0, wxEXPAND, 5 ); + + m_bpButtonLocalSyncCfg = new wxBitmapButton( m_panelTopCenter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalSyncCfg->SetToolTip( _("dummy") ); + + bSizer160->Add( m_bpButtonLocalSyncCfg, 0, wxEXPAND, 5 ); + + + bSizer1771->Add( bSizer160, 1, wxALIGN_CENTER_HORIZONTAL, 5 ); + + + m_panelTopCenter->SetSizer( bSizer1771 ); + m_panelTopCenter->Layout(); + bSizer1771->Fit( m_panelTopCenter ); + bSizer91->Add( m_panelTopCenter, 0, wxRIGHT|wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_panelTopRight = new wxPanel( m_panelDirectoryPairs, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelTopRight->SetMinSize( wxSize( 1, -1 ) ); + + wxBoxSizer* bSizer183; + bSizer183 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextResolvedPathR = new wxStaticText( m_panelTopRight, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextResolvedPathR->Wrap( -1 ); + bSizer183->Add( m_staticTextResolvedPathR, 0, wxALL, 2 ); + + wxBoxSizer* bSizer179; + bSizer179 = new wxBoxSizer( wxHORIZONTAL ); + + m_folderPathRight = new fff::FolderHistoryBox( m_panelTopRight, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer179->Add( m_folderPathRight, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolderRight = new wxButton( m_panelTopRight, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolderRight->SetToolTip( _("Select a folder") ); + + bSizer179->Add( m_buttonSelectFolderRight, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltFolderRight = new wxBitmapButton( m_panelTopRight, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltFolderRight->SetToolTip( _("Access online storage") ); + + bSizer179->Add( m_bpButtonSelectAltFolderRight, 0, wxEXPAND, 5 ); + + + bSizer183->Add( bSizer179, 0, wxEXPAND, 5 ); + + + m_panelTopRight->SetSizer( bSizer183 ); + m_panelTopRight->Layout(); + bSizer183->Fit( m_panelTopRight ); + bSizer91->Add( m_panelTopRight, 1, wxRIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer1601->Add( bSizer91, 0, wxEXPAND, 5 ); + + m_scrolledWindowFolderPairs = new wxScrolledWindow( m_panelDirectoryPairs, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), wxHSCROLL|wxVSCROLL ); + m_scrolledWindowFolderPairs->SetScrollRate( 5, 5 ); + m_scrolledWindowFolderPairs->SetMinSize( wxSize( -1, 0 ) ); + + bSizerAddFolderPairs = new wxBoxSizer( wxVERTICAL ); + + + m_scrolledWindowFolderPairs->SetSizer( bSizerAddFolderPairs ); + m_scrolledWindowFolderPairs->Layout(); + bSizerAddFolderPairs->Fit( m_scrolledWindowFolderPairs ); + bSizer1601->Add( m_scrolledWindowFolderPairs, 1, wxEXPAND, 5 ); + + + m_panelDirectoryPairs->SetSizer( bSizer1601 ); + m_panelDirectoryPairs->Layout(); + bSizer1601->Fit( m_panelDirectoryPairs ); + bSizerPanelHolder->Add( m_panelDirectoryPairs, 0, wxEXPAND, 5 ); + + m_gridOverview = new zen::Grid( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridOverview->SetScrollRate( 5, 5 ); + bSizerPanelHolder->Add( m_gridOverview, 0, 0, 5 ); + + m_panelCenter = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + wxBoxSizer* bSizer1711; + bSizer1711 = new wxBoxSizer( wxVERTICAL ); + + m_splitterMain = new fff::TripleSplitter( m_panelCenter, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + wxBoxSizer* bSizer1781; + bSizer1781 = new wxBoxSizer( wxHORIZONTAL ); + + m_gridMainL = new zen::Grid( m_splitterMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridMainL->SetScrollRate( 5, 5 ); + bSizer1781->Add( m_gridMainL, 1, wxEXPAND, 5 ); + + m_gridMainC = new zen::Grid( m_splitterMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridMainC->SetScrollRate( 5, 5 ); + bSizer1781->Add( m_gridMainC, 0, wxEXPAND, 5 ); + + m_gridMainR = new zen::Grid( m_splitterMain, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridMainR->SetScrollRate( 5, 5 ); + bSizer1781->Add( m_gridMainR, 1, wxEXPAND, 5 ); + + + m_splitterMain->SetSizer( bSizer1781 ); + m_splitterMain->Layout(); + bSizer1781->Fit( m_splitterMain ); + bSizer1711->Add( m_splitterMain, 1, wxEXPAND, 5 ); + + m_panelStatusBar = new wxPanel( m_panelCenter, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL|wxBORDER_STATIC ); + wxBoxSizer* bSizer451; + bSizer451 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer314; + bSizer314 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer314->Add( 0, 0, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + bSizerStatusLeftDirectories = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapSmallDirectoryLeft = new wxStaticBitmap( m_panelStatusBar, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStatusLeftDirectories->Add( m_bitmapSmallDirectoryLeft, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusLeftDirectories->Add( 2, 0, 0, 0, 5 ); + + m_staticTextStatusLeftDirs = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusLeftDirs->Wrap( -1 ); + bSizerStatusLeftDirectories->Add( m_staticTextStatusLeftDirs, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer314->Add( bSizerStatusLeftDirectories, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + bSizerStatusLeftFiles = new wxBoxSizer( wxHORIZONTAL ); + + + bSizerStatusLeftFiles->Add( 10, 0, 0, 0, 5 ); + + m_bitmapSmallFileLeft = new wxStaticBitmap( m_panelStatusBar, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStatusLeftFiles->Add( m_bitmapSmallFileLeft, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusLeftFiles->Add( 2, 0, 0, 0, 5 ); + + m_staticTextStatusLeftFiles = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusLeftFiles->Wrap( -1 ); + bSizerStatusLeftFiles->Add( m_staticTextStatusLeftFiles, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusLeftFiles->Add( 4, 0, 0, 0, 5 ); + + m_staticTextStatusLeftBytes = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusLeftBytes->Wrap( -1 ); + bSizerStatusLeftFiles->Add( m_staticTextStatusLeftBytes, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer314->Add( bSizerStatusLeftFiles, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer314->Add( 0, 0, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticLine* m_staticline9; + m_staticline9 = new wxStaticLine( m_panelStatusBar, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer314->Add( m_staticline9, 0, wxEXPAND|wxTOP, 2 ); + + + bSizer451->Add( bSizer314, 1, wxEXPAND, 5 ); + + + bSizer451->Add( 26, 0, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextStatusCenter = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusCenter->Wrap( -1 ); + bSizer451->Add( m_staticTextStatusCenter, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer451->Add( 26, 0, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer315; + bSizer315 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticLine* m_staticline10; + m_staticline10 = new wxStaticLine( m_panelStatusBar, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer315->Add( m_staticline10, 0, wxEXPAND|wxTOP, 2 ); + + + bSizer315->Add( 0, 0, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + bSizerStatusRightDirectories = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapSmallDirectoryRight = new wxStaticBitmap( m_panelStatusBar, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStatusRightDirectories->Add( m_bitmapSmallDirectoryRight, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusRightDirectories->Add( 2, 0, 0, 0, 5 ); + + m_staticTextStatusRightDirs = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusRightDirs->Wrap( -1 ); + bSizerStatusRightDirectories->Add( m_staticTextStatusRightDirs, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer315->Add( bSizerStatusRightDirectories, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + bSizerStatusRightFiles = new wxBoxSizer( wxHORIZONTAL ); + + + bSizerStatusRightFiles->Add( 10, 0, 0, 0, 5 ); + + m_bitmapSmallFileRight = new wxStaticBitmap( m_panelStatusBar, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStatusRightFiles->Add( m_bitmapSmallFileRight, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusRightFiles->Add( 2, 0, 0, 0, 5 ); + + m_staticTextStatusRightFiles = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusRightFiles->Wrap( -1 ); + bSizerStatusRightFiles->Add( m_staticTextStatusRightFiles, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatusRightFiles->Add( 4, 0, 0, 0, 5 ); + + m_staticTextStatusRightBytes = new wxStaticText( m_panelStatusBar, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatusRightBytes->Wrap( -1 ); + bSizerStatusRightFiles->Add( m_staticTextStatusRightBytes, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer315->Add( bSizerStatusRightFiles, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer315->Add( 0, 0, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer451->Add( bSizer315, 1, wxEXPAND, 5 ); + + + m_panelStatusBar->SetSizer( bSizer451 ); + m_panelStatusBar->Layout(); + bSizer451->Fit( m_panelStatusBar ); + bSizer1711->Add( m_panelStatusBar, 0, wxEXPAND, 5 ); + + + m_panelCenter->SetSizer( bSizer1711 ); + m_panelCenter->Layout(); + bSizer1711->Fit( m_panelCenter ); + bSizerPanelHolder->Add( m_panelCenter, 1, wxEXPAND, 5 ); + + m_panelSearch = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + wxBoxSizer* bSizer1713; + bSizer1713 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonHideSearch = new wxBitmapButton( m_panelSearch, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonHideSearch->SetToolTip( _("Close search bar") ); + + bSizer1713->Add( m_bpButtonHideSearch, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText101; + m_staticText101 = new wxStaticText( m_panelSearch, wxID_ANY, _("Find:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText101->Wrap( -1 ); + bSizer1713->Add( m_staticText101, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_textCtrlSearchTxt = new wxTextCtrl( m_panelSearch, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_PROCESS_ENTER|wxWANTS_CHARS ); + bSizer1713->Add( m_textCtrlSearchTxt, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + m_checkBoxMatchCase = new wxCheckBox( m_panelSearch, wxID_ANY, _("Match case"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer1713->Add( m_checkBoxMatchCase, 1, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + m_panelSearch->SetSizer( bSizer1713 ); + m_panelSearch->Layout(); + bSizer1713->Fit( m_panelSearch ); + bSizerPanelHolder->Add( m_panelSearch, 0, 0, 5 ); + + m_panelLog = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelLog->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizerLog = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer42; + bSizer42 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapSyncResult = new wxStaticBitmap( m_panelLog, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer42->Add( m_bitmapSyncResult, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_staticTextSyncResult = new wxStaticText( m_panelLog, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextSyncResult->Wrap( -1 ); + m_staticTextSyncResult->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer42->Add( m_staticTextSyncResult, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + + bSizer42->Add( 10, 0, 0, 0, 5 ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextProcessed = new wxStaticText( m_panelLog, wxID_ANY, _("Processed:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextProcessed->Wrap( -1 ); + ffgSizer11->Add( m_staticTextProcessed, 0, wxALIGN_RIGHT|wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextRemaining = new wxStaticText( m_panelLog, wxID_ANY, _("Remaining:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextRemaining->Wrap( -1 ); + ffgSizer11->Add( m_staticTextRemaining, 0, wxALIGN_RIGHT|wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer42->Add( ffgSizer11, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 10 ); + + m_panelItemStats = new wxPanel( m_panelLog, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelItemStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer291; + bSizer291 = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* ffgSizer111; + ffgSizer111 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer111->SetFlexibleDirection( wxBOTH ); + ffgSizer111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer293; + bSizer293 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapItemStat = new wxStaticBitmap( m_panelItemStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer293->Add( m_bitmapItemStat, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextItemsProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsProcessed->Wrap( -1 ); + m_staticTextItemsProcessed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer293->Add( m_staticTextItemsProcessed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer111->Add( bSizer293, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + m_staticTextBytesProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesProcessed->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesProcessed, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextItemsRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsRemaining->Wrap( -1 ); + m_staticTextItemsRemaining->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer111->Add( m_staticTextItemsRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextBytesRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesRemaining->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer291->Add( ffgSizer111, 0, wxALL, 5 ); + + + m_panelItemStats->SetSizer( bSizer291 ); + m_panelItemStats->Layout(); + bSizer291->Fit( m_panelItemStats ); + bSizer42->Add( m_panelItemStats, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + m_panelTimeStats = new wxPanel( m_panelLog, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelTimeStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer292; + bSizer292 = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* ffgSizer112; + ffgSizer112 = new wxFlexGridSizer( 0, 1, 5, 5 ); + ffgSizer112->SetFlexibleDirection( wxBOTH ); + ffgSizer112->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer294; + bSizer294 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapTimeStat = new wxStaticBitmap( m_panelTimeStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer294->Add( m_bitmapTimeStat, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextTimeElapsed = new wxStaticText( m_panelTimeStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeElapsed->Wrap( -1 ); + m_staticTextTimeElapsed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer294->Add( m_staticTextTimeElapsed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer112->Add( bSizer294, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + + bSizer292->Add( ffgSizer112, 0, wxALL, 5 ); + + + m_panelTimeStats->SetSizer( bSizer292 ); + m_panelTimeStats->Layout(); + bSizer292->Fit( m_panelTimeStats ); + bSizer42->Add( m_panelTimeStats, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + + bSizerLog->Add( bSizer42, 0, wxLEFT, 5 ); + + wxStaticLine* m_staticline70; + m_staticline70 = new wxStaticLine( m_panelLog, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerLog->Add( m_staticline70, 0, wxEXPAND, 5 ); + + + m_panelLog->SetSizer( bSizerLog ); + m_panelLog->Layout(); + bSizerLog->Fit( m_panelLog ); + bSizerPanelHolder->Add( m_panelLog, 0, 0, 5 ); + + m_panelConfig = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelConfig->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizerConfig = new wxBoxSizer( wxVERTICAL ); + + bSizerCfgHistoryButtons = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer17611; + bSizer17611 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonNew = new wxBitmapButton( m_panelConfig, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonNew->SetToolTip( _("dummy") ); + + bSizer17611->Add( m_bpButtonNew, 0, wxEXPAND, 5 ); + + wxStaticText* m_staticText951; + m_staticText951 = new wxStaticText( m_panelConfig, wxID_ANY, _("New"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText951->Wrap( -1 ); + bSizer17611->Add( m_staticText951, 0, wxALIGN_CENTER_HORIZONTAL|wxRIGHT|wxLEFT, 2 ); + + + bSizerCfgHistoryButtons->Add( bSizer17611, 0, 0, 5 ); + + wxBoxSizer* bSizer1761; + bSizer1761 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonOpen = new wxBitmapButton( m_panelConfig, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonOpen->SetToolTip( _("dummy") ); + + bSizer1761->Add( m_bpButtonOpen, 0, wxEXPAND, 5 ); + + wxStaticText* m_staticText95; + m_staticText95 = new wxStaticText( m_panelConfig, wxID_ANY, _("Open..."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText95->Wrap( -1 ); + bSizer1761->Add( m_staticText95, 0, wxALIGN_CENTER_HORIZONTAL|wxRIGHT|wxLEFT, 2 ); + + + bSizerCfgHistoryButtons->Add( bSizer1761, 0, 0, 5 ); + + wxBoxSizer* bSizer175; + bSizer175 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonSave = new wxBitmapButton( m_panelConfig, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSave->SetToolTip( _("dummy") ); + + bSizer175->Add( m_bpButtonSave, 0, wxEXPAND, 5 ); + + wxStaticText* m_staticText961; + m_staticText961 = new wxStaticText( m_panelConfig, wxID_ANY, _("Save"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText961->Wrap( -1 ); + bSizer175->Add( m_staticText961, 0, wxALIGN_CENTER_HORIZONTAL|wxRIGHT|wxLEFT, 2 ); + + + bSizerCfgHistoryButtons->Add( bSizer175, 0, 0, 5 ); + + wxBoxSizer* bSizer174; + bSizer174 = new wxBoxSizer( wxVERTICAL ); + + bSizerSaveAs = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonSaveAs = new wxBitmapButton( m_panelConfig, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSaveAs->SetToolTip( _("dummy") ); + + bSizerSaveAs->Add( m_bpButtonSaveAs, 1, 0, 5 ); + + m_bpButtonSaveAsBatch = new wxBitmapButton( m_panelConfig, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSaveAsBatch->SetToolTip( _("dummy") ); + + bSizerSaveAs->Add( m_bpButtonSaveAsBatch, 1, 0, 5 ); + + + bSizer174->Add( bSizerSaveAs, 0, wxEXPAND, 5 ); + + wxStaticText* m_staticText97; + m_staticText97 = new wxStaticText( m_panelConfig, wxID_ANY, _("Save as..."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText97->Wrap( -1 ); + bSizer174->Add( m_staticText97, 0, wxALIGN_CENTER_HORIZONTAL|wxRIGHT|wxLEFT, 2 ); + + + bSizerCfgHistoryButtons->Add( bSizer174, 0, 0, 5 ); + + + bSizerConfig->Add( bSizerCfgHistoryButtons, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + wxStaticLine* m_staticline81; + m_staticline81 = new wxStaticLine( m_panelConfig, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerConfig->Add( m_staticline81, 0, wxEXPAND|wxTOP, 5 ); + + + bSizerConfig->Add( 10, 0, 0, 0, 5 ); + + m_gridCfgHistory = new zen::Grid( m_panelConfig, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridCfgHistory->SetScrollRate( 5, 5 ); + bSizerConfig->Add( m_gridCfgHistory, 1, wxEXPAND, 5 ); + + + m_panelConfig->SetSizer( bSizerConfig ); + m_panelConfig->Layout(); + bSizerConfig->Fit( m_panelConfig ); + bSizerPanelHolder->Add( m_panelConfig, 0, 0, 5 ); + + m_panelViewFilter = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelViewFilter->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizerViewFilter = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonToggleLog = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizerViewFilter->Add( m_bpButtonToggleLog, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerViewFilter->Add( 0, 0, 1, wxEXPAND, 5 ); + + bSizerViewButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonViewType = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonViewType, 0, wxEXPAND, 5 ); + + + bSizerViewButtons->Add( 10, 10, 0, 0, 5 ); + + m_bpButtonShowExcluded = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowExcluded, 0, wxEXPAND, 5 ); + + + bSizerViewButtons->Add( 10, 10, 0, 0, 5 ); + + m_bpButtonShowDeleteLeft = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowDeleteLeft, 0, wxEXPAND, 5 ); + + m_bpButtonShowUpdateLeft = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowUpdateLeft, 0, wxEXPAND, 5 ); + + m_bpButtonShowCreateLeft = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowCreateLeft, 0, wxEXPAND, 5 ); + + m_bpButtonShowLeftOnly = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowLeftOnly, 0, wxEXPAND, 5 ); + + m_bpButtonShowLeftNewer = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowLeftNewer, 0, wxEXPAND, 5 ); + + m_bpButtonShowEqual = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowEqual, 0, wxEXPAND, 5 ); + + m_bpButtonShowDoNothing = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowDoNothing, 0, wxEXPAND, 5 ); + + m_bpButtonShowDifferent = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowDifferent, 0, wxEXPAND, 5 ); + + m_bpButtonShowRightNewer = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowRightNewer, 0, wxEXPAND, 5 ); + + m_bpButtonShowRightOnly = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowRightOnly, 0, wxEXPAND, 5 ); + + m_bpButtonShowCreateRight = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowCreateRight, 0, wxEXPAND, 5 ); + + m_bpButtonShowUpdateRight = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowUpdateRight, 0, wxEXPAND, 5 ); + + m_bpButtonShowDeleteRight = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowDeleteRight, 0, wxEXPAND, 5 ); + + m_bpButtonShowConflict = new zen::ToggleButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonShowConflict, 0, wxEXPAND, 5 ); + + m_bpButtonViewFilterContext = new wxBitmapButton( m_panelViewFilter, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizerViewButtons->Add( m_bpButtonViewFilterContext, 0, wxEXPAND, 5 ); + + + bSizerViewFilter->Add( bSizerViewButtons, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerViewFilter->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxStaticText* m_staticText96; + m_staticText96 = new wxStaticText( m_panelViewFilter, wxID_ANY, _("Statistics:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText96->Wrap( -1 ); + bSizerViewFilter->Add( m_staticText96, 0, wxALL|wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_panelStatistics = new wxPanel( m_panelViewFilter, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL|wxBORDER_SUNKEN ); + m_panelStatistics->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1801; + bSizer1801 = new wxBoxSizer( wxVERTICAL ); + + bSizerStatistics = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer173; + bSizer173 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapDeleteLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapDeleteLeft->SetToolTip( _("Number of files and folders that will be deleted") ); + + bSizer173->Add( m_bitmapDeleteLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer173->Add( 5, 2, 0, 0, 5 ); + + + bSizer173->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextDeleteLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDeleteLeft->Wrap( -1 ); + m_staticTextDeleteLeft->SetToolTip( _("Number of files and folders that will be deleted") ); + + bSizer173->Add( m_staticTextDeleteLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatistics->Add( bSizer173, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 10, 0, 0, 5 ); + + wxBoxSizer* bSizer172; + bSizer172 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapUpdateLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapUpdateLeft->SetToolTip( _("Number of files that will be updated") ); + + bSizer172->Add( m_bitmapUpdateLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer172->Add( 5, 2, 0, 0, 5 ); + + + bSizer172->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextUpdateLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextUpdateLeft->Wrap( -1 ); + m_staticTextUpdateLeft->SetToolTip( _("Number of files that will be updated") ); + + bSizer172->Add( m_staticTextUpdateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerStatistics->Add( bSizer172, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 5, 0, 0, 5 ); + + wxBoxSizer* bSizer1712; + bSizer1712 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapCreateLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapCreateLeft->SetToolTip( _("Number of files and folders that will be created") ); + + bSizer1712->Add( m_bitmapCreateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer1712->Add( 5, 2, 0, 0, 5 ); + + + bSizer1712->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextCreateLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCreateLeft->Wrap( -1 ); + m_staticTextCreateLeft->SetToolTip( _("Number of files and folders that will be created") ); + + bSizer1712->Add( m_staticTextCreateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerStatistics->Add( bSizer1712, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 5, 0, 0, 5 ); + + wxBoxSizer* bSizer311; + bSizer311 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapData = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapData->SetToolTip( _("Total bytes to copy") ); + + bSizer311->Add( m_bitmapData, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer311->Add( 5, 2, 0, 0, 5 ); + + + bSizer311->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextData = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextData->Wrap( -1 ); + m_staticTextData->SetToolTip( _("Total bytes to copy") ); + + bSizer311->Add( m_staticTextData, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatistics->Add( bSizer311, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 5, 0, 0, 5 ); + + wxBoxSizer* bSizer178; + bSizer178 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapCreateRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapCreateRight->SetToolTip( _("Number of files and folders that will be created") ); + + bSizer178->Add( m_bitmapCreateRight, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer178->Add( 5, 2, 0, 0, 5 ); + + + bSizer178->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextCreateRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCreateRight->Wrap( -1 ); + m_staticTextCreateRight->SetToolTip( _("Number of files and folders that will be created") ); + + bSizer178->Add( m_staticTextCreateRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatistics->Add( bSizer178, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 5, 0, 0, 5 ); + + wxBoxSizer* bSizer177; + bSizer177 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapUpdateRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapUpdateRight->SetToolTip( _("Number of files that will be updated") ); + + bSizer177->Add( m_bitmapUpdateRight, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer177->Add( 5, 2, 0, 0, 5 ); + + + bSizer177->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextUpdateRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextUpdateRight->Wrap( -1 ); + m_staticTextUpdateRight->SetToolTip( _("Number of files that will be updated") ); + + bSizer177->Add( m_staticTextUpdateRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatistics->Add( bSizer177, 0, wxEXPAND, 5 ); + + + bSizerStatistics->Add( 10, 10, 0, 0, 5 ); + + wxBoxSizer* bSizer176; + bSizer176 = new wxBoxSizer( wxVERTICAL ); + + m_bitmapDeleteRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapDeleteRight->SetToolTip( _("Number of files and folders that will be deleted") ); + + bSizer176->Add( m_bitmapDeleteRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer176->Add( 5, 2, 0, 0, 5 ); + + + bSizer176->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextDeleteRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDeleteRight->Wrap( -1 ); + m_staticTextDeleteRight->SetToolTip( _("Number of files and folders that will be deleted") ); + + bSizer176->Add( m_staticTextDeleteRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStatistics->Add( bSizer176, 0, wxEXPAND, 5 ); + + + bSizer1801->Add( bSizerStatistics, 0, wxALL, 4 ); + + + m_panelStatistics->SetSizer( bSizer1801 ); + m_panelStatistics->Layout(); + bSizer1801->Fit( m_panelStatistics ); + bSizerViewFilter->Add( m_panelStatistics, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + m_panelViewFilter->SetSizer( bSizerViewFilter ); + m_panelViewFilter->Layout(); + bSizerViewFilter->Fit( m_panelViewFilter ); + bSizerPanelHolder->Add( m_panelViewFilter, 0, 0, 5 ); + + + this->SetSizer( bSizerPanelHolder ); + this->Layout(); + bSizerPanelHolder->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( MainDialogGenerated::onClose ) ); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onConfigNew ), this, m_menuItemNew->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onConfigLoad ), this, m_menuItemLoad->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onConfigSave ), this, m_menuItemSave->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onConfigSaveAs ), this, m_menuItemSaveAs->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onSaveAsBatchJob ), this, m_menuItemSaveAsBatch->GetId()); + m_menuFile->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuQuit ), this, m_menuItemQuit->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onToggleLog ), this, m_menuItemShowLog->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onCompare ), this, m_menuItemCompare->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onCmpSettings ), this, m_menuItemCompSettings->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onConfigureFilter ), this, m_menuItemFilter->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onSyncSettings ), this, m_menuItemSyncSettings->GetId()); + m_menuActions->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onStartSync ), this, m_menuItemSynchronize->GetId()); + m_menuTools->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuOptions ), this, m_menuItemOptions->GetId()); + m_menuTools->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuFindItem ), this, m_menuItemFind->GetId()); + m_menuTools->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuExportFileList ), this, m_menuItemExportList->GetId()); + m_menuTools->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuResetLayout ), this, m_menuItemResetLayout->GetId()); + m_menuHelp->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onShowHelp ), this, m_menuItemHelp->GetId()); + m_menuHelp->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuCheckVersion ), this, m_menuItemCheckVersionNow->GetId()); + m_menuHelp->Bind(wxEVT_COMMAND_MENU_SELECTED, wxCommandEventHandler( MainDialogGenerated::onMenuAbout ), this, m_menuItemAbout->GetId()); + m_buttonCompare->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onCompare ), NULL, this ); + m_bpButtonCmpConfig->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onCmpSettings ), NULL, this ); + m_bpButtonCmpConfig->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onCompSettingsContextMouse ), NULL, this ); + m_bpButtonCmpContext->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onCompSettingsContext ), NULL, this ); + m_bpButtonCmpContext->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onCompSettingsContextMouse ), NULL, this ); + m_bpButtonFilter->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onConfigureFilter ), NULL, this ); + m_bpButtonFilter->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onGlobalFilterContextMouse ), NULL, this ); + m_bpButtonFilterContext->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onGlobalFilterContext ), NULL, this ); + m_bpButtonFilterContext->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onGlobalFilterContextMouse ), NULL, this ); + m_bpButtonSyncConfig->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onSyncSettings ), NULL, this ); + m_bpButtonSyncConfig->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onSyncSettingsContextMouse ), NULL, this ); + m_bpButtonSyncContext->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onSyncSettingsContext ), NULL, this ); + m_bpButtonSyncContext->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onSyncSettingsContextMouse ), NULL, this ); + m_buttonSync->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onStartSync ), NULL, this ); + m_bpButtonAddPair->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onTopFolderPairAdd ), NULL, this ); + m_bpButtonRemovePair->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onTopFolderPairRemove ), NULL, this ); + m_bpButtonSwapSides->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onSwapSides ), NULL, this ); + m_bpButtonLocalCompCfg->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onTopLocalCompCfg ), NULL, this ); + m_bpButtonLocalFilter->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onTopLocalFilterCfg ), NULL, this ); + m_bpButtonLocalSyncCfg->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onTopLocalSyncCfg ), NULL, this ); + m_bpButtonHideSearch->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onHideSearchPanel ), NULL, this ); + m_textCtrlSearchTxt->Connect( wxEVT_COMMAND_TEXT_ENTER, wxCommandEventHandler( MainDialogGenerated::onSearchGridEnter ), NULL, this ); + m_bpButtonNew->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onConfigNew ), NULL, this ); + m_bpButtonOpen->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onConfigLoad ), NULL, this ); + m_bpButtonSave->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onConfigSave ), NULL, this ); + m_bpButtonSaveAs->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onConfigSaveAs ), NULL, this ); + m_bpButtonSaveAsBatch->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onSaveAsBatchJob ), NULL, this ); + m_bpButtonToggleLog->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleLog ), NULL, this ); + m_bpButtonViewType->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewType ), NULL, this ); + m_bpButtonViewType->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewTypeContextMouse ), NULL, this ); + m_bpButtonShowExcluded->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowExcluded->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowDeleteLeft->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowDeleteLeft->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowUpdateLeft->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowUpdateLeft->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowCreateLeft->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowCreateLeft->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowLeftOnly->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowLeftOnly->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowLeftNewer->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowLeftNewer->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowEqual->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowEqual->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowDoNothing->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowDoNothing->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowDifferent->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowDifferent->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowRightNewer->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowRightNewer->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowRightOnly->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowRightOnly->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowCreateRight->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowCreateRight->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowUpdateRight->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowUpdateRight->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowDeleteRight->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowDeleteRight->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonShowConflict->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onToggleViewButton ), NULL, this ); + m_bpButtonShowConflict->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); + m_bpButtonViewFilterContext->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( MainDialogGenerated::onViewFilterContext ), NULL, this ); + m_bpButtonViewFilterContext->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( MainDialogGenerated::onViewFilterContextMouse ), NULL, this ); +} + +MainDialogGenerated::~MainDialogGenerated() +{ +} + +FolderPairPanelGenerated::FolderPairPanelGenerated( wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style, const wxString& name ) : wxPanel( parent, id, pos, size, style, name ) +{ + wxBoxSizer* bSizer74; + bSizer74 = new wxBoxSizer( wxHORIZONTAL ); + + m_panelLeft = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelLeft->SetMinSize( wxSize( 1, -1 ) ); + + wxBoxSizer* bSizer134; + bSizer134 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonFolderPairOptions = new wxBitmapButton( m_panelLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonFolderPairOptions->SetToolTip( _("Arrange folder pair") ); + + bSizer134->Add( m_bpButtonFolderPairOptions, 0, wxEXPAND, 5 ); + + m_bpButtonRemovePair = new wxBitmapButton( m_panelLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonRemovePair->SetToolTip( _("Remove folder pair") ); + + bSizer134->Add( m_bpButtonRemovePair, 0, wxEXPAND, 5 ); + + m_folderPathLeft = new fff::FolderHistoryBox( m_panelLeft, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer134->Add( m_folderPathLeft, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolderLeft = new wxButton( m_panelLeft, wxID_ANY, _("Browse"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonSelectFolderLeft->SetToolTip( _("Select a folder") ); + + bSizer134->Add( m_buttonSelectFolderLeft, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltFolderLeft = new wxBitmapButton( m_panelLeft, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltFolderLeft->SetToolTip( _("Access online storage") ); + + bSizer134->Add( m_bpButtonSelectAltFolderLeft, 0, wxEXPAND, 5 ); + + + m_panelLeft->SetSizer( bSizer134 ); + m_panelLeft->Layout(); + bSizer134->Fit( m_panelLeft ); + bSizer74->Add( m_panelLeft, 0, wxLEFT|wxEXPAND, 5 ); + + wxPanel* m_panel20; + m_panel20 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + wxBoxSizer* bSizer95; + bSizer95 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonLocalCompCfg = new wxBitmapButton( m_panel20, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalCompCfg->SetToolTip( _("dummy") ); + + bSizer95->Add( m_bpButtonLocalCompCfg, 0, wxEXPAND, 5 ); + + m_bpButtonLocalFilter = new wxBitmapButton( m_panel20, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalFilter->SetToolTip( _("dummy") ); + + bSizer95->Add( m_bpButtonLocalFilter, 0, wxEXPAND, 5 ); + + m_bpButtonLocalSyncCfg = new wxBitmapButton( m_panel20, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonLocalSyncCfg->SetToolTip( _("dummy") ); + + bSizer95->Add( m_bpButtonLocalSyncCfg, 0, wxEXPAND, 5 ); + + + m_panel20->SetSizer( bSizer95 ); + m_panel20->Layout(); + bSizer95->Fit( m_panel20 ); + bSizer74->Add( m_panel20, 0, wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + m_panelRight = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelRight->SetMinSize( wxSize( 1, -1 ) ); + + wxBoxSizer* bSizer135; + bSizer135 = new wxBoxSizer( wxHORIZONTAL ); + + m_folderPathRight = new fff::FolderHistoryBox( m_panelRight, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer135->Add( m_folderPathRight, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectFolderRight = new wxButton( m_panelRight, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolderRight->SetToolTip( _("Select a folder") ); + + bSizer135->Add( m_buttonSelectFolderRight, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltFolderRight = new wxBitmapButton( m_panelRight, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltFolderRight->SetToolTip( _("Access online storage") ); + + bSizer135->Add( m_bpButtonSelectAltFolderRight, 0, wxEXPAND, 5 ); + + + m_panelRight->SetSizer( bSizer135 ); + m_panelRight->Layout(); + bSizer135->Fit( m_panelRight ); + bSizer74->Add( m_panelRight, 1, wxRIGHT|wxEXPAND, 5 ); + + + this->SetSizer( bSizer74 ); + this->Layout(); +} + +FolderPairPanelGenerated::~FolderPairPanelGenerated() +{ +} + +ConfigDlgGenerated::ConfigDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer7; + bSizer7 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer190; + bSizer190 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer1911; + bSizer1911 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextFolderPairLabel = new wxStaticText( this, wxID_ANY, _("Folder pair:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextFolderPairLabel->Wrap( -1 ); + bSizer1911->Add( m_staticTextFolderPairLabel, 0, wxALL, 5 ); + + m_listBoxFolderPair = new wxListBox( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0, NULL, wxLB_NEEDED_SB ); + bSizer1911->Add( m_listBoxFolderPair, 1, 0, 5 ); + + + bSizer190->Add( bSizer1911, 0, wxEXPAND, 5 ); + + m_notebook = new wxNotebook( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxNB_NOPAGETHEME ); + m_panelCompSettingsTab = new wxPanel( m_notebook, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelCompSettingsTab->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer275; + bSizer275 = new wxBoxSizer( wxVERTICAL ); + + bSizerHeaderCompSettings = new wxBoxSizer( wxVERTICAL ); + + m_staticTextMainCompSettings = new wxStaticText( m_panelCompSettingsTab, wxID_ANY, _("Common settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMainCompSettings->Wrap( -1 ); + bSizerHeaderCompSettings->Add( m_staticTextMainCompSettings, 0, wxALL, 10 ); + + m_checkBoxUseLocalCmpOptions = new wxCheckBox( m_panelCompSettingsTab, wxID_ANY, _("Use local settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxUseLocalCmpOptions->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizerHeaderCompSettings->Add( m_checkBoxUseLocalCmpOptions, 0, wxALL|wxEXPAND, 10 ); + + m_staticlineCompHeader = new wxStaticLine( m_panelCompSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerHeaderCompSettings->Add( m_staticlineCompHeader, 0, wxEXPAND, 5 ); + + + bSizer275->Add( bSizerHeaderCompSettings, 0, wxEXPAND, 5 ); + + m_panelComparisonSettings = new wxPanel( m_panelCompSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelComparisonSettings->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer2561; + bSizer2561 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer159; + bSizer159 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer178; + bSizer178 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer182; + bSizer182 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText91; + m_staticText91 = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("Select a variant:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText91->Wrap( -1 ); + bSizer182->Add( m_staticText91, 0, wxALL, 5 ); + + wxGridSizer* gSizer2; + gSizer2 = new wxGridSizer( 0, 1, 0, 0 ); + + m_buttonByTimeSize = new zen::ToggleButton( m_panelComparisonSettings, wxID_ANY, _("File time and size"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonByTimeSize->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonByTimeSize->SetToolTip( _("dummy") ); + + gSizer2->Add( m_buttonByTimeSize, 0, wxEXPAND, 5 ); + + m_buttonByContent = new zen::ToggleButton( m_panelComparisonSettings, wxID_ANY, _("File content"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonByContent->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonByContent->SetToolTip( _("dummy") ); + + gSizer2->Add( m_buttonByContent, 0, wxEXPAND, 5 ); + + m_buttonBySize = new zen::ToggleButton( m_panelComparisonSettings, wxID_ANY, _("File size"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonBySize->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonBySize->SetToolTip( _("dummy") ); + + gSizer2->Add( m_buttonBySize, 0, wxEXPAND, 5 ); + + + bSizer182->Add( gSizer2, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer178->Add( bSizer182, 0, wxALL, 5 ); + + wxBoxSizer* bSizer2371; + bSizer2371 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapCompVariant = new wxStaticBitmap( m_panelComparisonSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer2371->Add( m_bitmapCompVariant, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextCompVarDescription = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCompVarDescription->Wrap( -1 ); + m_staticTextCompVarDescription->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer2371->Add( m_staticTextCompVarDescription, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer178->Add( bSizer2371, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer159->Add( bSizer178, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline33; + m_staticline33 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer159->Add( m_staticline33, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer1734; + bSizer1734 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer1734->Add( 0, 0, 1, 0, 5 ); + + wxBoxSizer* bSizer1721; + bSizer1721 = new wxBoxSizer( wxVERTICAL ); + + m_checkBoxSymlinksInclude = new wxCheckBox( m_panelComparisonSettings, wxID_ANY, _("Include &symbolic links:"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer1721->Add( m_checkBoxSymlinksInclude, 0, wxALL, 5 ); + + wxBoxSizer* bSizer176; + bSizer176 = new wxBoxSizer( wxVERTICAL ); + + m_radioBtnSymlinksFollow = new wxRadioButton( m_panelComparisonSettings, wxID_ANY, _("&Follow"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP ); + m_radioBtnSymlinksFollow->SetValue( true ); + bSizer176->Add( m_radioBtnSymlinksFollow, 0, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + m_radioBtnSymlinksDirect = new wxRadioButton( m_panelComparisonSettings, wxID_ANY, _("As &link"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer176->Add( m_radioBtnSymlinksDirect, 0, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1721->Add( bSizer176, 0, wxLEFT|wxEXPAND, 15 ); + + + bSizer1721->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink24; + m_hyperlink24 = new wxHyperlinkCtrl( m_panelComparisonSettings, wxID_ANY, _("More information"), wxT("https://freefilesync.org/manual.php?topic=comparison-settings"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink24->SetToolTip( _("https://freefilesync.org/manual.php?topic=comparison-settings") ); + + bSizer1721->Add( m_hyperlink24, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1734->Add( bSizer1721, 0, wxALL|wxEXPAND, 5 ); + + + bSizer1734->Add( 0, 0, 1, 0, 5 ); + + wxStaticLine* m_staticline44; + m_staticline44 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer1734->Add( m_staticline44, 0, wxEXPAND, 5 ); + + + bSizer1734->Add( 0, 0, 1, 0, 5 ); + + wxBoxSizer* bSizer1733; + bSizer1733 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText112; + m_staticText112 = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("&Ignore exact time shift [hh:mm]"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText112->Wrap( -1 ); + bSizer1733->Add( m_staticText112, 0, wxALL, 5 ); + + m_textCtrlTimeShift = new wxTextCtrl( m_panelComparisonSettings, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + m_textCtrlTimeShift->SetToolTip( _("List of file time offsets to ignore") ); + + bSizer1733->Add( m_textCtrlTimeShift, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + wxBoxSizer* bSizer197; + bSizer197 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText1381; + m_staticText1381 = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("Example:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1381->Wrap( -1 ); + m_staticText1381->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer197->Add( m_staticText1381, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + wxStaticText* m_staticText13811; + m_staticText13811 = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("1, 2, 4:30"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText13811->Wrap( -1 ); + m_staticText13811->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer197->Add( m_staticText13811, 0, wxBOTTOM|wxRIGHT, 5 ); + + + bSizer1733->Add( bSizer197, 0, 0, 5 ); + + + bSizer1733->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink241; + m_hyperlink241 = new wxHyperlinkCtrl( m_panelComparisonSettings, wxID_ANY, _("Handle daylight saving time"), wxT("https://freefilesync.org/manual.php?topic=daylight-saving-time"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink241->SetToolTip( _("https://freefilesync.org/manual.php?topic=daylight-saving-time") ); + + bSizer1733->Add( m_hyperlink241, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer1734->Add( bSizer1733, 0, wxALL|wxEXPAND, 5 ); + + + bSizer1734->Add( 0, 0, 1, 0, 5 ); + + + bSizer159->Add( bSizer1734, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline331; + m_staticline331 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer159->Add( m_staticline331, 0, wxEXPAND, 5 ); + + + bSizer159->Add( 0, 0, 1, 0, 5 ); + + bSizerCompMisc = new wxBoxSizer( wxVERTICAL ); + + wxStaticLine* m_staticline3311; + m_staticline3311 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerCompMisc->Add( m_staticline3311, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer2781; + bSizer2781 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* fgSizer61; + fgSizer61 = new wxFlexGridSizer( 0, 2, 5, 5 ); + fgSizer61->SetFlexibleDirection( wxBOTH ); + fgSizer61->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapIgnoreErrors = new wxStaticBitmap( m_panelComparisonSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer61->Add( m_bitmapIgnoreErrors, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxIgnoreErrors = new wxCheckBox( m_panelComparisonSettings, wxID_ANY, _("Ignore errors"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + fgSizer61->Add( m_checkBoxIgnoreErrors, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + m_bitmapRetryErrors = new wxStaticBitmap( m_panelComparisonSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer61->Add( m_bitmapRetryErrors, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_checkBoxAutoRetry = new wxCheckBox( m_panelComparisonSettings, wxID_ANY, _("Automatic retry"), wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer61->Add( m_checkBoxAutoRetry, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + + bSizer2781->Add( fgSizer61, 0, wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + fgSizerAutoRetry = new wxFlexGridSizer( 0, 2, 5, 10 ); + fgSizerAutoRetry->SetFlexibleDirection( wxBOTH ); + fgSizerAutoRetry->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText96; + m_staticText96 = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("Retry count:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText96->Wrap( -1 ); + fgSizerAutoRetry->Add( m_staticText96, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextAutoRetryDelay = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("Delay (in seconds):"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextAutoRetryDelay->Wrap( -1 ); + fgSizerAutoRetry->Add( m_staticTextAutoRetryDelay, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlAutoRetryCount = new wxSpinCtrl( m_panelComparisonSettings, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + fgSizerAutoRetry->Add( m_spinCtrlAutoRetryCount, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlAutoRetryDelay = new wxSpinCtrl( m_panelComparisonSettings, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + fgSizerAutoRetry->Add( m_spinCtrlAutoRetryDelay, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer2781->Add( fgSizerAutoRetry, 0, wxTOP|wxBOTTOM|wxRIGHT|wxALIGN_CENTER_VERTICAL, 10 ); + + + bSizerCompMisc->Add( bSizer2781, 0, wxEXPAND, 5 ); + + + bSizer159->Add( bSizerCompMisc, 0, wxEXPAND, 5 ); + + + bSizer2561->Add( bSizer159, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline751; + m_staticline751 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer2561->Add( m_staticline751, 0, wxEXPAND, 5 ); + + bSizerPerformance = new wxBoxSizer( wxVERTICAL ); + + m_panel57 = new wxPanel( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel57->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer2191; + bSizer2191 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapPerf = new wxStaticBitmap( m_panel57, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer2191->Add( m_bitmapPerf, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxStaticText* m_staticText13611; + m_staticText13611 = new wxStaticText( m_panel57, wxID_ANY, _("Performance improvements:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText13611->Wrap( -1 ); + bSizer2191->Add( m_staticText13611, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + + m_panel57->SetSizer( bSizer2191 ); + m_panel57->Layout(); + bSizer2191->Fit( m_panel57 ); + bSizerPerformance->Add( m_panel57, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline75; + m_staticline75 = new wxStaticLine( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerPerformance->Add( m_staticline75, 0, wxEXPAND, 5 ); + + m_hyperlinkPerfDeRequired = new wxHyperlinkCtrl( m_panelComparisonSettings, wxID_ANY, _("Requires FreeFileSync Donation Edition"), wxT("https://freefilesync.org/faq.php#donation-edition"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlinkPerfDeRequired->SetToolTip( _("https://freefilesync.org/faq.php#donation-edition") ); + + bSizerPerformance->Add( m_hyperlinkPerfDeRequired, 0, wxALL, 10 ); + + bSizer260 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextPerfParallelOps = new wxStaticText( m_panelComparisonSettings, wxID_ANY, _("Parallel file operations:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPerfParallelOps->Wrap( -1 ); + bSizer260->Add( m_staticTextPerfParallelOps, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + m_scrolledWindowPerf = new wxScrolledWindow( m_panelComparisonSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_scrolledWindowPerf->SetScrollRate( 5, 5 ); + m_scrolledWindowPerf->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + fgSizerPerf = new wxFlexGridSizer( 0, 2, 5, 5 ); + fgSizerPerf->SetFlexibleDirection( wxBOTH ); + fgSizerPerf->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + + m_scrolledWindowPerf->SetSizer( fgSizerPerf ); + m_scrolledWindowPerf->Layout(); + fgSizerPerf->Fit( m_scrolledWindowPerf ); + bSizer260->Add( m_scrolledWindowPerf, 1, wxALL|wxEXPAND, 5 ); + + + bSizerPerformance->Add( bSizer260, 1, wxALL|wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink1711; + m_hyperlink1711 = new wxHyperlinkCtrl( m_panelComparisonSettings, wxID_ANY, _("How to get the best performance?"), wxT("https://freefilesync.org/manual.php?topic=performance"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink1711->SetToolTip( _("https://freefilesync.org/manual.php?topic=performance") ); + + bSizerPerformance->Add( m_hyperlink1711, 0, wxBOTTOM|wxRIGHT|wxLEFT, 10 ); + + + bSizer2561->Add( bSizerPerformance, 1, wxEXPAND, 5 ); + + + m_panelComparisonSettings->SetSizer( bSizer2561 ); + m_panelComparisonSettings->Layout(); + bSizer2561->Fit( m_panelComparisonSettings ); + bSizer275->Add( m_panelComparisonSettings, 1, wxEXPAND, 5 ); + + + m_panelCompSettingsTab->SetSizer( bSizer275 ); + m_panelCompSettingsTab->Layout(); + bSizer275->Fit( m_panelCompSettingsTab ); + m_notebook->AddPage( m_panelCompSettingsTab, _("dummy"), false ); + m_panelFilterSettingsTab = new wxPanel( m_notebook, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelFilterSettingsTab->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer278; + bSizer278 = new wxBoxSizer( wxVERTICAL ); + + bSizerHeaderFilterSettings = new wxBoxSizer( wxVERTICAL ); + + m_staticTextMainFilterSettings = new wxStaticText( m_panelFilterSettingsTab, wxID_ANY, _("Common settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMainFilterSettings->Wrap( -1 ); + bSizerHeaderFilterSettings->Add( m_staticTextMainFilterSettings, 0, wxALL, 10 ); + + m_staticTextLocalFilterSettings = new wxStaticText( m_panelFilterSettingsTab, wxID_ANY, _("Local settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextLocalFilterSettings->Wrap( -1 ); + bSizerHeaderFilterSettings->Add( m_staticTextLocalFilterSettings, 0, wxALL, 10 ); + + m_staticlineFilterHeader = new wxStaticLine( m_panelFilterSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerHeaderFilterSettings->Add( m_staticlineFilterHeader, 0, wxEXPAND, 5 ); + + + bSizer278->Add( bSizerHeaderFilterSettings, 0, wxEXPAND, 5 ); + + wxPanel* m_panel571; + m_panel571 = new wxPanel( m_panelFilterSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel571->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer307; + bSizer307 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer301; + bSizer301 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer166; + bSizer166 = new wxBoxSizer( wxVERTICAL ); + + + bSizer166->Add( 0, 10, 0, 0, 5 ); + + wxBoxSizer* bSizer1661; + bSizer1661 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapInclude = new wxStaticBitmap( m_panel571, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer1661->Add( m_bitmapInclude, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + wxBoxSizer* bSizer1731; + bSizer1731 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText78; + m_staticText78 = new wxStaticText( m_panel571, wxID_ANY, _("Include:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText78->Wrap( -1 ); + bSizer1731->Add( m_staticText78, 0, 0, 5 ); + + m_textCtrlInclude = new wxTextCtrl( m_panel571, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_MULTILINE ); + bSizer1731->Add( m_textCtrlInclude, 1, wxEXPAND|wxTOP, 5 ); + + + bSizer1661->Add( bSizer1731, 1, wxEXPAND, 5 ); + + + bSizer166->Add( bSizer1661, 3, wxEXPAND|wxLEFT, 5 ); + + + bSizer166->Add( 0, 10, 0, 0, 5 ); + + wxBoxSizer* bSizer1651; + bSizer1651 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapExclude = new wxStaticBitmap( m_panel571, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer1651->Add( m_bitmapExclude, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxBoxSizer* bSizer1742; + bSizer1742 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer189; + bSizer189 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText77; + m_staticText77 = new wxStaticText( m_panel571, wxID_ANY, _("Exclude:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText77->Wrap( -1 ); + bSizer189->Add( m_staticText77, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer189->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink171; + m_hyperlink171 = new wxHyperlinkCtrl( m_panel571, wxID_ANY, _("Show examples"), wxT("https://freefilesync.org/manual.php?topic=exclude-files"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink171->SetToolTip( _("https://freefilesync.org/manual.php?topic=exclude-files") ); + + bSizer189->Add( m_hyperlink171, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + + bSizer1742->Add( bSizer189, 0, wxEXPAND, 5 ); + + m_textCtrlExclude = new wxTextCtrl( m_panel571, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_MULTILINE ); + bSizer1742->Add( m_textCtrlExclude, 1, wxEXPAND|wxTOP, 5 ); + + + bSizer1651->Add( bSizer1742, 1, wxEXPAND, 5 ); + + + bSizer166->Add( bSizer1651, 5, wxEXPAND|wxLEFT, 5 ); + + + bSizer301->Add( bSizer166, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline24; + m_staticline24 = new wxStaticLine( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer301->Add( m_staticline24, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer160; + bSizer160 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer168; + bSizer168 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapFilterSize = new wxStaticBitmap( m_panel571, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer168->Add( m_bitmapFilterSize, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + wxBoxSizer* bSizer158; + bSizer158 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText80; + m_staticText80 = new wxStaticText( m_panel571, wxID_ANY, _("File size:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText80->Wrap( -1 ); + bSizer158->Add( m_staticText80, 0, wxBOTTOM, 5 ); + + wxBoxSizer* bSizer162; + bSizer162 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText101; + m_staticText101 = new wxStaticText( m_panel571, wxID_ANY, _("Minimum:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText101->Wrap( -1 ); + bSizer162->Add( m_staticText101, 0, wxBOTTOM, 2 ); + + m_spinCtrlMinSize = new wxSpinCtrl( m_panel571, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + bSizer162->Add( m_spinCtrlMinSize, 0, wxEXPAND, 5 ); + + wxArrayString m_choiceUnitMinSizeChoices; + m_choiceUnitMinSize = new wxChoice( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choiceUnitMinSizeChoices, 0 ); + m_choiceUnitMinSize->SetSelection( 0 ); + bSizer162->Add( m_choiceUnitMinSize, 0, wxEXPAND, 5 ); + + + bSizer158->Add( bSizer162, 0, wxEXPAND, 5 ); + + + bSizer158->Add( 0, 10, 0, 0, 5 ); + + wxBoxSizer* bSizer163; + bSizer163 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText102; + m_staticText102 = new wxStaticText( m_panel571, wxID_ANY, _("Maximum:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText102->Wrap( -1 ); + bSizer163->Add( m_staticText102, 0, wxBOTTOM, 2 ); + + m_spinCtrlMaxSize = new wxSpinCtrl( m_panel571, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + bSizer163->Add( m_spinCtrlMaxSize, 0, wxEXPAND, 5 ); + + wxArrayString m_choiceUnitMaxSizeChoices; + m_choiceUnitMaxSize = new wxChoice( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choiceUnitMaxSizeChoices, 0 ); + m_choiceUnitMaxSize->SetSelection( 0 ); + bSizer163->Add( m_choiceUnitMaxSize, 0, wxEXPAND, 5 ); + + + bSizer158->Add( bSizer163, 0, wxEXPAND, 5 ); + + + bSizer168->Add( bSizer158, 1, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer160->Add( bSizer168, 2, wxEXPAND|wxALL, 5 ); + + wxStaticLine* m_staticline23; + m_staticline23 = new wxStaticLine( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer160->Add( m_staticline23, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer167; + bSizer167 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapFilterDate = new wxStaticBitmap( m_panel571, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer167->Add( m_bitmapFilterDate, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxBoxSizer* bSizer165; + bSizer165 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText79; + m_staticText79 = new wxStaticText( m_panel571, wxID_ANY, _("Time span:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText79->Wrap( -1 ); + bSizer165->Add( m_staticText79, 0, wxBOTTOM, 5 ); + + wxArrayString m_choiceUnitTimespanChoices; + m_choiceUnitTimespan = new wxChoice( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choiceUnitTimespanChoices, 0 ); + m_choiceUnitTimespan->SetSelection( 0 ); + bSizer165->Add( m_choiceUnitTimespan, 0, wxEXPAND, 5 ); + + m_spinCtrlTimespan = new wxSpinCtrl( m_panel571, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + bSizer165->Add( m_spinCtrlTimespan, 0, wxEXPAND, 5 ); + + + bSizer167->Add( bSizer165, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer160->Add( bSizer167, 1, wxEXPAND|wxALL, 5 ); + + wxStaticLine* m_staticline231; + m_staticline231 = new wxStaticLine( m_panel571, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer160->Add( m_staticline231, 0, wxEXPAND, 5 ); + + + bSizer301->Add( bSizer160, 0, wxEXPAND, 5 ); + + + bSizer307->Add( bSizer301, 1, wxEXPAND, 5 ); + + wxBoxSizer* bSizer302; + bSizer302 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextFilterDescr = new wxStaticText( m_panel571, wxID_ANY, _("Select filter rules to exclude certain files from synchronization.\nEnter file paths relative to their corresponding folder pair."), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextFilterDescr->Wrap( -1 ); + m_staticTextFilterDescr->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer302->Add( m_staticTextFilterDescr, 1, wxALL|wxALIGN_CENTER_VERTICAL, 10 ); + + wxBoxSizer* bSizer303; + bSizer303 = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonDefault = new wxButton( m_panel571, wxID_ANY, _("&Default"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer303->Add( m_buttonDefault, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonDefaultContext = new wxBitmapButton( m_panel571, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer303->Add( m_bpButtonDefaultContext, 0, wxEXPAND, 5 ); + + + bSizer302->Add( bSizer303, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL, 10 ); + + m_buttonClear = new wxButton( m_panel571, wxID_ANY, _("C&lear"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer302->Add( m_buttonClear, 0, wxALL|wxALIGN_CENTER_VERTICAL, 10 ); + + + bSizer307->Add( bSizer302, 0, wxEXPAND, 5 ); + + + m_panel571->SetSizer( bSizer307 ); + m_panel571->Layout(); + bSizer307->Fit( m_panel571 ); + bSizer278->Add( m_panel571, 1, wxEXPAND, 5 ); + + + m_panelFilterSettingsTab->SetSizer( bSizer278 ); + m_panelFilterSettingsTab->Layout(); + bSizer278->Fit( m_panelFilterSettingsTab ); + m_notebook->AddPage( m_panelFilterSettingsTab, _("dummy"), false ); + m_panelSyncSettingsTab = new wxPanel( m_notebook, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelSyncSettingsTab->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer276; + bSizer276 = new wxBoxSizer( wxVERTICAL ); + + bSizerHeaderSyncSettings = new wxBoxSizer( wxVERTICAL ); + + m_staticTextMainSyncSettings = new wxStaticText( m_panelSyncSettingsTab, wxID_ANY, _("Common settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMainSyncSettings->Wrap( -1 ); + bSizerHeaderSyncSettings->Add( m_staticTextMainSyncSettings, 0, wxALL, 10 ); + + m_checkBoxUseLocalSyncOptions = new wxCheckBox( m_panelSyncSettingsTab, wxID_ANY, _("Use local settings:"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerHeaderSyncSettings->Add( m_checkBoxUseLocalSyncOptions, 0, wxALL|wxEXPAND, 10 ); + + m_staticlineSyncHeader = new wxStaticLine( m_panelSyncSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerHeaderSyncSettings->Add( m_staticlineSyncHeader, 0, wxEXPAND, 5 ); + + + bSizer276->Add( bSizerHeaderSyncSettings, 0, wxEXPAND, 5 ); + + m_panelSyncSettings = new wxPanel( m_panelSyncSettingsTab, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelSyncSettings->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer232; + bSizer232 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer237; + bSizer237 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer235; + bSizer235 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText86; + m_staticText86 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Select a variant:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText86->Wrap( -1 ); + bSizer235->Add( m_staticText86, 0, wxALL, 5 ); + + wxGridSizer* gSizer1; + gSizer1 = new wxGridSizer( 0, 1, 0, 0 ); + + m_buttonTwoWay = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("Two way"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonTwoWay->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonTwoWay->SetToolTip( _("dummy") ); + + gSizer1->Add( m_buttonTwoWay, 0, wxEXPAND, 5 ); + + m_buttonMirror = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("Mirror"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonMirror->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonMirror->SetToolTip( _("dummy") ); + + gSizer1->Add( m_buttonMirror, 0, wxEXPAND, 5 ); + + m_buttonUpdate = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("Update"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonUpdate->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonUpdate->SetToolTip( _("dummy") ); + + gSizer1->Add( m_buttonUpdate, 0, wxEXPAND, 5 ); + + m_buttonCustom = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("Custom"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonCustom->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonCustom->SetToolTip( _("dummy") ); + + gSizer1->Add( m_buttonCustom, 0, wxEXPAND, 5 ); + + + bSizer235->Add( gSizer1, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer237->Add( bSizer235, 0, wxALL, 5 ); + + + bSizer237->Add( 10, 0, 0, 0, 5 ); + + wxBoxSizer* bSizer312; + bSizer312 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer311; + bSizer311 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapDatabase = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapDatabase->SetToolTip( _("sync.ffs_db") ); + + bSizer311->Add( m_bitmapDatabase, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxUseDatabase = new wxCheckBox( m_panelSyncSettings, wxID_ANY, _("Use database file to detect changes"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer311->Add( m_checkBoxUseDatabase, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer312->Add( bSizer311, 0, wxTOP|wxRIGHT|wxLEFT, 10 ); + + + bSizer312->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextSyncVarDescription = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextSyncVarDescription->Wrap( -1 ); + m_staticTextSyncVarDescription->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer312->Add( m_staticTextSyncVarDescription, 0, wxALL, 10 ); + + + bSizer312->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxBoxSizer* bSizer310; + bSizer310 = new wxBoxSizer( wxVERTICAL ); + + wxStaticLine* m_staticline431; + m_staticline431 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer310->Add( m_staticline431, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer201; + bSizer201 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticLine* m_staticline72; + m_staticline72 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer201->Add( m_staticline72, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer3121; + bSizer3121 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapMoveLeft = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapMoveLeft->SetToolTip( _("- Available not before the *second* synchronization\n- Not supported by all file systems") ); + + bSizer3121->Add( m_bitmapMoveLeft, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapMoveRight = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapMoveRight->SetToolTip( _("- Available not before the *second* synchronization\n- Not supported by all file systems") ); + + bSizer3121->Add( m_bitmapMoveRight, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextDetectMove = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Detect moved files"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDetectMove->Wrap( -1 ); + m_staticTextDetectMove->SetToolTip( _("- Available not before the *second* synchronization\n- Not supported by all file systems") ); + + bSizer3121->Add( m_staticTextDetectMove, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer201->Add( bSizer3121, 0, wxALL, 10 ); + + wxHyperlinkCtrl* m_hyperlink242; + m_hyperlink242 = new wxHyperlinkCtrl( m_panelSyncSettings, wxID_ANY, _("More information"), wxT("https://freefilesync.org/manual.php?topic=synchronization-settings"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink242->SetToolTip( _("https://freefilesync.org/manual.php?topic=synchronization-settings") ); + + bSizer201->Add( m_hyperlink242, 0, wxTOP|wxBOTTOM|wxRIGHT|wxALIGN_CENTER_VERTICAL, 10 ); + + wxStaticLine* m_staticline721; + m_staticline721 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer201->Add( m_staticline721, 0, wxEXPAND, 5 ); + + + bSizer310->Add( bSizer201, 0, 0, 5 ); + + + bSizer312->Add( bSizer310, 0, 0, 5 ); + + + bSizer237->Add( bSizer312, 0, wxEXPAND, 5 ); + + bSizerSyncDirHolder = new wxBoxSizer( wxHORIZONTAL ); + + bSizerSyncDirsDiff = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText184; + m_staticText184 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Difference"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText184->Wrap( -1 ); + bSizerSyncDirsDiff->Add( m_staticText184, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapLeftOnly = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapLeftOnly->SetToolTip( _("Item exists on left side only") ); + + ffgSizer11->Add( m_bitmapLeftOnly, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapLeftNewer = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapLeftNewer->SetToolTip( _("Left side is newer") ); + + ffgSizer11->Add( m_bitmapLeftNewer, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapDifferent = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapDifferent->SetToolTip( _("Items have different content") ); + + ffgSizer11->Add( m_bitmapDifferent, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapRightNewer = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapRightNewer->SetToolTip( _("Right side is newer") ); + + ffgSizer11->Add( m_bitmapRightNewer, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapRightOnly = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_bitmapRightOnly->SetToolTip( _("Item exists on right side only") ); + + ffgSizer11->Add( m_bitmapRightOnly, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonLeftOnly = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer11->Add( m_bpButtonLeftOnly, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonLeftNewer = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer11->Add( m_bpButtonLeftNewer, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonDifferent = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer11->Add( m_bpButtonDifferent, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonRightNewer = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer11->Add( m_bpButtonRightNewer, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonRightOnly = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer11->Add( m_bpButtonRightOnly, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerSyncDirsDiff->Add( ffgSizer11, 0, 0, 5 ); + + wxStaticText* m_staticText120; + m_staticText120 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Action"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText120->Wrap( -1 ); + bSizerSyncDirsDiff->Add( m_staticText120, 0, wxALIGN_CENTER_HORIZONTAL|wxTOP, 5 ); + + + bSizerSyncDirHolder->Add( bSizerSyncDirsDiff, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + bSizerSyncDirsChanges = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* ffgSizer111; + ffgSizer111 = new wxFlexGridSizer( 0, 3, 5, 5 ); + ffgSizer111->SetFlexibleDirection( wxBOTH ); + ffgSizer111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText12011; + m_staticText12011 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Create:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText12011->Wrap( -1 ); + ffgSizer111->Add( m_staticText12011, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT, 5 ); + + m_bpButtonLeftCreate = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonLeftCreate, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonRightCreate = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonRightCreate, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText12012; + m_staticText12012 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Update:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText12012->Wrap( -1 ); + ffgSizer111->Add( m_staticText12012, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonLeftUpdate = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonLeftUpdate, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonRightUpdate = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonRightUpdate, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText12013; + m_staticText12013 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Delete:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText12013->Wrap( -1 ); + ffgSizer111->Add( m_staticText12013, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT, 5 ); + + m_bpButtonLeftDelete = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonLeftDelete, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonRightDelete = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + ffgSizer111->Add( m_bpButtonRightDelete, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + ffgSizer111->Add( 0, 0, 0, 0, 5 ); + + wxStaticText* m_staticText1201; + m_staticText1201 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Left"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1201->Wrap( -1 ); + ffgSizer111->Add( m_staticText1201, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + wxStaticText* m_staticText1202; + m_staticText1202 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Right"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1202->Wrap( -1 ); + ffgSizer111->Add( m_staticText1202, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerSyncDirsChanges->Add( ffgSizer111, 0, 0, 5 ); + + + bSizerSyncDirHolder->Add( bSizerSyncDirsChanges, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer237->Add( bSizerSyncDirHolder, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + + bSizer237->Add( 0, 0, 1, 0, 5 ); + + + bSizer232->Add( bSizer237, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline54; + m_staticline54 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer232->Add( m_staticline54, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer2361; + bSizer2361 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer202; + bSizer202 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText87; + m_staticText87 = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Delete and overwrite:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText87->Wrap( -1 ); + bSizer202->Add( m_staticText87, 0, wxALL, 5 ); + + wxBoxSizer* bSizer234; + bSizer234 = new wxBoxSizer( wxVERTICAL ); + + m_buttonRecycler = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("&Recycle bin"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonRecycler->SetToolTip( _("dummy") ); + + bSizer234->Add( m_buttonRecycler, 0, wxEXPAND, 5 ); + + m_buttonPermanent = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("&Permanent"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonPermanent->SetToolTip( _("dummy") ); + + bSizer234->Add( m_buttonPermanent, 0, wxEXPAND, 5 ); + + m_buttonVersioning = new zen::ToggleButton( m_panelSyncSettings, wxID_ANY, _("&Versioning"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonVersioning->SetToolTip( _("dummy") ); + + bSizer234->Add( m_buttonVersioning, 0, wxEXPAND, 5 ); + + + bSizer202->Add( bSizer234, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer2361->Add( bSizer202, 0, wxALL, 5 ); + + bSizerVersioningHolder = new wxBoxSizer( wxVERTICAL ); + + + bSizerVersioningHolder->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxBoxSizer* bSizer2331; + bSizer2331 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapDeletionType = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer2331->Add( m_bitmapDeletionType, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_staticTextDeletionTypeDescription = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDeletionTypeDescription->Wrap( -1 ); + m_staticTextDeletionTypeDescription->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer2331->Add( m_staticTextDeletionTypeDescription, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizerVersioningHolder->Add( bSizer2331, 0, wxALL|wxEXPAND, 5 ); + + m_panelVersioning = new wxPanel( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelVersioning->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer191; + bSizer191 = new wxBoxSizer( wxVERTICAL ); + + + bSizer191->Add( 0, 5, 0, 0, 5 ); + + wxBoxSizer* bSizer252; + bSizer252 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapVersioning = new wxStaticBitmap( m_panelVersioning, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer252->Add( m_bitmapVersioning, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + wxBoxSizer* bSizer253; + bSizer253 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer254; + bSizer254 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText155; + m_staticText155 = new wxStaticText( m_panelVersioning, wxID_ANY, _("Move files to a user-defined folder"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText155->Wrap( -1 ); + m_staticText155->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer254->Add( m_staticText155, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizer254->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink243; + m_hyperlink243 = new wxHyperlinkCtrl( m_panelVersioning, wxID_ANY, _("Show examples"), wxT("https://freefilesync.org/manual.php?topic=versioning"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink243->SetToolTip( _("https://freefilesync.org/manual.php?topic=versioning") ); + + bSizer254->Add( m_hyperlink243, 0, wxLEFT|wxALIGN_BOTTOM, 5 ); + + + bSizer253->Add( bSizer254, 0, wxEXPAND|wxBOTTOM, 5 ); + + wxBoxSizer* bSizer156; + bSizer156 = new wxBoxSizer( wxHORIZONTAL ); + + m_versioningFolderPath = new fff::FolderHistoryBox( m_panelVersioning, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer156->Add( m_versioningFolderPath, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectVersioningFolder = new wxButton( m_panelVersioning, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectVersioningFolder->SetToolTip( _("Select a folder") ); + + bSizer156->Add( m_buttonSelectVersioningFolder, 0, wxEXPAND, 5 ); + + m_bpButtonSelectVersioningAltFolder = new wxBitmapButton( m_panelVersioning, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectVersioningAltFolder->SetToolTip( _("Access online storage") ); + + bSizer156->Add( m_bpButtonSelectVersioningAltFolder, 0, wxEXPAND, 5 ); + + + bSizer253->Add( bSizer156, 0, wxEXPAND, 5 ); + + + bSizer252->Add( bSizer253, 1, wxRIGHT, 5 ); + + + bSizer191->Add( bSizer252, 0, wxEXPAND|wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxBoxSizer* bSizer198; + bSizer198 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer255; + bSizer255 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer256; + bSizer256 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText93; + m_staticText93 = new wxStaticText( m_panelVersioning, wxID_ANY, _("Naming convention:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText93->Wrap( -1 ); + bSizer256->Add( m_staticText93, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxArrayString m_choiceVersioningStyleChoices; + m_choiceVersioningStyle = new wxChoice( m_panelVersioning, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choiceVersioningStyleChoices, 0 ); + m_choiceVersioningStyle->SetSelection( 0 ); + bSizer256->Add( m_choiceVersioningStyle, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer255->Add( bSizer256, 0, wxALL, 5 ); + + wxBoxSizer* bSizer257; + bSizer257 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextNamingCvtPart1 = new wxStaticText( m_panelVersioning, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextNamingCvtPart1->Wrap( -1 ); + m_staticTextNamingCvtPart1->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer257->Add( m_staticTextNamingCvtPart1, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextNamingCvtPart2Bold = new wxStaticText( m_panelVersioning, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextNamingCvtPart2Bold->Wrap( -1 ); + m_staticTextNamingCvtPart2Bold->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_staticTextNamingCvtPart2Bold->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer257->Add( m_staticTextNamingCvtPart2Bold, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextNamingCvtPart3 = new wxStaticText( m_panelVersioning, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextNamingCvtPart3->Wrap( -1 ); + m_staticTextNamingCvtPart3->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer257->Add( m_staticTextNamingCvtPart3, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer255->Add( bSizer257, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer198->Add( bSizer255, 0, wxALL, 5 ); + + wxStaticLine* m_staticline69; + m_staticline69 = new wxStaticLine( m_panelVersioning, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer198->Add( m_staticline69, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer258; + bSizer258 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextLimitVersions = new wxStaticText( m_panelVersioning, wxID_ANY, _("Limit file versions:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextLimitVersions->Wrap( -1 ); + bSizer258->Add( m_staticTextLimitVersions, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxFlexGridSizer* fgSizer15; + fgSizer15 = new wxFlexGridSizer( 0, 3, 5, 10 ); + fgSizer15->SetFlexibleDirection( wxBOTH ); + fgSizer15->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_checkBoxVersionMaxDays = new wxCheckBox( m_panelVersioning, wxID_ANY, _("Last x days:"), wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer15->Add( m_checkBoxVersionMaxDays, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxVersionCountMin = new wxCheckBox( m_panelVersioning, wxID_ANY, _("Minimum:"), wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer15->Add( m_checkBoxVersionCountMin, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxVersionCountMax = new wxCheckBox( m_panelVersioning, wxID_ANY, _("Maximum:"), wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer15->Add( m_checkBoxVersionCountMax, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlVersionMaxDays = new wxSpinCtrl( m_panelVersioning, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + fgSizer15->Add( m_spinCtrlVersionMaxDays, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlVersionCountMin = new wxSpinCtrl( m_panelVersioning, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + fgSizer15->Add( m_spinCtrlVersionCountMin, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlVersionCountMax = new wxSpinCtrl( m_panelVersioning, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + fgSizer15->Add( m_spinCtrlVersionCountMax, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer258->Add( fgSizer15, 0, wxALL, 5 ); + + + bSizer198->Add( bSizer258, 0, wxALL, 5 ); + + + bSizer191->Add( bSizer198, 0, wxEXPAND, 5 ); + + + m_panelVersioning->SetSizer( bSizer191 ); + m_panelVersioning->Layout(); + bSizer191->Fit( m_panelVersioning ); + bSizerVersioningHolder->Add( m_panelVersioning, 0, wxEXPAND, 5 ); + + + bSizerVersioningHolder->Add( 0, 0, 1, wxEXPAND, 5 ); + + + bSizer2361->Add( bSizerVersioningHolder, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer232->Add( bSizer2361, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline582; + m_staticline582 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer232->Add( m_staticline582, 0, wxEXPAND, 5 ); + + bSizerSyncMisc = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer292; + bSizer292 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer287; + bSizer287 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer290; + bSizer290 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer291; + bSizer291 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapEmail = new wxStaticBitmap( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer291->Add( m_bitmapEmail, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxSendEmail = new wxCheckBox( m_panelSyncSettings, wxID_ANY, _("Send email notification:"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer291->Add( m_checkBoxSendEmail, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer290->Add( bSizer291, 0, 0, 5 ); + + m_comboBoxEmail = new fff::CommandBox( m_panelSyncSettings, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer290->Add( m_comboBoxEmail, 0, wxEXPAND|wxTOP, 5 ); + + + bSizer287->Add( bSizer290, 1, wxRIGHT, 5 ); + + wxBoxSizer* bSizer289; + bSizer289 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonEmailAlways = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer289->Add( m_bpButtonEmailAlways, 0, 0, 5 ); + + m_bpButtonEmailErrorWarning = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer289->Add( m_bpButtonEmailErrorWarning, 0, 0, 5 ); + + m_bpButtonEmailErrorOnly = new wxBitmapButton( m_panelSyncSettings, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer289->Add( m_bpButtonEmailErrorOnly, 0, 0, 5 ); + + + bSizer287->Add( bSizer289, 0, wxLEFT, 5 ); + + + bSizer292->Add( bSizer287, 0, wxEXPAND, 5 ); + + m_hyperlinkPerfDeRequired2 = new wxHyperlinkCtrl( m_panelSyncSettings, wxID_ANY, _("Requires FreeFileSync Donation Edition"), wxT("https://freefilesync.org/faq.php#donation-edition"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlinkPerfDeRequired2->SetToolTip( _("https://freefilesync.org/faq.php#donation-edition") ); + + bSizer292->Add( m_hyperlinkPerfDeRequired2, 0, wxALL, 5 ); + + + bSizerSyncMisc->Add( bSizer292, 0, wxEXPAND|wxALL, 10 ); + + wxStaticLine* m_staticline57; + m_staticline57 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizerSyncMisc->Add( m_staticline57, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer293; + bSizer293 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer2372; + bSizer2372 = new wxBoxSizer( wxHORIZONTAL ); + + m_panelLogfile = new wxPanel( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelLogfile->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1912; + bSizer1912 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer279; + bSizer279 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapLogFile = new wxStaticBitmap( m_panelLogfile, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer279->Add( m_bitmapLogFile, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxOverrideLogPath = new wxCheckBox( m_panelLogfile, wxID_ANY, _("&Change log folder:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxOverrideLogPath->SetValue(true); + bSizer279->Add( m_checkBoxOverrideLogPath, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + + bSizer279->Add( 0, 0, 1, 0, 5 ); + + m_bpButtonShowLogFolder = new wxBitmapButton( m_panelLogfile, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonShowLogFolder->SetToolTip( _("dummy") ); + + bSizer279->Add( m_bpButtonShowLogFolder, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer1912->Add( bSizer279, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer297; + bSizer297 = new wxBoxSizer( wxHORIZONTAL ); + + m_logFolderPath = new fff::FolderHistoryBox( m_panelLogfile, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer297->Add( m_logFolderPath, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectLogFolder = new wxButton( m_panelLogfile, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectLogFolder->SetToolTip( _("Select a folder") ); + + bSizer297->Add( m_buttonSelectLogFolder, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltLogFolder = new wxBitmapButton( m_panelLogfile, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltLogFolder->SetToolTip( _("Access online storage") ); + + bSizer297->Add( m_bpButtonSelectAltLogFolder, 0, wxEXPAND, 5 ); + + + bSizer1912->Add( bSizer297, 0, wxEXPAND|wxTOP, 5 ); + + + m_panelLogfile->SetSizer( bSizer1912 ); + m_panelLogfile->Layout(); + bSizer1912->Fit( m_panelLogfile ); + bSizer2372->Add( m_panelLogfile, 1, 0, 5 ); + + + bSizer293->Add( bSizer2372, 0, wxALL|wxEXPAND, 10 ); + + wxStaticLine* m_staticline80; + m_staticline80 = new wxStaticLine( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer293->Add( m_staticline80, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer247; + bSizer247 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextPostSync = new wxStaticText( m_panelSyncSettings, wxID_ANY, _("Run a command:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPostSync->Wrap( -1 ); + bSizer247->Add( m_staticTextPostSync, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxArrayString m_choicePostSyncConditionChoices; + m_choicePostSyncCondition = new wxChoice( m_panelSyncSettings, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choicePostSyncConditionChoices, 0 ); + m_choicePostSyncCondition->SetSelection( 0 ); + bSizer247->Add( m_choicePostSyncCondition, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_comboBoxPostSyncCommand = new fff::CommandBox( m_panelSyncSettings, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer247->Add( m_comboBoxPostSyncCommand, 1, wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer293->Add( bSizer247, 0, wxALL|wxEXPAND, 10 ); + + + bSizerSyncMisc->Add( bSizer293, 1, 0, 5 ); + + + bSizer232->Add( bSizerSyncMisc, 1, wxEXPAND, 5 ); + + + m_panelSyncSettings->SetSizer( bSizer232 ); + m_panelSyncSettings->Layout(); + bSizer232->Fit( m_panelSyncSettings ); + bSizer276->Add( m_panelSyncSettings, 1, wxEXPAND, 5 ); + + + m_panelSyncSettingsTab->SetSizer( bSizer276 ); + m_panelSyncSettingsTab->Layout(); + bSizer276->Fit( m_panelSyncSettingsTab ); + m_notebook->AddPage( m_panelSyncSettingsTab, _("dummy"), true ); + + bSizer190->Add( m_notebook, 1, wxEXPAND, 5 ); + + + bSizer7->Add( bSizer190, 1, wxEXPAND, 5 ); + + m_panelNotes = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelNotes->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer3021; + bSizer3021 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer17311; + bSizer17311 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapNotes = new wxStaticBitmap( m_panelNotes, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer17311->Add( m_bitmapNotes, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + wxStaticText* m_staticText781; + m_staticText781 = new wxStaticText( m_panelNotes, wxID_ANY, _("Notes:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText781->Wrap( -1 ); + bSizer17311->Add( m_staticText781, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_textCtrNotes = new wxTextCtrl( m_panelNotes, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_MULTILINE ); + bSizer17311->Add( m_textCtrNotes, 1, wxEXPAND, 5 ); + + + bSizer3021->Add( bSizer17311, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline83; + m_staticline83 = new wxStaticLine( m_panelNotes, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer3021->Add( m_staticline83, 0, wxEXPAND, 5 ); + + + m_panelNotes->SetSizer( bSizer3021 ); + m_panelNotes->Layout(); + bSizer3021->Fit( m_panelNotes ); + bSizer7->Add( m_panelNotes, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonAddNotes = new zen::BitmapTextButton( this, wxID_ANY, _("Add ¬es"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonAddNotes, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStdButtons->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer7->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer7 ); + this->Layout(); + bSizer7->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( ConfigDlgGenerated::onClose ) ); + m_listBoxFolderPair->Connect( wxEVT_KEY_DOWN, wxKeyEventHandler( ConfigDlgGenerated::onListBoxKeyEvent ), NULL, this ); + m_listBoxFolderPair->Connect( wxEVT_COMMAND_LISTBOX_SELECTED, wxCommandEventHandler( ConfigDlgGenerated::onSelectFolderPair ), NULL, this ); + m_checkBoxUseLocalCmpOptions->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleLocalCompSettings ), NULL, this ); + m_buttonByTimeSize->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onCompByTimeSize ), NULL, this ); + m_buttonByTimeSize->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onCompByTimeSizeDouble ), NULL, this ); + m_buttonByContent->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onCompByContent ), NULL, this ); + m_buttonByContent->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onCompByContentDouble ), NULL, this ); + m_buttonBySize->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onCompBySize ), NULL, this ); + m_buttonBySize->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onCompBySizeDouble ), NULL, this ); + m_checkBoxSymlinksInclude->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onChangeCompOption ), NULL, this ); + m_checkBoxIgnoreErrors->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleIgnoreErrors ), NULL, this ); + m_checkBoxAutoRetry->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleAutoRetry ), NULL, this ); + m_textCtrlInclude->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( ConfigDlgGenerated::onChangeFilterOption ), NULL, this ); + m_textCtrlExclude->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( ConfigDlgGenerated::onChangeFilterOption ), NULL, this ); + m_choiceUnitMinSize->Connect( wxEVT_COMMAND_CHOICE_SELECTED, wxCommandEventHandler( ConfigDlgGenerated::onChangeFilterOption ), NULL, this ); + m_choiceUnitMaxSize->Connect( wxEVT_COMMAND_CHOICE_SELECTED, wxCommandEventHandler( ConfigDlgGenerated::onChangeFilterOption ), NULL, this ); + m_choiceUnitTimespan->Connect( wxEVT_COMMAND_CHOICE_SELECTED, wxCommandEventHandler( ConfigDlgGenerated::onChangeFilterOption ), NULL, this ); + m_buttonDefault->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onFilterDefault ), NULL, this ); + m_buttonDefault->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( ConfigDlgGenerated::onFilterDefaultContextMouse ), NULL, this ); + m_bpButtonDefaultContext->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onFilterDefaultContext ), NULL, this ); + m_bpButtonDefaultContext->Connect( wxEVT_RIGHT_DOWN, wxMouseEventHandler( ConfigDlgGenerated::onFilterDefaultContextMouse ), NULL, this ); + m_buttonClear->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onFilterClear ), NULL, this ); + m_checkBoxUseLocalSyncOptions->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleLocalSyncSettings ), NULL, this ); + m_buttonTwoWay->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onSyncTwoWay ), NULL, this ); + m_buttonTwoWay->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onSyncTwoWayDouble ), NULL, this ); + m_buttonMirror->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onSyncMirror ), NULL, this ); + m_buttonMirror->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onSyncMirrorDouble ), NULL, this ); + m_buttonUpdate->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onSyncUpdate ), NULL, this ); + m_buttonUpdate->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onSyncUpdateDouble ), NULL, this ); + m_buttonCustom->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onSyncCustom ), NULL, this ); + m_buttonCustom->Connect( wxEVT_LEFT_DCLICK, wxMouseEventHandler( ConfigDlgGenerated::onSyncCustomDouble ), NULL, this ); + m_checkBoxUseDatabase->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleUseDatabase ), NULL, this ); + m_bpButtonLeftOnly->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onLeftOnly ), NULL, this ); + m_bpButtonLeftNewer->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onLeftNewer ), NULL, this ); + m_bpButtonDifferent->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onDifferent ), NULL, this ); + m_bpButtonRightNewer->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onRightNewer ), NULL, this ); + m_bpButtonRightOnly->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onRightOnly ), NULL, this ); + m_bpButtonLeftCreate->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onLeftCreate ), NULL, this ); + m_bpButtonRightCreate->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onRightCreate ), NULL, this ); + m_bpButtonLeftUpdate->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onLeftUpdate ), NULL, this ); + m_bpButtonRightUpdate->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onRightUpdate ), NULL, this ); + m_bpButtonLeftDelete->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onLeftDelete ), NULL, this ); + m_bpButtonRightDelete->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onRightDelete ), NULL, this ); + m_buttonRecycler->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onDeletionRecycler ), NULL, this ); + m_buttonPermanent->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onDeletionPermanent ), NULL, this ); + m_buttonVersioning->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onDeletionVersioning ), NULL, this ); + m_choiceVersioningStyle->Connect( wxEVT_COMMAND_CHOICE_SELECTED, wxCommandEventHandler( ConfigDlgGenerated::onChangeVersioningStyle ), NULL, this ); + m_checkBoxVersionMaxDays->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleVersioningLimit ), NULL, this ); + m_checkBoxVersionCountMin->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleVersioningLimit ), NULL, this ); + m_checkBoxVersionCountMax->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleVersioningLimit ), NULL, this ); + m_checkBoxSendEmail->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleMiscEmail ), NULL, this ); + m_bpButtonEmailAlways->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onEmailAlways ), NULL, this ); + m_bpButtonEmailErrorWarning->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onEmailErrorWarning ), NULL, this ); + m_bpButtonEmailErrorOnly->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onEmailErrorOnly ), NULL, this ); + m_checkBoxOverrideLogPath->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onToggleMiscOption ), NULL, this ); + m_bpButtonShowLogFolder->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onShowLogFolder ), NULL, this ); + m_buttonAddNotes->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onAddNotes ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ConfigDlgGenerated::onCancel ), NULL, this ); +} + +ConfigDlgGenerated::~ConfigDlgGenerated() +{ +} + +CloudSetupDlgGenerated::CloudSetupDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer134; + bSizer134 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapCloud = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapCloud, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + wxBoxSizer* bSizer272; + bSizer272 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText136; + m_staticText136 = new wxStaticText( this, wxID_ANY, _("Connection type:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText136->Wrap( -1 ); + bSizer272->Add( m_staticText136, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxBoxSizer* bSizer231; + bSizer231 = new wxBoxSizer( wxHORIZONTAL ); + + m_toggleBtnGdrive = new wxToggleButton( this, wxID_ANY, _("Google Drive"), wxDefaultPosition, wxDefaultSize, 0 ); + m_toggleBtnGdrive->SetValue( true ); + m_toggleBtnGdrive->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer231->Add( m_toggleBtnGdrive, 0, wxTOP|wxBOTTOM|wxLEFT|wxEXPAND, 5 ); + + m_toggleBtnSftp = new wxToggleButton( this, wxID_ANY, _("SFTP"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_toggleBtnSftp->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer231->Add( m_toggleBtnSftp, 0, wxTOP|wxBOTTOM|wxLEFT|wxEXPAND, 5 ); + + m_toggleBtnFtp = new wxToggleButton( this, wxID_ANY, _("FTP"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_toggleBtnFtp->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer231->Add( m_toggleBtnFtp, 0, wxALL|wxEXPAND, 5 ); + + + bSizer272->Add( bSizer231, 0, 0, 5 ); + + + bSizer72->Add( bSizer272, 0, wxALL, 5 ); + + + bSizer134->Add( bSizer72, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline371; + m_staticline371 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline371, 0, wxEXPAND, 5 ); + + wxPanel* m_panel41; + m_panel41 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel41->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer185; + bSizer185 = new wxBoxSizer( wxVERTICAL ); + + bSizerGdrive = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer284; + bSizer284 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer307; + bSizer307 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer306; + bSizer306 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapGdriveUser = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer306->Add( m_bitmapGdriveUser, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText166; + m_staticText166 = new wxStaticText( m_panel41, wxID_ANY, _("Connected user accounts:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText166->Wrap( -1 ); + bSizer306->Add( m_staticText166, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer307->Add( bSizer306, 0, wxALL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_listBoxGdriveUsers = new wxListBox( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0, NULL, wxLB_NEEDED_SB|wxLB_SINGLE|wxLB_SORT ); + bSizer307->Add( m_listBoxGdriveUsers, 1, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + wxBoxSizer* bSizer3002; + bSizer3002 = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonGdriveAddUser = new zen::BitmapTextButton( m_panel41, wxID_ANY, _("&Add connection"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer3002->Add( m_buttonGdriveAddUser, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonGdriveRemoveUser = new zen::BitmapTextButton( m_panel41, wxID_ANY, _("&Disconnect"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer3002->Add( m_buttonGdriveRemoveUser, 1, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer307->Add( bSizer3002, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer284->Add( bSizer307, 0, wxALL|wxEXPAND, 5 ); + + wxStaticLine* m_staticline841; + m_staticline841 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer284->Add( m_staticline841, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer3041; + bSizer3041 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer305; + bSizer305 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapGdriveDrive = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer305->Add( m_bitmapGdriveDrive, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText186; + m_staticText186 = new wxStaticText( m_panel41, wxID_ANY, _("Select drive:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText186->Wrap( -1 ); + bSizer305->Add( m_staticText186, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer3041->Add( bSizer305, 0, wxALL, 5 ); + + m_listBoxGdriveDrives = new wxListBox( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0, NULL, wxLB_NEEDED_SB|wxLB_SINGLE ); + bSizer3041->Add( m_listBoxGdriveDrives, 1, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizer284->Add( bSizer3041, 1, wxALL|wxEXPAND, 5 ); + + + bSizerGdrive->Add( bSizer284, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline73; + m_staticline73 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerGdrive->Add( m_staticline73, 0, wxEXPAND, 5 ); + + + bSizer185->Add( bSizerGdrive, 1, wxEXPAND, 5 ); + + bSizerServer = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer276; + bSizer276 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapServer = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer276->Add( m_bitmapServer, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText12311; + m_staticText12311 = new wxStaticText( m_panel41, wxID_ANY, _("Server name or IP address:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText12311->Wrap( -1 ); + bSizer276->Add( m_staticText12311, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_textCtrlServer = new wxTextCtrl( m_panel41, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer276->Add( m_textCtrlServer, 1, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText1233; + m_staticText1233 = new wxStaticText( m_panel41, wxID_ANY, _("Port:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1233->Wrap( -1 ); + bSizer276->Add( m_staticText1233, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_textCtrlPort = new wxTextCtrl( m_panel41, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer276->Add( m_textCtrlPort, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerServer->Add( bSizer276, 0, wxALL|wxEXPAND, 5 ); + + wxStaticLine* m_staticline58; + m_staticline58 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerServer->Add( m_staticline58, 0, wxEXPAND, 5 ); + + + bSizer185->Add( bSizerServer, 0, wxEXPAND, 5 ); + + bSizerAuth = new wxBoxSizer( wxVERTICAL ); + + bSizerAuthInner = new wxBoxSizer( wxHORIZONTAL ); + + bSizerFtpEncrypt = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer2181; + bSizer2181 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText1251; + m_staticText1251 = new wxStaticText( m_panel41, wxID_ANY, _("Encryption:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1251->Wrap( -1 ); + bSizer2181->Add( m_staticText1251, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + m_radioBtnEncryptNone = new wxRadioButton( m_panel41, wxID_ANY, _("&Disabled"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP ); + m_radioBtnEncryptNone->SetValue( true ); + bSizer2181->Add( m_radioBtnEncryptNone, 0, wxEXPAND|wxALL, 5 ); + + m_radioBtnEncryptSsl = new wxRadioButton( m_panel41, wxID_ANY, _("&Explicit SSL/TLS"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer2181->Add( m_radioBtnEncryptSsl, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + + bSizerFtpEncrypt->Add( bSizer2181, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticLine* m_staticline5721; + m_staticline5721 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizerFtpEncrypt->Add( m_staticline5721, 0, wxEXPAND, 5 ); + + + bSizerAuthInner->Add( bSizerFtpEncrypt, 0, wxEXPAND, 5 ); + + bSizerSftpAuth = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer218; + bSizer218 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText125; + m_staticText125 = new wxStaticText( m_panel41, wxID_ANY, _("Authentication:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText125->Wrap( -1 ); + bSizer218->Add( m_staticText125, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + m_radioBtnPassword = new wxRadioButton( m_panel41, wxID_ANY, _("&Password"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP ); + m_radioBtnPassword->SetValue( true ); + bSizer218->Add( m_radioBtnPassword, 0, wxEXPAND|wxALL, 5 ); + + m_radioBtnKeyfile = new wxRadioButton( m_panel41, wxID_ANY, _("&Key file"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer218->Add( m_radioBtnKeyfile, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + m_radioBtnAgent = new wxRadioButton( m_panel41, wxID_ANY, _("&SSH agent"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer218->Add( m_radioBtnAgent, 0, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + + bSizerSftpAuth->Add( bSizer218, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticLine* m_staticline572; + m_staticline572 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizerSftpAuth->Add( m_staticline572, 0, wxEXPAND, 5 ); + + + bSizerAuthInner->Add( bSizerSftpAuth, 0, wxEXPAND, 5 ); + + m_panelAuth = new wxPanel( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelAuth->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer221; + bSizer221 = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* fgSizer161; + fgSizer161 = new wxFlexGridSizer( 0, 2, 0, 0 ); + fgSizer161->AddGrowableCol( 1 ); + fgSizer161->SetFlexibleDirection( wxBOTH ); + fgSizer161->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText123; + m_staticText123 = new wxStaticText( m_panelAuth, wxID_ANY, _("Username:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText123->Wrap( -1 ); + fgSizer161->Add( m_staticText123, 0, wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_textCtrlUserName = new wxTextCtrl( m_panelAuth, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer161->Add( m_textCtrlUserName, 0, wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextKeyfile = new wxStaticText( m_panelAuth, wxID_ANY, _("Private key file:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextKeyfile->Wrap( -1 ); + fgSizer161->Add( m_staticTextKeyfile, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT, 5 ); + + bSizerKeyFile = new wxBoxSizer( wxHORIZONTAL ); + + m_textCtrlKeyfilePath = new wxTextCtrl( m_panelAuth, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerKeyFile->Add( m_textCtrlKeyfilePath, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectKeyfile = new wxButton( m_panelAuth, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectKeyfile->SetToolTip( _("Select a folder") ); + + bSizerKeyFile->Add( m_buttonSelectKeyfile, 0, wxEXPAND, 5 ); + + + fgSizer161->Add( bSizerKeyFile, 0, wxALL|wxEXPAND, 5 ); + + m_staticTextPassword = new wxStaticText( m_panelAuth, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPassword->Wrap( -1 ); + fgSizer161->Add( m_staticTextPassword, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + bSizerPassword = new wxBoxSizer( wxHORIZONTAL ); + + m_textCtrlPasswordVisible = new wxTextCtrl( m_panelAuth, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerPassword->Add( m_textCtrlPasswordVisible, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_textCtrlPasswordHidden = new wxTextCtrl( m_panelAuth, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_PASSWORD ); + bSizerPassword->Add( m_textCtrlPasswordHidden, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_checkBoxShowPassword = new wxCheckBox( m_panelAuth, wxID_ANY, _("&Show password"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerPassword->Add( m_checkBoxShowPassword, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_checkBoxPasswordPrompt = new wxCheckBox( m_panelAuth, wxID_ANY, _("Prompt during login"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerPassword->Add( m_checkBoxPasswordPrompt, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer161->Add( bSizerPassword, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + + bSizer221->Add( fgSizer161, 0, wxALL|wxEXPAND, 5 ); + + + m_panelAuth->SetSizer( bSizer221 ); + m_panelAuth->Layout(); + bSizer221->Fit( m_panelAuth ); + bSizerAuthInner->Add( m_panelAuth, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerAuth->Add( bSizerAuthInner, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline581; + m_staticline581 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerAuth->Add( m_staticline581, 0, wxEXPAND, 5 ); + + + bSizer185->Add( bSizerAuth, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer269; + bSizer269 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer3051; + bSizer3051 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer270; + bSizer270 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapServerDir = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer270->Add( m_bitmapServerDir, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText1232; + m_staticText1232 = new wxStaticText( m_panel41, wxID_ANY, _("Directory on server:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1232->Wrap( -1 ); + bSizer270->Add( m_staticText1232, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer3051->Add( bSizer270, 0, wxTOP|wxRIGHT|wxLEFT|wxALIGN_BOTTOM, 5 ); + + + bSizer3051->Add( 0, 0, 1, 0, 5 ); + + wxBoxSizer* bSizer3031; + bSizer3031 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer303; + bSizer303 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticLine* m_staticline83; + m_staticline83 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer303->Add( m_staticline83, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer3042; + bSizer3042 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextTimeout = new wxStaticText( m_panel41, wxID_ANY, _("Access timeout (in seconds):"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeout->Wrap( -1 ); + bSizer3042->Add( m_staticTextTimeout, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_spinCtrlTimeout = new wxSpinCtrl( m_panel41, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + bSizer3042->Add( m_spinCtrlTimeout, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer303->Add( bSizer3042, 0, wxALL, 5 ); + + + bSizer3031->Add( bSizer303, 0, wxALIGN_RIGHT, 5 ); + + wxStaticLine* m_staticline82; + m_staticline82 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer3031->Add( m_staticline82, 0, wxEXPAND, 5 ); + + + bSizer3051->Add( bSizer3031, 0, wxBOTTOM, 10 ); + + + bSizer269->Add( bSizer3051, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer217; + bSizer217 = new wxBoxSizer( wxHORIZONTAL ); + + m_textCtrlServerPath = new wxTextCtrl( m_panel41, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer217->Add( m_textCtrlServerPath, 1, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + m_buttonSelectFolder = new wxButton( m_panel41, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectFolder->SetToolTip( _("Select a folder") ); + + bSizer217->Add( m_buttonSelectFolder, 0, wxRIGHT|wxEXPAND, 5 ); + + + bSizer269->Add( bSizer217, 0, wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + + bSizer269->Add( 0, 10, 0, 0, 5 ); + + + bSizer185->Add( bSizer269, 0, wxEXPAND, 5 ); + + + m_panel41->SetSizer( bSizer185 ); + m_panel41->Layout(); + bSizer185->Fit( m_panel41 ); + bSizer134->Add( m_panel41, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline571; + m_staticline571 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline571, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer219; + bSizer219 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer219->Add( 5, 0, 0, 0, 5 ); + + m_bitmapPerf = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer219->Add( m_bitmapPerf, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxStaticText* m_staticText1361; + m_staticText1361 = new wxStaticText( this, wxID_ANY, _("Performance improvements:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1361->Wrap( -1 ); + bSizer219->Add( m_staticText1361, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + + bSizer219->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink171; + m_hyperlink171 = new wxHyperlinkCtrl( this, wxID_ANY, _("How to get the best performance?"), wxT("https://freefilesync.org/manual.php?topic=ftp-setup"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink171->SetToolTip( _("https://freefilesync.org/manual.php?topic=ftp-setup") ); + + bSizer219->Add( m_hyperlink171, 0, wxALL|wxALIGN_CENTER_VERTICAL, 10 ); + + + bSizer134->Add( bSizer219, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline57; + m_staticline57 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline57, 0, wxEXPAND, 5 ); + + wxPanel* m_panel411; + m_panel411 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel411->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1851; + bSizer1851 = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* fgSizer1611; + fgSizer1611 = new wxFlexGridSizer( 0, 2, 0, 0 ); + fgSizer1611->AddGrowableCol( 1 ); + fgSizer1611->SetFlexibleDirection( wxBOTH ); + fgSizer1611->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + bSizerConnectionsLabel = new wxBoxSizer( wxVERTICAL ); + + m_staticTextConnectionsLabel = new wxStaticText( m_panel411, wxID_ANY, _("Parallel file operations:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextConnectionsLabel->Wrap( -1 ); + bSizerConnectionsLabel->Add( m_staticTextConnectionsLabel, 0, 0, 5 ); + + m_staticTextConnectionsLabelSub = new wxStaticText( m_panel411, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextConnectionsLabelSub->Wrap( -1 ); + bSizerConnectionsLabel->Add( m_staticTextConnectionsLabelSub, 0, wxALIGN_RIGHT, 5 ); + + + fgSizer1611->Add( bSizerConnectionsLabel, 0, wxTOP|wxBOTTOM|wxLEFT|wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer300; + bSizer300 = new wxBoxSizer( wxHORIZONTAL ); + + m_spinCtrlConnectionCount = new wxSpinCtrl( m_panel411, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + bSizer300->Add( m_spinCtrlConnectionCount, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextConnectionCountDescr = new wxStaticText( m_panel411, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextConnectionCountDescr->Wrap( -1 ); + m_staticTextConnectionCountDescr->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer300->Add( m_staticTextConnectionCountDescr, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_hyperlinkDeRequired = new wxHyperlinkCtrl( m_panel411, wxID_ANY, _("Requires FreeFileSync Donation Edition"), wxT("https://freefilesync.org/faq.php#donation-edition"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlinkDeRequired->SetToolTip( _("https://freefilesync.org/faq.php#donation-edition") ); + + bSizer300->Add( m_hyperlinkDeRequired, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer1611->Add( bSizer300, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextChannelCountSftp = new wxStaticText( m_panel411, wxID_ANY, _("SFTP channels per connection:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextChannelCountSftp->Wrap( -1 ); + fgSizer1611->Add( m_staticTextChannelCountSftp, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + wxBoxSizer* bSizer3001; + bSizer3001 = new wxBoxSizer( wxHORIZONTAL ); + + m_spinCtrlChannelCountSftp = new wxSpinCtrl( m_panel411, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + bSizer3001->Add( m_spinCtrlChannelCountSftp, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonChannelCountSftp = new wxButton( m_panel411, wxID_ANY, _("Detect server limit"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer3001->Add( m_buttonChannelCountSftp, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + fgSizer1611->Add( bSizer3001, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer1611->Add( 0, 0, 0, 0, 5 ); + + wxBoxSizer* bSizer304; + bSizer304 = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxAllowZlib = new wxCheckBox( m_panel411, wxID_ANY, _("Enable &compression"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer304->Add( m_checkBoxAllowZlib, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_staticTextZlibDescr = new wxStaticText( m_panel411, wxID_ANY, _("(zlib)"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextZlibDescr->Wrap( -1 ); + m_staticTextZlibDescr->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer304->Add( m_staticTextZlibDescr, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + fgSizer1611->Add( bSizer304, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer1851->Add( fgSizer1611, 0, wxALL, 5 ); + + + m_panel411->SetSizer( bSizer1851 ); + m_panel411->Layout(); + bSizer1851->Fit( m_panel411 ); + bSizer134->Add( m_panel411, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline12; + m_staticline12 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline12, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer134->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer134 ); + this->Layout(); + bSizer134->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( CloudSetupDlgGenerated::onClose ) ); + m_toggleBtnGdrive->Connect( wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onConnectionGdrive ), NULL, this ); + m_toggleBtnSftp->Connect( wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onConnectionSftp ), NULL, this ); + m_toggleBtnFtp->Connect( wxEVT_COMMAND_TOGGLEBUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onConnectionFtp ), NULL, this ); + m_listBoxGdriveUsers->Connect( wxEVT_COMMAND_LISTBOX_SELECTED, wxCommandEventHandler( CloudSetupDlgGenerated::onGdriveUserSelect ), NULL, this ); + m_buttonGdriveAddUser->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onGdriveUserAdd ), NULL, this ); + m_buttonGdriveRemoveUser->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onGdriveUserRemove ), NULL, this ); + m_radioBtnPassword->Connect( wxEVT_COMMAND_RADIOBUTTON_SELECTED, wxCommandEventHandler( CloudSetupDlgGenerated::onAuthPassword ), NULL, this ); + m_radioBtnKeyfile->Connect( wxEVT_COMMAND_RADIOBUTTON_SELECTED, wxCommandEventHandler( CloudSetupDlgGenerated::onAuthKeyfile ), NULL, this ); + m_radioBtnAgent->Connect( wxEVT_COMMAND_RADIOBUTTON_SELECTED, wxCommandEventHandler( CloudSetupDlgGenerated::onAuthAgent ), NULL, this ); + m_buttonSelectKeyfile->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onSelectKeyfile ), NULL, this ); + m_textCtrlPasswordVisible->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( CloudSetupDlgGenerated::onTypingPassword ), NULL, this ); + m_textCtrlPasswordHidden->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( CloudSetupDlgGenerated::onTypingPassword ), NULL, this ); + m_checkBoxShowPassword->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onToggleShowPassword ), NULL, this ); + m_checkBoxPasswordPrompt->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onTogglePasswordPrompt ), NULL, this ); + m_buttonSelectFolder->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onBrowseCloudFolder ), NULL, this ); + m_buttonChannelCountSftp->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onDetectServerChannelLimit ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CloudSetupDlgGenerated::onCancel ), NULL, this ); +} + +CloudSetupDlgGenerated::~CloudSetupDlgGenerated() +{ +} + +AbstractFolderPickerGenerated::AbstractFolderPickerGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer134; + bSizer134 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel41; + m_panel41 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel41->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer185; + bSizer185 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextStatus = new wxStaticText( m_panel41, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatus->Wrap( -1 ); + bSizer185->Add( m_staticTextStatus, 0, wxALL, 5 ); + + m_treeCtrlFileSystem = new wxTreeCtrl( m_panel41, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), wxTR_FULL_ROW_HIGHLIGHT|wxTR_HAS_BUTTONS|wxTR_LINES_AT_ROOT|wxTR_NO_LINES|wxBORDER_NONE ); + bSizer185->Add( m_treeCtrlFileSystem, 1, wxEXPAND, 5 ); + + + m_panel41->SetSizer( bSizer185 ); + m_panel41->Layout(); + bSizer185->Fit( m_panel41 ); + bSizer134->Add( m_panel41, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline12; + m_staticline12 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline12, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("Select Folder"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer134->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer134 ); + this->Layout(); + bSizer134->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( AbstractFolderPickerGenerated::onClose ) ); + m_treeCtrlFileSystem->Connect( wxEVT_COMMAND_TREE_ITEM_EXPANDING, wxTreeEventHandler( AbstractFolderPickerGenerated::onExpandNode ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AbstractFolderPickerGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AbstractFolderPickerGenerated::onCancel ), NULL, this ); +} + +AbstractFolderPickerGenerated::~AbstractFolderPickerGenerated() +{ +} + +SyncConfirmationDlgGenerated::SyncConfirmationDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer134; + bSizer134 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapSync = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapSync, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextCaption = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCaption->Wrap( -1 ); + bSizer72->Add( m_staticTextCaption, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer134->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline371; + m_staticline371 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline371, 0, wxEXPAND, 5 ); + + m_panelStatistics = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelStatistics->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer185; + bSizer185 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer185->Add( 40, 0, 0, 0, 5 ); + + + bSizer185->Add( 0, 0, 1, 0, 5 ); + + wxStaticLine* m_staticline38; + m_staticline38 = new wxStaticLine( m_panelStatistics, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer185->Add( m_staticline38, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer162; + bSizer162 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer182; + bSizer182 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText84; + m_staticText84 = new wxStaticText( m_panelStatistics, wxID_ANY, _("Variant:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText84->Wrap( -1 ); + bSizer182->Add( m_staticText84, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + + bSizer182->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_staticTextSyncVar = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextSyncVar->Wrap( -1 ); + m_staticTextSyncVar->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer182->Add( m_staticTextSyncVar, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_bitmapSyncVar = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer182->Add( m_bitmapSyncVar, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer182->Add( 0, 0, 1, wxEXPAND, 5 ); + + + bSizer162->Add( bSizer182, 0, wxALL|wxEXPAND, 5 ); + + wxStaticLine* m_staticline14; + m_staticline14 = new wxStaticLine( m_panelStatistics, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer162->Add( m_staticline14, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer181; + bSizer181 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText83; + m_staticText83 = new wxStaticText( m_panelStatistics, wxID_ANY, _("Statistics:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText83->Wrap( -1 ); + bSizer181->Add( m_staticText83, 0, wxALL, 5 ); + + wxFlexGridSizer* fgSizer11; + fgSizer11 = new wxFlexGridSizer( 2, 7, 2, 10 ); + fgSizer11->SetFlexibleDirection( wxBOTH ); + fgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapDeleteLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapDeleteLeft->SetToolTip( _("Number of files and folders that will be deleted") ); + + fgSizer11->Add( m_bitmapDeleteLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapUpdateLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapUpdateLeft->SetToolTip( _("Number of files that will be updated") ); + + fgSizer11->Add( m_bitmapUpdateLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapCreateLeft = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapCreateLeft->SetToolTip( _("Number of files and folders that will be created") ); + + fgSizer11->Add( m_bitmapCreateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bitmapData = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapData->SetToolTip( _("Total bytes to copy") ); + + fgSizer11->Add( m_bitmapData, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bitmapCreateRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapCreateRight->SetToolTip( _("Number of files and folders that will be created") ); + + fgSizer11->Add( m_bitmapCreateRight, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bitmapUpdateRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapUpdateRight->SetToolTip( _("Number of files that will be updated") ); + + fgSizer11->Add( m_bitmapUpdateRight, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bitmapDeleteRight = new wxStaticBitmap( m_panelStatistics, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + m_bitmapDeleteRight->SetToolTip( _("Number of files and folders that will be deleted") ); + + fgSizer11->Add( m_bitmapDeleteRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextDeleteLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDeleteLeft->Wrap( -1 ); + m_staticTextDeleteLeft->SetToolTip( _("Number of files and folders that will be deleted") ); + + fgSizer11->Add( m_staticTextDeleteLeft, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextUpdateLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextUpdateLeft->Wrap( -1 ); + m_staticTextUpdateLeft->SetToolTip( _("Number of files that will be updated") ); + + fgSizer11->Add( m_staticTextUpdateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_staticTextCreateLeft = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCreateLeft->Wrap( -1 ); + m_staticTextCreateLeft->SetToolTip( _("Number of files and folders that will be created") ); + + fgSizer11->Add( m_staticTextCreateLeft, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_staticTextData = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextData->Wrap( -1 ); + m_staticTextData->SetToolTip( _("Total bytes to copy") ); + + fgSizer11->Add( m_staticTextData, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextCreateRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextCreateRight->Wrap( -1 ); + m_staticTextCreateRight->SetToolTip( _("Number of files and folders that will be created") ); + + fgSizer11->Add( m_staticTextCreateRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextUpdateRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextUpdateRight->Wrap( -1 ); + m_staticTextUpdateRight->SetToolTip( _("Number of files that will be updated") ); + + fgSizer11->Add( m_staticTextUpdateRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextDeleteRight = new wxStaticText( m_panelStatistics, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDeleteRight->Wrap( -1 ); + m_staticTextDeleteRight->SetToolTip( _("Number of files and folders that will be deleted") ); + + fgSizer11->Add( m_staticTextDeleteRight, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer181->Add( fgSizer11, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + + bSizer162->Add( bSizer181, 0, wxEXPAND|wxALL, 5 ); + + + bSizer185->Add( bSizer162, 0, 0, 5 ); + + wxStaticLine* m_staticline381; + m_staticline381 = new wxStaticLine( m_panelStatistics, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer185->Add( m_staticline381, 0, wxEXPAND, 5 ); + + + bSizer185->Add( 0, 0, 1, 0, 5 ); + + + bSizer185->Add( 40, 0, 0, 0, 5 ); + + + m_panelStatistics->SetSizer( bSizer185 ); + m_panelStatistics->Layout(); + bSizer185->Fit( m_panelStatistics ); + bSizer134->Add( m_panelStatistics, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline12; + m_staticline12 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer134->Add( m_staticline12, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer164; + bSizer164 = new wxBoxSizer( wxVERTICAL ); + + m_checkBoxDontShowAgain = new wxCheckBox( this, wxID_ANY, _("&Don't show this dialog again"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer164->Add( m_checkBoxDontShowAgain, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("Start"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer164->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + bSizer134->Add( bSizer164, 1, wxEXPAND, 5 ); + + + this->SetSizer( bSizer134 ); + this->Layout(); + bSizer134->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( SyncConfirmationDlgGenerated::onClose ) ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( SyncConfirmationDlgGenerated::onStartSync ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( SyncConfirmationDlgGenerated::onCancel ), NULL, this ); +} + +SyncConfirmationDlgGenerated::~SyncConfirmationDlgGenerated() +{ +} + +CompareProgressDlgGenerated::CompareProgressDlgGenerated( wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style, const wxString& name ) : wxPanel( parent, id, pos, size, style, name ) +{ + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer1811; + bSizer1811 = new wxBoxSizer( wxVERTICAL ); + + + bSizer1811->Add( 0, 0, 1, 0, 5 ); + + m_staticTextStatus = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatus->Wrap( -1 ); + bSizer1811->Add( m_staticTextStatus, 0, wxTOP|wxRIGHT|wxLEFT, 10 ); + + wxBoxSizer* bSizer199; + bSizer199 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer199->Add( 10, 0, 0, 0, 5 ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextProcessed = new wxStaticText( this, wxID_ANY, _("Processed:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextProcessed->Wrap( -1 ); + ffgSizer11->Add( m_staticTextProcessed, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxRIGHT, 5 ); + + m_staticTextRemaining = new wxStaticText( this, wxID_ANY, _("Remaining:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextRemaining->Wrap( -1 ); + ffgSizer11->Add( m_staticTextRemaining, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizer199->Add( ffgSizer11, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 10 ); + + m_panelItemStats = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelItemStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer291; + bSizer291 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer111; + ffgSizer111 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer111->SetFlexibleDirection( wxBOTH ); + ffgSizer111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer293; + bSizer293 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapItemStat = new wxStaticBitmap( m_panelItemStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer293->Add( m_bitmapItemStat, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextItemsProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsProcessed->Wrap( -1 ); + m_staticTextItemsProcessed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer293->Add( m_staticTextItemsProcessed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer111->Add( bSizer293, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + m_staticTextBytesProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesProcessed->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesProcessed, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextItemsRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsRemaining->Wrap( -1 ); + m_staticTextItemsRemaining->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer111->Add( m_staticTextItemsRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextBytesRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesRemaining->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer291->Add( ffgSizer111, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelItemStats->SetSizer( bSizer291 ); + m_panelItemStats->Layout(); + bSizer291->Fit( m_panelItemStats ); + bSizer199->Add( m_panelItemStats, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 10 ); + + m_panelTimeStats = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelTimeStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer292; + bSizer292 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer112; + ffgSizer112 = new wxFlexGridSizer( 0, 1, 5, 5 ); + ffgSizer112->SetFlexibleDirection( wxBOTH ); + ffgSizer112->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer294; + bSizer294 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapTimeStat = new wxStaticBitmap( m_panelTimeStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer294->Add( m_bitmapTimeStat, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextTimeElapsed = new wxStaticText( m_panelTimeStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeElapsed->Wrap( -1 ); + m_staticTextTimeElapsed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer294->Add( m_staticTextTimeElapsed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer112->Add( bSizer294, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + m_staticTextTimeRemaining = new wxStaticText( m_panelTimeStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeRemaining->Wrap( -1 ); + m_staticTextTimeRemaining->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer112->Add( m_staticTextTimeRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer292->Add( ffgSizer112, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelTimeStats->SetSizer( bSizer292 ); + m_panelTimeStats->Layout(); + bSizer292->Fit( m_panelTimeStats ); + bSizer199->Add( m_panelTimeStats, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 10 ); + + wxFlexGridSizer* ffgSizer114; + ffgSizer114 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer114->SetFlexibleDirection( wxBOTH ); + ffgSizer114->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextErrors = new wxStaticText( this, wxID_ANY, _("Errors:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextErrors->Wrap( -1 ); + ffgSizer114->Add( m_staticTextErrors, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxRIGHT, 5 ); + + m_staticTextWarnings = new wxStaticText( this, wxID_ANY, _("Warnings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextWarnings->Wrap( -1 ); + ffgSizer114->Add( m_staticTextWarnings, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizer199->Add( ffgSizer114, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 10 ); + + m_panelErrorStats = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelErrorStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer2921; + bSizer2921 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer1121; + ffgSizer1121 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer1121->SetFlexibleDirection( wxBOTH ); + ffgSizer1121->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapErrors = new wxStaticBitmap( m_panelErrorStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer1121->Add( m_bitmapErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextErrorCount = new wxStaticText( m_panelErrorStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextErrorCount->Wrap( -1 ); + m_staticTextErrorCount->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer1121->Add( m_staticTextErrorCount, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_bitmapWarnings = new wxStaticBitmap( m_panelErrorStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer1121->Add( m_bitmapWarnings, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextWarningCount = new wxStaticText( m_panelErrorStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextWarningCount->Wrap( -1 ); + m_staticTextWarningCount->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer1121->Add( m_staticTextWarningCount, 0, wxALIGN_BOTTOM|wxALIGN_RIGHT, 5 ); + + + bSizer2921->Add( ffgSizer1121, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelErrorStats->SetSizer( bSizer2921 ); + m_panelErrorStats->Layout(); + bSizer2921->Fit( m_panelErrorStats ); + bSizer199->Add( m_panelErrorStats, 0, wxEXPAND|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + wxFlexGridSizer* ffgSizer1141; + ffgSizer1141 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer1141->SetFlexibleDirection( wxBOTH ); + ffgSizer1141->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + bSizerErrorsRetry = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapRetryErrors = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerErrorsRetry->Add( m_bitmapRetryErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText1461; + m_staticText1461 = new wxStaticText( this, wxID_ANY, _("Automatic retry"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1461->Wrap( -1 ); + bSizerErrorsRetry->Add( m_staticText1461, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + m_staticTextRetryCount = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextRetryCount->Wrap( -1 ); + bSizerErrorsRetry->Add( m_staticTextRetryCount, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + ffgSizer1141->Add( bSizerErrorsRetry, 0, wxALIGN_CENTER_VERTICAL, 10 ); + + bSizerErrorsIgnore = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapIgnoreErrors = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerErrorsIgnore->Add( m_bitmapIgnoreErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText146; + m_staticText146 = new wxStaticText( this, wxID_ANY, _("Ignore errors"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText146->Wrap( -1 ); + bSizerErrorsIgnore->Add( m_staticText146, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + ffgSizer1141->Add( bSizerErrorsIgnore, 0, wxALIGN_CENTER_VERTICAL, 10 ); + + + bSizer199->Add( ffgSizer1141, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + bSizerProgressGraph = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer113; + ffgSizer113 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer113->SetFlexibleDirection( wxBOTH ); + ffgSizer113->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText99; + m_staticText99 = new wxStaticText( this, wxID_ANY, _("Bytes:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText99->Wrap( -1 ); + ffgSizer113->Add( m_staticText99, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText100; + m_staticText100 = new wxStaticText( this, wxID_ANY, _("Items:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText100->Wrap( -1 ); + ffgSizer113->Add( m_staticText100, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizerProgressGraph->Add( ffgSizer113, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + m_panelProgressGraph = new zen::Graph2D( this, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_panelProgressGraph->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + bSizerProgressGraph->Add( m_panelProgressGraph, 1, wxEXPAND, 5 ); + + + bSizer199->Add( bSizerProgressGraph, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 10 ); + + + bSizer1811->Add( bSizer199, 0, wxEXPAND, 5 ); + + + bSizer1811->Add( 0, 0, 1, 0, 5 ); + + + this->SetSizer( bSizer1811 ); + this->Layout(); + bSizer1811->Fit( this ); +} + +CompareProgressDlgGenerated::~CompareProgressDlgGenerated() +{ +} + +SyncProgressPanelGenerated::SyncProgressPanelGenerated( wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style, const wxString& name ) : wxPanel( parent, id, pos, size, style, name ) +{ + bSizerRoot = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel53; + m_panel53 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel53->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer301; + bSizer301 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer42; + bSizer42 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer42->Add( 0, 0, 1, 0, 5 ); + + m_bitmapStatus = new wxStaticBitmap( m_panel53, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer42->Add( m_bitmapStatus, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer305; + bSizer305 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextPhase = new wxStaticText( m_panel53, wxID_ANY, _("Synchronizing..."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPhase->Wrap( -1 ); + m_staticTextPhase->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer305->Add( m_staticTextPhase, 0, wxALIGN_BOTTOM, 5 ); + + m_staticTextPercentTotal = new wxStaticText( m_panel53, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPercentTotal->Wrap( -1 ); + bSizer305->Add( m_staticTextPercentTotal, 0, wxALIGN_BOTTOM, 5 ); + + + bSizer42->Add( bSizer305, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + wxBoxSizer* bSizer247; + bSizer247 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer247->Add( 0, 0, 1, 0, 5 ); + + m_bpButtonMinimizeToTray = new wxBitmapButton( m_panel53, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonMinimizeToTray->SetToolTip( _("Minimize to notification area") ); + + bSizer247->Add( m_bpButtonMinimizeToTray, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer42->Add( bSizer247, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer301->Add( bSizer42, 0, wxEXPAND|wxTOP|wxBOTTOM, 5 ); + + bSizerStatusText = new wxBoxSizer( wxVERTICAL ); + + m_staticTextStatus = new wxStaticText( m_panel53, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStatus->Wrap( -1 ); + bSizerStatusText->Add( m_staticTextStatus, 0, wxEXPAND|wxLEFT, 15 ); + + + bSizerStatusText->Add( 0, 10, 0, 0, 5 ); + + + bSizer301->Add( bSizerStatusText, 0, wxEXPAND, 5 ); + + + m_panel53->SetSizer( bSizer301 ); + m_panel53->Layout(); + bSizer301->Fit( m_panel53 ); + bSizerRoot->Add( m_panel53, 0, wxEXPAND, 5 ); + + m_panelProgress = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelProgress->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer173; + bSizer173 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer161; + bSizer161 = new wxBoxSizer( wxVERTICAL ); + + m_panelGraphBytes = new zen::Graph2D( m_panelProgress, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_panelGraphBytes->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizer161->Add( m_panelGraphBytes, 1, wxEXPAND|wxLEFT, 10 ); + + wxBoxSizer* bSizer232; + bSizer232 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer233; + bSizer233 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer175; + bSizer175 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapGraphKeyBytes = new wxStaticBitmap( m_panelProgress, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer175->Add( m_bitmapGraphKeyBytes, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText99; + m_staticText99 = new wxStaticText( m_panelProgress, wxID_ANY, _("Bytes"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText99->Wrap( -1 ); + bSizer175->Add( m_staticText99, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer233->Add( bSizer175, 0, wxALL, 5 ); + + + bSizer233->Add( 0, 0, 1, 0, 5 ); + + wxBoxSizer* bSizer174; + bSizer174 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapGraphKeyItems = new wxStaticBitmap( m_panelProgress, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer174->Add( m_bitmapGraphKeyItems, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText100; + m_staticText100 = new wxStaticText( m_panelProgress, wxID_ANY, _("Items"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText100->Wrap( -1 ); + bSizer174->Add( m_staticText100, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer233->Add( bSizer174, 0, wxALL, 5 ); + + + bSizer232->Add( bSizer233, 1, wxEXPAND|wxRIGHT|wxLEFT, 5 ); + + wxBoxSizer* bSizer304; + bSizer304 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextProcessed = new wxStaticText( m_panelProgress, wxID_ANY, _("Processed:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextProcessed->Wrap( -1 ); + ffgSizer11->Add( m_staticTextProcessed, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxRIGHT, 5 ); + + m_staticTextRemaining = new wxStaticText( m_panelProgress, wxID_ANY, _("Remaining:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextRemaining->Wrap( -1 ); + ffgSizer11->Add( m_staticTextRemaining, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizer304->Add( ffgSizer11, 0, wxTOP|wxBOTTOM|wxALIGN_CENTER_VERTICAL, 10 ); + + m_panelItemStats = new wxPanel( m_panelProgress, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelItemStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer291; + bSizer291 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer111; + ffgSizer111 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer111->SetFlexibleDirection( wxBOTH ); + ffgSizer111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer293; + bSizer293 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapItemStat = new wxStaticBitmap( m_panelItemStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer293->Add( m_bitmapItemStat, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextItemsProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsProcessed->Wrap( -1 ); + m_staticTextItemsProcessed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer293->Add( m_staticTextItemsProcessed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer111->Add( bSizer293, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + m_staticTextBytesProcessed = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesProcessed->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesProcessed, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextItemsRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextItemsRemaining->Wrap( -1 ); + m_staticTextItemsRemaining->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer111->Add( m_staticTextItemsRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + m_staticTextBytesRemaining = new wxStaticText( m_panelItemStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextBytesRemaining->Wrap( -1 ); + ffgSizer111->Add( m_staticTextBytesRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer291->Add( ffgSizer111, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelItemStats->SetSizer( bSizer291 ); + m_panelItemStats->Layout(); + bSizer291->Fit( m_panelItemStats ); + bSizer304->Add( m_panelItemStats, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 10 ); + + m_panelTimeStats = new wxPanel( m_panelProgress, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelTimeStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer292; + bSizer292 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer112; + ffgSizer112 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer112->SetFlexibleDirection( wxBOTH ); + ffgSizer112->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxBoxSizer* bSizer294; + bSizer294 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapTimeStat = new wxStaticBitmap( m_panelTimeStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer294->Add( m_bitmapTimeStat, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_staticTextTimeElapsed = new wxStaticText( m_panelTimeStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeElapsed->Wrap( -1 ); + m_staticTextTimeElapsed->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer294->Add( m_staticTextTimeElapsed, 0, wxALIGN_BOTTOM, 5 ); + + + ffgSizer112->Add( bSizer294, 0, wxEXPAND|wxALIGN_RIGHT, 5 ); + + m_staticTextTimeRemaining = new wxStaticText( m_panelTimeStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextTimeRemaining->Wrap( -1 ); + m_staticTextTimeRemaining->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer112->Add( m_staticTextTimeRemaining, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer292->Add( ffgSizer112, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelTimeStats->SetSizer( bSizer292 ); + m_panelTimeStats->Layout(); + bSizer292->Fit( m_panelTimeStats ); + bSizer304->Add( m_panelTimeStats, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 10 ); + + wxFlexGridSizer* ffgSizer114; + ffgSizer114 = new wxFlexGridSizer( 2, 0, 5, 5 ); + ffgSizer114->SetFlexibleDirection( wxBOTH ); + ffgSizer114->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextErrors = new wxStaticText( m_panelProgress, wxID_ANY, _("Errors:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextErrors->Wrap( -1 ); + ffgSizer114->Add( m_staticTextErrors, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxRIGHT, 5 ); + + m_staticTextWarnings = new wxStaticText( m_panelProgress, wxID_ANY, _("Warnings:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextWarnings->Wrap( -1 ); + ffgSizer114->Add( m_staticTextWarnings, 0, wxALIGN_RIGHT|wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizer304->Add( ffgSizer114, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 10 ); + + m_panelErrorStats = new wxPanel( m_panelProgress, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + m_panelErrorStats->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer2921; + bSizer2921 = new wxBoxSizer( wxHORIZONTAL ); + + wxFlexGridSizer* ffgSizer1121; + ffgSizer1121 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer1121->SetFlexibleDirection( wxBOTH ); + ffgSizer1121->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapErrors = new wxStaticBitmap( m_panelErrorStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer1121->Add( m_bitmapErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextErrorCount = new wxStaticText( m_panelErrorStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextErrorCount->Wrap( -1 ); + m_staticTextErrorCount->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer1121->Add( m_staticTextErrorCount, 0, wxALIGN_BOTTOM|wxALIGN_RIGHT, 5 ); + + m_bitmapWarnings = new wxStaticBitmap( m_panelErrorStats, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer1121->Add( m_bitmapWarnings, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextWarningCount = new wxStaticText( m_panelErrorStats, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextWarningCount->Wrap( -1 ); + m_staticTextWarningCount->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + ffgSizer1121->Add( m_staticTextWarningCount, 0, wxALIGN_RIGHT|wxALIGN_BOTTOM, 5 ); + + + bSizer2921->Add( ffgSizer1121, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + m_panelErrorStats->SetSizer( bSizer2921 ); + m_panelErrorStats->Layout(); + bSizer2921->Fit( m_panelErrorStats ); + bSizer304->Add( m_panelErrorStats, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 10 ); + + + bSizer232->Add( bSizer304, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer232->Add( 0, 0, 1, 0, 5 ); + + bSizerDynSpace = new wxBoxSizer( wxVERTICAL ); + + + bSizerDynSpace->Add( 0, 0, 0, 0, 5 ); + + + bSizer232->Add( bSizerDynSpace, 0, 0, 5 ); + + + bSizer161->Add( bSizer232, 0, wxEXPAND, 5 ); + + m_panelGraphItems = new zen::Graph2D( m_panelProgress, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_panelGraphItems->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + bSizer161->Add( m_panelGraphItems, 1, wxEXPAND|wxLEFT, 10 ); + + bSizerProgressFooter = new wxBoxSizer( wxHORIZONTAL ); + + bSizerErrorsRetry = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapRetryErrors = new wxStaticBitmap( m_panelProgress, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerErrorsRetry->Add( m_bitmapRetryErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText1461; + m_staticText1461 = new wxStaticText( m_panelProgress, wxID_ANY, _("Automatic retry"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1461->Wrap( -1 ); + bSizerErrorsRetry->Add( m_staticText1461, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + m_staticTextRetryCount = new wxStaticText( m_panelProgress, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextRetryCount->Wrap( -1 ); + bSizerErrorsRetry->Add( m_staticTextRetryCount, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + + bSizerProgressFooter->Add( bSizerErrorsRetry, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + bSizerErrorsIgnore = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapIgnoreErrors = new wxStaticBitmap( m_panelProgress, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerErrorsIgnore->Add( m_bitmapIgnoreErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText146; + m_staticText146 = new wxStaticText( m_panelProgress, wxID_ANY, _("Ignore errors"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText146->Wrap( -1 ); + bSizerErrorsIgnore->Add( m_staticText146, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + + bSizerProgressFooter->Add( bSizerErrorsIgnore, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + + bSizerProgressFooter->Add( 0, 0, 1, wxEXPAND, 5 ); + + wxStaticText* m_staticText137; + m_staticText137 = new wxStaticText( m_panelProgress, wxID_ANY, _("When finished:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText137->Wrap( -1 ); + bSizerProgressFooter->Add( m_staticText137, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxArrayString m_choicePostSyncActionChoices; + m_choicePostSyncAction = new wxChoice( m_panelProgress, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choicePostSyncActionChoices, 0 ); + m_choicePostSyncAction->SetSelection( 0 ); + bSizerProgressFooter->Add( m_choicePostSyncAction, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer161->Add( bSizerProgressFooter, 0, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 10 ); + + + bSizer173->Add( bSizer161, 1, wxEXPAND|wxLEFT, 5 ); + + + m_panelProgress->SetSizer( bSizer173 ); + m_panelProgress->Layout(); + bSizer173->Fit( m_panelProgress ); + bSizerRoot->Add( m_panelProgress, 1, wxEXPAND, 5 ); + + m_notebookResult = new wxNotebook( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxNB_FIXEDWIDTH ); + m_notebookResult->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + + bSizerRoot->Add( m_notebookResult, 1, wxEXPAND, 5 ); + + m_staticlineFooter = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerRoot->Add( m_staticlineFooter, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + + bSizerStdButtons->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_checkBoxAutoClose = new wxCheckBox( this, wxID_ANY, _("Auto-close"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStdButtons->Add( m_checkBoxAutoClose, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_buttonClose = new wxButton( this, wxID_OK, _("Close"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonClose->Enable( false ); + + bSizerStdButtons->Add( m_buttonClose, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonPause = new wxButton( this, wxID_ANY, _("&Pause"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonPause, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonStop = new wxButton( this, wxID_CANCEL, _("Stop"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonStop, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizerRoot->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizerRoot ); + this->Layout(); + bSizerRoot->Fit( this ); +} + +SyncProgressPanelGenerated::~SyncProgressPanelGenerated() +{ +} + +LogPanelGenerated::LogPanelGenerated( wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style, const wxString& name ) : wxPanel( parent, id, pos, size, style, name ) +{ + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer153; + bSizer153 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer154; + bSizer154 = new wxBoxSizer( wxVERTICAL ); + + m_bpButtonErrors = new zen::ToggleButton( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer154->Add( m_bpButtonErrors, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bpButtonWarnings = new zen::ToggleButton( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer154->Add( m_bpButtonWarnings, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_bpButtonInfo = new zen::ToggleButton( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer154->Add( m_bpButtonInfo, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer153->Add( bSizer154, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + wxStaticLine* m_staticline13; + m_staticline13 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer153->Add( m_staticline13, 0, wxEXPAND, 5 ); + + m_gridMessages = new zen::Grid( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridMessages->SetScrollRate( 5, 5 ); + bSizer153->Add( m_gridMessages, 1, wxEXPAND, 5 ); + + + this->SetSizer( bSizer153 ); + this->Layout(); + bSizer153->Fit( this ); + + // Connect Events + m_bpButtonErrors->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( LogPanelGenerated::onErrors ), NULL, this ); + m_bpButtonWarnings->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( LogPanelGenerated::onWarnings ), NULL, this ); + m_bpButtonInfo->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( LogPanelGenerated::onInfo ), NULL, this ); +} + +LogPanelGenerated::~LogPanelGenerated() +{ +} + +BatchDlgGenerated::BatchDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer54; + bSizer54 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapBatchJob = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer72->Add( m_bitmapBatchJob, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextHeader = new wxStaticText( this, wxID_ANY, _("Create a batch file for unattended synchronization. To start, double-click this file or schedule in a task planner: %x"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHeader->Wrap( -1 ); + bSizer72->Add( m_staticTextHeader, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer54->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline18; + m_staticline18 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline18, 0, wxEXPAND, 5 ); + + wxPanel* m_panel35; + m_panel35 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel35->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer172; + bSizer172 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer180; + bSizer180 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer2361; + bSizer2361 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText146; + m_staticText146 = new wxStaticText( m_panel35, wxID_ANY, _("Progress dialog:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText146->Wrap( -1 ); + bSizer2361->Add( m_staticText146, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapMinimizeToTray = new wxStaticBitmap( m_panel35, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_bitmapMinimizeToTray, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxRunMinimized = new wxCheckBox( m_panel35, wxID_ANY, _("Run minimized"), wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_checkBoxRunMinimized, 0, wxEXPAND|wxALIGN_CENTER_VERTICAL, 5 ); + + + ffgSizer11->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_checkBoxAutoClose = new wxCheckBox( m_panel35, wxID_ANY, _("Auto-close"), wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_checkBoxAutoClose, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + + bSizer2361->Add( ffgSizer11, 0, wxEXPAND|wxALL, 5 ); + + + bSizer180->Add( bSizer2361, 0, wxALL, 5 ); + + wxStaticLine* m_staticline26; + m_staticline26 = new wxStaticLine( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer180->Add( m_staticline26, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer242; + bSizer242 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer243; + bSizer243 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapIgnoreErrors = new wxStaticBitmap( m_panel35, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer243->Add( m_bitmapIgnoreErrors, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_checkBoxIgnoreErrors = new wxCheckBox( m_panel35, wxID_ANY, _("Ignore errors"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer243->Add( m_checkBoxIgnoreErrors, 1, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer242->Add( bSizer243, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxBoxSizer* bSizer246; + bSizer246 = new wxBoxSizer( wxVERTICAL ); + + m_radioBtnErrorDialogShow = new wxRadioButton( m_panel35, wxID_ANY, _("&Show error message"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP ); + m_radioBtnErrorDialogShow->SetValue( true ); + m_radioBtnErrorDialogShow->SetToolTip( _("Show pop-up on errors or warnings") ); + + bSizer246->Add( m_radioBtnErrorDialogShow, 0, wxALL|wxEXPAND, 5 ); + + m_radioBtnErrorDialogCancel = new wxRadioButton( m_panel35, wxID_ANY, _("&Cancel"), wxDefaultPosition, wxDefaultSize, 0 ); + m_radioBtnErrorDialogCancel->SetToolTip( _("Stop synchronization at first error") ); + + bSizer246->Add( m_radioBtnErrorDialogCancel, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + + bSizer242->Add( bSizer246, 0, wxALIGN_CENTER_HORIZONTAL|wxLEFT, 15 ); + + + bSizer180->Add( bSizer242, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticLine* m_staticline261; + m_staticline261 = new wxStaticLine( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer180->Add( m_staticline261, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer247; + bSizer247 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText137; + m_staticText137 = new wxStaticText( m_panel35, wxID_ANY, _("When finished:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText137->Wrap( -1 ); + bSizer247->Add( m_staticText137, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxArrayString m_choicePostSyncActionChoices; + m_choicePostSyncAction = new wxChoice( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choicePostSyncActionChoices, 0 ); + m_choicePostSyncAction->SetSelection( 0 ); + bSizer247->Add( m_choicePostSyncAction, 0, wxALL, 5 ); + + + bSizer180->Add( bSizer247, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticLine* m_staticline262; + m_staticline262 = new wxStaticLine( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer180->Add( m_staticline262, 0, wxEXPAND, 5 ); + + + bSizer172->Add( bSizer180, 0, 0, 5 ); + + wxStaticLine* m_staticline25; + m_staticline25 = new wxStaticLine( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer172->Add( m_staticline25, 0, wxEXPAND, 5 ); + + wxHyperlinkCtrl* m_hyperlink17; + m_hyperlink17 = new wxHyperlinkCtrl( m_panel35, wxID_ANY, _("How can I schedule a batch job?"), wxT("https://freefilesync.org/manual.php?topic=schedule-a-batch-job"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink17->SetToolTip( _("https://freefilesync.org/manual.php?topic=schedule-a-batch-job") ); + + bSizer172->Add( m_hyperlink17, 0, wxALL, 10 ); + + + m_panel35->SetSizer( bSizer172 ); + m_panel35->Layout(); + bSizer172->Fit( m_panel35 ); + bSizer54->Add( m_panel35, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline13; + m_staticline13 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline13, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonSaveAs = new wxButton( this, wxID_SAVE, _("Save &as..."), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonSaveAs->SetDefault(); + m_buttonSaveAs->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonSaveAs, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer54->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer54 ); + this->Layout(); + bSizer54->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( BatchDlgGenerated::onClose ) ); + m_checkBoxRunMinimized->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( BatchDlgGenerated::onToggleRunMinimized ), NULL, this ); + m_checkBoxIgnoreErrors->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( BatchDlgGenerated::onToggleIgnoreErrors ), NULL, this ); + m_buttonSaveAs->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( BatchDlgGenerated::onSaveBatchJob ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( BatchDlgGenerated::onCancel ), NULL, this ); +} + +BatchDlgGenerated::~BatchDlgGenerated() +{ +} + +DeleteDlgGenerated::DeleteDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer24; + bSizer24 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapDeleteType = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapDeleteType, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextHeader = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHeader->Wrap( -1 ); + bSizer72->Add( m_staticTextHeader, 0, wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer24->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline91; + m_staticline91 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline91, 0, wxEXPAND, 5 ); + + wxPanel* m_panel31; + m_panel31 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel31->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer185; + bSizer185 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer185->Add( 60, 0, 0, 0, 5 ); + + wxStaticLine* m_staticline42; + m_staticline42 = new wxStaticLine( m_panel31, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer185->Add( m_staticline42, 0, wxEXPAND, 5 ); + + m_textCtrlFileList = new wxTextCtrl( m_panel31, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_DONTWRAP|wxTE_MULTILINE|wxTE_READONLY|wxBORDER_NONE ); + bSizer185->Add( m_textCtrlFileList, 1, wxEXPAND, 5 ); + + + m_panel31->SetSizer( bSizer185 ); + m_panel31->Layout(); + bSizer185->Fit( m_panel31 ); + bSizer24->Add( m_panel31, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline9; + m_staticline9 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline9, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxUseRecycler = new wxCheckBox( this, wxID_ANY, _("&Recycle bin"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStdButtons->Add( m_checkBoxUseRecycler, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizerStdButtons->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_buttonOK = new wxButton( this, wxID_OK, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer24->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer24 ); + this->Layout(); + bSizer24->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( DeleteDlgGenerated::onClose ) ); + m_checkBoxUseRecycler->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( DeleteDlgGenerated::onUseRecycler ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( DeleteDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( DeleteDlgGenerated::onCancel ), NULL, this ); +} + +DeleteDlgGenerated::~DeleteDlgGenerated() +{ +} + +CopyToDlgGenerated::CopyToDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer24; + bSizer24 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapCopyTo = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapCopyTo, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextHeader = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHeader->Wrap( -1 ); + bSizer72->Add( m_staticTextHeader, 0, wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer24->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline91; + m_staticline91 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline91, 0, wxEXPAND, 5 ); + + wxPanel* m_panel31; + m_panel31 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel31->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer242; + bSizer242 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer185; + bSizer185 = new wxBoxSizer( wxHORIZONTAL ); + + + bSizer185->Add( 60, 0, 0, 0, 5 ); + + wxStaticLine* m_staticline42; + m_staticline42 = new wxStaticLine( m_panel31, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer185->Add( m_staticline42, 0, wxEXPAND, 5 ); + + m_textCtrlFileList = new wxTextCtrl( m_panel31, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_DONTWRAP|wxTE_MULTILINE|wxTE_READONLY|wxBORDER_NONE ); + bSizer185->Add( m_textCtrlFileList, 1, wxEXPAND, 5 ); + + + bSizer242->Add( bSizer185, 1, wxEXPAND, 5 ); + + wxBoxSizer* bSizer182; + bSizer182 = new wxBoxSizer( wxHORIZONTAL ); + + m_targetFolderPath = new fff::FolderHistoryBox( m_panel31, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer182->Add( m_targetFolderPath, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectTargetFolder = new wxButton( m_panel31, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectTargetFolder->SetToolTip( _("Select a folder") ); + + bSizer182->Add( m_buttonSelectTargetFolder, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltTargetFolder = new wxBitmapButton( m_panel31, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltTargetFolder->SetToolTip( _("Access online storage") ); + + bSizer182->Add( m_bpButtonSelectAltTargetFolder, 0, wxEXPAND, 5 ); + + + bSizer242->Add( bSizer182, 0, wxALL|wxEXPAND, 10 ); + + + m_panel31->SetSizer( bSizer242 ); + m_panel31->Layout(); + bSizer242->Fit( m_panel31 ); + bSizer24->Add( m_panel31, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline9; + m_staticline9 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline9, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer189; + bSizer189 = new wxBoxSizer( wxVERTICAL ); + + m_checkBoxKeepRelPath = new wxCheckBox( this, wxID_ANY, _("&Keep relative paths"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxKeepRelPath->SetValue(true); + bSizer189->Add( m_checkBoxKeepRelPath, 0, wxALL|wxEXPAND, 5 ); + + m_checkBoxOverwriteIfExists = new wxCheckBox( this, wxID_ANY, _("&Overwrite existing files"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxOverwriteIfExists->SetValue(true); + bSizer189->Add( m_checkBoxOverwriteIfExists, 0, wxBOTTOM|wxRIGHT|wxLEFT|wxEXPAND, 5 ); + + + bSizerStdButtons->Add( bSizer189, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStdButtons->Add( 0, 0, 1, wxEXPAND, 5 ); + + m_buttonOK = new wxButton( this, wxID_OK, _("Copy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer24->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer24 ); + this->Layout(); + bSizer24->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( CopyToDlgGenerated::onClose ) ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CopyToDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CopyToDlgGenerated::onCancel ), NULL, this ); +} + +CopyToDlgGenerated::~CopyToDlgGenerated() +{ +} + +RenameDlgGenerated::RenameDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer24; + bSizer24 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapRename = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapRename, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextHeader = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHeader->Wrap( -1 ); + bSizer72->Add( m_staticTextHeader, 0, wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer24->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline91; + m_staticline91 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline91, 0, wxEXPAND, 5 ); + + wxPanel* m_panel31; + m_panel31 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel31->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer242; + bSizer242 = new wxBoxSizer( wxVERTICAL ); + + m_gridRenamePreview = new zen::Grid( m_panel31, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHSCROLL|wxVSCROLL ); + m_gridRenamePreview->SetScrollRate( 5, 5 ); + bSizer242->Add( m_gridRenamePreview, 1, wxEXPAND, 5 ); + + m_staticlinePreview = new wxStaticLine( m_panel31, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer242->Add( m_staticlinePreview, 0, wxEXPAND, 5 ); + + m_staticTextPlaceholderDescription = new wxStaticText( m_panel31, wxID_ANY, _("Placeholders represent differences between the names."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPlaceholderDescription->Wrap( -1 ); + m_staticTextPlaceholderDescription->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer242->Add( m_staticTextPlaceholderDescription, 0, wxTOP|wxRIGHT|wxLEFT|wxALIGN_CENTER_HORIZONTAL, 10 ); + + m_textCtrlNewName = new wxTextCtrl( m_panel31, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer242->Add( m_textCtrlNewName, 0, wxEXPAND|wxALL, 10 ); + + + m_panel31->SetSizer( bSizer242 ); + m_panel31->Layout(); + bSizer242->Fit( m_panel31 ); + bSizer24->Add( m_panel31, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline9; + m_staticline9 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline9, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("&Rename"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer24->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer24 ); + this->Layout(); + bSizer24->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( RenameDlgGenerated::onClose ) ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( RenameDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( RenameDlgGenerated::onCancel ), NULL, this ); +} + +RenameDlgGenerated::~RenameDlgGenerated() +{ +} + +OptionsDlgGenerated::OptionsDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer95; + bSizer95 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapSettings = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer72->Add( m_bitmapSettings, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + wxStaticText* m_staticText44; + m_staticText44 = new wxStaticText( this, wxID_ANY, _("The following settings are used for all synchronization jobs."), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticText44->Wrap( -1 ); + bSizer72->Add( m_staticText44, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer95->Add( bSizer72, 0, 0, 5 ); + + wxStaticLine* m_staticline20; + m_staticline20 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer95->Add( m_staticline20, 0, wxEXPAND, 5 ); + + wxPanel* m_panel39; + m_panel39 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel39->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer166; + bSizer166 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer186; + bSizer186 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer160; + bSizer160 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer176; + bSizer176 = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxFailSafe = new wxCheckBox( m_panel39, wxID_ANY, _("Fail-safe file copy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxFailSafe->SetValue(true); + m_checkBoxFailSafe->SetToolTip( _("Copy to a temporary file (*.ffs_tmp) before overwriting target.\nThis guarantees a consistent state even in case of a serious error.") ); + + bSizer176->Add( m_checkBoxFailSafe, 1, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText911; + m_staticText911 = new wxStaticText( m_panel39, wxID_ANY, _("("), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText911->Wrap( -1 ); + m_staticText911->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer176->Add( m_staticText911, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText91; + m_staticText91 = new wxStaticText( m_panel39, wxID_ANY, _("recommended"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText91->Wrap( -1 ); + m_staticText91->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer176->Add( m_staticText91, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText9111; + m_staticText9111 = new wxStaticText( m_panel39, wxID_ANY, _(")"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText9111->Wrap( -1 ); + m_staticText9111->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer176->Add( m_staticText9111, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer160->Add( bSizer176, 0, wxEXPAND, 5 ); + + bSizerLockedFiles = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxCopyLocked = new wxCheckBox( m_panel39, wxID_ANY, _("Copy locked files"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxCopyLocked->SetValue(true); + m_checkBoxCopyLocked->SetToolTip( _("Copy shared or locked files using the Volume Shadow Copy Service.") ); + + bSizerLockedFiles->Add( m_checkBoxCopyLocked, 1, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText921; + m_staticText921 = new wxStaticText( m_panel39, wxID_ANY, _("("), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText921->Wrap( -1 ); + m_staticText921->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizerLockedFiles->Add( m_staticText921, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText92; + m_staticText92 = new wxStaticText( m_panel39, wxID_ANY, _("requires administrator rights"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText92->Wrap( -1 ); + m_staticText92->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizerLockedFiles->Add( m_staticText92, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText922; + m_staticText922 = new wxStaticText( m_panel39, wxID_ANY, _(")"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText922->Wrap( -1 ); + m_staticText922->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizerLockedFiles->Add( m_staticText922, 0, wxTOP|wxBOTTOM|wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer160->Add( bSizerLockedFiles, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer178; + bSizer178 = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxCopyPermissions = new wxCheckBox( m_panel39, wxID_ANY, _("Copy file access permissions"), wxDefaultPosition, wxDefaultSize, 0 ); + m_checkBoxCopyPermissions->SetValue(true); + m_checkBoxCopyPermissions->SetToolTip( _("Transfer file and folder permissions.") ); + + bSizer178->Add( m_checkBoxCopyPermissions, 1, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxStaticText* m_staticText931; + m_staticText931 = new wxStaticText( m_panel39, wxID_ANY, _("("), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText931->Wrap( -1 ); + m_staticText931->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer178->Add( m_staticText931, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText93; + m_staticText93 = new wxStaticText( m_panel39, wxID_ANY, _("requires administrator rights"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText93->Wrap( -1 ); + m_staticText93->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer178->Add( m_staticText93, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM, 5 ); + + wxStaticText* m_staticText932; + m_staticText932 = new wxStaticText( m_panel39, wxID_ANY, _(")"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText932->Wrap( -1 ); + m_staticText932->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer178->Add( m_staticText932, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer160->Add( bSizer178, 0, wxEXPAND, 5 ); + + + bSizer186->Add( bSizer160, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxStaticLine* m_staticline39; + m_staticline39 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer186->Add( m_staticline39, 0, wxEXPAND, 5 ); + + bSizerColorTheme = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapColorTheme = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerColorTheme->Add( m_bitmapColorTheme, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + wxBoxSizer* bSizer310; + bSizer310 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText198; + m_staticText198 = new wxStaticText( m_panel39, wxID_ANY, _("Color theme:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText198->Wrap( -1 ); + bSizer310->Add( m_staticText198, 0, wxBOTTOM, 5 ); + + wxArrayString m_choiceColorThemeChoices; + m_choiceColorTheme = new wxChoice( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_choiceColorThemeChoices, 0 ); + m_choiceColorTheme->SetSelection( 0 ); + bSizer310->Add( m_choiceColorTheme, 0, 0, 5 ); + + + bSizerColorTheme->Add( bSizer310, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer186->Add( bSizerColorTheme, 0, wxEXPAND|wxALL, 5 ); + + + bSizer166->Add( bSizer186, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline191; + m_staticline191 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer166->Add( m_staticline191, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer292; + bSizer292 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapWarnings = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer292->Add( m_bitmapWarnings, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + wxStaticText* m_staticText182; + m_staticText182 = new wxStaticText( m_panel39, wxID_ANY, _("Show hidden dialogs and warning messages again:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText182->Wrap( -1 ); + bSizer292->Add( m_staticText182, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextHiddenDialogsCount = new wxStaticText( m_panel39, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHiddenDialogsCount->Wrap( -1 ); + m_staticTextHiddenDialogsCount->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer292->Add( m_staticTextHiddenDialogsCount, 0, wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonShowHiddenDialogs = new wxButton( m_panel39, wxID_ANY, _("&Show details"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer292->Add( m_buttonShowHiddenDialogs, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer166->Add( bSizer292, 0, wxALL, 10 ); + + wxArrayString m_checkListHiddenDialogsChoices; + m_checkListHiddenDialogs = new wxCheckListBox( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, m_checkListHiddenDialogsChoices, wxLB_EXTENDED ); + bSizer166->Add( m_checkListHiddenDialogs, 1, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 10 ); + + wxStaticLine* m_staticline1911; + m_staticline1911 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer166->Add( m_staticline1911, 0, wxEXPAND, 5 ); + + wxFlexGridSizer* fgSizer25111; + fgSizer25111 = new wxFlexGridSizer( 0, 2, 0, 0 ); + fgSizer25111->AddGrowableCol( 1 ); + fgSizer25111->AddGrowableRow( 0 ); + fgSizer25111->SetFlexibleDirection( wxBOTH ); + fgSizer25111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapLogFile = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer25111->Add( m_bitmapLogFile, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer296; + bSizer296 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText163; + m_staticText163 = new wxStaticText( m_panel39, wxID_ANY, _("Default log folder:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText163->Wrap( -1 ); + bSizer296->Add( m_staticText163, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bpButtonShowLogFolder = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonShowLogFolder->SetToolTip( _("dummy") ); + + bSizer296->Add( m_bpButtonShowLogFolder, 0, wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer25111->Add( bSizer296, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + + fgSizer25111->Add( 0, 0, 0, 0, 5 ); + + m_panelLogfile = new wxPanel( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelLogfile->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer279; + bSizer279 = new wxBoxSizer( wxHORIZONTAL ); + + m_logFolderPath = new fff::FolderHistoryBox( m_panelLogfile, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, 0 ); + bSizer279->Add( m_logFolderPath, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectLogFolder = new wxButton( m_panelLogfile, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectLogFolder->SetToolTip( _("Select a folder") ); + + bSizer279->Add( m_buttonSelectLogFolder, 0, wxEXPAND, 5 ); + + m_bpButtonSelectAltLogFolder = new wxBitmapButton( m_panelLogfile, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + m_bpButtonSelectAltLogFolder->SetToolTip( _("Access online storage") ); + + bSizer279->Add( m_bpButtonSelectAltLogFolder, 0, wxEXPAND, 5 ); + + + m_panelLogfile->SetSizer( bSizer279 ); + m_panelLogfile->Layout(); + bSizer279->Fit( m_panelLogfile ); + fgSizer25111->Add( m_panelLogfile, 0, wxEXPAND, 5 ); + + + fgSizer25111->Add( 0, 0, 0, 0, 5 ); + + wxBoxSizer* bSizer297; + bSizer297 = new wxBoxSizer( wxHORIZONTAL ); + + m_checkBoxLogFilesMaxAge = new wxCheckBox( m_panel39, wxID_ANY, _("&Delete logs after x days:"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer297->Add( m_checkBoxLogFilesMaxAge, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_spinCtrlLogFilesMaxAge = new wxSpinCtrl( m_panel39, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 1, 2000000000, 1 ); + bSizer297->Add( m_spinCtrlLogFilesMaxAge, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT, 5 ); + + wxStaticLine* m_staticline81; + m_staticline81 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer297->Add( m_staticline81, 0, wxEXPAND|wxRIGHT|wxLEFT, 5 ); + + wxStaticText* m_staticText184; + m_staticText184 = new wxStaticText( m_panel39, wxID_ANY, _("Log file format:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText184->Wrap( -1 ); + bSizer297->Add( m_staticText184, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + wxFlexGridSizer* fgSizer251; + fgSizer251 = new wxFlexGridSizer( 0, 1, 5, 0 ); + fgSizer251->SetFlexibleDirection( wxBOTH ); + fgSizer251->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_radioBtnLogHtml = new wxRadioButton( m_panel39, wxID_ANY, _("&HTML"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP ); + m_radioBtnLogHtml->SetValue( true ); + fgSizer251->Add( m_radioBtnLogHtml, 0, wxEXPAND, 5 ); + + m_radioBtnLogText = new wxRadioButton( m_panel39, wxID_ANY, _("&Plain text"), wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer251->Add( m_radioBtnLogText, 0, wxEXPAND, 5 ); + + + bSizer297->Add( fgSizer251, 0, wxLEFT|wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer25111->Add( bSizer297, 0, wxTOP, 5 ); + + + bSizer166->Add( fgSizer25111, 0, wxALL|wxEXPAND, 10 ); + + wxStaticLine* m_staticline361; + m_staticline361 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer166->Add( m_staticline361, 0, wxEXPAND, 5 ); + + wxFlexGridSizer* fgSizer251111; + fgSizer251111 = new wxFlexGridSizer( 0, 2, 0, 0 ); + fgSizer251111->AddGrowableCol( 1 ); + fgSizer251111->AddGrowableRow( 0 ); + fgSizer251111->SetFlexibleDirection( wxBOTH ); + fgSizer251111->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_bitmapNotificationSounds = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + fgSizer251111->Add( m_bitmapNotificationSounds, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText851; + m_staticText851 = new wxStaticText( m_panel39, wxID_ANY, _("Notification sounds:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText851->Wrap( -1 ); + fgSizer251111->Add( m_staticText851, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + fgSizer251111->Add( 0, 0, 0, 0, 5 ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 0, 3, 0, 10 ); + ffgSizer11->AddGrowableCol( 2 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText171; + m_staticText171 = new wxStaticText( m_panel39, wxID_ANY, _("Comparison finished:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText171->Wrap( -1 ); + m_staticText171->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + ffgSizer11->Add( m_staticText171, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapCompareDone = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_bitmapCompareDone, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer290; + bSizer290 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonPlayCompareDone = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer290->Add( m_bpButtonPlayCompareDone, 0, wxEXPAND, 5 ); + + m_textCtrlSoundPathCompareDone = new wxTextCtrl( m_panel39, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer290->Add( m_textCtrlSoundPathCompareDone, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectSoundCompareDone = new wxButton( m_panel39, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectSoundCompareDone->SetToolTip( _("Select a folder") ); + + bSizer290->Add( m_buttonSelectSoundCompareDone, 0, wxEXPAND, 5 ); + + + ffgSizer11->Add( bSizer290, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + wxStaticText* m_staticText1711; + m_staticText1711 = new wxStaticText( m_panel39, wxID_ANY, _("Synchronization finished:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1711->Wrap( -1 ); + m_staticText1711->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + ffgSizer11->Add( m_staticText1711, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapSyncDone = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_bitmapSyncDone, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer2901; + bSizer2901 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonPlaySyncDone = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer2901->Add( m_bpButtonPlaySyncDone, 0, wxEXPAND, 5 ); + + m_textCtrlSoundPathSyncDone = new wxTextCtrl( m_panel39, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer2901->Add( m_textCtrlSoundPathSyncDone, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectSoundSyncDone = new wxButton( m_panel39, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectSoundSyncDone->SetToolTip( _("Select a folder") ); + + bSizer2901->Add( m_buttonSelectSoundSyncDone, 0, wxEXPAND, 5 ); + + + ffgSizer11->Add( bSizer2901, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + wxStaticText* m_staticText17111; + m_staticText17111 = new wxStaticText( m_panel39, wxID_ANY, _("Unattended error message:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText17111->Wrap( -1 ); + m_staticText17111->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + ffgSizer11->Add( m_staticText17111, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + m_bitmapAlertPending = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_bitmapAlertPending, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer29011; + bSizer29011 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonPlayAlertPending = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer29011->Add( m_bpButtonPlayAlertPending, 0, wxEXPAND, 5 ); + + m_textCtrlSoundPathAlertPending = new wxTextCtrl( m_panel39, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer29011->Add( m_textCtrlSoundPathAlertPending, 1, wxALIGN_CENTER_VERTICAL, 5 ); + + m_buttonSelectSoundAlertPending = new wxButton( m_panel39, wxID_ANY, _("Browse"), wxDefaultPosition, wxDefaultSize, 0 ); + m_buttonSelectSoundAlertPending->SetToolTip( _("Select a folder") ); + + bSizer29011->Add( m_buttonSelectSoundAlertPending, 0, wxEXPAND, 5 ); + + + ffgSizer11->Add( bSizer29011, 1, wxEXPAND, 5 ); + + + fgSizer251111->Add( ffgSizer11, 0, wxEXPAND|wxTOP, 5 ); + + + bSizer166->Add( fgSizer251111, 0, wxALL|wxEXPAND, 10 ); + + wxStaticLine* m_staticline3611; + m_staticline3611 = new wxStaticLine( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer166->Add( m_staticline3611, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer2971; + bSizer2971 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapConsole = new wxStaticBitmap( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer2971->Add( m_bitmapConsole, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText85; + m_staticText85 = new wxStaticText( m_panel39, wxID_ANY, _("Customize context menu:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText85->Wrap( -1 ); + bSizer2971->Add( m_staticText85, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + m_buttonShowCtxCustomize = new wxButton( m_panel39, wxID_ANY, _("&Show details"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer2971->Add( m_buttonShowCtxCustomize, 0, wxALIGN_CENTER_VERTICAL|wxLEFT, 5 ); + + + bSizer166->Add( bSizer2971, 0, wxALL, 10 ); + + bSizerContextCustomize = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer181; + bSizer181 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer2991; + bSizer2991 = new wxBoxSizer( wxVERTICAL ); + + + bSizer2991->Add( 0, 0, 1, 0, 5 ); + + wxBoxSizer* bSizer193; + bSizer193 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonAddRow = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer193->Add( m_bpButtonAddRow, 0, wxALIGN_BOTTOM, 5 ); + + m_bpButtonRemoveRow = new wxBitmapButton( m_panel39, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), wxBU_AUTODRAW|0 ); + bSizer193->Add( m_bpButtonRemoveRow, 0, wxALIGN_BOTTOM, 5 ); + + + bSizer2991->Add( bSizer193, 0, 0, 5 ); + + + bSizer181->Add( bSizer2991, 1, wxEXPAND, 5 ); + + wxFlexGridSizer* fgSizer37; + fgSizer37 = new wxFlexGridSizer( 0, 2, 0, 10 ); + fgSizer37->SetFlexibleDirection( wxBOTH ); + fgSizer37->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + wxStaticText* m_staticText174; + m_staticText174 = new wxStaticText( m_panel39, wxID_ANY, _("%item_path%"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText174->Wrap( -1 ); + m_staticText174->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_MODERN, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, wxEmptyString ) ); + m_staticText174->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText174, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText175; + m_staticText175 = new wxStaticText( m_panel39, wxID_ANY, _("Full file or folder path"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText175->Wrap( -1 ); + m_staticText175->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText175, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText178; + m_staticText178 = new wxStaticText( m_panel39, wxID_ANY, _("%local_path%"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText178->Wrap( -1 ); + m_staticText178->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_MODERN, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, wxEmptyString ) ); + m_staticText178->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText178, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText179; + m_staticText179 = new wxStaticText( m_panel39, wxID_ANY, _("Temporary local copy for SFTP and MTP storage"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText179->Wrap( -1 ); + m_staticText179->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText179, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText189; + m_staticText189 = new wxStaticText( m_panel39, wxID_ANY, _("%item_name%"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText189->Wrap( -1 ); + m_staticText189->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_MODERN, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, wxEmptyString ) ); + m_staticText189->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText189, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText190; + m_staticText190 = new wxStaticText( m_panel39, wxID_ANY, _("File or folder name"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText190->Wrap( -1 ); + m_staticText190->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText190, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText176; + m_staticText176 = new wxStaticText( m_panel39, wxID_ANY, _("%parent_path%"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText176->Wrap( -1 ); + m_staticText176->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_MODERN, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL, false, wxEmptyString ) ); + m_staticText176->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + fgSizer37->Add( m_staticText176, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + wxBoxSizer* bSizer298; + bSizer298 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText177; + m_staticText177 = new wxStaticText( m_panel39, wxID_ANY, _("Parent folder path"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText177->Wrap( -1 ); + m_staticText177->SetForegroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_GRAYTEXT ) ); + + bSizer298->Add( m_staticText177, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer298->Add( 0, 0, 1, 0, 5 ); + + wxHyperlinkCtrl* m_hyperlink17; + m_hyperlink17 = new wxHyperlinkCtrl( m_panel39, wxID_ANY, _("Show examples"), wxT("https://freefilesync.org/manual.php?topic=external-applications"), wxDefaultPosition, wxDefaultSize, wxHL_DEFAULT_STYLE ); + m_hyperlink17->SetToolTip( _("https://freefilesync.org/manual.php?topic=external-applications") ); + + bSizer298->Add( m_hyperlink17, 0, wxLEFT|wxALIGN_BOTTOM, 5 ); + + + fgSizer37->Add( bSizer298, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + + bSizer181->Add( fgSizer37, 0, wxBOTTOM|wxLEFT, 10 ); + + + bSizerContextCustomize->Add( bSizer181, 0, wxEXPAND, 5 ); + + m_gridCustomCommand = new wxGrid( m_panel39, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0 ); + + // Grid + m_gridCustomCommand->CreateGrid( 3, 2 ); + m_gridCustomCommand->EnableEditing( true ); + m_gridCustomCommand->EnableGridLines( true ); + m_gridCustomCommand->EnableDragGridSize( false ); + m_gridCustomCommand->SetMargins( 0, 0 ); + + // Columns + m_gridCustomCommand->EnableDragColMove( false ); + m_gridCustomCommand->EnableDragColSize( false ); + m_gridCustomCommand->SetColLabelValue( 0, _("Description") ); + m_gridCustomCommand->SetColLabelValue( 1, _("Command line") ); + m_gridCustomCommand->SetColLabelSize( -1 ); + m_gridCustomCommand->SetColLabelAlignment( wxALIGN_CENTER, wxALIGN_CENTER ); + + // Rows + m_gridCustomCommand->EnableDragRowSize( false ); + m_gridCustomCommand->SetRowLabelSize( 1 ); + m_gridCustomCommand->SetRowLabelAlignment( wxALIGN_CENTER, wxALIGN_CENTER ); + + // Label Appearance + + // Cell Defaults + m_gridCustomCommand->SetDefaultCellAlignment( wxALIGN_LEFT, wxALIGN_TOP ); + bSizerContextCustomize->Add( m_gridCustomCommand, 1, wxEXPAND, 5 ); + + + bSizer166->Add( bSizerContextCustomize, 1, wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT, 10 ); + + + m_panel39->SetSizer( bSizer166 ); + m_panel39->Layout(); + bSizer166->Fit( m_panel39 ); + bSizer95->Add( m_panel39, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline36; + m_staticline36 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer95->Add( m_staticline36, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonDefault = new wxButton( this, wxID_DEFAULT, _("&Default"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonDefault, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizerStdButtons->Add( 0, 0, 1, 0, 5 ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer95->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer95 ); + this->Layout(); + bSizer95->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( OptionsDlgGenerated::onClose ) ); + m_choiceColorTheme->Connect( wxEVT_COMMAND_CHOICE_SELECTED, wxCommandEventHandler( OptionsDlgGenerated::onChangeColorTheme ), NULL, this ); + m_buttonShowHiddenDialogs->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onShowHiddenDialogs ), NULL, this ); + m_checkListHiddenDialogs->Connect( wxEVT_COMMAND_CHECKLISTBOX_TOGGLED, wxCommandEventHandler( OptionsDlgGenerated::onToggleHiddenDialog ), NULL, this ); + m_bpButtonShowLogFolder->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onShowLogFolder ), NULL, this ); + m_checkBoxLogFilesMaxAge->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onToggleLogfilesLimit ), NULL, this ); + m_bpButtonPlayCompareDone->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onPlayCompareDone ), NULL, this ); + m_textCtrlSoundPathCompareDone->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( OptionsDlgGenerated::onChangeSoundFilePath ), NULL, this ); + m_buttonSelectSoundCompareDone->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onSelectSoundCompareDone ), NULL, this ); + m_bpButtonPlaySyncDone->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onPlaySyncDone ), NULL, this ); + m_textCtrlSoundPathSyncDone->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( OptionsDlgGenerated::onChangeSoundFilePath ), NULL, this ); + m_buttonSelectSoundSyncDone->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onSelectSoundSyncDone ), NULL, this ); + m_bpButtonPlayAlertPending->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onPlayAlertPending ), NULL, this ); + m_textCtrlSoundPathAlertPending->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( OptionsDlgGenerated::onChangeSoundFilePath ), NULL, this ); + m_buttonSelectSoundAlertPending->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onSelectSoundAlertPending ), NULL, this ); + m_buttonShowCtxCustomize->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onShowContextCustomize ), NULL, this ); + m_bpButtonAddRow->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onAddRow ), NULL, this ); + m_bpButtonRemoveRow->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onRemoveRow ), NULL, this ); + m_buttonDefault->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onDefault ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( OptionsDlgGenerated::onCancel ), NULL, this ); +} + +OptionsDlgGenerated::~OptionsDlgGenerated() +{ +} + +SelectTimespanDlgGenerated::SelectTimespanDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer96; + bSizer96 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel35; + m_panel35 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel35->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer98; + bSizer98 = new wxBoxSizer( wxHORIZONTAL ); + + m_calendarFrom = new wxCalendarCtrl( m_panel35, wxID_ANY, wxDefaultDateTime, wxDefaultPosition, wxDefaultSize, wxCAL_SHOW_HOLIDAYS|wxCAL_SHOW_SURROUNDING_WEEKS|wxBORDER_NONE ); + bSizer98->Add( m_calendarFrom, 0, wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_calendarTo = new wxCalendarCtrl( m_panel35, wxID_ANY, wxDefaultDateTime, wxDefaultPosition, wxDefaultSize, wxCAL_SHOW_HOLIDAYS|wxCAL_SHOW_SURROUNDING_WEEKS|wxBORDER_NONE ); + bSizer98->Add( m_calendarTo, 0, wxALL, 10 ); + + + m_panel35->SetSizer( bSizer98 ); + m_panel35->Layout(); + bSizer98->Fit( m_panel35 ); + bSizer96->Add( m_panel35, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline21; + m_staticline21 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer96->Add( m_staticline21, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer96->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer96 ); + this->Layout(); + bSizer96->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( SelectTimespanDlgGenerated::onClose ) ); + m_calendarFrom->Connect( wxEVT_CALENDAR_SEL_CHANGED, wxCalendarEventHandler( SelectTimespanDlgGenerated::onChangeSelectionFrom ), NULL, this ); + m_calendarTo->Connect( wxEVT_CALENDAR_SEL_CHANGED, wxCalendarEventHandler( SelectTimespanDlgGenerated::onChangeSelectionTo ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( SelectTimespanDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( SelectTimespanDlgGenerated::onCancel ), NULL, this ); +} + +SelectTimespanDlgGenerated::~SelectTimespanDlgGenerated() +{ +} + +AboutDlgGenerated::AboutDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer31; + bSizer31 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel41; + m_panel41 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel41->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer174; + bSizer174 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapLogoLeft = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer174->Add( m_bitmapLogoLeft, 0, wxBOTTOM, 5 ); + + wxStaticLine* m_staticline81; + m_staticline81 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer174->Add( m_staticline81, 0, wxEXPAND, 5 ); + + bSizerMainSection = new wxBoxSizer( wxVERTICAL ); + + wxStaticLine* m_staticline82; + m_staticline82 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMainSection->Add( m_staticline82, 0, wxEXPAND, 5 ); + + m_bitmapLogo = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerMainSection->Add( m_bitmapLogo, 0, 0, 5 ); + + wxStaticLine* m_staticline341; + m_staticline341 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMainSection->Add( m_staticline341, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer298; + bSizer298 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticFfsTextVersion = new wxStaticText( m_panel41, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticFfsTextVersion->Wrap( -1 ); + bSizer298->Add( m_staticFfsTextVersion, 0, wxALIGN_BOTTOM, 5 ); + + m_staticTextFfsVariant = new wxStaticText( m_panel41, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextFfsVariant->Wrap( -1 ); + m_staticTextFfsVariant->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer298->Add( m_staticTextFfsVariant, 0, wxLEFT|wxALIGN_BOTTOM, 10 ); + + + bSizerMainSection->Add( bSizer298, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + wxStaticLine* m_staticline3411; + m_staticline3411 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMainSection->Add( m_staticline3411, 0, wxEXPAND, 5 ); + + bSizerDonate = new wxBoxSizer( wxVERTICAL ); + + + bSizerDonate->Add( 0, 0, 1, 0, 5 ); + + m_panelDonate = new wxPanel( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panelDonate->SetBackgroundColour( wxColour( 153, 170, 187 ) ); + + wxBoxSizer* bSizer183; + bSizer183 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapAnimalSmall = new wxStaticBitmap( m_panelDonate, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer183->Add( m_bitmapAnimalSmall, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + wxPanel* m_panel39; + m_panel39 = new wxPanel( m_panelDonate, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel39->SetBackgroundColour( wxColour( 248, 248, 248 ) ); + + wxBoxSizer* bSizer184; + bSizer184 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextDonate = new wxStaticText( m_panel39, wxID_ANY, _("Get the Donation Edition with bonus features and help keep FreeFileSync ad-free."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDonate->Wrap( -1 ); + m_staticTextDonate->SetForegroundColour( wxColour( 0, 0, 0 ) ); + + bSizer184->Add( m_staticTextDonate, 0, wxALIGN_CENTER_HORIZONTAL|wxLEFT|wxALIGN_CENTER_VERTICAL, 10 ); + + + m_panel39->SetSizer( bSizer184 ); + m_panel39->Layout(); + bSizer184->Fit( m_panel39 ); + bSizer183->Add( m_panel39, 1, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 5 ); + + + m_panelDonate->SetSizer( bSizer183 ); + m_panelDonate->Layout(); + bSizer183->Fit( m_panelDonate ); + bSizerDonate->Add( m_panelDonate, 0, wxEXPAND, 5 ); + + m_buttonDonate1 = new zen::BitmapTextButton( m_panel41, wxID_ANY, _("Support with a donation"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonDonate1->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonDonate1->SetToolTip( _("https://freefilesync.org/donate") ); + + bSizerDonate->Add( m_buttonDonate1, 0, wxEXPAND|wxALL, 10 ); + + + bSizerDonate->Add( 0, 0, 1, 0, 5 ); + + + bSizerMainSection->Add( bSizerDonate, 1, wxEXPAND, 5 ); + + m_bitmapAnimalBig = new wxStaticBitmap( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerMainSection->Add( m_bitmapAnimalBig, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + wxStaticLine* m_staticline3412; + m_staticline3412 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizerMainSection->Add( m_staticline3412, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer186; + bSizer186 = new wxBoxSizer( wxVERTICAL ); + + wxStaticText* m_staticText94; + m_staticText94 = new wxStaticText( m_panel41, wxID_ANY, _("Share your feedback and ideas:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText94->Wrap( -1 ); + bSizer186->Add( m_staticText94, 0, wxALIGN_CENTER_HORIZONTAL|wxTOP|wxRIGHT|wxLEFT, 5 ); + + wxBoxSizer* bSizer289; + bSizer289 = new wxBoxSizer( wxHORIZONTAL ); + + m_bpButtonForum = new wxBitmapButton( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + m_bpButtonForum->SetToolTip( _("https://freefilesync.org/forum") ); + + bSizer289->Add( m_bpButtonForum, 0, wxALL|wxEXPAND, 5 ); + + m_bpButtonEmail = new wxBitmapButton( m_panel41, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, wxBU_AUTODRAW|0 ); + bSizer289->Add( m_bpButtonEmail, 0, wxTOP|wxBOTTOM|wxRIGHT|wxEXPAND, 5 ); + + + bSizer186->Add( bSizer289, 0, wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizerMainSection->Add( bSizer186, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + + bSizer174->Add( bSizerMainSection, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline37; + m_staticline37 = new wxStaticLine( m_panel41, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_VERTICAL ); + bSizer174->Add( m_staticline37, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer177; + bSizer177 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextThanksForLoc = new wxStaticText( m_panel41, wxID_ANY, _("Many thanks for translation:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextThanksForLoc->Wrap( -1 ); + bSizer177->Add( m_staticTextThanksForLoc, 0, wxALL, 5 ); + + m_scrolledWindowTranslators = new wxScrolledWindow( m_panel41, wxID_ANY, wxDefaultPosition, wxSize( -1, -1 ), wxVSCROLL ); + m_scrolledWindowTranslators->SetScrollRate( 10, 10 ); + m_scrolledWindowTranslators->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + fgSizerTranslators = new wxFlexGridSizer( 0, 2, 2, 10 ); + fgSizerTranslators->SetFlexibleDirection( wxBOTH ); + fgSizerTranslators->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + + m_scrolledWindowTranslators->SetSizer( fgSizerTranslators ); + m_scrolledWindowTranslators->Layout(); + fgSizerTranslators->Fit( m_scrolledWindowTranslators ); + bSizer177->Add( m_scrolledWindowTranslators, 1, wxEXPAND|wxLEFT, 5 ); + + + bSizer174->Add( bSizer177, 0, wxEXPAND|wxLEFT, 5 ); + + + m_panel41->SetSizer( bSizer174 ); + m_panel41->Layout(); + bSizer174->Fit( m_panel41 ); + bSizer31->Add( m_panel41, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline36; + m_staticline36 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer31->Add( m_staticline36, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonShowSupporterDetails = new wxButton( this, wxID_ANY, _("Thank you, %x, for your support!"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizerStdButtons->Add( m_buttonShowSupporterDetails, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + + bSizerStdButtons->Add( 0, 0, 1, 0, 5 ); + + m_buttonDonate2 = new zen::BitmapTextButton( this, wxID_ANY, _("&Donate"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonDonate2->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + m_buttonDonate2->SetToolTip( _("https://freefilesync.org/donate") ); + + bSizerStdButtons->Add( m_buttonDonate2, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_buttonClose = new wxButton( this, wxID_OK, _("Close"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonClose->SetDefault(); + bSizerStdButtons->Add( m_buttonClose, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer31->Add( bSizerStdButtons, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer31 ); + this->Layout(); + bSizer31->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( AboutDlgGenerated::onClose ) ); + m_buttonDonate1->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onDonate ), NULL, this ); + m_bpButtonForum->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onOpenForum ), NULL, this ); + m_bpButtonEmail->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onSendEmail ), NULL, this ); + m_buttonShowSupporterDetails->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onShowSupporterDetails ), NULL, this ); + m_buttonDonate2->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onDonate ), NULL, this ); + m_buttonClose->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( AboutDlgGenerated::onOkay ), NULL, this ); +} + +AboutDlgGenerated::~AboutDlgGenerated() +{ +} + +DownloadProgressDlgGenerated::DownloadProgressDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + + wxBoxSizer* bSizer24; + bSizer24 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer72; + bSizer72 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapDownloading = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer72->Add( m_bitmapDownloading, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + m_staticTextHeader = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHeader->Wrap( -1 ); + bSizer72->Add( m_staticTextHeader, 0, wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + + bSizer72->Add( 20, 0, 0, 0, 5 ); + + + bSizer24->Add( bSizer72, 0, 0, 5 ); + + wxBoxSizer* bSizer212; + bSizer212 = new wxBoxSizer( wxVERTICAL ); + + m_gaugeProgress = new wxGauge( this, wxID_ANY, 100, wxDefaultPosition, wxDefaultSize, wxGA_HORIZONTAL ); + m_gaugeProgress->SetValue( 0 ); + bSizer212->Add( m_gaugeProgress, 0, wxEXPAND|wxRIGHT|wxLEFT, 5 ); + + m_staticTextDetails = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextDetails->Wrap( -1 ); + bSizer212->Add( m_staticTextDetails, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + + bSizer24->Add( bSizer212, 0, wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + wxStaticLine* m_staticline9; + m_staticline9 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline9, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonCancel->SetDefault(); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer24->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer24 ); + this->Layout(); + bSizer24->Fit( this ); + + // Connect Events + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( DownloadProgressDlgGenerated::onCancel ), NULL, this ); +} + +DownloadProgressDlgGenerated::~DownloadProgressDlgGenerated() +{ +} + +CfgHighlightDlgGenerated::CfgHighlightDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer96; + bSizer96 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel35; + m_panel35 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel35->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer98; + bSizer98 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer238; + bSizer238 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextHighlight = new wxStaticText( m_panel35, wxID_ANY, _("Highlight configurations that have not been run for more than the following number of days:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextHighlight->Wrap( -1 ); + bSizer238->Add( m_staticTextHighlight, 0, wxTOP|wxRIGHT|wxLEFT, 5 ); + + m_spinCtrlOverdueDays = new wxSpinCtrl( m_panel35, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxSP_ARROW_KEYS, 0, 2000000000, 0 ); + bSizer238->Add( m_spinCtrlOverdueDays, 0, wxALL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + + bSizer98->Add( bSizer238, 1, wxALL|wxEXPAND, 5 ); + + + m_panel35->SetSizer( bSizer98 ); + m_panel35->Layout(); + bSizer98->Fit( m_panel35 ); + bSizer96->Add( m_panel35, 0, 0, 5 ); + + wxStaticLine* m_staticline21; + m_staticline21 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer96->Add( m_staticline21, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer96->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer96 ); + this->Layout(); + bSizer96->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( CfgHighlightDlgGenerated::onClose ) ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CfgHighlightDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( CfgHighlightDlgGenerated::onCancel ), NULL, this ); +} + +CfgHighlightDlgGenerated::~CfgHighlightDlgGenerated() +{ +} + +PasswordPromptDlgGenerated::PasswordPromptDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer96; + bSizer96 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel35; + m_panel35 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel35->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer98; + bSizer98 = new wxBoxSizer( wxHORIZONTAL ); + + wxBoxSizer* bSizer238; + bSizer238 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextMain = new wxStaticText( m_panel35, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMain->Wrap( -1 ); + bSizer238->Add( m_staticTextMain, 1, wxALL, 5 ); + + wxBoxSizer* bSizer305; + bSizer305 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextPassword = new wxStaticText( m_panel35, wxID_ANY, _("Password:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextPassword->Wrap( -1 ); + bSizer305->Add( m_staticTextPassword, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_textCtrlPasswordVisible = new wxTextCtrl( m_panel35, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer305->Add( m_textCtrlPasswordVisible, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_textCtrlPasswordHidden = new wxTextCtrl( m_panel35, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_PASSWORD ); + bSizer305->Add( m_textCtrlPasswordHidden, 1, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 5 ); + + m_checkBoxShowPassword = new wxCheckBox( m_panel35, wxID_ANY, _("&Show password"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer305->Add( m_checkBoxShowPassword, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer238->Add( bSizer305, 0, wxEXPAND|wxTOP|wxBOTTOM, 5 ); + + bSizerError = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapError = new wxStaticBitmap( m_panel35, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizerError->Add( m_bitmapError, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextError = new wxStaticText( m_panel35, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextError->Wrap( -1 ); + bSizerError->Add( m_staticTextError, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer238->Add( bSizerError, 0, 0, 5 ); + + + bSizer98->Add( bSizer238, 1, wxALL, 5 ); + + + m_panel35->SetSizer( bSizer98 ); + m_panel35->Layout(); + bSizer98->Fit( m_panel35 ); + bSizer96->Add( m_panel35, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline21; + m_staticline21 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer96->Add( m_staticline21, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonOK = new wxButton( this, wxID_OK, _("OK"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonOK->SetDefault(); + m_buttonOK->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizerStdButtons->Add( m_buttonOK, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer96->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer96 ); + this->Layout(); + bSizer96->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( PasswordPromptDlgGenerated::onClose ) ); + m_textCtrlPasswordVisible->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( PasswordPromptDlgGenerated::onTypingPassword ), NULL, this ); + m_textCtrlPasswordHidden->Connect( wxEVT_COMMAND_TEXT_UPDATED, wxCommandEventHandler( PasswordPromptDlgGenerated::onTypingPassword ), NULL, this ); + m_checkBoxShowPassword->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( PasswordPromptDlgGenerated::onToggleShowPassword ), NULL, this ); + m_buttonOK->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PasswordPromptDlgGenerated::onOkay ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PasswordPromptDlgGenerated::onCancel ), NULL, this ); +} + +PasswordPromptDlgGenerated::~PasswordPromptDlgGenerated() +{ +} + +ActivationDlgGenerated::ActivationDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer54; + bSizer54 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel35; + m_panel35 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel35->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer172; + bSizer172 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer165; + bSizer165 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapActivation = new wxStaticBitmap( m_panel35, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer165->Add( m_bitmapActivation, 0, wxALL, 10 ); + + wxBoxSizer* bSizer16; + bSizer16 = new wxBoxSizer( wxVERTICAL ); + + + bSizer16->Add( 0, 10, 0, 0, 5 ); + + m_richTextLastError = new wxRichTextCtrl( m_panel35, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_READONLY|wxBORDER_NONE|wxVSCROLL|wxWANTS_CHARS ); + bSizer16->Add( m_richTextLastError, 1, wxEXPAND, 5 ); + + + bSizer165->Add( bSizer16, 1, wxEXPAND, 5 ); + + + bSizer172->Add( bSizer165, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline82; + m_staticline82 = new wxStaticLine( m_panel35, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer172->Add( m_staticline82, 0, wxEXPAND, 5 ); + + m_staticTextMain = new wxStaticText( m_panel35, wxID_ANY, _("Activate FreeFileSync by one of the following methods:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMain->Wrap( -1 ); + bSizer172->Add( m_staticTextMain, 0, wxALL, 10 ); + + + m_panel35->SetSizer( bSizer172 ); + m_panel35->Layout(); + bSizer172->Fit( m_panel35 ); + bSizer54->Add( m_panel35, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline181; + m_staticline181 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline181, 0, wxEXPAND|wxBOTTOM, 5 ); + + wxStaticLine* m_staticline18111; + m_staticline18111 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline18111, 0, wxEXPAND|wxTOP, 5 ); + + wxPanel* m_panel3511; + m_panel3511 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel3511->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer263; + bSizer263 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer234; + bSizer234 = new wxBoxSizer( wxHORIZONTAL ); + + m_staticTextMain1 = new wxStaticText( m_panel3511, wxID_ANY, _("1."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMain1->Wrap( -1 ); + bSizer234->Add( m_staticTextMain1, 0, wxRIGHT|wxALIGN_CENTER_VERTICAL, 5 ); + + wxStaticText* m_staticText136; + m_staticText136 = new wxStaticText( m_panel3511, wxID_ANY, _("Activate via internet now:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText136->Wrap( -1 ); + bSizer234->Add( m_staticText136, 1, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_buttonActivateOnline = new wxButton( m_panel3511, wxID_ANY, _("Activate online"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonActivateOnline->SetDefault(); + m_buttonActivateOnline->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer234->Add( m_buttonActivateOnline, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer263->Add( bSizer234, 0, wxEXPAND|wxALL, 10 ); + + + m_panel3511->SetSizer( bSizer263 ); + m_panel3511->Layout(); + bSizer263->Fit( m_panel3511 ); + bSizer54->Add( m_panel3511, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline181111; + m_staticline181111 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline181111, 0, wxEXPAND|wxBOTTOM, 5 ); + + wxStaticLine* m_staticline181112; + m_staticline181112 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline181112, 0, wxEXPAND|wxTOP, 5 ); + + wxPanel* m_panel351; + m_panel351 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel351->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer266; + bSizer266 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer237; + bSizer237 = new wxBoxSizer( wxVERTICAL ); + + wxBoxSizer* bSizer236; + bSizer236 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText175; + m_staticText175 = new wxStaticText( m_panel351, wxID_ANY, _("2."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText175->Wrap( -1 ); + bSizer236->Add( m_staticText175, 0, wxRIGHT|wxALIGN_BOTTOM, 5 ); + + wxStaticText* m_staticText1361; + m_staticText1361 = new wxStaticText( m_panel351, wxID_ANY, _("Retrieve an offline activation key from the following URL:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText1361->Wrap( -1 ); + bSizer236->Add( m_staticText1361, 1, wxRIGHT|wxALIGN_BOTTOM, 5 ); + + m_buttonCopyUrl = new wxButton( m_panel351, wxID_ANY, _("&Copy to clipboard"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer236->Add( m_buttonCopyUrl, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer237->Add( bSizer236, 0, wxEXPAND|wxBOTTOM, 5 ); + + m_richTextManualActivationUrl = new wxRichTextCtrl( m_panel351, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_READONLY|wxBORDER_NONE|wxVSCROLL|wxWANTS_CHARS ); + bSizer237->Add( m_richTextManualActivationUrl, 0, wxEXPAND|wxBOTTOM, 5 ); + + wxBoxSizer* bSizer235; + bSizer235 = new wxBoxSizer( wxHORIZONTAL ); + + wxStaticText* m_staticText13611; + m_staticText13611 = new wxStaticText( m_panel351, wxID_ANY, _("Enter activation key:"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticText13611->Wrap( -1 ); + bSizer235->Add( m_staticText13611, 0, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_textCtrlOfflineActivationKey = new wxTextCtrl( m_panel351, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize( -1, -1 ), wxTE_PROCESS_ENTER ); + bSizer235->Add( m_textCtrlOfflineActivationKey, 1, wxALIGN_CENTER_VERTICAL|wxRIGHT, 5 ); + + m_buttonActivateOffline = new wxButton( m_panel351, wxID_ANY, _("Activate offline"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_buttonActivateOffline->SetFont( wxFont( wxNORMAL_FONT->GetPointSize(), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD, false, wxEmptyString ) ); + + bSizer235->Add( m_buttonActivateOffline, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer237->Add( bSizer235, 0, wxEXPAND|wxTOP, 5 ); + + + bSizer266->Add( bSizer237, 0, wxALL|wxEXPAND, 10 ); + + + m_panel351->SetSizer( bSizer266 ); + m_panel351->Layout(); + bSizer266->Fit( m_panel351 ); + bSizer54->Add( m_panel351, 0, wxEXPAND, 5 ); + + wxStaticLine* m_staticline13; + m_staticline13 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer54->Add( m_staticline13, 0, wxEXPAND, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer54->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + this->SetSizer( bSizer54 ); + this->Layout(); + bSizer54->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( ActivationDlgGenerated::onClose ) ); + m_buttonActivateOnline->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ActivationDlgGenerated::onActivateOnline ), NULL, this ); + m_buttonCopyUrl->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ActivationDlgGenerated::onCopyUrl ), NULL, this ); + m_textCtrlOfflineActivationKey->Connect( wxEVT_COMMAND_TEXT_ENTER, wxCommandEventHandler( ActivationDlgGenerated::onOfflineActivationEnter ), NULL, this ); + m_buttonActivateOffline->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ActivationDlgGenerated::onActivateOffline ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( ActivationDlgGenerated::onCancel ), NULL, this ); +} + +ActivationDlgGenerated::~ActivationDlgGenerated() +{ +} + +WarnAccessRightsMissingDlgGenerated::WarnAccessRightsMissingDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer330; + bSizer330 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapGrantAccess = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer330->Add( m_bitmapGrantAccess, 0, wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT, 10 ); + + wxBoxSizer* bSizer95; + bSizer95 = new wxBoxSizer( wxVERTICAL ); + + m_staticTextDescr = new wxStaticText( this, wxID_ANY, _("FreeFileSync requires access rights to avoid \"Operation not permitted\" errors when synchronizing your data (e.g. Mail, Messages, Calendars)."), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + m_staticTextDescr->Wrap( -1 ); + bSizer95->Add( m_staticTextDescr, 0, wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL|wxALL, 10 ); + + wxStaticLine* m_staticline20; + m_staticline20 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer95->Add( m_staticline20, 0, wxEXPAND, 5 ); + + wxPanel* m_panel39; + m_panel39 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel39->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer166; + bSizer166 = new wxBoxSizer( wxVERTICAL ); + + wxFlexGridSizer* ffgSizer11; + ffgSizer11 = new wxFlexGridSizer( 0, 2, 5, 5 ); + ffgSizer11->SetFlexibleDirection( wxBOTH ); + ffgSizer11->SetNonFlexibleGrowMode( wxFLEX_GROWMODE_SPECIFIED ); + + m_staticTextStep1 = new wxStaticText( m_panel39, wxID_ANY, _("1."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStep1->Wrap( -1 ); + ffgSizer11->Add( m_staticTextStep1, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_buttonLocateBundle = new wxButton( m_panel39, wxID_ANY, _("Locate the FreeFileSync app"), wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_buttonLocateBundle, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + m_staticTextStep2 = new wxStaticText( m_panel39, wxID_ANY, _("2."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStep2->Wrap( -1 ); + ffgSizer11->Add( m_staticTextStep2, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_buttonOpenSecurity = new wxButton( m_panel39, wxID_ANY, _("Open Security && Privacy"), wxDefaultPosition, wxDefaultSize, 0 ); + ffgSizer11->Add( m_buttonOpenSecurity, 0, wxALIGN_CENTER_VERTICAL|wxEXPAND, 5 ); + + m_staticTextStep3 = new wxStaticText( m_panel39, wxID_ANY, _("3."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextStep3->Wrap( -1 ); + ffgSizer11->Add( m_staticTextStep3, 0, wxALIGN_CENTER_VERTICAL|wxALIGN_CENTER_HORIZONTAL, 5 ); + + m_staticTextGrantAccess = new wxStaticText( m_panel39, wxID_ANY, _("Drag FreeFileSync into the panel."), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextGrantAccess->Wrap( -1 ); + ffgSizer11->Add( m_staticTextGrantAccess, 0, wxALIGN_CENTER_VERTICAL, 5 ); + + + bSizer166->Add( ffgSizer11, 0, wxALL|wxALIGN_CENTER_HORIZONTAL, 10 ); + + + m_panel39->SetSizer( bSizer166 ); + m_panel39->Layout(); + bSizer166->Fit( m_panel39 ); + bSizer95->Add( m_panel39, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline36; + m_staticline36 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer95->Add( m_staticline36, 0, wxEXPAND, 5 ); + + m_checkBoxDontShowAgain = new wxCheckBox( this, wxID_ANY, _("&Don't show this dialog again"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer95->Add( m_checkBoxDontShowAgain, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonClose = new wxButton( this, wxID_OK, _("Close"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonClose->SetDefault(); + bSizerStdButtons->Add( m_buttonClose, 0, wxALIGN_CENTER_VERTICAL|wxALL, 5 ); + + + bSizer95->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + bSizer330->Add( bSizer95, 1, wxEXPAND, 5 ); + + + this->SetSizer( bSizer330 ); + this->Layout(); + bSizer330->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( WarnAccessRightsMissingDlgGenerated::onClose ) ); + m_buttonLocateBundle->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( WarnAccessRightsMissingDlgGenerated::onShowAppBundle ), NULL, this ); + m_buttonOpenSecurity->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( WarnAccessRightsMissingDlgGenerated::onOpenSecuritySettings ), NULL, this ); + m_checkBoxDontShowAgain->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( WarnAccessRightsMissingDlgGenerated::onCheckBoxClick ), NULL, this ); + m_buttonClose->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( WarnAccessRightsMissingDlgGenerated::onOkay ), NULL, this ); +} + +WarnAccessRightsMissingDlgGenerated::~WarnAccessRightsMissingDlgGenerated() +{ +} diff --git a/FreeFileSync/Source/ui/gui_generated.h b/FreeFileSync/Source/ui/gui_generated.h new file mode 100644 index 0000000..25152be --- /dev/null +++ b/FreeFileSync/Source/ui/gui_generated.h @@ -0,0 +1,1275 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +namespace zen { class BitmapTextButton; } +namespace zen { class ToggleButton; } + +#include "wx+/bitmap_button.h" +#include "folder_history_box.h" +#include "wx+/grid.h" +#include "triple_splitter.h" +#include "wx+/toggle_button.h" +#include "command_box.h" +#include "wx+/graph.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "zen/i18n.h" + +/////////////////////////////////////////////////////////////////////////// + + +/////////////////////////////////////////////////////////////////////////////// +/// Class MainDialogGenerated +/////////////////////////////////////////////////////////////////////////////// +class MainDialogGenerated : public wxFrame +{ +private: + +protected: + wxMenuBar* m_menubar; + wxMenu* m_menuFile; + wxMenuItem* m_menuItemNew; + wxMenuItem* m_menuItemLoad; + wxMenuItem* m_menuItemSave; + wxMenuItem* m_menuItemSaveAs; + wxMenuItem* m_menuItemSaveAsBatch; + wxMenuItem* m_menuItemQuit; + wxMenu* m_menuActions; + wxMenuItem* m_menuItemShowLog; + wxMenuItem* m_menuItemCompare; + wxMenuItem* m_menuItemCompSettings; + wxMenuItem* m_menuItemFilter; + wxMenuItem* m_menuItemSyncSettings; + wxMenuItem* m_menuItemSynchronize; + wxMenu* m_menuTools; + wxMenuItem* m_menuItemOptions; + wxMenu* m_menuLanguages; + wxMenuItem* m_menuItemFind; + wxMenuItem* m_menuItemExportList; + wxMenuItem* m_menuItemResetLayout; + wxMenuItem* m_menuItemShowMain; + wxMenuItem* m_menuItemShowFolders; + wxMenuItem* m_menuItemShowViewFilter; + wxMenuItem* m_menuItemShowConfig; + wxMenuItem* m_menuItemShowOverview; + wxMenu* m_menuHelp; + wxMenuItem* m_menuItemHelp; + wxMenuItem* m_menuItemCheckVersionNow; + wxMenuItem* m_menuItemAbout; + wxBoxSizer* bSizerPanelHolder; + wxPanel* m_panelTopButtons; + wxBoxSizer* bSizerTopButtons; + wxButton* m_buttonCancel; + zen::BitmapTextButton* m_buttonCompare; + wxBitmapButton* m_bpButtonCmpConfig; + wxBitmapButton* m_bpButtonCmpContext; + wxBitmapButton* m_bpButtonFilter; + wxBitmapButton* m_bpButtonFilterContext; + wxBitmapButton* m_bpButtonSyncConfig; + wxBitmapButton* m_bpButtonSyncContext; + zen::BitmapTextButton* m_buttonSync; + wxPanel* m_panelDirectoryPairs; + wxPanel* m_panelTopLeft; + wxStaticText* m_staticTextResolvedPathL; + wxBitmapButton* m_bpButtonAddPair; + fff::FolderHistoryBox* m_folderPathLeft; + wxButton* m_buttonSelectFolderLeft; + wxBitmapButton* m_bpButtonSelectAltFolderLeft; + wxPanel* m_panelTopCenter; + wxBitmapButton* m_bpButtonSwapSides; + wxPanel* m_panelTopRight; + wxStaticText* m_staticTextResolvedPathR; + fff::FolderHistoryBox* m_folderPathRight; + wxButton* m_buttonSelectFolderRight; + wxBitmapButton* m_bpButtonSelectAltFolderRight; + wxScrolledWindow* m_scrolledWindowFolderPairs; + wxBoxSizer* bSizerAddFolderPairs; + zen::Grid* m_gridOverview; + wxPanel* m_panelCenter; + fff::TripleSplitter* m_splitterMain; + zen::Grid* m_gridMainL; + zen::Grid* m_gridMainC; + zen::Grid* m_gridMainR; + wxPanel* m_panelStatusBar; + wxBoxSizer* bSizerStatusLeftDirectories; + wxStaticBitmap* m_bitmapSmallDirectoryLeft; + wxStaticText* m_staticTextStatusLeftDirs; + wxBoxSizer* bSizerStatusLeftFiles; + wxStaticBitmap* m_bitmapSmallFileLeft; + wxStaticText* m_staticTextStatusLeftFiles; + wxStaticText* m_staticTextStatusLeftBytes; + wxStaticText* m_staticTextStatusCenter; + wxBoxSizer* bSizerStatusRightDirectories; + wxStaticBitmap* m_bitmapSmallDirectoryRight; + wxStaticText* m_staticTextStatusRightDirs; + wxBoxSizer* bSizerStatusRightFiles; + wxStaticBitmap* m_bitmapSmallFileRight; + wxStaticText* m_staticTextStatusRightFiles; + wxStaticText* m_staticTextStatusRightBytes; + wxPanel* m_panelSearch; + wxBitmapButton* m_bpButtonHideSearch; + wxTextCtrl* m_textCtrlSearchTxt; + wxCheckBox* m_checkBoxMatchCase; + wxPanel* m_panelLog; + wxBoxSizer* bSizerLog; + wxStaticBitmap* m_bitmapSyncResult; + wxStaticText* m_staticTextSyncResult; + wxStaticText* m_staticTextProcessed; + wxStaticText* m_staticTextRemaining; + wxPanel* m_panelItemStats; + wxStaticBitmap* m_bitmapItemStat; + wxStaticText* m_staticTextItemsProcessed; + wxStaticText* m_staticTextBytesProcessed; + wxStaticText* m_staticTextItemsRemaining; + wxStaticText* m_staticTextBytesRemaining; + wxPanel* m_panelTimeStats; + wxStaticBitmap* m_bitmapTimeStat; + wxStaticText* m_staticTextTimeElapsed; + wxPanel* m_panelConfig; + wxBoxSizer* bSizerConfig; + wxBoxSizer* bSizerCfgHistoryButtons; + wxBitmapButton* m_bpButtonNew; + wxBitmapButton* m_bpButtonOpen; + wxBitmapButton* m_bpButtonSave; + wxBoxSizer* bSizerSaveAs; + wxBitmapButton* m_bpButtonSaveAs; + wxBitmapButton* m_bpButtonSaveAsBatch; + zen::Grid* m_gridCfgHistory; + wxPanel* m_panelViewFilter; + wxBoxSizer* bSizerViewFilter; + zen::ToggleButton* m_bpButtonToggleLog; + wxBoxSizer* bSizerViewButtons; + zen::ToggleButton* m_bpButtonViewType; + zen::ToggleButton* m_bpButtonShowExcluded; + zen::ToggleButton* m_bpButtonShowDeleteLeft; + zen::ToggleButton* m_bpButtonShowUpdateLeft; + zen::ToggleButton* m_bpButtonShowCreateLeft; + zen::ToggleButton* m_bpButtonShowLeftOnly; + zen::ToggleButton* m_bpButtonShowLeftNewer; + zen::ToggleButton* m_bpButtonShowEqual; + zen::ToggleButton* m_bpButtonShowDoNothing; + zen::ToggleButton* m_bpButtonShowDifferent; + zen::ToggleButton* m_bpButtonShowRightNewer; + zen::ToggleButton* m_bpButtonShowRightOnly; + zen::ToggleButton* m_bpButtonShowCreateRight; + zen::ToggleButton* m_bpButtonShowUpdateRight; + zen::ToggleButton* m_bpButtonShowDeleteRight; + zen::ToggleButton* m_bpButtonShowConflict; + wxBitmapButton* m_bpButtonViewFilterContext; + wxPanel* m_panelStatistics; + wxBoxSizer* bSizerStatistics; + wxStaticBitmap* m_bitmapDeleteLeft; + wxStaticText* m_staticTextDeleteLeft; + wxStaticBitmap* m_bitmapUpdateLeft; + wxStaticText* m_staticTextUpdateLeft; + wxStaticBitmap* m_bitmapCreateLeft; + wxStaticText* m_staticTextCreateLeft; + wxStaticBitmap* m_bitmapData; + wxStaticText* m_staticTextData; + wxStaticBitmap* m_bitmapCreateRight; + wxStaticText* m_staticTextCreateRight; + wxStaticBitmap* m_bitmapUpdateRight; + wxStaticText* m_staticTextUpdateRight; + wxStaticBitmap* m_bitmapDeleteRight; + wxStaticText* m_staticTextDeleteRight; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onConfigNew( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigLoad( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigSave( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigSaveAs( wxCommandEvent& event ) { event.Skip(); } + virtual void onSaveAsBatchJob( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuQuit( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleLog( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompare( wxCommandEvent& event ) { event.Skip(); } + virtual void onCmpSettings( wxCommandEvent& event ) { event.Skip(); } + virtual void onConfigureFilter( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncSettings( wxCommandEvent& event ) { event.Skip(); } + virtual void onStartSync( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuOptions( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuFindItem( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuExportFileList( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuResetLayout( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowHelp( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuCheckVersion( wxCommandEvent& event ) { event.Skip(); } + virtual void onMenuAbout( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompSettingsContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onCompSettingsContext( wxCommandEvent& event ) { event.Skip(); } + virtual void onGlobalFilterContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onGlobalFilterContext( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncSettingsContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onSyncSettingsContext( wxCommandEvent& event ) { event.Skip(); } + virtual void onTopFolderPairAdd( wxCommandEvent& event ) { event.Skip(); } + virtual void onTopFolderPairRemove( wxCommandEvent& event ) { event.Skip(); } + virtual void onSwapSides( wxCommandEvent& event ) { event.Skip(); } + virtual void onTopLocalCompCfg( wxCommandEvent& event ) { event.Skip(); } + virtual void onTopLocalFilterCfg( wxCommandEvent& event ) { event.Skip(); } + virtual void onTopLocalSyncCfg( wxCommandEvent& event ) { event.Skip(); } + virtual void onHideSearchPanel( wxCommandEvent& event ) { event.Skip(); } + virtual void onSearchGridEnter( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleViewType( wxCommandEvent& event ) { event.Skip(); } + virtual void onViewTypeContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onToggleViewButton( wxCommandEvent& event ) { event.Skip(); } + virtual void onViewFilterContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onViewFilterContext( wxCommandEvent& event ) { event.Skip(); } + + +public: + wxBitmapButton* m_bpButtonRemovePair; + wxBitmapButton* m_bpButtonLocalCompCfg; + wxBitmapButton* m_bpButtonLocalFilter; + wxBitmapButton* m_bpButtonLocalSyncCfg; + + MainDialogGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("dummy"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_FRAME_STYLE|wxTAB_TRAVERSAL ); + + ~MainDialogGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class FolderPairPanelGenerated +/////////////////////////////////////////////////////////////////////////////// +class FolderPairPanelGenerated : public wxPanel +{ +private: + +protected: + wxButton* m_buttonSelectFolderLeft; + wxBitmapButton* m_bpButtonSelectAltFolderLeft; + wxPanel* m_panelRight; + wxButton* m_buttonSelectFolderRight; + wxBitmapButton* m_bpButtonSelectAltFolderRight; + +public: + wxPanel* m_panelLeft; + wxBitmapButton* m_bpButtonFolderPairOptions; + wxBitmapButton* m_bpButtonRemovePair; + fff::FolderHistoryBox* m_folderPathLeft; + wxBitmapButton* m_bpButtonLocalCompCfg; + wxBitmapButton* m_bpButtonLocalFilter; + wxBitmapButton* m_bpButtonLocalSyncCfg; + fff::FolderHistoryBox* m_folderPathRight; + + FolderPairPanelGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( 698, 67 ), long style = 0, const wxString& name = wxEmptyString ); + + ~FolderPairPanelGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class ConfigDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class ConfigDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticText* m_staticTextFolderPairLabel; + wxListBox* m_listBoxFolderPair; + wxNotebook* m_notebook; + wxPanel* m_panelCompSettingsTab; + wxBoxSizer* bSizerHeaderCompSettings; + wxStaticText* m_staticTextMainCompSettings; + wxCheckBox* m_checkBoxUseLocalCmpOptions; + wxStaticLine* m_staticlineCompHeader; + wxPanel* m_panelComparisonSettings; + zen::ToggleButton* m_buttonByTimeSize; + zen::ToggleButton* m_buttonByContent; + zen::ToggleButton* m_buttonBySize; + wxStaticBitmap* m_bitmapCompVariant; + wxStaticText* m_staticTextCompVarDescription; + wxCheckBox* m_checkBoxSymlinksInclude; + wxRadioButton* m_radioBtnSymlinksFollow; + wxRadioButton* m_radioBtnSymlinksDirect; + wxTextCtrl* m_textCtrlTimeShift; + wxBoxSizer* bSizerCompMisc; + wxStaticBitmap* m_bitmapIgnoreErrors; + wxCheckBox* m_checkBoxIgnoreErrors; + wxStaticBitmap* m_bitmapRetryErrors; + wxCheckBox* m_checkBoxAutoRetry; + wxFlexGridSizer* fgSizerAutoRetry; + wxStaticText* m_staticTextAutoRetryDelay; + wxSpinCtrl* m_spinCtrlAutoRetryCount; + wxSpinCtrl* m_spinCtrlAutoRetryDelay; + wxBoxSizer* bSizerPerformance; + wxPanel* m_panel57; + wxStaticBitmap* m_bitmapPerf; + wxHyperlinkCtrl* m_hyperlinkPerfDeRequired; + wxBoxSizer* bSizer260; + wxStaticText* m_staticTextPerfParallelOps; + wxScrolledWindow* m_scrolledWindowPerf; + wxFlexGridSizer* fgSizerPerf; + wxPanel* m_panelFilterSettingsTab; + wxBoxSizer* bSizerHeaderFilterSettings; + wxStaticText* m_staticTextMainFilterSettings; + wxStaticText* m_staticTextLocalFilterSettings; + wxStaticLine* m_staticlineFilterHeader; + wxStaticBitmap* m_bitmapInclude; + wxTextCtrl* m_textCtrlInclude; + wxStaticBitmap* m_bitmapExclude; + wxTextCtrl* m_textCtrlExclude; + wxStaticBitmap* m_bitmapFilterSize; + wxSpinCtrl* m_spinCtrlMinSize; + wxChoice* m_choiceUnitMinSize; + wxSpinCtrl* m_spinCtrlMaxSize; + wxChoice* m_choiceUnitMaxSize; + wxStaticBitmap* m_bitmapFilterDate; + wxChoice* m_choiceUnitTimespan; + wxSpinCtrl* m_spinCtrlTimespan; + wxStaticText* m_staticTextFilterDescr; + wxButton* m_buttonDefault; + wxBitmapButton* m_bpButtonDefaultContext; + wxButton* m_buttonClear; + wxPanel* m_panelSyncSettingsTab; + wxBoxSizer* bSizerHeaderSyncSettings; + wxStaticText* m_staticTextMainSyncSettings; + wxCheckBox* m_checkBoxUseLocalSyncOptions; + wxStaticLine* m_staticlineSyncHeader; + wxPanel* m_panelSyncSettings; + zen::ToggleButton* m_buttonTwoWay; + zen::ToggleButton* m_buttonMirror; + zen::ToggleButton* m_buttonUpdate; + zen::ToggleButton* m_buttonCustom; + wxStaticBitmap* m_bitmapDatabase; + wxCheckBox* m_checkBoxUseDatabase; + wxStaticText* m_staticTextSyncVarDescription; + wxStaticBitmap* m_bitmapMoveLeft; + wxStaticBitmap* m_bitmapMoveRight; + wxStaticText* m_staticTextDetectMove; + wxBoxSizer* bSizerSyncDirHolder; + wxBoxSizer* bSizerSyncDirsDiff; + wxStaticBitmap* m_bitmapLeftOnly; + wxStaticBitmap* m_bitmapLeftNewer; + wxStaticBitmap* m_bitmapDifferent; + wxStaticBitmap* m_bitmapRightNewer; + wxStaticBitmap* m_bitmapRightOnly; + wxBitmapButton* m_bpButtonLeftOnly; + wxBitmapButton* m_bpButtonLeftNewer; + wxBitmapButton* m_bpButtonDifferent; + wxBitmapButton* m_bpButtonRightNewer; + wxBitmapButton* m_bpButtonRightOnly; + wxBoxSizer* bSizerSyncDirsChanges; + wxBitmapButton* m_bpButtonLeftCreate; + wxBitmapButton* m_bpButtonRightCreate; + wxBitmapButton* m_bpButtonLeftUpdate; + wxBitmapButton* m_bpButtonRightUpdate; + wxBitmapButton* m_bpButtonLeftDelete; + wxBitmapButton* m_bpButtonRightDelete; + zen::ToggleButton* m_buttonRecycler; + zen::ToggleButton* m_buttonPermanent; + zen::ToggleButton* m_buttonVersioning; + wxBoxSizer* bSizerVersioningHolder; + wxStaticBitmap* m_bitmapDeletionType; + wxStaticText* m_staticTextDeletionTypeDescription; + wxPanel* m_panelVersioning; + wxStaticBitmap* m_bitmapVersioning; + fff::FolderHistoryBox* m_versioningFolderPath; + wxButton* m_buttonSelectVersioningFolder; + wxBitmapButton* m_bpButtonSelectVersioningAltFolder; + wxChoice* m_choiceVersioningStyle; + wxStaticText* m_staticTextNamingCvtPart1; + wxStaticText* m_staticTextNamingCvtPart2Bold; + wxStaticText* m_staticTextNamingCvtPart3; + wxStaticText* m_staticTextLimitVersions; + wxCheckBox* m_checkBoxVersionMaxDays; + wxCheckBox* m_checkBoxVersionCountMin; + wxCheckBox* m_checkBoxVersionCountMax; + wxSpinCtrl* m_spinCtrlVersionMaxDays; + wxSpinCtrl* m_spinCtrlVersionCountMin; + wxSpinCtrl* m_spinCtrlVersionCountMax; + wxBoxSizer* bSizerSyncMisc; + wxStaticBitmap* m_bitmapEmail; + wxCheckBox* m_checkBoxSendEmail; + fff::CommandBox* m_comboBoxEmail; + wxBitmapButton* m_bpButtonEmailAlways; + wxBitmapButton* m_bpButtonEmailErrorWarning; + wxBitmapButton* m_bpButtonEmailErrorOnly; + wxHyperlinkCtrl* m_hyperlinkPerfDeRequired2; + wxPanel* m_panelLogfile; + wxStaticBitmap* m_bitmapLogFile; + wxCheckBox* m_checkBoxOverrideLogPath; + wxBitmapButton* m_bpButtonShowLogFolder; + fff::FolderHistoryBox* m_logFolderPath; + wxButton* m_buttonSelectLogFolder; + wxBitmapButton* m_bpButtonSelectAltLogFolder; + wxStaticText* m_staticTextPostSync; + wxChoice* m_choicePostSyncCondition; + fff::CommandBox* m_comboBoxPostSyncCommand; + wxPanel* m_panelNotes; + wxStaticBitmap* m_bitmapNotes; + wxTextCtrl* m_textCtrNotes; + wxBoxSizer* bSizerStdButtons; + zen::BitmapTextButton* m_buttonAddNotes; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onListBoxKeyEvent( wxKeyEvent& event ) { event.Skip(); } + virtual void onSelectFolderPair( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleLocalCompSettings( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompByTimeSize( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompByTimeSizeDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onCompByContent( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompByContentDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onCompBySize( wxCommandEvent& event ) { event.Skip(); } + virtual void onCompBySizeDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onChangeCompOption( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleIgnoreErrors( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleAutoRetry( wxCommandEvent& event ) { event.Skip(); } + virtual void onChangeFilterOption( wxCommandEvent& event ) { event.Skip(); } + virtual void onFilterDefault( wxCommandEvent& event ) { event.Skip(); } + virtual void onFilterDefaultContextMouse( wxMouseEvent& event ) { event.Skip(); } + virtual void onFilterDefaultContext( wxCommandEvent& event ) { event.Skip(); } + virtual void onFilterClear( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleLocalSyncSettings( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncTwoWay( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncTwoWayDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onSyncMirror( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncMirrorDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onSyncUpdate( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncUpdateDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onSyncCustom( wxCommandEvent& event ) { event.Skip(); } + virtual void onSyncCustomDouble( wxMouseEvent& event ) { event.Skip(); } + virtual void onToggleUseDatabase( wxCommandEvent& event ) { event.Skip(); } + virtual void onLeftOnly( wxCommandEvent& event ) { event.Skip(); } + virtual void onLeftNewer( wxCommandEvent& event ) { event.Skip(); } + virtual void onDifferent( wxCommandEvent& event ) { event.Skip(); } + virtual void onRightNewer( wxCommandEvent& event ) { event.Skip(); } + virtual void onRightOnly( wxCommandEvent& event ) { event.Skip(); } + virtual void onLeftCreate( wxCommandEvent& event ) { event.Skip(); } + virtual void onRightCreate( wxCommandEvent& event ) { event.Skip(); } + virtual void onLeftUpdate( wxCommandEvent& event ) { event.Skip(); } + virtual void onRightUpdate( wxCommandEvent& event ) { event.Skip(); } + virtual void onLeftDelete( wxCommandEvent& event ) { event.Skip(); } + virtual void onRightDelete( wxCommandEvent& event ) { event.Skip(); } + virtual void onDeletionRecycler( wxCommandEvent& event ) { event.Skip(); } + virtual void onDeletionPermanent( wxCommandEvent& event ) { event.Skip(); } + virtual void onDeletionVersioning( wxCommandEvent& event ) { event.Skip(); } + virtual void onChangeVersioningStyle( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleVersioningLimit( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleMiscEmail( wxCommandEvent& event ) { event.Skip(); } + virtual void onEmailAlways( wxCommandEvent& event ) { event.Skip(); } + virtual void onEmailErrorWarning( wxCommandEvent& event ) { event.Skip(); } + virtual void onEmailErrorOnly( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleMiscOption( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowLogFolder( wxCommandEvent& event ) { event.Skip(); } + virtual void onAddNotes( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + ConfigDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Synchronization Settings"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxMAXIMIZE_BOX|wxRESIZE_BORDER ); + + ~ConfigDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class CloudSetupDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class CloudSetupDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapCloud; + wxToggleButton* m_toggleBtnGdrive; + wxToggleButton* m_toggleBtnSftp; + wxToggleButton* m_toggleBtnFtp; + wxBoxSizer* bSizerGdrive; + wxStaticBitmap* m_bitmapGdriveUser; + wxListBox* m_listBoxGdriveUsers; + zen::BitmapTextButton* m_buttonGdriveAddUser; + zen::BitmapTextButton* m_buttonGdriveRemoveUser; + wxStaticBitmap* m_bitmapGdriveDrive; + wxListBox* m_listBoxGdriveDrives; + wxBoxSizer* bSizerServer; + wxStaticBitmap* m_bitmapServer; + wxTextCtrl* m_textCtrlServer; + wxTextCtrl* m_textCtrlPort; + wxBoxSizer* bSizerAuth; + wxBoxSizer* bSizerAuthInner; + wxBoxSizer* bSizerFtpEncrypt; + wxRadioButton* m_radioBtnEncryptNone; + wxRadioButton* m_radioBtnEncryptSsl; + wxBoxSizer* bSizerSftpAuth; + wxRadioButton* m_radioBtnPassword; + wxRadioButton* m_radioBtnKeyfile; + wxRadioButton* m_radioBtnAgent; + wxPanel* m_panelAuth; + wxTextCtrl* m_textCtrlUserName; + wxStaticText* m_staticTextKeyfile; + wxBoxSizer* bSizerKeyFile; + wxTextCtrl* m_textCtrlKeyfilePath; + wxButton* m_buttonSelectKeyfile; + wxStaticText* m_staticTextPassword; + wxBoxSizer* bSizerPassword; + wxTextCtrl* m_textCtrlPasswordVisible; + wxTextCtrl* m_textCtrlPasswordHidden; + wxCheckBox* m_checkBoxShowPassword; + wxCheckBox* m_checkBoxPasswordPrompt; + wxStaticBitmap* m_bitmapServerDir; + wxStaticText* m_staticTextTimeout; + wxSpinCtrl* m_spinCtrlTimeout; + wxTextCtrl* m_textCtrlServerPath; + wxButton* m_buttonSelectFolder; + wxStaticBitmap* m_bitmapPerf; + wxBoxSizer* bSizerConnectionsLabel; + wxStaticText* m_staticTextConnectionsLabel; + wxStaticText* m_staticTextConnectionsLabelSub; + wxSpinCtrl* m_spinCtrlConnectionCount; + wxStaticText* m_staticTextConnectionCountDescr; + wxHyperlinkCtrl* m_hyperlinkDeRequired; + wxStaticText* m_staticTextChannelCountSftp; + wxSpinCtrl* m_spinCtrlChannelCountSftp; + wxButton* m_buttonChannelCountSftp; + wxCheckBox* m_checkBoxAllowZlib; + wxStaticText* m_staticTextZlibDescr; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onConnectionGdrive( wxCommandEvent& event ) { event.Skip(); } + virtual void onConnectionSftp( wxCommandEvent& event ) { event.Skip(); } + virtual void onConnectionFtp( wxCommandEvent& event ) { event.Skip(); } + virtual void onGdriveUserSelect( wxCommandEvent& event ) { event.Skip(); } + virtual void onGdriveUserAdd( wxCommandEvent& event ) { event.Skip(); } + virtual void onGdriveUserRemove( wxCommandEvent& event ) { event.Skip(); } + virtual void onAuthPassword( wxCommandEvent& event ) { event.Skip(); } + virtual void onAuthKeyfile( wxCommandEvent& event ) { event.Skip(); } + virtual void onAuthAgent( wxCommandEvent& event ) { event.Skip(); } + virtual void onSelectKeyfile( wxCommandEvent& event ) { event.Skip(); } + virtual void onTypingPassword( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleShowPassword( wxCommandEvent& event ) { event.Skip(); } + virtual void onTogglePasswordPrompt( wxCommandEvent& event ) { event.Skip(); } + virtual void onBrowseCloudFolder( wxCommandEvent& event ) { event.Skip(); } + virtual void onDetectServerChannelLimit( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + CloudSetupDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Access Online Storage"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~CloudSetupDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class AbstractFolderPickerGenerated +/////////////////////////////////////////////////////////////////////////////// +class AbstractFolderPickerGenerated : public wxDialog +{ +private: + +protected: + wxStaticText* m_staticTextStatus; + wxTreeCtrl* m_treeCtrlFileSystem; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onExpandNode( wxTreeEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + AbstractFolderPickerGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Select a folder"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxMAXIMIZE_BOX|wxRESIZE_BORDER ); + + ~AbstractFolderPickerGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class SyncConfirmationDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class SyncConfirmationDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapSync; + wxStaticText* m_staticTextCaption; + wxPanel* m_panelStatistics; + wxStaticText* m_staticTextSyncVar; + wxStaticBitmap* m_bitmapSyncVar; + wxStaticBitmap* m_bitmapDeleteLeft; + wxStaticBitmap* m_bitmapUpdateLeft; + wxStaticBitmap* m_bitmapCreateLeft; + wxStaticBitmap* m_bitmapData; + wxStaticBitmap* m_bitmapCreateRight; + wxStaticBitmap* m_bitmapUpdateRight; + wxStaticBitmap* m_bitmapDeleteRight; + wxStaticText* m_staticTextDeleteLeft; + wxStaticText* m_staticTextUpdateLeft; + wxStaticText* m_staticTextCreateLeft; + wxStaticText* m_staticTextData; + wxStaticText* m_staticTextCreateRight; + wxStaticText* m_staticTextUpdateRight; + wxStaticText* m_staticTextDeleteRight; + wxCheckBox* m_checkBoxDontShowAgain; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onStartSync( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + SyncConfirmationDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = wxEmptyString, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~SyncConfirmationDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class CompareProgressDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class CompareProgressDlgGenerated : public wxPanel +{ +private: + +protected: + wxStaticText* m_staticTextStatus; + wxStaticText* m_staticTextProcessed; + wxStaticText* m_staticTextRemaining; + wxPanel* m_panelItemStats; + wxStaticBitmap* m_bitmapItemStat; + wxStaticText* m_staticTextItemsProcessed; + wxStaticText* m_staticTextBytesProcessed; + wxStaticText* m_staticTextItemsRemaining; + wxStaticText* m_staticTextBytesRemaining; + wxPanel* m_panelTimeStats; + wxStaticBitmap* m_bitmapTimeStat; + wxStaticText* m_staticTextTimeElapsed; + wxStaticText* m_staticTextTimeRemaining; + wxStaticText* m_staticTextErrors; + wxStaticText* m_staticTextWarnings; + wxPanel* m_panelErrorStats; + wxStaticBitmap* m_bitmapErrors; + wxStaticText* m_staticTextErrorCount; + wxStaticBitmap* m_bitmapWarnings; + wxStaticText* m_staticTextWarningCount; + wxBoxSizer* bSizerErrorsRetry; + wxStaticBitmap* m_bitmapRetryErrors; + wxStaticText* m_staticTextRetryCount; + wxBoxSizer* bSizerErrorsIgnore; + wxStaticBitmap* m_bitmapIgnoreErrors; + wxBoxSizer* bSizerProgressGraph; + zen::Graph2D* m_panelProgressGraph; + +public: + + CompareProgressDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxBORDER_RAISED, const wxString& name = wxEmptyString ); + + ~CompareProgressDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class SyncProgressPanelGenerated +/////////////////////////////////////////////////////////////////////////////// +class SyncProgressPanelGenerated : public wxPanel +{ +private: + +protected: + +public: + wxBoxSizer* bSizerRoot; + wxStaticBitmap* m_bitmapStatus; + wxStaticText* m_staticTextPhase; + wxStaticText* m_staticTextPercentTotal; + wxBitmapButton* m_bpButtonMinimizeToTray; + wxBoxSizer* bSizerStatusText; + wxStaticText* m_staticTextStatus; + wxPanel* m_panelProgress; + zen::Graph2D* m_panelGraphBytes; + wxStaticBitmap* m_bitmapGraphKeyBytes; + wxStaticBitmap* m_bitmapGraphKeyItems; + wxStaticText* m_staticTextProcessed; + wxStaticText* m_staticTextRemaining; + wxPanel* m_panelItemStats; + wxStaticBitmap* m_bitmapItemStat; + wxStaticText* m_staticTextItemsProcessed; + wxStaticText* m_staticTextBytesProcessed; + wxStaticText* m_staticTextItemsRemaining; + wxStaticText* m_staticTextBytesRemaining; + wxPanel* m_panelTimeStats; + wxStaticBitmap* m_bitmapTimeStat; + wxStaticText* m_staticTextTimeElapsed; + wxStaticText* m_staticTextTimeRemaining; + wxStaticText* m_staticTextErrors; + wxStaticText* m_staticTextWarnings; + wxPanel* m_panelErrorStats; + wxStaticBitmap* m_bitmapErrors; + wxStaticText* m_staticTextErrorCount; + wxStaticBitmap* m_bitmapWarnings; + wxStaticText* m_staticTextWarningCount; + wxBoxSizer* bSizerDynSpace; + zen::Graph2D* m_panelGraphItems; + wxBoxSizer* bSizerProgressFooter; + wxBoxSizer* bSizerErrorsRetry; + wxStaticBitmap* m_bitmapRetryErrors; + wxStaticText* m_staticTextRetryCount; + wxBoxSizer* bSizerErrorsIgnore; + wxStaticBitmap* m_bitmapIgnoreErrors; + wxChoice* m_choicePostSyncAction; + wxNotebook* m_notebookResult; + wxStaticLine* m_staticlineFooter; + wxBoxSizer* bSizerStdButtons; + wxCheckBox* m_checkBoxAutoClose; + wxButton* m_buttonClose; + wxButton* m_buttonPause; + wxButton* m_buttonStop; + + SyncProgressPanelGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxTAB_TRAVERSAL, const wxString& name = wxEmptyString ); + + ~SyncProgressPanelGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class LogPanelGenerated +/////////////////////////////////////////////////////////////////////////////// +class LogPanelGenerated : public wxPanel +{ +private: + +protected: + zen::ToggleButton* m_bpButtonErrors; + zen::ToggleButton* m_bpButtonWarnings; + zen::ToggleButton* m_bpButtonInfo; + zen::Grid* m_gridMessages; + + // Virtual event handlers, override them in your derived class + virtual void onErrors( wxCommandEvent& event ) { event.Skip(); } + virtual void onWarnings( wxCommandEvent& event ) { event.Skip(); } + virtual void onInfo( wxCommandEvent& event ) { event.Skip(); } + + +public: + + LogPanelGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxTAB_TRAVERSAL, const wxString& name = wxEmptyString ); + + ~LogPanelGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class BatchDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class BatchDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapBatchJob; + wxStaticText* m_staticTextHeader; + wxStaticBitmap* m_bitmapMinimizeToTray; + wxCheckBox* m_checkBoxRunMinimized; + wxCheckBox* m_checkBoxAutoClose; + wxStaticBitmap* m_bitmapIgnoreErrors; + wxCheckBox* m_checkBoxIgnoreErrors; + wxRadioButton* m_radioBtnErrorDialogShow; + wxRadioButton* m_radioBtnErrorDialogCancel; + wxChoice* m_choicePostSyncAction; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonSaveAs; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onToggleRunMinimized( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleIgnoreErrors( wxCommandEvent& event ) { event.Skip(); } + virtual void onSaveBatchJob( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + BatchDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Save as a Batch Job"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~BatchDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class DeleteDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class DeleteDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapDeleteType; + wxStaticText* m_staticTextHeader; + wxTextCtrl* m_textCtrlFileList; + wxBoxSizer* bSizerStdButtons; + wxCheckBox* m_checkBoxUseRecycler; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onUseRecycler( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + DeleteDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Delete Items"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxMAXIMIZE_BOX|wxRESIZE_BORDER ); + + ~DeleteDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class CopyToDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class CopyToDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapCopyTo; + wxStaticText* m_staticTextHeader; + wxTextCtrl* m_textCtrlFileList; + fff::FolderHistoryBox* m_targetFolderPath; + wxButton* m_buttonSelectTargetFolder; + wxBitmapButton* m_bpButtonSelectAltTargetFolder; + wxBoxSizer* bSizerStdButtons; + wxCheckBox* m_checkBoxKeepRelPath; + wxCheckBox* m_checkBoxOverwriteIfExists; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + CopyToDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Copy Items"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxMAXIMIZE_BOX|wxRESIZE_BORDER ); + + ~CopyToDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class RenameDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class RenameDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapRename; + wxStaticText* m_staticTextHeader; + zen::Grid* m_gridRenamePreview; + wxStaticLine* m_staticlinePreview; + wxStaticText* m_staticTextPlaceholderDescription; + wxTextCtrl* m_textCtrlNewName; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + RenameDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Rename Items"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize( -1, -1 ), long style = wxDEFAULT_DIALOG_STYLE|wxMAXIMIZE_BOX|wxRESIZE_BORDER ); + + ~RenameDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class OptionsDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class OptionsDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapSettings; + wxCheckBox* m_checkBoxFailSafe; + wxBoxSizer* bSizerLockedFiles; + wxCheckBox* m_checkBoxCopyLocked; + wxCheckBox* m_checkBoxCopyPermissions; + wxBoxSizer* bSizerColorTheme; + wxStaticBitmap* m_bitmapColorTheme; + wxChoice* m_choiceColorTheme; + wxStaticBitmap* m_bitmapWarnings; + wxStaticText* m_staticTextHiddenDialogsCount; + wxButton* m_buttonShowHiddenDialogs; + wxCheckListBox* m_checkListHiddenDialogs; + wxStaticBitmap* m_bitmapLogFile; + wxBitmapButton* m_bpButtonShowLogFolder; + wxPanel* m_panelLogfile; + fff::FolderHistoryBox* m_logFolderPath; + wxButton* m_buttonSelectLogFolder; + wxBitmapButton* m_bpButtonSelectAltLogFolder; + wxCheckBox* m_checkBoxLogFilesMaxAge; + wxSpinCtrl* m_spinCtrlLogFilesMaxAge; + wxRadioButton* m_radioBtnLogHtml; + wxRadioButton* m_radioBtnLogText; + wxStaticBitmap* m_bitmapNotificationSounds; + wxStaticBitmap* m_bitmapCompareDone; + wxBitmapButton* m_bpButtonPlayCompareDone; + wxTextCtrl* m_textCtrlSoundPathCompareDone; + wxButton* m_buttonSelectSoundCompareDone; + wxStaticBitmap* m_bitmapSyncDone; + wxBitmapButton* m_bpButtonPlaySyncDone; + wxTextCtrl* m_textCtrlSoundPathSyncDone; + wxButton* m_buttonSelectSoundSyncDone; + wxStaticBitmap* m_bitmapAlertPending; + wxBitmapButton* m_bpButtonPlayAlertPending; + wxTextCtrl* m_textCtrlSoundPathAlertPending; + wxButton* m_buttonSelectSoundAlertPending; + wxStaticBitmap* m_bitmapConsole; + wxButton* m_buttonShowCtxCustomize; + wxBoxSizer* bSizerContextCustomize; + wxBitmapButton* m_bpButtonAddRow; + wxBitmapButton* m_bpButtonRemoveRow; + wxGrid* m_gridCustomCommand; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonDefault; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onChangeColorTheme( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowHiddenDialogs( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleHiddenDialog( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowLogFolder( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleLogfilesLimit( wxCommandEvent& event ) { event.Skip(); } + virtual void onPlayCompareDone( wxCommandEvent& event ) { event.Skip(); } + virtual void onChangeSoundFilePath( wxCommandEvent& event ) { event.Skip(); } + virtual void onSelectSoundCompareDone( wxCommandEvent& event ) { event.Skip(); } + virtual void onPlaySyncDone( wxCommandEvent& event ) { event.Skip(); } + virtual void onSelectSoundSyncDone( wxCommandEvent& event ) { event.Skip(); } + virtual void onPlayAlertPending( wxCommandEvent& event ) { event.Skip(); } + virtual void onSelectSoundAlertPending( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowContextCustomize( wxCommandEvent& event ) { event.Skip(); } + virtual void onAddRow( wxCommandEvent& event ) { event.Skip(); } + virtual void onRemoveRow( wxCommandEvent& event ) { event.Skip(); } + virtual void onDefault( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + OptionsDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Options"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~OptionsDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class SelectTimespanDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class SelectTimespanDlgGenerated : public wxDialog +{ +private: + +protected: + wxCalendarCtrl* m_calendarFrom; + wxCalendarCtrl* m_calendarTo; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onChangeSelectionFrom( wxCalendarEvent& event ) { event.Skip(); } + virtual void onChangeSelectionTo( wxCalendarEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + SelectTimespanDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Select Time Span"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~SelectTimespanDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class AboutDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class AboutDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapLogoLeft; + wxBoxSizer* bSizerMainSection; + wxStaticBitmap* m_bitmapLogo; + wxStaticText* m_staticFfsTextVersion; + wxStaticText* m_staticTextFfsVariant; + wxBoxSizer* bSizerDonate; + wxPanel* m_panelDonate; + wxStaticBitmap* m_bitmapAnimalSmall; + wxStaticText* m_staticTextDonate; + zen::BitmapTextButton* m_buttonDonate1; + wxStaticBitmap* m_bitmapAnimalBig; + wxBitmapButton* m_bpButtonForum; + wxBitmapButton* m_bpButtonEmail; + wxStaticText* m_staticTextThanksForLoc; + wxScrolledWindow* m_scrolledWindowTranslators; + wxFlexGridSizer* fgSizerTranslators; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonShowSupporterDetails; + zen::BitmapTextButton* m_buttonDonate2; + wxButton* m_buttonClose; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onDonate( wxCommandEvent& event ) { event.Skip(); } + virtual void onOpenForum( wxCommandEvent& event ) { event.Skip(); } + virtual void onSendEmail( wxCommandEvent& event ) { event.Skip(); } + virtual void onShowSupporterDetails( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + + +public: + + AboutDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("About"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~AboutDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class DownloadProgressDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class DownloadProgressDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapDownloading; + wxStaticText* m_staticTextHeader; + wxGauge* m_gaugeProgress; + wxStaticText* m_staticTextDetails; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + DownloadProgressDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = wxEmptyString, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = 0 ); + + ~DownloadProgressDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class CfgHighlightDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class CfgHighlightDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticText* m_staticTextHighlight; + wxSpinCtrl* m_spinCtrlOverdueDays; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + CfgHighlightDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Highlight Configurations"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~CfgHighlightDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class PasswordPromptDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class PasswordPromptDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticText* m_staticTextMain; + wxStaticText* m_staticTextPassword; + wxTextCtrl* m_textCtrlPasswordVisible; + wxTextCtrl* m_textCtrlPasswordHidden; + wxCheckBox* m_checkBoxShowPassword; + wxBoxSizer* bSizerError; + wxStaticBitmap* m_bitmapError; + wxStaticText* m_staticTextError; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonOK; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onTypingPassword( wxCommandEvent& event ) { event.Skip(); } + virtual void onToggleShowPassword( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + PasswordPromptDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("dummy"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~PasswordPromptDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class ActivationDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class ActivationDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapActivation; + wxRichTextCtrl* m_richTextLastError; + wxStaticText* m_staticTextMain; + wxStaticText* m_staticTextMain1; + wxButton* m_buttonActivateOnline; + wxButton* m_buttonCopyUrl; + wxRichTextCtrl* m_richTextManualActivationUrl; + wxTextCtrl* m_textCtrlOfflineActivationKey; + wxButton* m_buttonActivateOffline; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onActivateOnline( wxCommandEvent& event ) { event.Skip(); } + virtual void onCopyUrl( wxCommandEvent& event ) { event.Skip(); } + virtual void onOfflineActivationEnter( wxCommandEvent& event ) { event.Skip(); } + virtual void onActivateOffline( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + ActivationDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("dummy"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~ActivationDlgGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class WarnAccessRightsMissingDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class WarnAccessRightsMissingDlgGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapGrantAccess; + wxStaticText* m_staticTextDescr; + wxStaticText* m_staticTextStep1; + wxButton* m_buttonLocateBundle; + wxStaticText* m_staticTextStep2; + wxButton* m_buttonOpenSecurity; + wxStaticText* m_staticTextStep3; + wxStaticText* m_staticTextGrantAccess; + wxCheckBox* m_checkBoxDontShowAgain; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonClose; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onShowAppBundle( wxCommandEvent& event ) { event.Skip(); } + virtual void onOpenSecuritySettings( wxCommandEvent& event ) { event.Skip(); } + virtual void onCheckBoxClick( wxCommandEvent& event ) { event.Skip(); } + virtual void onOkay( wxCommandEvent& event ) { event.Skip(); } + + +public: + + WarnAccessRightsMissingDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("Grant Full Disk Access"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~WarnAccessRightsMissingDlgGenerated(); + +}; + diff --git a/FreeFileSync/Source/ui/gui_status_handler.cpp b/FreeFileSync/Source/ui/gui_status_handler.cpp new file mode 100644 index 0000000..f3a3df3 --- /dev/null +++ b/FreeFileSync/Source/ui/gui_status_handler.cpp @@ -0,0 +1,716 @@ +// ***************************************************************************** +// * 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 "gui_status_handler.h" +#include +#include +#include +#include + +using namespace zen; +using namespace fff; + +namespace +{ +constexpr std::chrono::seconds TEMP_PANEL_DISPLAY_DELAY(1); +} + +StatusHandlerTemporaryPanel::StatusHandlerTemporaryPanel(MainDialog& dlg, + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileAlertPending) : + mainDlg_(dlg), + ignoreErrors_(ignoreErrors), + autoRetryCount_(autoRetryCount), + autoRetryDelay_(autoRetryDelay), + soundFileAlertPending_(soundFileAlertPending), + startTime_(startTime) +{ + mainDlg_.compareStatus_->init(*this, ignoreErrors_, autoRetryCount_); //clear old values before showing panel + + //showStatsPanel(); => delay and avoid GUI distraction for short-lived tasks + + mainDlg_.Update(); //don't wait until idle event! + + //register keys + mainDlg_. Bind(wxEVT_CHAR_HOOK, &StatusHandlerTemporaryPanel::onLocalKeyEvent, this); + mainDlg_.m_buttonCancel->Bind(wxEVT_COMMAND_BUTTON_CLICKED, &StatusHandlerTemporaryPanel::onAbortCompare, this); +} + + +void StatusHandlerTemporaryPanel::showStatsPanel() +{ + assert(!mainDlg_.auiMgr_.GetPane(mainDlg_.compareStatus_->getAsWindow()).IsShown()); + { + //------------------------------------------------------------------ + const wxAuiPaneInfo& topPanel = mainDlg_.auiMgr_.GetPane(mainDlg_.m_panelTopButtons); + wxAuiPaneInfo& statusPanel = mainDlg_.auiMgr_.GetPane(mainDlg_.compareStatus_->getAsWindow()); + + //determine best status panel row near top panel + switch (topPanel.dock_direction) + { + case wxAUI_DOCK_TOP: + case wxAUI_DOCK_BOTTOM: + statusPanel.Layer (topPanel.dock_layer); + statusPanel.Direction(topPanel.dock_direction); + statusPanel.Row (topPanel.dock_row + 1); + break; + + case wxAUI_DOCK_LEFT: + case wxAUI_DOCK_RIGHT: + statusPanel.Layer (std::max(0, topPanel.dock_layer - 1)); + statusPanel.Direction(wxAUI_DOCK_TOP); + statusPanel.Row (0); + break; + //case wxAUI_DOCK_CENTRE: + } + + const bool statusRowTaken = [&] + { + for (wxAuiPaneInfo& paneInfo : mainDlg_.auiMgr_.GetAllPanes()) + //doesn't matter if paneInfo.IsShown() or not! => move down in either case! + if (&paneInfo != &statusPanel && + paneInfo.dock_layer == statusPanel.dock_layer && + paneInfo.dock_direction == statusPanel.dock_direction && + paneInfo.dock_row == statusPanel.dock_row) + return true; + + return false; + }(); + + //move all rows that are in the way one step further + if (statusRowTaken) + for (wxAuiPaneInfo& paneInfo : mainDlg_.auiMgr_.GetAllPanes()) + if (&paneInfo != &statusPanel && + paneInfo.dock_layer == statusPanel.dock_layer && + paneInfo.dock_direction == statusPanel.dock_direction && + paneInfo.dock_row >= statusPanel.dock_row) + ++paneInfo.dock_row; + //------------------------------------------------------------------ + + statusPanel.Show(); + mainDlg_.auiMgr_.Update(); + mainDlg_.compareStatus_->getAsWindow()->Refresh(); //macOS: fix background corruption for the statistics boxes (call *after* wxAuiManager::Update() + } +} + + +StatusHandlerTemporaryPanel::~StatusHandlerTemporaryPanel() +{ + if (!errorLog_.empty()) //prepareResult() was not called! + std::abort(); + + //Workaround wxAuiManager crash when starting panel resizing during comparison and holding button until after comparison has finished: + //- unlike regular window resizing, wxAuiManager does not run a dedicated event loop while the mouse button is held + //- wxAuiManager internally stores the panel index that is currently resized + //- our hiding of the compare status panel invalidates this index + // => the next mouse move will have wxAuiManager crash => another fine piece of "wxQuality" code + // => mitigate: + wxMouseCaptureLostEvent dummy; + mainDlg_.ProcessEvent(dummy); //trigger wxAuiManager::OnCaptureLost(); should be no-op if no mouse buttons are pressed + if (wxWindow::GetCapture() == &mainDlg_) + mainDlg_.ReleaseMouse(); + + mainDlg_.auiMgr_.GetPane(mainDlg_.compareStatus_->getAsWindow()).Hide(); + mainDlg_.auiMgr_.Update(); + + //unregister keys + [[maybe_unused]] bool ubOk1 = mainDlg_. Unbind(wxEVT_CHAR_HOOK, &StatusHandlerTemporaryPanel::onLocalKeyEvent, this); + [[maybe_unused]] bool ubOk2 = mainDlg_.m_buttonCancel->Unbind(wxEVT_COMMAND_BUTTON_CLICKED, &StatusHandlerTemporaryPanel::onAbortCompare, this); + assert(ubOk1 && ubOk2); + + mainDlg_.compareStatus_->teardown(); +} + + +StatusHandlerTemporaryPanel::Result StatusHandlerTemporaryPanel::prepareResult() //noexcept!! +{ + const std::chrono::milliseconds totalTime = mainDlg_.compareStatus_->pauseAndGetTotalTime(); + + //append "extra" log for sync errors that could not otherwise be reported: + if (const ErrorLog extraLog = fetchExtraLog(); + !extraLog.empty()) + { + append(errorLog_, extraLog); + std::stable_sort(errorLog_.begin(), errorLog_.end(), [](const LogEntry& lhs, const LogEntry& rhs) { return lhs.time < rhs.time; }); + } + + //determine post-sync status irrespective of further errors during tear-down + const TaskResult syncResult = [&] + { + if (taskCancelled()) + { + logMsg(errorLog_, _("Stopped"), MSG_TYPE_ERROR); //= user cancel + return TaskResult::cancelled; + } + + const ErrorLogStats logCount = getStats(errorLog_); + if (logCount.errors > 0) + return TaskResult::error; + else if (logCount.warnings > 0) + return TaskResult::warning; + else + return TaskResult::success; + }(); + + const ProcessSummary summary + { + startTime_, syncResult, {} /*jobNames*/, + getCurrentStats(), + getTotalStats (), + totalTime + }; + + return {summary, makeSharedRef(std::exchange(errorLog_, {}))}; //see check in ~StatusHandlerTemporaryPanel() +} + + +void StatusHandlerTemporaryPanel::initNewPhase(int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) +{ + StatusHandler::initNewPhase(itemsTotal, bytesTotal, phaseID); + + mainDlg_.compareStatus_->initNewPhase(); //call after "StatusHandler::initNewPhase" + + //macOS needs a full yield to update GUI and get rid of "dummy" texts + requestUiUpdate(true /*force*/); //throw CancelProcess +} + + +void StatusHandlerTemporaryPanel::logMessage(const std::wstring& msg, MsgType type) +{ + logMsg(errorLog_, msg, [&] + { + switch (type) + { + case MsgType::info: return MSG_TYPE_INFO; + case MsgType::warning: return MSG_TYPE_WARNING; + case MsgType::error: return MSG_TYPE_ERROR; + } + assert(false); + return MSG_TYPE_ERROR; + }()); + requestUiUpdate(false /*force*/); //throw CancelProcess +} + + +void StatusHandlerTemporaryPanel::reportWarning(const std::wstring& msg, bool& warningActive) +{ + PauseTimers dummy(*mainDlg_.compareStatus_); + + logMsg(errorLog_, msg, MSG_TYPE_WARNING); + + if (!warningActive) //if errors are ignored, then warnings should also + return; + + if (!mainDlg_.compareStatus_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + bool dontWarnAgain = false; + switch (showConfirmationDialog(&mainDlg_, DialogInfoType::warning, + PopupDialogCfg().setDetailInstructions(msg). + alertWhenPending(soundFileAlertPending_). + setCheckBox(dontWarnAgain, _("&Don't show this warning again")), + _("&Ignore"))) + { + case ConfirmationButton::accept: + warningActive = !dontWarnAgain; + break; + case ConfirmationButton::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + //else: if errors are ignored, then warnings should also +} + + +ProcessCallback::Response StatusHandlerTemporaryPanel::reportError(const ErrorInfo& errorInfo) +{ + PauseTimers dummy(*mainDlg_.compareStatus_); + + //log actual fail time (not "now"!) + const time_t failTime = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - + std::chrono::duration_cast(std::chrono::steady_clock::now() - errorInfo.failTime)); + //auto-retry + if (errorInfo.retryNumber < autoRetryCount_) + { + logMsg(errorLog_, errorInfo.msg + L"\n-> " + _("Automatic retry"), MSG_TYPE_INFO, failTime); + delayAndCountDown(errorInfo.failTime + autoRetryDelay_ - std::chrono::steady_clock::now(), + [&, statusPrefix = _("Automatic retry") + + (errorInfo.retryNumber == 0 ? L"" : L' ' + formatNumber(errorInfo.retryNumber + 1)) + SPACED_DASH, + statusPostfix = SPACED_DASH + _("Error") + L": " + replaceCpy(errorInfo.msg, L'\n', L' ')](const std::wstring& timeRemMsg) + { this->updateStatus(statusPrefix + timeRemMsg + statusPostfix); }); //throw CancelProcess + return ProcessCallback::retry; + } + + //always, except for "retry": + auto guardWriteLog = makeGuard([&] { logMsg(errorLog_, errorInfo.msg, MSG_TYPE_ERROR, failTime); }); + + if (!mainDlg_.compareStatus_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(&mainDlg_, DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(errorInfo.msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"), _("&Retry"))) + { + case ConfirmationButton3::accept: //ignore + return ProcessCallback::ignore; + + case ConfirmationButton3::accept2: //ignore all + mainDlg_.compareStatus_->setOptionIgnoreErrors(true); + return ProcessCallback::ignore; + + case ConfirmationButton3::decline: //retry + guardWriteLog.dismiss(); + logMsg(errorLog_, errorInfo.msg + L"\n-> " + _("Retrying operation..."), //explain why there are duplicate "doing operation X" info messages in the log! + MSG_TYPE_INFO, failTime); + return ProcessCallback::retry; + + case ConfirmationButton3::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + else + return ProcessCallback::ignore; + + assert(false); + return ProcessCallback::ignore; //dummy return value +} + + +void StatusHandlerTemporaryPanel::reportFatalError(const std::wstring& msg) +{ + PauseTimers dummy(*mainDlg_.compareStatus_); + + logMsg(errorLog_, msg, MSG_TYPE_ERROR); + + if (!mainDlg_.compareStatus_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(&mainDlg_, DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"))) + { + case ConfirmationButton2::accept: //ignore + break; + + case ConfirmationButton2::accept2: //ignore all + mainDlg_.compareStatus_->setOptionIgnoreErrors(true); + break; + + case ConfirmationButton2::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } +} + + +Statistics::ErrorStats StatusHandlerTemporaryPanel::getErrorStats() const +{ + //errorLog_ is an "append only" structure, so we can make getErrorStats() complexity "constant time": + std::for_each(errorLog_.begin() + errorStatsRowsChecked_, errorLog_.end(), [&](const LogEntry& entry) + { + switch (entry.type) + { + case MSG_TYPE_INFO: + break; + case MSG_TYPE_WARNING: + ++errorStatsBuf_.warningCount; + break; + case MSG_TYPE_ERROR: + ++errorStatsBuf_.errorCount; + break; + } + }); + errorStatsRowsChecked_ = errorLog_.size(); + + return errorStatsBuf_; +} + + +void StatusHandlerTemporaryPanel::forceUiUpdateNoThrow() +{ + if (!mainDlg_.auiMgr_.GetPane(mainDlg_.compareStatus_->getAsWindow()).IsShown() && + std::chrono::steady_clock::now() > panelInitTime_ + TEMP_PANEL_DISPLAY_DELAY) + showStatsPanel(); + + mainDlg_.compareStatus_->updateGui(); +} + + +void StatusHandlerTemporaryPanel::onLocalKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + if (keyCode == WXK_ESCAPE) + return userRequestCancel(); + + event.Skip(); +} + + +void StatusHandlerTemporaryPanel::onAbortCompare(wxCommandEvent& event) +{ + userRequestCancel(); +} + +//######################################################################################################## + +StatusHandlerFloatingDialog::StatusHandlerFloatingDialog(wxFrame* parentDlg, + const std::vector& jobNames, + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileSyncComplete, + const Zstring& soundFileAlertPending, + const WindowLayout::Dimensions& dim, + bool autoCloseDialog) : + jobNames_(jobNames), + startTime_(startTime), + autoRetryCount_(autoRetryCount), + autoRetryDelay_(autoRetryDelay), + soundFileSyncComplete_(soundFileSyncComplete), + soundFileAlertPending_(soundFileAlertPending) +{ + //set *after* initializer list => callbacks during construction to getErrorStats()! + progressDlg_ = SyncProgressDialog::create(dim, [this] { userRequestCancel(); }, *this, parentDlg, true /*showProgress*/, autoCloseDialog, + jobNames, std::chrono::system_clock::to_time_t(startTime), ignoreErrors, autoRetryCount, PostSyncAction::none); +} + + +StatusHandlerFloatingDialog::~StatusHandlerFloatingDialog() +{ + if (progressDlg_) //prepareResult() was not called! + std::abort(); +} + + +StatusHandlerFloatingDialog::Result StatusHandlerFloatingDialog::prepareResult() +{ + //keep correct summary window stats considering count down timer, system sleep + const std::chrono::milliseconds totalTime = progressDlg_->pauseAndGetTotalTime(); + + //append "extra" log for sync errors that could not otherwise be reported: + if (const ErrorLog extraLog = fetchExtraLog(); + !extraLog.empty()) + { + append(errorLog_.ref(), extraLog); + std::stable_sort(errorLog_.ref().begin(), errorLog_.ref().end(), [](const LogEntry& lhs, const LogEntry& rhs) { return lhs.time < rhs.time; }); + } + + //determine post-sync status irrespective of further errors during tear-down + assert(!syncResult_); + syncResult_ = [&] + { + if (taskCancelled()) //= user cancel + { + assert(*taskCancelled() == CancelReason::user); //"stop on first error" is ffs_batch-only + logMsg(errorLog_.ref(), _("Stopped"), MSG_TYPE_ERROR); + return TaskResult::cancelled; + } + + const ErrorLogStats logCount = getStats(errorLog_.ref()); + if (logCount.errors > 0) + return TaskResult::error; + else if (logCount.warnings > 0) + return TaskResult::warning; + + if (getTotalStats() == ProgressStats()) + logMsg(errorLog_.ref(), _("Nothing to synchronize"), MSG_TYPE_INFO); + return TaskResult::success; + }(); + + assert(*syncResult_ == TaskResult::cancelled || currentPhase() == ProcessPhase::sync); + + const ProcessSummary summary + { + startTime_, *syncResult_, jobNames_, + getCurrentStats(), + getTotalStats (), + totalTime + }; + + return {summary, errorLog_}; +} + + +StatusHandlerFloatingDialog::DlgOptions StatusHandlerFloatingDialog::showResult() +{ + bool autoClose = false; + bool suspend = false; + FinalRequest finalRequest = FinalRequest::none; + + if (taskCancelled()) + assert(*taskCancelled() == CancelReason::user); //"stop on first error" is only for ffs_batch + else + { + //--------------------- post sync actions ---------------------- + //give user chance to cancel shutdown; do *not* consider the sync itself cancelled + auto proceedWithShutdown = [&](const std::wstring& operationName) + { + if (progressDlg_->getWindowIfVisible()) + try + { + assert(!endsWith(operationName, L".")); + auto notifyStatus = [&](const std::wstring& timeRemMsg) { updateStatus(operationName + L"... " + timeRemMsg); /*throw CancelProcess*/ }; + + delayAndCountDown(std::chrono::seconds(10), notifyStatus); //throw CancelProcess + } + catch (CancelProcess&) { return false; } + + return true; + }; + + switch (progressDlg_->getAndFreezePostSyncAction()) + { + case PostSyncAction::none: + autoClose = progressDlg_->getOptionAutoCloseDialog(); + break; + case PostSyncAction::exit: + autoClose = true; + finalRequest = FinalRequest::exit; //program exit must be handled by calling context! + break; + case PostSyncAction::sleep: + if (proceedWithShutdown(_("System: Sleep"))) + { + autoClose = progressDlg_->getOptionAutoCloseDialog(); + suspend = true; + } + break; + case PostSyncAction::shutdown: + if (proceedWithShutdown(_("System: Shut down"))) + { + autoClose = true; + finalRequest = FinalRequest::shutdown; //system shutdown must be handled by calling context! + } + break; + } + } + + if (suspend) //*before* showing results dialog + try + { + suspendSystem(); //throw FileError + } + catch (const FileError& e) { logMsg(errorLog_.ref(), e.toString(), MSG_TYPE_ERROR); } + + //--------------------- sound notification ---------------------- + if (!taskCancelled() && !suspend && !autoClose && //only play when actually showing results dialog + !soundFileSyncComplete_.empty()) + { + //wxWidgets shows modal error dialog by default => "no, wxWidgets, NO!" + wxLog* oldLogTarget = wxLog::SetActiveTarget(new wxLogStderr); //transfer and receive ownership! + ZEN_ON_SCOPE_EXIT(delete wxLog::SetActiveTarget(oldLogTarget)); + + wxSound::Play(utfTo(soundFileSyncComplete_), wxSOUND_ASYNC); + } + //if (::GetForegroundWindow() != GetHWND()) + // RequestUserAttention(); -> probably too much since task bar is already colorized with Taskbar::Status::error or Status::normal + + const auto [autoCloseSelected, dim] = progressDlg_->destroy(autoClose, + finalRequest == FinalRequest::none /*restoreParentFrame*/, + *syncResult_, errorLog_); + //caveat: calls back to getErrorStats() => *share* (and not move) errorLog_ + progressDlg_ = nullptr; + + return {autoCloseSelected, dim, finalRequest}; +} + + +void StatusHandlerFloatingDialog::initNewPhase(int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) +{ + assert(phaseID == ProcessPhase::sync); + StatusHandler::initNewPhase(itemsTotal, bytesTotal, phaseID); + progressDlg_->initNewPhase(); //call after "StatusHandler::initNewPhase" + + //macOS needs a full yield to update GUI and get rid of "dummy" texts + requestUiUpdate(true /*force*/); //throw CancelProcess +} + + + +void StatusHandlerFloatingDialog::logMessage(const std::wstring& msg, MsgType type) +{ + logMsg(errorLog_.ref(), msg, [&] + { + switch (type) + { + case MsgType::info: return MSG_TYPE_INFO; + case MsgType::warning: return MSG_TYPE_WARNING; + case MsgType::error: return MSG_TYPE_ERROR; + } + assert(false); + return MSG_TYPE_ERROR; + }()); + requestUiUpdate(false /*force*/); //throw CancelProcess +} + + +void StatusHandlerFloatingDialog::reportWarning(const std::wstring& msg, bool& warningActive) +{ + PauseTimers dummy(*progressDlg_); + + logMsg(errorLog_.ref(), msg, MSG_TYPE_WARNING); + + if (!warningActive) + return; + + if (!progressDlg_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + bool dontWarnAgain = false; + switch (showConfirmationDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::warning, + PopupDialogCfg().setDetailInstructions(msg). + alertWhenPending(soundFileAlertPending_). + setCheckBox(dontWarnAgain, _("&Don't show this warning again")), + _("&Ignore"))) + { + case ConfirmationButton::accept: + warningActive = !dontWarnAgain; + break; + case ConfirmationButton::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + //else: if errors are ignored, then warnings should be, too +} + + +ProcessCallback::Response StatusHandlerFloatingDialog::reportError(const ErrorInfo& errorInfo) +{ + PauseTimers dummy(*progressDlg_); + + //log actual fail time (not "now"!) + const time_t failTime = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - + std::chrono::duration_cast(std::chrono::steady_clock::now() - errorInfo.failTime)); + //auto-retry + if (errorInfo.retryNumber < autoRetryCount_) + { + logMsg(errorLog_.ref(), errorInfo.msg + L"\n-> " + _("Automatic retry"), MSG_TYPE_INFO, failTime); + delayAndCountDown(errorInfo.failTime + autoRetryDelay_ - std::chrono::steady_clock::now(), + [&, statusPrefix = _("Automatic retry") + + (errorInfo.retryNumber == 0 ? L"" : L' ' + formatNumber(errorInfo.retryNumber + 1)) + SPACED_DASH, + statusPostfix = SPACED_DASH + _("Error") + L": " + replaceCpy(errorInfo.msg, L'\n', L' ')](const std::wstring& timeRemMsg) + { this->updateStatus(statusPrefix + timeRemMsg + statusPostfix); }); //throw CancelProcess + return ProcessCallback::retry; + } + + //always, except for "retry": + auto guardWriteLog = makeGuard([&] { logMsg(errorLog_.ref(), errorInfo.msg, MSG_TYPE_ERROR, failTime); }); + + if (!progressDlg_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(errorInfo.msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"), _("&Retry"))) + { + case ConfirmationButton3::accept: //ignore + return ProcessCallback::ignore; + + case ConfirmationButton3::accept2: //ignore all + progressDlg_->setOptionIgnoreErrors(true); + return ProcessCallback::ignore; + + case ConfirmationButton3::decline: //retry + guardWriteLog.dismiss(); + logMsg(errorLog_.ref(), errorInfo.msg + L"\n-> " + _("Retrying operation..."), //explain why there are duplicate "doing operation X" info messages in the log! + MSG_TYPE_INFO, failTime); + return ProcessCallback::retry; + + case ConfirmationButton3::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } + else + return ProcessCallback::ignore; + + assert(false); + return ProcessCallback::ignore; //dummy value +} + + +void StatusHandlerFloatingDialog::reportFatalError(const std::wstring& msg) +{ + PauseTimers dummy(*progressDlg_); + + logMsg(errorLog_.ref(), msg, MSG_TYPE_ERROR); + + if (!progressDlg_->getOptionIgnoreErrors()) + { + forceUiUpdateNoThrow(); //noexcept! => don't throw here when error occurs during clean up! + + switch (showConfirmationDialog(progressDlg_->getWindowIfVisible(), DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(msg). + alertWhenPending(soundFileAlertPending_), + _("&Ignore"), _("Ignore &all"))) + { + case ConfirmationButton2::accept: //ignore + break; + + case ConfirmationButton2::accept2: //ignore all + progressDlg_->setOptionIgnoreErrors(true); + break; + + case ConfirmationButton2::cancel: + cancelProcessNow(CancelReason::user); //throw CancelProcess + break; + } + } +} + + +Statistics::ErrorStats StatusHandlerFloatingDialog::getErrorStats() const +{ + //errorLog_ is an "append only" structure, so we can make getErrorStats() complexity "constant time": + std::for_each(errorLog_.ref().begin() + errorStatsRowsChecked_, errorLog_.ref().end(), [&](const LogEntry& entry) + { + switch (entry.type) + { + case MSG_TYPE_INFO: + break; + case MSG_TYPE_WARNING: + ++errorStatsBuf_.warningCount; + break; + case MSG_TYPE_ERROR: + ++errorStatsBuf_.errorCount; + break; + } + }); + errorStatsRowsChecked_ = errorLog_.ref().size(); + + return errorStatsBuf_; +} + + +void StatusHandlerFloatingDialog::updateDataProcessed(int itemsDelta, int64_t bytesDelta) //noexcept! +{ + StatusHandler::updateDataProcessed(itemsDelta, bytesDelta); + + //note: this method should NOT throw in order to properly allow undoing setting of statistics! + progressDlg_->notifyProgressChange(); //noexcept + //for "curveDataBytes_->addRecord()" +} + + +void StatusHandlerFloatingDialog::forceUiUpdateNoThrow() +{ + progressDlg_->updateGui(); +} diff --git a/FreeFileSync/Source/ui/gui_status_handler.h b/FreeFileSync/Source/ui/gui_status_handler.h new file mode 100644 index 0000000..252bd1c --- /dev/null +++ b/FreeFileSync/Source/ui/gui_status_handler.h @@ -0,0 +1,129 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GUI_STATUS_HANDLER_H_0183247018545 +#define GUI_STATUS_HANDLER_H_0183247018545 + +#include +#include +#include "progress_indicator.h" +#include "main_dlg.h" +#include "../status_handler.h" + + +namespace fff +{ +//classes handling sync and compare errors as well as status feedback + +//internally pumps window messages => disable GUI controls to avoid unexpected callbacks! +class StatusHandlerTemporaryPanel : private wxEvtHandler, public StatusHandler +{ +public: + StatusHandlerTemporaryPanel(MainDialog& dlg, + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileAlertPending); + ~StatusHandlerTemporaryPanel(); + + void initNewPhase (int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) override; // + void logMessage (const std::wstring& msg, MsgType type) override; // + void reportWarning (const std::wstring& msg, bool& warningActive) override; //throw CancelProcess + Response reportError (const ErrorInfo& errorInfo) override; // + void reportFatalError(const std::wstring& msg) override; // + ErrorStats getErrorStats() const override; + + void forceUiUpdateNoThrow() override; + + struct Result + { + ProcessSummary summary; + zen::SharedRef errorLog; + }; + Result prepareResult(); //noexcept!! + +private: + void onLocalKeyEvent(wxKeyEvent& event); + void onAbortCompare(wxCommandEvent& event); //handle abort button click + void showStatsPanel(); + + MainDialog& mainDlg_; + zen::ErrorLog errorLog_; + mutable Statistics::ErrorStats errorStatsBuf_{}; + mutable size_t errorStatsRowsChecked_ = 0; + const bool ignoreErrors_; + const size_t autoRetryCount_; + const std::chrono::seconds autoRetryDelay_; + const Zstring soundFileAlertPending_; + const std::chrono::system_clock::time_point startTime_; + const std::chrono::steady_clock::time_point panelInitTime_ = std::chrono::steady_clock::now(); +}; + + +//StatusHandlerFloatingDialog(SyncProgressDialog) will internally process Window messages! disable GUI controls to avoid unexpected callbacks! +class StatusHandlerFloatingDialog : public StatusHandler +{ +public: + StatusHandlerFloatingDialog(wxFrame* parentDlg, + const std::vector& jobNames, + const std::chrono::system_clock::time_point& startTime, + bool ignoreErrors, + size_t autoRetryCount, + std::chrono::seconds autoRetryDelay, + const Zstring& soundFileSyncComplete, + const Zstring& soundFileAlertPending, + const zen::WindowLayout::Dimensions& dim, + bool autoCloseDialog); //noexcept! + ~StatusHandlerFloatingDialog(); + + void initNewPhase (int itemsTotal, int64_t bytesTotal, ProcessPhase phaseID) override; // + void logMessage (const std::wstring& msg, MsgType type) override; // + void reportWarning (const std::wstring& msg, bool& warningActive) override; //throw CancelProcess + Response reportError (const ErrorInfo& errorInfo) override; // + void reportFatalError(const std::wstring& msg) override; // + ErrorStats getErrorStats() const override; + + void updateDataProcessed(int itemsDelta, int64_t bytesDelta) override; //noexcept!! + void forceUiUpdateNoThrow() override; // + + struct Result + { + ProcessSummary summary; + zen::SharedRef errorLog; + }; + Result prepareResult(); + + enum class FinalRequest + { + none, + exit, + shutdown + }; + struct DlgOptions + { + bool autoCloseSelected; + zen::WindowLayout::Dimensions dim; + FinalRequest finalRequest; + }; + DlgOptions showResult(); + +private: + const std::vector jobNames_; + const std::chrono::system_clock::time_point startTime_; + const size_t autoRetryCount_; + const std::chrono::seconds autoRetryDelay_; + const Zstring soundFileSyncComplete_; + const Zstring soundFileAlertPending_; + SyncProgressDialog* progressDlg_; //managed to have the same lifetime as this handler! + zen::SharedRef errorLog_ = zen::makeSharedRef(); + mutable Statistics::ErrorStats errorStatsBuf_{}; + mutable size_t errorStatsRowsChecked_ = 0; + std::optional syncResult_; +}; +} + +#endif //GUI_STATUS_HANDLER_H_0183247018545 diff --git a/FreeFileSync/Source/ui/log_panel.cpp b/FreeFileSync/Source/ui/log_panel.cpp new file mode 100644 index 0000000..3a0a456 --- /dev/null +++ b/FreeFileSync/Source/ui/log_panel.cpp @@ -0,0 +1,545 @@ +// ***************************************************************************** +// * 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_panel.h" +#include +#include +#include +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +inline wxColor getColorGridLine() { return {192, 192, 192}; } //light grey + + +inline +wxImage getImageButtonPressed(const char* imageName) +{ + return layOver(loadImage("msg_button_pressed"), + loadImage(imageName)); +} + + +inline +wxImage getImageButtonReleased(const char* imageName) +{ + return greyScale(loadImage(imageName)); + //loadImage(utfTo(imageName)).ConvertToGreyscale(1.0/3, 1.0/3, 1.0/3); //treat all channels equally! + //brighten(output, 30); +} + + +enum class ColumnTypeLog +{ + time, + severity, + text, +}; +} + + +//a vector-view on ErrorLog considering multi-line messages: prepare consumption by Grid +class fff::MessageView +{ +public: + explicit MessageView(const SharedRef& log) : log_(log) {} + + size_t rowsOnView() const { return viewRef_.size(); } + + struct LogEntryView + { + time_t time = 0; + MessageType type = MSG_TYPE_INFO; + std::string_view messageLine; + bool firstLine = false; //if LogEntry::message spans multiple rows + }; + + std::optional getEntry(size_t row) const + { + if (row < viewRef_.size()) + { + const Line& line = viewRef_[row]; + + LogEntryView output; + output.time = line.logIt->time; + output.type = line.logIt->type; + output.messageLine = extractLine(line.logIt->message, line.row); + output.firstLine = line.row == 0; //this is virtually always correct, unless first line of the original message is empty! + return output; + } + return {}; + } + + void updateView(int includedTypes) //MSG_TYPE_INFO | MSG_TYPE_WARNING, etc. see error_log.h + { + viewRef_.clear(); + + for (auto it = log_.ref().begin(); it != log_.ref().end(); ++it) + if (it->type & includedTypes) + { + assert(!startsWith(it->message, '\n')); + + size_t rowNumber = 0; + bool lastCharNewline = true; + for (const char c : it->message) + if (c == '\n') + { + if (!lastCharNewline) //do not reference empty lines! + viewRef_.push_back({it, rowNumber}); + ++rowNumber; + lastCharNewline = true; + } + else + lastCharNewline = false; + + if (!lastCharNewline) + viewRef_.push_back({it, rowNumber}); + } + } + +private: + static std::string_view extractLine(const Zstringc& message, size_t textRow) + { + auto it1 = message.begin(); + for (;;) + { + auto it2 = std::find_if(it1, message.end(), [](const char c) { return c == '\n'; }); + if (textRow == 0) + return makeStringView(it1, it2 - it1); + + if (it2 == message.end()) + { + assert(false); + return makeStringView(it1, 0); + } + + it1 = it2 + 1; //skip newline + --textRow; + } + } + + struct Line + { + ErrorLog::const_iterator logIt; //always bound! + size_t row; //LogEntry::message may span multiple rows + }; + + std::vector viewRef_; //partial view on log_ + /* /|\ + | updateView() + | */ + const SharedRef log_; +}; + +//----------------------------------------------------------------------------- +namespace +{ +//Grid data implementation referencing MessageView +class GridDataMessages : public GridData +{ +public: + explicit GridDataMessages(const SharedRef& log) : msgView_(log) {} + + MessageView& getDataView() { return msgView_; } + + size_t getRowCount() const override { return msgView_.rowsOnView(); } + + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (const std::optional entry = msgView_.getEntry(row)) + switch (static_cast(colType)) + { + case ColumnTypeLog::time: + if (entry->firstLine) + return utfTo(formatTime(formatTimeTag, getLocalTime(entry->time))); //empty string on error + break; + + case ColumnTypeLog::severity: + if (entry->firstLine) + switch (entry->type) + { + case MSG_TYPE_INFO: + return _("Info"); + case MSG_TYPE_WARNING: + return _("Warning"); + case MSG_TYPE_ERROR: + return _("Error"); + } + break; + + case ColumnTypeLog::text: + return utfTo(entry->messageLine); + } + return std::wstring(); + } + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + if (!enabled || !selected) + ; //clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default + else + GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); + + //-------------- draw item separation line ----------------- + const bool drawBottomLine = [&] //don't separate multi-line messages + { + if (std::optional nextEntry = msgView_.getEntry(row + 1)) + return nextEntry->firstLine; + return true; + }(); + + if (drawBottomLine) + clearArea(dc, {rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)}, getColorGridLine()); + //-------------------------------------------------------- + } + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + wxDCTextColourChanger textColor(dc); + if (enabled && selected) //accessibility: always set *both* foreground AND background colors! + textColor.Set(*wxBLACK); + + wxRect rectTmp = rect; + + if (std::optional entry = msgView_.getEntry(row)) + switch (static_cast(colType)) + { + case ColumnTypeLog::time: + drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_CENTER); + break; + + case ColumnTypeLog::severity: + if (entry->firstLine) + { + wxImage msgTypeIcon = [&] + { + switch (entry->type) + { + case MSG_TYPE_INFO: + return loadImage("msg_info", dipToScreen(getMenuIconDipSize())); + case MSG_TYPE_WARNING: + return loadImage("msg_warning", dipToScreen(getMenuIconDipSize())); + case MSG_TYPE_ERROR: + return loadImage("msg_error", dipToScreen(getMenuIconDipSize())); + } + assert(false); + return wxNullImage; + }(); + drawBitmapRtlNoMirror(dc, enabled ? msgTypeIcon : msgTypeIcon.ConvertToDisabled(), rectTmp, wxALIGN_CENTER); + } + break; + + case ColumnTypeLog::text: + rectTmp.x += getColumnGapLeft(); + rectTmp.width -= getColumnGapLeft(); + drawCellText(dc, rectTmp, getValue(row, colType)); + break; + } + } + + int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override + { + // -> synchronize renderCell() <-> getBestSize() + + if (msgView_.getEntry(row)) + switch (static_cast(colType)) + { + case ColumnTypeLog::time: + return 2 * getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth(); + + case ColumnTypeLog::severity: + return dipToWxsize(getMenuIconDipSize()); + + case ColumnTypeLog::text: + return getColumnGapLeft() + dc.GetTextExtent(getValue(row, colType)).GetWidth(); + } + return 0; + } + + static int getColumnTimeDefaultWidth(Grid& grid) + { + wxInfoDC dc(&grid.getMainWin()); + dc.SetFont(grid.getMainWin().GetFont()); + return 2 * getColumnGapLeft() + dc.GetTextExtent(utfTo(formatTime(formatTimeTag))).GetWidth(); + } + + static int getColumnSeverityDefaultWidth() + { + return dipToWxsize(getMenuIconDipSize()); + } + + static int getRowDefaultHeight(const Grid& grid) + { + return std::max(dipToWxsize(getMenuIconDipSize()), grid.getMainWin().GetCharHeight() + dipToWxsize(2) /*extra space*/) + dipToWxsize(1) /*bottom border*/; + } + + std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override + { + switch (static_cast(colType)) + { + case ColumnTypeLog::time: + case ColumnTypeLog::text: + break; + + case ColumnTypeLog::severity: + return getValue(row, colType); + } + return std::wstring(); + } + + std::wstring getColumnLabel(ColumnType colType) const override { return std::wstring(); } + +private: + MessageView msgView_; +}; +} + +//######################################################################################## + +LogPanel::LogPanel(wxWindow* parent) : LogPanelGenerated(parent) +{ + const int rowHeight = GridDataMessages::getRowDefaultHeight(*m_gridMessages); + const int colMsgTimeWidth = GridDataMessages::getColumnTimeDefaultWidth(*m_gridMessages); + const int colMsgSeverityWidth = GridDataMessages::getColumnSeverityDefaultWidth(); + + m_gridMessages->setColumnLabelHeight(0); + m_gridMessages->showRowLabel(false); + m_gridMessages->setRowHeight(rowHeight); + m_gridMessages->setColumnConfig( + { + {static_cast(ColumnTypeLog::time ), colMsgTimeWidth, 0, true}, + {static_cast(ColumnTypeLog::severity), colMsgSeverityWidth, 0, true}, + {static_cast(ColumnTypeLog::text ), -colMsgTimeWidth - colMsgSeverityWidth, 1, true}, + }); + + //support for CTRL + C + m_gridMessages->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onGridKeyEvent(event); }); + + m_gridMessages->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onMsgGridContext(event); }); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + setLog(nullptr); +} + + +void LogPanel::setLog(const std::shared_ptr& log) +{ + SharedRef newLog = [&] + { + if (log) + return SharedRef(log); + + ErrorLog dummyLog; + logMsg(dummyLog, _("No log entries"), MSG_TYPE_INFO); + return makeSharedRef(std::move(dummyLog)); + }(); + + const ErrorLogStats logCount = getStats(newLog.ref()); + + auto initButton = [](ToggleButton& btn, const char* imgName, const wxString& tooltip) + { + btn.init(getImageButtonPressed(imgName), getImageButtonReleased(imgName)); + btn.SetToolTip(tooltip); + }; + + initButton(*m_bpButtonErrors, "msg_error", _("Error" ) + L" (" + formatNumber(logCount.errors) + L')'); + initButton(*m_bpButtonWarnings, "msg_warning", _("Warning") + L" (" + formatNumber(logCount.warnings) + L')'); + initButton(*m_bpButtonInfo, "msg_info", _("Info" ) + L" (" + formatNumber(logCount.infos) + L')'); + + m_bpButtonErrors ->setActive(true); + m_bpButtonWarnings->setActive(true); + m_bpButtonInfo ->setActive(logCount.warnings + logCount.errors == 0); + + m_bpButtonErrors ->Show(logCount.errors != 0); + m_bpButtonWarnings->Show(logCount.warnings != 0); + m_bpButtonInfo ->Show(logCount.infos != 0); + + m_gridMessages->setDataProvider(std::make_shared(newLog)); + + updateGrid(); +} + + +MessageView& LogPanel::getDataView() +{ + if (auto* prov = dynamic_cast(m_gridMessages->getDataProvider())) + return prov->getDataView(); + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] m_gridMessages was not initialized."); +} + + +void LogPanel::updateGrid() +{ + int includedTypes = 0; + if (m_bpButtonErrors->isActive()) + includedTypes |= MSG_TYPE_ERROR; + + if (m_bpButtonWarnings->isActive()) + includedTypes |= MSG_TYPE_WARNING; + + if (m_bpButtonInfo->isActive()) + includedTypes |= MSG_TYPE_INFO; + + getDataView().updateView(includedTypes); //update MVC "model" + m_gridMessages->Refresh(); //update MVC "view" +} + +void LogPanel::onErrors(wxCommandEvent& event) +{ + m_bpButtonErrors->toggle(); + updateGrid(); +} + + +void LogPanel::onWarnings(wxCommandEvent& event) +{ + m_bpButtonWarnings->toggle(); + updateGrid(); +} + + +void LogPanel::onInfo(wxCommandEvent& event) +{ + m_bpButtonInfo->toggle(); + updateGrid(); +} + + +void LogPanel::onMsgGridContext(GridContextMenuEvent& event) +{ + const std::vector selection = m_gridMessages->getSelectedRows(); + + const size_t rowCount = [&]() -> size_t + { + if (auto prov = m_gridMessages->getDataProvider()) + return prov->getRowCount(); + return 0; + }(); + + ContextMenu menu; + menu.addItem(_("&Copy") + L"\tCtrl+C", [this] { copySelectionToClipboard(); }, loadImage("item_copy_sicon"), !selection.empty()); + menu.addSeparator(); + + menu.addItem(_("Select all") + L"\tCtrl+A", [this] { m_gridMessages->selectAllRows(GridEventPolicy::allow); }, wxNullImage, rowCount > 0); + + menu.popup(*m_gridMessages, event.mousePos_); +} + + +void LogPanel::onGridKeyEvent(wxKeyEvent& event) +{ + int keyCode = event.GetKeyCode(); + + if (event.ControlDown()) + switch (keyCode) + { + case 'C': + case WXK_INSERT: //CTRL + C || CTRL + INS + case WXK_NUMPAD_INSERT: + copySelectionToClipboard(); + return; // -> swallow event! don't allow default grid commands! + } + + //else + //switch (keyCode) + //{ + // case WXK_RETURN: + // case WXK_NUMPAD_ENTER: + // return; + //} + + event.Skip(); //unknown keypress: propagate +} + + +void LogPanel::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + if (!processingKeyEventHandler_) //avoid recursion + { + processingKeyEventHandler_ = true; + ZEN_ON_SCOPE_EXIT(processingKeyEventHandler_ = false); + + const int keyCode = event.GetKeyCode(); + + if (event.ControlDown()) + switch (keyCode) + { + case 'A': + m_gridMessages->SetFocus(); + m_gridMessages->selectAllRows(GridEventPolicy::allow); + return; // -> swallow event! don't allow default grid commands! + } + else + switch (keyCode) + { + //redirect certain (unhandled) keys directly to grid! + case WXK_UP: + case WXK_DOWN: + case WXK_LEFT: + case WXK_RIGHT: + case WXK_PAGEUP: + case WXK_PAGEDOWN: + case WXK_HOME: + case WXK_END: + + case WXK_NUMPAD_UP: + case WXK_NUMPAD_DOWN: + case WXK_NUMPAD_LEFT: + case WXK_NUMPAD_RIGHT: + case WXK_NUMPAD_PAGEUP: + case WXK_NUMPAD_PAGEDOWN: + case WXK_NUMPAD_HOME: + case WXK_NUMPAD_END: + if (!isComponentOf(wxWindow::FindFocus(), m_gridMessages) && //don't propagate keyboard commands if grid is already in focus + m_gridMessages->IsEnabled()) + { + m_gridMessages->SetFocus(); + + event.SetEventType(wxEVT_KEY_DOWN); //the grid event handler doesn't expect wxEVT_CHAR_HOOK! + m_gridMessages->getMainWin().GetEventHandler()->ProcessEvent(event); //propagating event catched at wxTheApp to child leads to recursion, but we prevented it... + event.Skip(false); //definitively handled now! + return; + } + break; + } + } + event.Skip(); +} + + +void LogPanel::copySelectionToClipboard() +{ + try + { + wxString clipBuf; //perf: old wxString didn't model exponential growth, but now it's std::string-based: + static_assert(std::is_same_v); + + if (auto prov = m_gridMessages->getDataProvider()) + { + std::vector colAttr = m_gridMessages->getColumnConfig(); + std::erase_if(colAttr, [](const Grid::ColAttributes& ca) { return !ca.visible; }); + + for (size_t row : m_gridMessages->getSelectedRows()) + for (auto it = colAttr.begin(); it != colAttr.end(); ++it) + { + clipBuf += prov->getValue(row, it->type); + clipBuf += it == colAttr.end() - 1 ? L'\n' : L'\t'; + } + } + + setClipboardText(clipBuf); + } + catch (const std::bad_alloc& e) + { + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setMainInstructions(_("Out of memory.") + L' ' + utfTo(e.what()))); + } +} diff --git a/FreeFileSync/Source/ui/log_panel.h b/FreeFileSync/Source/ui/log_panel.h new file mode 100644 index 0000000..6bfeff9 --- /dev/null +++ b/FreeFileSync/Source/ui/log_panel.h @@ -0,0 +1,43 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef LOG_PANEL_3218470817450193 +#define LOG_PANEL_3218470817450193 + +#include +#include "gui_generated.h" +#include + + +namespace fff +{ +class MessageView; + +class LogPanel : public LogPanelGenerated +{ +public: + explicit LogPanel(wxWindow* parent); + + void setLog(const std::shared_ptr& log); + +private: + MessageView& getDataView(); + void updateGrid(); + + void onErrors (wxCommandEvent& event) override; + void onWarnings(wxCommandEvent& event) override; + void onInfo (wxCommandEvent& event) override; + void onMsgGridContext (zen::GridContextMenuEvent& event); + void onGridKeyEvent (wxKeyEvent& event); + void onLocalKeyEvent(wxKeyEvent& event); + + void copySelectionToClipboard(); + + bool processingKeyEventHandler_ = false; +}; +} + +#endif //LOG_PANEL_3218470817450193 diff --git a/FreeFileSync/Source/ui/main_dlg.cpp b/FreeFileSync/Source/ui/main_dlg.cpp new file mode 100644 index 0000000..62b7252 --- /dev/null +++ b/FreeFileSync/Source/ui/main_dlg.cpp @@ -0,0 +1,6497 @@ +// ***************************************************************************** +// * 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 "main_dlg.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cfg_grid.h" +#include "folder_selector.h" +#include "tree_grid.h" +#include "version_check.h" +#include "gui_status_handler.h" +#include "small_dlgs.h" +#include "rename_dlg.h" +#include "folder_pair.h" +#include "search_grid.h" +#include "batch_config.h" +#include "app_icon.h" +#include "../base_tools.h" +#include "../afs/concrete.h" +#include "../afs/native.h" +#include "../base/comparison.h" +#include "../base/algorithm.h" +#include "../base/lock_holder.h" +#include "../base/icon_loader.h" +#include "../ffs_paths.h" +#include "../localization.h" +#include "../version/version.h" +#include "../afs/gdrive.h" + +using namespace zen; +using namespace fff; + + +namespace +{ +const size_t EXT_APP_MASS_INVOKE_THRESHOLD = 10; //more is likely a user mistake (Explorer uses limit of 15) +const size_t EXT_APP_MAX_TOTAL_WAIT_TIME_MS = 1000; + +const int TOP_BUTTON_OPTIMAL_WIDTH_DIP = 170; +constexpr std::chrono::milliseconds LAST_USED_CFG_EXISTENCE_CHECK_TIME_MAX(500); +constexpr std::chrono::milliseconds FILE_GRID_POST_UPDATE_DELAY(400); + +const ZstringView macroNameItemPath = Zstr("%item_path%"); +const ZstringView macroNameItemPath2 = Zstr("%item_path2%"); +const ZstringView macroNameItemPaths = Zstr("%item_paths%"); +const ZstringView macroNameLocalPath = Zstr("%local_path%"); +const ZstringView macroNameLocalPath2 = Zstr("%local_path2%"); +const ZstringView macroNameLocalPaths = Zstr("%local_paths%"); +const ZstringView macroNameItemName = Zstr("%item_name%"); +const ZstringView macroNameItemName2 = Zstr("%item_name2%"); +const ZstringView macroNameItemNames = Zstr("%item_names%"); +const ZstringView macroNameParentPath = Zstr("%parent_path%"); +const ZstringView macroNameParentPath2 = Zstr("%parent_path2%"); +const ZstringView macroNameParentPaths = Zstr("%parent_paths%"); + +bool containsFileItemMacro(const Zstring& commandLinePhrase) +{ + return contains(commandLinePhrase, macroNameItemPath ) || + contains(commandLinePhrase, macroNameItemPath2 ) || + contains(commandLinePhrase, macroNameItemPaths ) || + contains(commandLinePhrase, macroNameLocalPath ) || + contains(commandLinePhrase, macroNameLocalPath2 ) || + contains(commandLinePhrase, macroNameLocalPaths ) || + contains(commandLinePhrase, macroNameItemName ) || + contains(commandLinePhrase, macroNameItemName2 ) || + contains(commandLinePhrase, macroNameItemNames ) || + contains(commandLinePhrase, macroNameParentPath ) || + contains(commandLinePhrase, macroNameParentPath2) || + contains(commandLinePhrase, macroNameParentPaths); +} + +//let's NOT create wxWidgets objects statically: +wxColor getColorHighlightCompareButton() { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0, 0, 0x80} : wxColor{236, 236, 255}; } //dark + light blue +wxColor getColorHighlightSyncButton () { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0, 0x40, 0} : wxColor{230, 255, 215}; } //dark + light green + +wxColor getColorAuiPanelCaptionText() { return wxSystemSettings::GetAppearance().IsDark() ? 0xdadada : 0xffffff; } +wxColor getColorAuiPanelCaptionBack() { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0, 0x4f, 0x8e} : wxColor{51, 147, 223}; } //dark + medium blue +wxColor getColorAuiPanelCaptionBackGradient() { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0, 0x3d, 0x6e} : wxColor{ 0, 120, 215}; } //dark + medium blue + +wxColor getColorFlashStatusInfo() +{ + return enhanceContrast({31, 57, 226} /*blue*/, wxWindow::GetClassDefaultAttributes(wxWindowVariant::wxWINDOW_VARIANT_NORMAL).colBg, + 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 +} + +IconBuffer::IconSize convert(GridIconSize isize) +{ + switch (isize) + { + case GridIconSize::small: + return IconBuffer::IconSize::small; + case GridIconSize::medium: + return IconBuffer::IconSize::medium; + case GridIconSize::large: + return IconBuffer::IconSize::large; + } + return IconBuffer::IconSize::small; +} + + +bool acceptDialogFileDrop(const std::vector& shellItemPaths) +{ + return std::any_of(shellItemPaths.begin(), shellItemPaths.end(), [](const Zstring& shellItemPath) + { + const Zstring ext = getFileExtension(shellItemPath); + return equalAsciiNoCase(ext, "ffs_gui") || + equalAsciiNoCase(ext, "ffs_batch"); + }); +} + + + +FfsGuiConfig getDefaultGuiConfig(const FilterConfig& defaultFilter) +{ + FfsGuiConfig defaultCfg; + + //set default file filter: this is only ever relevant when creating new configurations! + //a default FfsGuiConfig does not need these user-specific exclusions! + defaultCfg.mainCfg.globalFilter = defaultFilter; + + return defaultCfg; +} +} + +//------------------------------------------------------------------ +/* class hierarchy: + + template<> + FolderPairPanelBasic + /|\ + | + template<> + FolderPairCallback FolderPairPanelGenerated + /|\ /|\ + _________|_________ ________| + | | | + FolderPairFirst FolderPairPanel +*/ + +template +class fff::FolderPairCallback : public FolderPairPanelBasic //implements callback functionality to MainDialog as imposed by FolderPairPanelBasic +{ +public: + FolderPairCallback(GuiPanel& basicPanel, MainDialog& mainDlg, + + wxPanel& dropWindow1L, + wxPanel& dropWindow1R, + wxButton& selectFolderButtonL, + wxButton& selectFolderButtonR, + wxButton& selectSftpButtonL, + wxButton& selectSftpButtonR, + FolderHistoryBox& dirpathL, + FolderHistoryBox& dirpathR, + Zstring& folderLastSelectedL, + Zstring& folderLastSelectedR, + Zstring& sftpKeyFileLastSelected, + wxStaticText* staticTextL, + wxStaticText* staticTextR, + wxWindow* dropWindow2L, + wxWindow* dropWindow2R) : + FolderPairPanelBasic(basicPanel), //pass FolderPairPanelGenerated part... + mainDlg_(mainDlg), + folderSelectorLeft_ (&mainDlg, dropWindow1L, selectFolderButtonL, selectSftpButtonL, dirpathL, folderLastSelectedL, + sftpKeyFileLastSelected, staticTextL, dropWindow2L, droppedPathsFilter_, getDeviceParallelOps_, setDeviceParallelOps_), + folderSelectorRight_(&mainDlg, dropWindow1R, selectFolderButtonR, selectSftpButtonR, dirpathR, folderLastSelectedR, + sftpKeyFileLastSelected, staticTextR, dropWindow2R, droppedPathsFilter_, getDeviceParallelOps_, setDeviceParallelOps_) + { + folderSelectorLeft_ .setSiblingSelector(&folderSelectorRight_); + folderSelectorRight_.setSiblingSelector(&folderSelectorLeft_); + + folderSelectorLeft_ .Bind(EVENT_ON_FOLDER_SELECTED, [&mainDlg](wxCommandEvent& event) { mainDlg.onFolderSelected(event); }); + folderSelectorRight_.Bind(EVENT_ON_FOLDER_SELECTED, [&mainDlg](wxCommandEvent& event) { mainDlg.onFolderSelected(event); }); + } + + void setValues(const LocalPairConfig& lpc) + { + this->setConfig(lpc.localCmpCfg, lpc.localSyncCfg, lpc.localFilter); + folderSelectorLeft_ .setPath(lpc.folderPathPhraseLeft); + folderSelectorRight_.setPath(lpc.folderPathPhraseRight); + } + + LocalPairConfig getValues() const + { + return + { + folderSelectorLeft_ .getPath(), + folderSelectorRight_.getPath(), + this->getCompConfig(), + this->getSyncConfig(), + this->getFilterConfig() + }; + } + +private: + MainConfiguration getMainConfig() const override { return mainDlg_.getConfig().mainCfg; } + wxWindow* getParentWindow() override { return &mainDlg_; } + + void onLocalCompCfgChange () override { mainDlg_.applyCompareConfig(false /*setDefaultViewType*/); } + void onLocalSyncCfgChange () override { mainDlg_.applySyncDirections(); } + void onLocalFilterCfgChange() override { mainDlg_.applyFilterConfig(); } //re-apply filter + + const std::function& shellItemPaths)> droppedPathsFilter_ = [&](const std::vector& shellItemPaths) + { + if (acceptDialogFileDrop(shellItemPaths)) + { + assert(!shellItemPaths.empty()); + mainDlg_.loadConfiguration(shellItemPaths); + return false; //don't set dropped paths + } + return true; //do set dropped paths + }; + + const std::function getDeviceParallelOps_ = [&](const Zstring& folderPathPhrase) + { + return getDeviceParallelOps(mainDlg_.currentCfg_.mainCfg.deviceParallelOps, folderPathPhrase); + }; + + const std::function setDeviceParallelOps_ = [&](const Zstring& folderPathPhrase, size_t parallelOps) + { + setDeviceParallelOps(mainDlg_.currentCfg_.mainCfg.deviceParallelOps, folderPathPhrase, parallelOps); + mainDlg_.updateUnsavedCfgStatus(); + }; + + MainDialog& mainDlg_; + FolderSelector folderSelectorLeft_; + FolderSelector folderSelectorRight_; +}; + + +class fff::FolderPairPanel : + public FolderPairPanelGenerated, //FolderPairPanel "owns" FolderPairPanelGenerated! + public FolderPairCallback +{ +public: + FolderPairPanel(wxWindow* parent, + MainDialog& mainDlg, + Zstring& folderLastSelectedL, + Zstring& folderLastSelectedR, + Zstring& sftpKeyFileLastSelected) : + FolderPairPanelGenerated(parent), + FolderPairCallback(static_cast(*this), mainDlg, + + *m_panelLeft, + *m_panelRight, + *m_buttonSelectFolderLeft, + *m_buttonSelectFolderRight, + *m_bpButtonSelectAltFolderLeft, + *m_bpButtonSelectAltFolderRight, + *m_folderPathLeft, + *m_folderPathRight, + folderLastSelectedL, + folderLastSelectedR, + sftpKeyFileLastSelected, + nullptr /*staticText*/, nullptr /*staticText*/, + nullptr /*dropWindow2*/, nullptr /*dropWindow2*/) {} +}; + + +class fff::FolderPairFirst : public FolderPairCallback +{ +public: + FolderPairFirst(MainDialog& mainDlg, + Zstring& folderLastSelectedL, + Zstring& folderLastSelectedR, + Zstring& sftpKeyFileLastSelected) : + FolderPairCallback(mainDlg, mainDlg, + + *mainDlg.m_panelTopLeft, + *mainDlg.m_panelTopRight, + *mainDlg.m_buttonSelectFolderLeft, + *mainDlg.m_buttonSelectFolderRight, + *mainDlg.m_bpButtonSelectAltFolderLeft, + *mainDlg.m_bpButtonSelectAltFolderRight, + *mainDlg.m_folderPathLeft, + *mainDlg.m_folderPathRight, + folderLastSelectedL, + folderLastSelectedR, + sftpKeyFileLastSelected, + mainDlg.m_staticTextResolvedPathL, + mainDlg.m_staticTextResolvedPathR, + &mainDlg.m_gridMainL->getMainWin(), + &mainDlg.m_gridMainR->getMainWin()) {} +}; + + + +//--------------------------------------------------------------------------------------------- +class MainDialog::UiInputDisabler +{ +public: + UiInputDisabler(MainDialog& mainDlg, bool enableAbort) : mainDlg_(mainDlg) + { + disableGuiElementsImpl(enableAbort); + } + + ~UiInputDisabler() + { + if (!dismissed_ ) + { + wxTheApp->Yield(); //GUI update before enabling buttons again: prevent strange behaviour of delayed button clicks + enableGuiElementsImpl(); + } + } + + void dismiss() { dismissed_ = true; } + +private: + UiInputDisabler (const UiInputDisabler&) = delete; + UiInputDisabler& operator=(const UiInputDisabler&) = delete; + + void disableGuiElementsImpl(bool enableAbort); //dis-/enable all elements (except abort button) that might receive unwanted user input + void enableGuiElementsImpl(); //during long-running processes: comparison, deletion + + MainDialog& mainDlg_; + bool dismissed_ = false; +}; + + +void MainDialog::UiInputDisabler::disableGuiElementsImpl(bool enableAbort) +{ + //disables all elements (except abort button) that might receive user input during long-running processes: + //when changing consider: comparison, synchronization, manual deletion + + //OS X: wxWidgets portability promise is again a mess: http://wxwidgets.10942.n7.nabble.com/Disable-panel-and-appropriate-children-windows-linux-macos-td35357.html + + mainDlg_.EnableCloseButton(false); //closing main dialog is not allowed during synchronization! crash! + //EnableCloseButton(false) just does not work reliably! + //- Windows: dialog can still be closed by clicking the task bar preview window with the middle mouse button or by pressing ALT+F4! + //- OS X: Quit/Preferences menu items still enabled during sync, + // ([[m_macWindow standardWindowButton:NSWindowCloseButton] setEnabled:enable]) does not stick after calling Maximize() ([m_macWindow zoom:nil]) + //- Linux: it just works! :) + + + for (size_t pos = 0; pos < mainDlg_.m_menubar->GetMenuCount(); ++pos) + mainDlg_.m_menubar->EnableTop(pos, false); + + if (enableAbort) + { + mainDlg_.m_buttonCancel->Enable(); + mainDlg_.m_buttonCancel->Show(); + //if (m_buttonCancel->IsShownOnScreen()) -> needed? + mainDlg_.m_buttonCancel->SetFocus(); + mainDlg_.m_buttonCompare->Disable(); + mainDlg_.m_buttonCompare->Hide(); + mainDlg_.m_panelTopButtons->Layout(); + + mainDlg_.m_bpButtonCmpConfig ->Disable(); + mainDlg_.m_bpButtonCmpContext ->Disable(); + mainDlg_.m_bpButtonFilter ->Disable(); + mainDlg_.m_bpButtonFilterContext->Disable(); + mainDlg_.m_bpButtonSyncConfig ->Disable(); + mainDlg_.m_bpButtonSyncContext->Disable(); + mainDlg_.m_buttonSync ->Disable(); + } + else + mainDlg_.m_panelTopButtons->Disable(); + + mainDlg_.m_panelDirectoryPairs->Disable(); + mainDlg_.m_gridOverview ->Disable(); + mainDlg_.m_panelCenter ->Disable(); + mainDlg_.m_panelSearch ->Disable(); + mainDlg_.m_panelLog ->Disable(); + mainDlg_.m_panelConfig ->Disable(); + mainDlg_.m_panelViewFilter ->Disable(); + + mainDlg_.Refresh(); //wxWidgets fails to do this automatically for child items of disabled windows +} + + +void MainDialog::UiInputDisabler::enableGuiElementsImpl() +{ + //wxGTK, yet another QOI issue: some stupid bug keeps moving main dialog to top!! + mainDlg_.EnableCloseButton(true); + + for (size_t pos = 0; pos < mainDlg_.m_menubar->GetMenuCount(); ++pos) + mainDlg_.m_menubar->EnableTop(pos, true); + + mainDlg_.m_buttonCancel->Disable(); + mainDlg_.m_buttonCancel->Hide(); + mainDlg_.m_buttonCompare->Enable(); + mainDlg_.m_buttonCompare->Show(); + mainDlg_.m_panelTopButtons->Layout(); + + mainDlg_.m_bpButtonCmpConfig ->Enable(); + mainDlg_.m_bpButtonCmpContext ->Enable(); + mainDlg_.m_bpButtonFilter ->Enable(); + mainDlg_.m_bpButtonFilterContext->Enable(); + mainDlg_.m_bpButtonSyncConfig ->Enable(); + mainDlg_.m_bpButtonSyncContext->Enable(); + mainDlg_.m_buttonSync ->Enable(); + + mainDlg_.m_panelTopButtons->Enable(); + + mainDlg_.m_panelDirectoryPairs->Enable(); + mainDlg_.m_gridOverview ->Enable(); + mainDlg_.m_panelCenter ->Enable(); + mainDlg_.m_panelSearch ->Enable(); + mainDlg_.m_panelLog ->Enable(); + mainDlg_.m_panelConfig ->Enable(); + mainDlg_.m_panelViewFilter ->Enable(); + + mainDlg_.Refresh(); + //mainDlg_.auiMgr_.Update(); needed on macOS; 2021-02-01: apparently not anymore! +} +//--------------------------------------------------------------------------------------------- + + +namespace +{ +void updateTopButton(wxBitmapButton& btn, + const wxImage& img, + const wxString& varName, const char* varIconName /*optional*/, + const char* extraIconName /*optional*/, + const wxColor& highlightCol /*optional*/) +{ + const wxColor backCol = highlightCol.IsOk() ? highlightCol : btn.GetBackgroundColour(); + wxImage iconImg = highlightCol.IsOk() ? img : greyScale(img); + + wxImage btnLabelImg = createImageFromText(btn.GetLabelText(), btn.GetFont(), + enhanceContrast(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNTEXT), backCol, 4.5 /*contrastRatioMin*/)); + + wxImage varLabelImg = createImageFromText(varName, wxNORMAL_FONT->Bold(), + enhanceContrast(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT), backCol, 4.5 /*contrastRatioMin*/)); + wxImage varImg = varLabelImg; + if (varIconName) + { + wxImage varIcon = mirrorIfRtl(loadImage(varIconName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize()))); + + varImg = btn.GetLayoutDirection() != wxLayout_RightToLeft ? + stackImages(varLabelImg, varIcon, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)) : + stackImages(varIcon, varLabelImg, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)); + } + + wxImage btnImg = stackImages(btnLabelImg, varImg, ImageStackLayout::vertical, ImageStackAlignment::center); + + btnImg = btn.GetLayoutDirection() != wxLayout_RightToLeft ? + stackImages(iconImg, btnImg, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)) : + stackImages(btnImg, iconImg, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)); + + if (extraIconName) + { + const wxImage exImg = loadImage(extraIconName, dipToScreen(20)); + + btnImg = btn.GetLayoutDirection() != wxLayout_RightToLeft ? + stackImages(btnImg, exImg, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)) : + stackImages(exImg, btnImg, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)); + } + + wxSize btnSize = btnImg.GetSize() + wxSize(dipToScreen(5 + 5), 0) /*border space*/; + btnSize.x = std::max(btnSize.x, dipToScreen(TOP_BUTTON_OPTIMAL_WIDTH_DIP)); + btnSize.y += dipToScreen(2 + 2); //border space + btnImg = resizeCanvas(btnImg, btnSize, wxALIGN_CENTER); + + if (highlightCol.IsOk()) + btnImg = layOver(rectangleImage(btnImg.GetSize(), highlightCol), btnImg, wxALIGN_CENTER); + + setImage(btn, btnImg); +} +} + +//################################################################################################################################## + +void MainDialog::create(const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath) +{ + std::vector cfgFilePaths = globalCfg.mainDlg.config.lastUsedFiles; + + //------------------------------------------------------------------------------------------ + //check existence of all files in parallel: + AsyncFirstResult firstUnavailableFile; + + for (const Zstring& filePath : cfgFilePaths) + firstUnavailableFile.addJob([filePath]() -> std::optional + { + try + { + assert(!filePath.empty()); + getItemType(filePath); //throw FileError + return {}; + } + catch (FileError&) { return std::false_type(); } + }); + + //potentially slow network access: give all checks 500ms to finish + const bool allFilesAvailable = firstUnavailableFile.timedWait(LAST_USED_CFG_EXISTENCE_CHECK_TIME_MAX) && //false: time elapsed + !firstUnavailableFile.get(); //no missing + if (!allFilesAvailable) + cfgFilePaths.clear(); //we do NOT want to show an error due to last config file missing on application start! + //------------------------------------------------------------------------------------------ + + if (cfgFilePaths.empty()) + try //3. ...to load auto-save config (should not block) + { + const Zstring lastRunConfigFilePath = getLastRunConfigPath(); + + getItemType(lastRunConfigFilePath); //throw FileError + cfgFilePaths.push_back(lastRunConfigFilePath); + } + catch (FileError&) {} //not-existing/access error? => user may click on [Last session] later + + + FfsGuiConfig guiCfg = getDefaultGuiConfig(globalCfg.defaultFilter); + + if (!cfgFilePaths.empty()) + try + { + std::wstring warningMsg; + std::tie(guiCfg, warningMsg) = readAnyConfig(cfgFilePaths); //throw FileError + + if (!warningMsg.empty()) + showNotificationDialog(nullptr, DialogInfoType::warning, PopupDialogCfg().setDetailInstructions(warningMsg)); + //what about showing as changed config on parsing errors???? + } + catch (const FileError& e) + { + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } + //------------------------------------------------------------------------------------------ + + create(guiCfg, cfgFilePaths, globalCfg, globalCfgFilePath, false /*startComparison*/); +} + + +void MainDialog::create(const FfsGuiConfig& guiCfg, const std::vector& cfgFilePaths, + const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath, + bool startComparison) +{ + MainDialog* mainDlg = new MainDialog(guiCfg, cfgFilePaths, globalCfg, globalCfgFilePath); + + //avoid Windows 10 white flash when showing dark mode window: https://chromium-review.googlesource.com/c/chromium/src/+/6092335 +#if 0 //variant 1: works, but no fade-in animation +#include +#pragma comment(lib, "dwmapi.lib") + + BOOL cloak = true; //requires Windows 8 and later + bool cloaked = SUCCEEDED(::DwmSetWindowAttribute(mainDlg->GetHWND(), DWMWA_CLOAK, &cloak, sizeof(cloak))); + + mainDlg->Show(); + + if (cloaked) + { + /*BOOL success = */ ::UpdateWindow(mainDlg->GetHWND()); + BOOL cloak = false; + /*HRESULT hr = */::DwmSetWindowAttribute(mainDlg->GetHWND(), DWMWA_CLOAK, &cloak, sizeof(cloak)); + } +#endif +#if 0 //variant 2: works, but different fade-in animation + mainDlg->Iconize(); + mainDlg->Show(); + mainDlg->Iconize(false); +#endif + + mainDlg->Show(); + + //------------------------------------------------------------------------------------------ + //construction complete! trigger special events: + //------------------------------------------------------------------------------------------ + + //show welcome dialog after FreeFileSync update => show *before* any other dialogs + if (mainDlg->globalCfg_.welcomeDialogLastVersion != ffsVersion) + { + mainDlg->globalCfg_.welcomeDialogLastVersion = ffsVersion; + + //showAboutDialog(mainDlg); => dialog centered incorrectly (Centos) + //mainDlg->CallAfter([mainDlg] { showAboutDialog(mainDlg); }); => dialog centered incorrectly (Windows, Centos) + mainDlg->guiQueue_.processAsync([] {}, [mainDlg]() { showAboutDialog(mainDlg); }); //apparently oh-kay? + } + + + //if FFS is started with a *.ffs_gui file as commandline parameter AND all directories contained exist, comparison shall be started right away + if (startComparison) + { + const MainConfiguration currMainCfg = mainDlg->getConfig().mainCfg; + + //------------------------------------------------------------------------------------------ + //harmonize checks with comparison.cpp:: checkForIncompleteInput() + //we're really doing two checks: 1. check directory existence 2. check config validity -> don't mix them! + bool havePartialPair = false; + bool haveFullPair = false; + + std::vector folderPathsToCheck; + + auto addFolderCheck = [&](const LocalPairConfig& lpc) + { + const AbstractPath folderPathL = createAbstractPath(lpc.folderPathPhraseLeft); + const AbstractPath folderPathR = createAbstractPath(lpc.folderPathPhraseRight); + + if (AFS::isNullPath(folderPathL) != AFS::isNullPath(folderPathR)) //only skip check if both sides are empty! + havePartialPair = true; + else if (!AFS::isNullPath(folderPathL)) + haveFullPair = true; + + if (!AFS::isNullPath(folderPathL)) + folderPathsToCheck.push_back(folderPathL); //noexcept + if (!AFS::isNullPath(folderPathR)) + folderPathsToCheck.push_back(folderPathR); //noexcept + }; + + addFolderCheck(currMainCfg.firstPair); + for (const LocalPairConfig& lpc : currMainCfg.additionalPairs) + addFolderCheck(lpc); + //------------------------------------------------------------------------------------------ + + if (havePartialPair != haveFullPair) //either all pairs full or all half-filled -> validity check! + { + //check existence of all directories in parallel! + AsyncFirstResult firstMissingDir; + for (const AbstractPath& folderPath : folderPathsToCheck) + firstMissingDir.addJob([folderPath]() -> std::optional + { + try + { + if (AFS::getItemType(folderPath) != AFS::ItemType::file) //throw FileError + return {}; + } + catch (FileError&) {} + return std::false_type(); + }); + + const bool startComparisonNow = !firstMissingDir.timedWait(std::chrono::milliseconds(500)) || //= no result yet => start comparison anyway! + !firstMissingDir.get(); //= all directories exist + + if (startComparisonNow) //simulate click on "compare" + { + wxCommandEvent dummy2(wxEVT_COMMAND_BUTTON_CLICKED); + mainDlg->m_buttonCompare->Command(dummy2); + } + } + } +} + + +MainDialog::MainDialog(const FfsGuiConfig& guiCfg, const std::vector& cfgFilePaths, + const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath) : + MainDialogGenerated(nullptr), + globalCfgFilePath_(globalCfgFilePath), + folderHistoryLeft_ (std::make_shared(globalCfg.mainDlg.folderHistoryLeft, globalCfg.folderHistoryMax)), + folderHistoryRight_(std::make_shared(globalCfg.mainDlg.folderHistoryRight, globalCfg.folderHistoryMax)), + imgTrashSmall_([] +{ + try { return extractWxImage(fff::getTrashIcon(dipToScreen(getMenuIconDipSize()))); /*throw SysError*/ } + catch (SysError&) { assert(false); return loadImage("delete_recycler", dipToScreen(getMenuIconDipSize())); } +} +()), + +imgFileManagerSmall_([] +{ + try { return extractWxImage(fff::getFileManagerIcon(dipToScreen(getMenuIconDipSize()))); /*throw SysError*/ } + catch (SysError&) { assert(false); return loadImage("file_manager", dipToScreen(getMenuIconDipSize())); } +}()) +{ + SetSizeHints(dipToWxsize(640), dipToWxsize(400)); + + //setup sash: detach + reparent: + m_splitterMain->SetSizer(nullptr); //alas wxFormbuilder doesn't allow us to have child windows without a sizer, so we have to remove it here + m_splitterMain->setupWindows(m_gridMainL, m_gridMainC, m_gridMainR); + + + setRelativeFontSize(*m_buttonCompare, 1.4); + setRelativeFontSize(*m_buttonSync, 1.4); + setRelativeFontSize(*m_buttonCancel, 1.4); + + SetIcon(getFfsIcon()); //set application icon + + auto generateSaveAsImage = [](const char* layoverName) + { + const wxSize oldSize = loadImage("cfg_save").GetSize(); + + wxImage backImg = loadImage("cfg_save", oldSize.GetWidth() * 9 / 10); + backImg = resizeCanvas(backImg, oldSize, wxALIGN_BOTTOM | wxALIGN_LEFT); + + return layOver(backImg, loadImage(layoverName, backImg.GetWidth() * 7 / 10), wxALIGN_TOP | wxALIGN_RIGHT); + }; + + setImage(*m_bpButtonCmpConfig, loadImage("options_compare")); + setImage(*m_bpButtonSyncConfig, loadImage("options_sync")); + + setImage(*m_bpButtonCmpContext, mirrorIfRtl(loadImage("button_arrow_right"))); + setImage(*m_bpButtonFilterContext, mirrorIfRtl(loadImage("button_arrow_right"))); + setImage(*m_bpButtonSyncContext, mirrorIfRtl(loadImage("button_arrow_right"))); + setImage(*m_bpButtonViewFilterContext, mirrorIfRtl(loadImage("button_arrow_right"))); + + //m_bpButtonNew ->set dynamically + setImage(*m_bpButtonOpen, loadImage("cfg_load")); + //m_bpButtonSave ->set dynamically + setImage(*m_bpButtonSaveAs, generateSaveAsImage("start_sync")); + setImage(*m_bpButtonSaveAsBatch, generateSaveAsImage("cfg_batch")); + + setImage(*m_bpButtonAddPair, loadImage("item_add")); + setImage(*m_bpButtonHideSearch, loadImage("close_panel")); + //setImage(*m_bpButtonToggleLog, loadImage("log_file")); + + m_bpButtonFilter ->SetMinSize({screenToWxsize(loadImage("options_filter").GetWidth()) + dipToWxsize(27), -1}); //make the filter button wider + m_textCtrlSearchTxt->SetMinSize({dipToWxsize(220), -1}); + + //---------------------------------------------------------------------------------------- + wxImage labelImage = createImageFromText(_("Select view:"), m_bpButtonViewType->GetFont(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNTEXT)); + + labelImage = resizeCanvas(labelImage, labelImage.GetSize() + wxSize(dipToScreen(10), 0), wxALIGN_CENTER); //add border space + + auto generateViewTypeImage = [&](const char* imgName) + { + return stackImages(labelImage, mirrorIfRtl(loadImage(imgName)), ImageStackLayout::vertical, ImageStackAlignment::center); + }; + m_bpButtonViewType->init(generateViewTypeImage("viewtype_sync_action"), + generateViewTypeImage("viewtype_cmp_result")); + //tooltip is updated dynamically in setViewTypeSyncAction() + //---------------------------------------------------------------------------------------- + m_bpButtonShowExcluded ->SetToolTip(_("Show filtered or temporarily excluded files")); + m_bpButtonShowEqual ->SetToolTip(_("Show files that are equal")); + m_bpButtonShowConflict ->SetToolTip(_("Show conflicts")); + + m_bpButtonShowCreateLeft ->SetToolTip(_("Show files that will be created on the left side")); + m_bpButtonShowCreateRight->SetToolTip(_("Show files that will be created on the right side")); + m_bpButtonShowDeleteLeft ->SetToolTip(_("Show files that will be deleted on the left side")); + m_bpButtonShowDeleteRight->SetToolTip(_("Show files that will be deleted on the right side")); + m_bpButtonShowUpdateLeft ->SetToolTip(_("Show files that will be updated on the left side")); + m_bpButtonShowUpdateRight->SetToolTip(_("Show files that will be updated on the right side")); + m_bpButtonShowDoNothing ->SetToolTip(_("Show files that won't be copied")); + + m_bpButtonShowLeftOnly ->SetToolTip(_("Show files that exist on left side only")); + m_bpButtonShowRightOnly ->SetToolTip(_("Show files that exist on right side only")); + m_bpButtonShowLeftNewer ->SetToolTip(_("Show files that are newer on left")); + m_bpButtonShowRightNewer->SetToolTip(_("Show files that are newer on right")); + m_bpButtonShowDifferent ->SetToolTip(_("Show files that are different")); + //---------------------------------------------------------------------------------------- + + const wxImage& imgFile = IconBuffer::genericFileIcon(IconBuffer::IconSize::small); + const wxImage& imgDir = IconBuffer::genericDirIcon (IconBuffer::IconSize::small); + + //init log panel + setRelativeFontSize(*m_staticTextSyncResult, 1.5); + + setImage(*m_bitmapItemStat, imgFile); + + wxImage imgTime = loadImage("time", -1 /*maxWidth*/, imgFile.GetHeight()); + setImage(*m_bitmapTimeStat, imgTime); + m_bitmapTimeStat->SetMinSize({-1, screenToWxsize(imgFile.GetHeight())}); + + logPanel_ = new LogPanel(m_panelLog); //pass ownership + bSizerLog->Add(logPanel_, 1, wxEXPAND); + + setLastOperationLog(ProcessSummary(), nullptr /*errorLog*/); + + //we have to use the OS X naming convention by default, because wxMac permanently populates the display menu when the wxMenuItem is created for the first time! + //=> other wx ports are not that badly programmed; therefore revert: + assert(m_menuItemOptions->GetItemLabel() == _("&Preferences") + L"\tCtrl+,"); //"Ctrl" is automatically mapped to command button! + m_menuItemOptions->SetItemLabel(_("&Options")); + + //---------------- support for dockable gui style -------------------------------- + bSizerPanelHolder->Detach(m_panelTopButtons); + bSizerPanelHolder->Detach(m_panelLog); + bSizerPanelHolder->Detach(m_panelDirectoryPairs); + bSizerPanelHolder->Detach(m_gridOverview); + bSizerPanelHolder->Detach(m_panelCenter); + bSizerPanelHolder->Detach(m_panelConfig); + bSizerPanelHolder->Detach(m_panelViewFilter); + + auiMgr_.SetDockSizeConstraint(1 /*width_pct*/, 1 /*height_pct*/); //get rid: interferes with programmatic layout changes + doesn't limit what user can do + + auiMgr_.SetManagedWindow(this); + auiMgr_.SetFlags(wxAUI_MGR_DEFAULT | wxAUI_MGR_LIVE_RESIZE); + + auiMgr_.Bind(wxEVT_AUI_PANE_CLOSE, [](wxAuiManagerEvent& event) + { + //wxAuiManager::ClosePane already calls wxAuiManager::RestorePane if wxAuiPaneInfo::IsMaximized + if (wxAuiPaneInfo* pi = event.GetPane()) + if (!pi->IsMaximized()) + pi->best_size = pi->rect.GetSize(); //ensure current window sizes will be used when pane is shown again: + + assert(event.GetPane()->rect != wxSize()); + }); + + //daily WTF: wxAuiManager ignores old directory pane size in wxAuiPaneInfo::rect + //and calculates new window sizes based on best_size/min_size during wxEVT_AUI_PANE_RESTORE! + auiMgr_.Bind(wxEVT_AUI_PANE_MAXIMIZE, [this](wxAuiManagerEvent& event) + { + wxAuiPaneInfo& dirPane = auiMgr_.GetPane(m_panelDirectoryPairs); + wxAuiPaneInfo& logPane = auiMgr_.GetPane(m_panelLog); + assert(event.GetPane() == &logPane); + + //ensure current window sizes will be used during wxEVT_AUI_PANE_RESTORE: + dirPane.best_size = dirPane.rect.GetSize(); + logPane.best_size = logPane.rect.GetSize(); + + assert(dirPane.rect != wxSize()); + assert(logPane.rect != wxSize()); + }); + + auiMgr_.Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent& event) + { + if (event.GetPane() == &auiMgr_.GetPane(m_panelLog)) + { + event.Veto(); + showLogPanel(false); + } + }); + + compareStatus_.emplace(*this); //integrate the compare status panel (in hidden state) + + //caption required for all panes that can be manipulated by the users => used by context menu + auiMgr_.AddPane(m_panelCenter, + wxAuiPaneInfo().Name(L"CenterPanel").CenterPane().PaneBorder(false)); + + //set comparison button label tentatively for m_panelTopButtons to receive final height: + updateTopButton(*m_buttonCompare, loadImage("compare"), getVariantName(CompareVariant::timeSize), "cmp_time", nullptr /*extraIconName*/, wxNullColour); + m_panelTopButtons->GetSizer()->SetSizeHints(m_panelTopButtons); //~=Fit() + SetMinSize() + + m_buttonCancel->SetMinSize({std::max(m_buttonCancel->GetSize().x, dipToWxsize(TOP_BUTTON_OPTIMAL_WIDTH_DIP)), + std::max(m_buttonCancel->GetSize().y, m_buttonCompare->GetSize().y) + }); + + auiMgr_.AddPane(m_panelTopButtons, + wxAuiPaneInfo().Name(L"TopPanel").Layer(2).Top().Row(1).Caption(_("Main Bar")).CaptionVisible(false). + PaneBorder(false).Gripper(). + //BestSize(-1, m_panelTopButtons->GetSize().GetHeight() + dipToWxsize(10)). + MinSize(dipToWxsize(TOP_BUTTON_OPTIMAL_WIDTH_DIP), m_panelTopButtons->GetSize().GetHeight())); + //note: min height is calculated incorrectly by wxAuiManager if panes with and without caption are in the same row => use smaller min-size + + auiMgr_.AddPane(compareStatus_->getAsWindow(), + wxAuiPaneInfo().Name(L"ProgressPanel").Layer(2).Top().Row(2).CaptionVisible(false).PaneBorder(false).Hide(). + //wxAui does not consider the progress panel's wxRAISED_BORDER and set's too small a panel height! => use correct value from wxWindow::GetSize() + MinSize(-1, compareStatus_->getAsWindow()->GetSize().GetHeight())); //bonus: minimal height isn't a bad idea anyway + + m_panelDirectoryPairs->GetSizer()->SetSizeHints(m_panelDirectoryPairs); //~=Fit() + SetMinSize() + auiMgr_.AddPane(m_panelDirectoryPairs, + wxAuiPaneInfo().Name(L"FoldersPanel").Layer(2).Top().Row(3).Caption(_("Folder Pairs")).CaptionVisible(false).PaneBorder(false).Gripper(). + /* yes, m_panelDirectoryPairs's min height is overwritten in updateGuiForFolderPair(), but the default height might be wrong + after increasing text size (Win10 Settings -> Accessibility -> Text size), e.g. to 150%: + auiMgr_.LoadPerspective will load a too small "dock_size", so m_panelTopLeft/m_panelTopCenter will have squashed height */ + MinSize(dipToWxsize(100), m_panelDirectoryPairs->GetSize().y).CloseButton(false)); + + m_panelSearch->GetSizer()->SetSizeHints(m_panelSearch); //~=Fit() + SetMinSize() + auiMgr_.AddPane(m_panelSearch, + wxAuiPaneInfo().Name(L"SearchPanel").Layer(2).Bottom().Row(3).Caption(_("Find")).CaptionVisible(false).PaneBorder(false).Gripper(). + MinSize(dipToWxsize(100), m_panelSearch->GetSize().y).Hide()); + + auiMgr_.AddPane(m_panelLog, + wxAuiPaneInfo().Name(L"LogPanel").Layer(2).Bottom().Row(2).Caption(_("Log")).MaximizeButton().Hide(). + MinSize (dipToWxsize(100), dipToWxsize(100)). + BestSize(dipToWxsize(600), dipToWxsize(300))); + + m_panelViewFilter->GetSizer()->SetSizeHints(m_panelViewFilter); //~=Fit() + SetMinSize() + auiMgr_.AddPane(m_panelViewFilter, + wxAuiPaneInfo().Name(L"ViewFilterPanel").Layer(2).Bottom().Row(1).Caption(_("View Settings")).CaptionVisible(false). + PaneBorder(false).Gripper().MinSize(dipToWxsize(80), m_panelViewFilter->GetSize().y)); + + m_panelConfig->GetSizer()->SetSizeHints(m_panelConfig); //~=Fit() + SetMinSize() + auiMgr_.AddPane(m_panelConfig, + wxAuiPaneInfo().Name(L"ConfigPanel").Layer(3).Left().Position(1).Caption(_("Configuration")).MinSize(bSizerCfgHistoryButtons->GetSize())); + + auiMgr_.AddPane(m_gridOverview, + wxAuiPaneInfo().Name(L"OverviewPanel").Layer(3).Left().Position(2).Caption(_("Overview")). + MinSize (dipToWxsize(100), dipToWxsize(100)). + BestSize(dipToWxsize(300), -1)); + { + wxAuiDockArt* artProvider = auiMgr_.GetArtProvider(); + + wxFont font = artProvider->GetFont(wxAUI_DOCKART_CAPTION_FONT); + font.SetWeight(wxFONTWEIGHT_BOLD); + font.SetPointSize(wxNORMAL_FONT->GetPointSize()); //= larger than the wxAuiDockArt default; looks better on OS X + artProvider->SetFont(wxAUI_DOCKART_CAPTION_FONT, font); + artProvider->SetMetric(wxAUI_DOCKART_CAPTION_SIZE, font.GetPixelSize().GetHeight() + dipToWxsize(2 + 2)); + + //- fix wxWidgets 3.1.0 insane color scheme + artProvider->SetColor(wxAUI_DOCKART_INACTIVE_CAPTION_TEXT_COLOUR, getColorAuiPanelCaptionText()); //accessibility: always set both foreground AND background colors! + artProvider->SetColor(wxAUI_DOCKART_INACTIVE_CAPTION_COLOUR, getColorAuiPanelCaptionBack()); + artProvider->SetColor(wxAUI_DOCKART_INACTIVE_CAPTION_GRADIENT_COLOUR, getColorAuiPanelCaptionBackGradient()); + } + //auiMgr_.Update(); -> redundant; called by setGlobalCfgOnInit() below + + defaultPerspective_ = auiMgr_.SavePerspective(); //does not need wxAuiManager::Update()! + //---------------------------------------------------------------------------------- + //register view layout context menu + m_panelTopButtons->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onSetLayoutContext(event); }); + m_panelConfig ->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onSetLayoutContext(event); }); + m_panelViewFilter->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onSetLayoutContext(event); }); + m_panelStatusBar ->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onSetLayoutContext(event); }); + //---------------------------------------------------------------------------------- + + //file grid: sorting + m_gridMainL->Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onGridLabelLeftClickRim(event, true /*leftSide*/); }); + m_gridMainR->Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onGridLabelLeftClickRim(event, false /*leftSide*/); }); + m_gridMainC->Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onGridLabelLeftClickC(event); }); + + m_gridMainL->Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onGridLabelContextRim(event, true /*leftSide*/); }); + m_gridMainR->Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onGridLabelContextRim(event, false /*leftSide*/); }); + m_gridMainC->Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onGridLabelContextC(event); }); + + //file grid: context menu + m_gridMainL->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onGridContextRim(event, true /*leftSide*/); }); + m_gridMainR->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onGridContextRim(event, false /*leftSide*/); }); + + m_gridMainL->Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this](GridClickEvent& event) { onGridGroupContextRim(event, true /*leftSide*/); }); + m_gridMainR->Bind(EVENT_GRID_MOUSE_RIGHT_DOWN, [this](GridClickEvent& event) { onGridGroupContextRim(event, false /*leftSide*/); }); + + m_gridMainL->Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onGridDoubleClickRim(event, true /*leftSide*/); }); + m_gridMainR->Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onGridDoubleClickRim(event, false /*leftSide*/); }); + + //tree grid: + m_gridOverview->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onTreeGridContext (event); }); + m_gridOverview->Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onTreeGridSelection(event); }); + + //cfg grid: + m_gridCfgHistory->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onCfgGridKeyEvent(event); }); + m_gridCfgHistory->Bind(EVENT_GRID_SELECT_RANGE, [this](GridSelectEvent& event) { onCfgGridSelection (event); }); + m_gridCfgHistory->Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onCfgGridDoubleClick (event); }); + m_gridCfgHistory->Bind(EVENT_GRID_CONTEXT_MENU, [this](GridContextMenuEvent& event) { onCfgGridContext (event); }); + m_gridCfgHistory->Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onCfgGridLabelContext (event); }); + m_gridCfgHistory->Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onCfgGridLabelLeftClick(event); }); + //---------------------------------------------------------------------------------- + + m_panelSearch->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onSearchPanelKeyPressed(event); }); + + + //set tool tips with (non-translated!) short cut hint + auto setCommandToolTip = [](wxButton& btn, const wxString& label, wxString shortcut) + { + wxString tooltip = wxControl::RemoveMnemonics(label); + if (!shortcut.empty()) + { + tooltip += L" (" + shortcut + L')'; + } + btn.SetToolTip(tooltip); + }; + setCommandToolTip(*m_bpButtonNew, _("&New"), L"Ctrl+N"); // + setCommandToolTip(*m_bpButtonOpen, _("&Open..."), L"Ctrl+O"); // + setCommandToolTip(*m_bpButtonSave, _("&Save"), L"Ctrl+S"); //reuse texts from GUI builder + setCommandToolTip(*m_bpButtonSaveAs, _("Save &as..."), L""); // + setCommandToolTip(*m_bpButtonSaveAsBatch, _("Save as &batch job..."), L""); // + + setCommandToolTip(*m_bpButtonToggleLog, _("Show &log"), L"F4"); // + setCommandToolTip(*m_buttonCompare, _("Start &comparison"), L"F5"); // + setCommandToolTip(*m_bpButtonCmpConfig, _("C&omparison settings"), L"F6"); // + setCommandToolTip(*m_bpButtonSyncConfig, _("S&ynchronization settings"), L"F8"); // + setCommandToolTip(*m_buttonSync, _("Start &synchronization"), L"F9"); // + setCommandToolTip(*m_bpButtonSwapSides, _("Swap sides"), L"Ctrl+Tab"); + + //m_bpButtonCmpContext ->SetToolTip(m_bpButtonCmpConfig ->GetToolTipText()); + //m_bpButtonSyncContext->SetToolTip(m_bpButtonSyncConfig->GetToolTipText()); + + + setImage(*m_bitmapSmallDirectoryLeft, imgDir); + setImage(*m_bitmapSmallFileLeft, imgFile); + setImage(*m_bitmapSmallDirectoryRight, imgDir); + setImage(*m_bitmapSmallFileRight, imgFile); + + //---------------------- menu bar---------------------------- + setImage(*m_menuItemNew, loadImage("cfg_new", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemLoad, loadImage("cfg_load", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemSave, loadImage("cfg_save", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemSaveAsBatch, loadImage("cfg_batch", dipToScreen(getMenuIconDipSize()))); + + setImage(*m_menuItemShowLog, loadImage("log_file", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemCompare, loadImage("compare", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemCompSettings, loadImage("options_compare", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemFilter, loadImage("options_filter", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemSyncSettings, loadImage("options_sync", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemSynchronize, loadImage("start_sync", dipToScreen(getMenuIconDipSize()))); + + setImage(*m_menuItemOptions, loadImage("settings", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemFind, loadImage("find_sicon")); + setImage(*m_menuItemResetLayout, loadImage("reset_sicon")); + + setImage(*m_menuItemHelp, loadImage("help", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemAbout, loadImage("about", dipToScreen(getMenuIconDipSize()))); + setImage(*m_menuItemCheckVersionNow, loadImage("update_check", dipToScreen(getMenuIconDipSize()))); + + fixMenuIcons(*m_menuFile); + fixMenuIcons(*m_menuActions); + fixMenuIcons(*m_menuTools); + fixMenuIcons(*m_menuHelp); + + //create language selection menu + for (const TranslationInfo& ti : getAvailableTranslations()) + { + wxMenuItem* newItem = new wxMenuItem(m_menuLanguages, wxID_ANY, ti.languageName); + setImage(*newItem, loadImage(ti.languageFlag)); //GTK: set *before* inserting into menu + + m_menuLanguages->Bind(wxEVT_COMMAND_MENU_SELECTED, [this, langId = ti.languageID](wxCommandEvent&) { switchProgramLanguage(langId); }, newItem->GetId()); + m_menuLanguages->Append(newItem); //pass ownership + } + + //set up layout items to toggle showing hidden panels + m_menuItemShowMain ->SetItemLabel(replaceCpy(_("Show \"%x\""), L"%x", _("Main Bar"))); + m_menuItemShowFolders ->SetItemLabel(replaceCpy(_("Show \"%x\""), L"%x", _("Folder Pairs"))); + m_menuItemShowViewFilter->SetItemLabel(replaceCpy(_("Show \"%x\""), L"%x", _("View Settings"))); + m_menuItemShowConfig ->SetItemLabel(replaceCpy(_("Show \"%x\""), L"%x", _("Configuration"))); + m_menuItemShowOverview ->SetItemLabel(replaceCpy(_("Show \"%x\""), L"%x", _("Overview"))); + + auto setupLayoutMenuEvent = [&](wxMenuItem* menuItem, wxWindow* panelWindow) + { + m_menuTools->Bind(wxEVT_COMMAND_MENU_SELECTED, [this, panelWindow](wxCommandEvent&) + { + this->auiMgr_.GetPane(panelWindow).Show(); + this->auiMgr_.Update(); + }, menuItem->GetId()); + + //"hide" menu items by default + detachedMenuItems_.insert(m_menuTools->Remove(menuItem)); //pass ownership + }; + setupLayoutMenuEvent(m_menuItemShowMain, m_panelTopButtons); + setupLayoutMenuEvent(m_menuItemShowFolders, m_panelDirectoryPairs); + setupLayoutMenuEvent(m_menuItemShowViewFilter, m_panelViewFilter); + setupLayoutMenuEvent(m_menuItemShowConfig, m_panelConfig); + setupLayoutMenuEvent(m_menuItemShowOverview, m_gridOverview); + + m_menuTools->Bind(wxEVT_MENU_OPEN, [this](wxMenuEvent& event) { onOpenMenuTools(event); }); + + //notify about (logical) application main window => program won't quit, but stay on this dialog + wxTheApp->SetTopWindow(this); + wxTheApp->SetExitOnFrameDelete(true); + + //init handling of first folder pair + firstFolderPair_ = std::make_unique(*this, + globalCfg_.mainDlg.folderLastSelectedLeft, + globalCfg_.mainDlg.folderLastSelectedRight, + globalCfg_.sftpKeyFileLastSelected); + + //init grid settings + filegrid::init(*m_gridMainL, *m_gridMainC, *m_gridMainR); + treegrid::init(*m_gridOverview); + cfggrid ::init(*m_gridCfgHistory); + + + //initialize and load configuration + setGlobalCfgOnInit(globalCfg); //calls auiMgr_.Update() + setConfig(guiCfg, cfgFilePaths); //expects auiMgr_.Update(): e.g. recalcMaxFolderPairsVisible() + + //support for CTRL + C and DEL on grids + m_gridMainL->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onGridKeyEvent(event, *m_gridMainL, true /*leftSide*/); }); + m_gridMainC->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onGridKeyEvent(event, *m_gridMainC, true /*leftSide*/); }); + m_gridMainR->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onGridKeyEvent(event, *m_gridMainR, false /*leftSide*/); }); + + m_gridOverview->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onTreeKeyEvent(event); }); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + //drag and drop .ffs_gui and .ffs_batch on main dialog + setupFileDrop(*this); + Bind(EVENT_DROP_FILE, [this](FileDropEvent& event) { onDialogFilesDropped(event); }); + + //calculate witdh of folder pair manually (if scrollbars are visible) + m_panelTopLeft->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { onResizeLeftFolderWidth(event); }); + + m_panelTopLeft ->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onTopFolderPairKeyEvent(event); }); + m_panelTopCenter->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onTopFolderPairKeyEvent(event); }); + m_panelTopRight ->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onTopFolderPairKeyEvent(event); }); + + //dynamically change sizer direction depending on size + m_panelTopButtons->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { onResizeTopButtonPanel(event); }); + m_panelConfig ->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { onResizeConfigPanel (event); }); + m_panelViewFilter->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { onResizeViewPanel (event); }); + wxSizeEvent dummy3; + onResizeTopButtonPanel(dummy3); // + onResizeConfigPanel (dummy3); //call once on window creation + onResizeViewPanel (dummy3); // + + const int scrollDelta = m_buttonSelectFolderLeft->GetSize().y; //more approriate than GetCharHeight() here + m_scrolledWindowFolderPairs->SetScrollRate(scrollDelta, scrollDelta); + + //event handler for manual (un-)checking of rows and setting of sync direction + m_gridMainC->Bind(EVENT_GRID_CHECK_ROWS, [this](CheckRowsEvent& event) { onCheckRows (event); }); + m_gridMainC->Bind(EVENT_GRID_SYNC_DIRECTION, [this](SyncDirectionEvent& event) { onSetSyncDirection(event); }); + + //mainly to update row label sizes... + updateGui(); + + //register regular check for update on next idle event + Bind(wxEVT_IDLE, &MainDialog::onStartupUpdateCheck, this); + + //asynchronous call to wxWindow::Dimensions(): fix superfluous frame on right and bottom when FFS is started in fullscreen mode + Bind(wxEVT_IDLE, &MainDialog::onLayoutWindowAsync, this); + wxCommandEvent evtDummy; //call once before onLayoutWindowAsync() + onResizeLeftFolderWidth(evtDummy); // + + + onSystemShutdownRegister(onBeforeSystemShutdownCookie_); + + //show and clear "extra" log in case of startup errors: + guiQueue_.processAsync([] { std::this_thread::sleep_for(std::chrono::milliseconds(500)); }, [this] //give worker threads some time to (potentially) log extra errors + { + if (!operationInProgress_ && folderCmp_.empty()) //don't show if main dialog is otherwise busy! + { + ErrorLog extraLog = fetchExtraLog(); + + try //clean up remnant logs from previous FFS runs: + { + traverseFolder(getConfigDirPath(), [&](const FileInfo& fi) //"ErrorLog 2023-07-05 105207.073.xml" + { + if (startsWith(fi.itemName, Zstr("ErrorLog ")) && endsWith(fi.itemName, Zstr(".xml"))) //case-sensitive + { + append(extraLog, loadErrorLog(fi.fullPath)); //throw FileError + removeFilePlain(fi.fullPath); //throw FileError + //yeah, "read + delete" is a bit racy... + } + }, nullptr, nullptr); //throw FileError + } + catch (const FileError& e) { logMsg(extraLog, e.toString(), MessageType::MSG_TYPE_ERROR); } + + std::stable_sort(extraLog.begin(), extraLog.end(), [](const LogEntry& lhs, const LogEntry& rhs) { return lhs.time < rhs.time; }); + + if (!extraLog.empty()) + { + const ErrorLogStats logCount = getStats(extraLog); + const TaskResult taskResult = logCount.errors > 0 ? TaskResult::error : (logCount.warnings > 0 ? TaskResult::warning : TaskResult::success); + setLastOperationLog({.result = taskResult}, make_shared(std::move(extraLog))); + showLogPanel(true); + } + } + }); + + + //scroll cfg history to last used position. We cannot do this earlier e.g. in setGlobalCfgOnInit() + //1. setConfig() indirectly calls cfggrid::addAndSelect() which changes cfg history scroll position + //2. Grid::makeRowVisible() requires final window height! => do this after window resizing is complete + if (m_gridCfgHistory->getRowCount() > 0) + m_gridCfgHistory->scrollTo(std::clamp(globalCfg.mainDlg.config.topRowPos, //must be set *after* wxAuiManager::LoadPerspective() to have any effect + 0, m_gridCfgHistory->getRowCount() - 1)); + //first selected item should *always* be visible: + const std::vector selectedRows = m_gridCfgHistory->getSelectedRows(); + if (!selectedRows.empty()) + { + m_gridCfgHistory->setGridCursor(selectedRows[0], GridEventPolicy::deny); + //= Grid::makeRowVisible() + set grid cursor (+ select cursor row => undo:) + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + } + //start up: user most likely wants to change config, or start comparison by pressing ENTER + m_gridCfgHistory->SetFocus(); +} + + +MainDialog::~MainDialog() +{ + std::wstring errorMsg; + try //LastRun.ffs_gui + { + writeConfig(getConfig(), lastRunConfigPath_); //throw FileError + } + catch (const FileError& e) { errorMsg += e.toString() + L"\n\n"; } + + try //GlobalSettings.xml + { + writeConfig(getGlobalCfgBeforeExit(), globalCfgFilePath_); //throw FileError + } + catch (const FileError& e) { errorMsg += e.toString() + L"\n\n"; } + + //don't annoy users on read-only drives: it's enough to show a single error message + if (!errorMsg.empty()) + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(trimCpy(errorMsg))); + + //auiMgr_.UnInit(); - "since wxWidgets 3.1.4 [...] it will be called automatically when this window is destroyed, as well as when the manager itself is." + + for (wxMenuItem* item : detachedMenuItems_) + delete item; //something's got to give + + //no need for wxEventHandler::Unbind(): event sources are components of this window and are destroyed, too +} + +//------------------------------------------------------------------------------------------------------------------------------------- + +void MainDialog::onBeforeSystemShutdown() +{ + try { writeConfig(getConfig(), lastRunConfigPath_); } + catch (const FileError& e) { logExtraError(e.toString()); } + + try { writeConfig(getGlobalCfgBeforeExit(), globalCfgFilePath_); } + catch (const FileError& e) { logExtraError(e.toString()); } +} + + +void MainDialog::onClose(wxCloseEvent& event) +{ + //wxEVT_END_SESSION is already handled by application.cpp::onSystemShutdown()! + + //regular destruction handling + if (event.CanVeto()) + { + //=> veto all attempts to close the main window while comparison or synchronization are running: + if (operationInProgress_) + { + event.Veto(); + Raise(); //=what Windows does when vetoing a close (via middle mouse on taskbar preview) while showing a modal dialog + SetFocus(); // + return; + } + + const bool cancelled = !saveOldConfig(); //notify user about changed settings + if (cancelled) //...or error + { + event.Veto(); + return; + } + } + + Destroy(); +} + + +void MainDialog::setGlobalCfgOnInit(const GlobalConfig& globalCfg) +{ + globalCfg_ = globalCfg; + + DpiLayout layout; + if (auto it = globalCfg.dpiLayouts.find(getDpiScalePercent()); + it != globalCfg.dpiLayouts.end()) + layout = it->second; + + //caveat: set/get language asymmmetry! setLanguage(globalCfg.programLanguage); //throw FileError + //we need to set language before creating this class! + + WindowLayout::setInitial(*this, {layout.mainDlg.size, layout.mainDlg.pos, layout.mainDlg.isMaximized}, {dipToWxsize(900), dipToWxsize(600)} /*defaultSize*/); + + //set column attributes + m_gridMainL ->setColumnConfig(convertColAttributes(layout.fileColumnAttribsLeft, getFileGridDefaultColAttribsLeft())); + m_gridMainR ->setColumnConfig(convertColAttributes(layout.fileColumnAttribsRight, getFileGridDefaultColAttribsLeft())); + m_splitterMain->setSashOffset(globalCfg.mainDlg.sashOffset); + + m_gridOverview->setColumnConfig(convertColAttributes(layout.overviewColumnAttribs, getOverviewDefaultColAttribs())); + treegrid::setShowPercentage(*m_gridOverview, globalCfg.mainDlg.overview.showPercentBar); + + treegrid::getDataView(*m_gridOverview).setSortDirection(globalCfg.mainDlg.overview.lastSortColumn, globalCfg.mainDlg.overview.lastSortAscending); + + //-------------------------------------------------------------------------------- + //load list of configuration files + cfggrid::getDataView(*m_gridCfgHistory).set(globalCfg.mainDlg.config.fileHistory); + + //globalCfg.mainDlg.cfgGridTopRowPos => defer evaluation until later within MainDialog constructor + m_gridCfgHistory->setColumnConfig(convertColAttributes(layout.configColumnAttribs, getCfgGridDefaultColAttribs())); + cfggrid::getDataView(*m_gridCfgHistory).setSortDirection(globalCfg.mainDlg.config.lastSortColumn, globalCfg.mainDlg.config.lastSortAscending); + cfggrid::setSyncOverdueDays(*m_gridCfgHistory, globalCfg.mainDlg.config.syncOverdueDays); + //m_gridCfgHistory->Refresh(); <- implicit in last call + + //remove non-existent items: sufficient to call once at startup + std::vector cfgFilePaths; + for (const ConfigFileItem& item : globalCfg.mainDlg.config.fileHistory) + cfgFilePaths.push_back(item.cfgFilePath); + + cfgHistoryRemoveObsolete(cfgFilePaths); + + //are we spawning too many async jobs, considering cfgHistoryRemoveObsolete()!? + cfgHistoryUpdateNotes(cfgFilePaths); + //-------------------------------------------------------------------------------- + + //load list of last used folders + m_folderPathLeft ->setHistory(folderHistoryLeft_); + m_folderPathRight->setHistory(folderHistoryRight_); + + //show/hide file icons + filegrid::setupIcons(*m_gridMainL, *m_gridMainC, *m_gridMainR, globalCfg.mainDlg.showIcons, convert(globalCfg.mainDlg.iconSize)); + + filegrid::setItemPathForm(*m_gridMainL, globalCfg.mainDlg.itemPathFormatLeftGrid); + filegrid::setItemPathForm(*m_gridMainR, globalCfg.mainDlg.itemPathFormatRightGrid); + + //-------------------------------------------------------------------------------- + m_checkBoxMatchCase->SetValue(globalCfg_.mainDlg.textSearchRespectCase); + + //work around wxAuiManager::LoadPerspective overwriting pane captions with old values (might be different language!) + std::vector> paneCaptions; + for (wxAuiPaneInfo& paneInfo : auiMgr_.GetAllPanes()) + paneCaptions.emplace_back(&paneInfo, paneInfo.caption); + + //compare progress dialog minimum sizes are layout-dependent + can't be changed by user => don't load stale values from config + std::vector> paneConstraints; + auto preserveConstraint = [&paneConstraints](wxAuiPaneInfo& pane) { paneConstraints.emplace_back(&pane, pane.min_size, pane.best_size); }; + + wxAuiPaneInfo& progPane = auiMgr_.GetPane(compareStatus_->getAsWindow()); + preserveConstraint(progPane); + preserveConstraint(auiMgr_.GetPane(m_panelTopButtons)); + preserveConstraint(auiMgr_.GetPane(m_panelDirectoryPairs)); + preserveConstraint(auiMgr_.GetPane(m_panelSearch)); + preserveConstraint(auiMgr_.GetPane(m_panelViewFilter)); + preserveConstraint(auiMgr_.GetPane(m_panelConfig)); + + auiMgr_.LoadPerspective(layout.panelLayout, false /*update: don't call wxAuiManager::Update() yet*/); + + //restore original captions + for (const auto& [paneInfo, caption] : paneCaptions) + paneInfo->Caption(caption); + + //restore pane layout constraints + for (auto& [pane, minSize, bestSize] : paneConstraints) + { + pane->min_size = minSize; + pane->best_size = bestSize; + } + //-------------------------------------------------------------------------------- + + //if MainDialog::onBeforeSystemShutdown() is called while comparison is active, this panel is saved and restored as "visible" + progPane.Hide(); + + auiMgr_.GetPane(m_panelSearch).Hide(); //no need to show it on startup + auiMgr_.GetPane(m_panelLog ).Hide(); // + + auiMgr_.Update(); +} + + +GlobalConfig MainDialog::getGlobalCfgBeforeExit() +{ + Freeze(); //no need to Thaw() again!! + recalcMaxFolderPairsVisible(); + //-------------------------------------------------------------------------------- + GlobalConfig globalSettings = globalCfg_; + + globalSettings.programLanguage = getLanguage(); + + //retrieve column attributes + globalSettings.dpiLayouts[getDpiScalePercent()].fileColumnAttribsLeft = convertColAttributes(m_gridMainL->getColumnConfig()); + globalSettings.dpiLayouts[getDpiScalePercent()].fileColumnAttribsRight = convertColAttributes(m_gridMainR->getColumnConfig()); + globalSettings.mainDlg.sashOffset = m_splitterMain->getSashOffset(); + + globalSettings.dpiLayouts[getDpiScalePercent()].overviewColumnAttribs = convertColAttributes(m_gridOverview->getColumnConfig()); + globalSettings.mainDlg.overview.showPercentBar = treegrid::getShowPercentage(*m_gridOverview); + + const auto [sortCol, ascending] = treegrid::getDataView(*m_gridOverview).getSortConfig(); + globalSettings.mainDlg.overview.lastSortColumn = sortCol; + globalSettings.mainDlg.overview.lastSortAscending = ascending; + + //-------------------------------------------------------------------------------- + //write list of configuration files + std::vector cfgHistory + { + //make sure [Last session] is always part of history list + ConfigFileItem(lastRunConfigPath_, LastRunStats{}, wxSystemSettings::GetAppearance().IsDark() ? 0xb7b7b7 : 0xdddddd /*grey from onCfgGridContext()*/) + }; + + for (const ConfigFileItem& item : cfggrid::getDataView(*m_gridCfgHistory).get()) + if (equalNativePath(item.cfgFilePath, lastRunConfigPath_)) + cfgHistory[0] = item; //preserve users's background color choice + else + cfgHistory.push_back(item); + + //trim excess elements (oldest first) + if (cfgHistory.size() > globalSettings.mainDlg.config.histItemsMax) + cfgHistory.resize(globalSettings.mainDlg.config.histItemsMax); + + globalSettings.mainDlg.config.fileHistory = std::move(cfgHistory); + globalSettings.mainDlg.config.topRowPos = m_gridCfgHistory->getRowAtWinPos(0); + globalSettings.dpiLayouts[getDpiScalePercent()].configColumnAttribs = convertColAttributes(m_gridCfgHistory->getColumnConfig()); + globalSettings.mainDlg.config.syncOverdueDays = cfggrid::getSyncOverdueDays(*m_gridCfgHistory); + + std::tie(globalSettings.mainDlg.config.lastSortColumn, + globalSettings.mainDlg.config.lastSortAscending) = cfggrid::getDataView(*m_gridCfgHistory).getSortDirection(); + //-------------------------------------------------------------------------------- + globalSettings.mainDlg.config.lastUsedFiles = activeConfigFiles_; + + //write list of last used folders + globalSettings.mainDlg.folderHistoryLeft = folderHistoryLeft_ ->getList(); + globalSettings.mainDlg.folderHistoryRight = folderHistoryRight_->getList(); + + globalSettings.mainDlg.textSearchRespectCase = m_checkBoxMatchCase->GetValue(); + + wxAuiPaneInfo& logPane = auiMgr_.GetPane(m_panelLog); + assert(m_bpButtonToggleLog->isActive() == logPane.IsShown()); + + if (logPane.IsShown()) + { + if (logPane.IsMaximized()) + auiMgr_.RestorePane(logPane); //!= wxAuiPaneInfo::Restore() which does not un-hide other panels (WTF!?) + else //ensure current window sizes will be used when pane is shown again: + logPane.best_size = logPane.rect.GetSize(); + } + //else: logPane.best_size already contains non-maximized value + + //auiMgr_.Update(); //[!] not needed + globalSettings.dpiLayouts[getDpiScalePercent()].panelLayout = auiMgr_.SavePerspective(); //does not need wxAuiManager::Update()! + + const auto& [size, pos, isMaximized] = WindowLayout::getBeforeClose(*this); //call *after* wxAuiManager::SavePerspective()! + globalSettings.dpiLayouts[getDpiScalePercent()].mainDlg = {size, pos, isMaximized}; + + return globalSettings; +} + + +namespace +{ +//user expectations for partial sync: +// 1. selected folder implies also processing child items +// 2. to-be-moved item requires also processing target item +std::vector expandSelectionForPartialSync(const std::vector& selection) +{ + std::vector output; + + for (FileSystemObject* fsObj : selection) + visitFSObjectRecursively(*fsObj, [&](FolderPair& folder) { output.push_back(&folder); }, + [&](FilePair& file) + { + output.push_back(&file); + switch (file.getSyncOperation()) //evaluate comparison result and sync direction + { + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + if (FilePair* refFile = file.getMovePair()) + output.push_back(refFile); + else assert(false); + break; + + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + case SO_UNRESOLVED_CONFLICT: + case SO_DO_NOTHING: + case SO_EQUAL: + break; + } + }, + [&](SymlinkPair& symlink) { output.push_back(&symlink); }); + + removeDuplicates(output); + return output; +} + + +bool selectionIncludesNonEqualItem(const std::vector& selection) +{ + struct ItemFound {}; + try + { + auto onFsItem = [](FileSystemObject& fsObj) { if (fsObj.getSyncOperation() != SO_EQUAL) throw ItemFound(); }; + + for (FileSystemObject* fsObj : selection) + visitFSObjectRecursively(*fsObj, onFsItem, onFsItem, onFsItem); + return false; + } + catch (ItemFound&) { return true;} +} +} + + +void MainDialog::setSyncDirManually(const std::vector& selection, SyncDirection direction) +{ + if (!selectionIncludesNonEqualItem(selection)) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + + for (FileSystemObject* fsObj : selection) + { + setSyncDirectionRec(direction, *fsObj); //set new direction (recursively) + setActiveStatus(true, *fsObj); //works recursively for directories + } + updateGui(); +} + + +void MainDialog::setIncludedManually(const std::vector& selection, bool setActive) +{ + //if hidefiltered is active, there should be no filtered elements on screen => current element was filtered out + assert(m_bpButtonShowExcluded->isActive() || !setActive); + + if (selection.empty()) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + + for (FileSystemObject* fsObj : selection) + setActiveStatus(setActive, *fsObj); //works recursively for directories + + updateGuiDelayedIf(!m_bpButtonShowExcluded->isActive()); //show update GUI before removing rows +} + + +void MainDialog::copyGridSelectionToClipboard(const zen::Grid& grid) +{ + try + { + wxString clipBuf; //perf: old wxString didn't model exponential growth, but now it's std::string-based: + static_assert(std::is_same_v); + + if (auto prov = grid.getDataProvider()) + { + std::vector colAttr = grid.getColumnConfig(); + std::erase_if(colAttr, [](const Grid::ColAttributes& ca) { return !ca.visible; }); + + for (size_t row : grid.getSelectedRows()) + for (auto it = colAttr.begin(); it != colAttr.end(); ++it) + { + clipBuf += prov->getValue(row, it->type); + clipBuf += it == colAttr.end() - 1 ? L'\n' : L'\t'; + } + } + + setClipboardText(clipBuf); + } + catch (const std::bad_alloc& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setMainInstructions(_("Out of memory.") + L' ' + utfTo(e.what()))); + } +} + + +void MainDialog::copyPathsToClipboard(const std::vector& selectionL, + const std::vector& selectionR) +{ + try + { + wxString clipBuf; //perf: old wxString didn't model exponential growth, but now it's std::string-based: + static_assert(std::is_same_v); + + auto appendPath = [&](const AbstractPath& itemPath) + { + if (!clipBuf.empty()) + clipBuf += L'\n'; + clipBuf += AFS::getDisplayPath(itemPath); + }; + + for (const FileSystemObject* fsObj : selectionL) + //if (!fsObj->isEmpty()) + appendPath(fsObj->getAbstractPath()); + + for (const FileSystemObject* fsObj : selectionR) + //if (!fsObj->isEmpty()) + appendPath(fsObj->getAbstractPath()); + + setClipboardText(clipBuf); + } + catch (const std::bad_alloc& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setMainInstructions(_("Out of memory.") + L' ' + utfTo(e.what()))); + } +} + + +std::vector MainDialog::getGridSelection(bool fromLeft, bool fromRight) const +{ + std::vector selectedRows; + + if (fromLeft) + append(selectedRows, m_gridMainL->getSelectedRows()); + + if (fromRight) + append(selectedRows, m_gridMainR->getSelectedRows()); + + removeDuplicates(selectedRows); + assert(std::is_sorted(selectedRows.begin(), selectedRows.end())); + + return filegrid::getDataView(*m_gridMainC).getAllFileRef(selectedRows); +} + + +std::vector MainDialog::getTreeSelection() const +{ + std::vector output; + + for (size_t row : m_gridOverview->getSelectedRows()) + if (std::unique_ptr node = treegrid::getDataView(*m_gridOverview).getLine(row)) + { + if (auto root = dynamic_cast(node.get())) + { + //selecting root means "select everything", *ignoring* current view filter! + for (FileSystemObject& fsObj : root->baseFolder.subfolders()) //no need to explicitly add child elements! + output.push_back(&fsObj); + for (FileSystemObject& fsObj : root->baseFolder.files()) + output.push_back(&fsObj); + for (FileSystemObject& fsObj : root->baseFolder.symlinks()) + output.push_back(&fsObj); + } + else if (auto dir = dynamic_cast(node.get())) + output.push_back(&(dir->folder)); + else if (auto file = dynamic_cast(node.get())) + append(output, file->filesAndLinks); + else assert(false); + } + return output; +} + + +void MainDialog::copyToAlternateFolder(const std::vector& selectionL, + const std::vector& selectionR) +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + std::vector copyLeft; + std::vector copyRight; + + for (const FileSystemObject* fsObj : selectionL) + if (!fsObj->isEmpty()) + copyLeft.push_back(fsObj); + + for (const FileSystemObject* fsObj : selectionR) + if (!fsObj->isEmpty()) + copyRight.push_back(fsObj); + + if (copyLeft.empty() && copyRight.empty()) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + + const int itemCount = static_cast(copyLeft.size() + copyRight.size()); + std::wstring itemList; + + for (const FileSystemObject* fsObj : copyLeft) + itemList += AFS::getDisplayPath(fsObj->getAbstractPath()) + L'\n'; + + for (const FileSystemObject* fsObj : copyRight) + itemList += AFS::getDisplayPath(fsObj->getAbstractPath()) + L'\n'; + //------------------------------------------------------------------ + + FocusPreserver fp; + + if (showCopyToDialog(this, + itemList, itemCount, + globalCfg_.mainDlg.copyToCfg.targetFolderPath, + globalCfg_.mainDlg.copyToCfg.targetFolderLastSelected, + globalCfg_.mainDlg.copyToCfg.folderHistory, globalCfg_.folderHistoryMax, + globalCfg_.sftpKeyFileLastSelected, + globalCfg_.mainDlg.copyToCfg.keepRelPaths, + globalCfg_.mainDlg.copyToCfg.overwriteIfExists) != ConfirmationButton::accept) + return; + + const auto& guiCfg = getConfig(); + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + try + { + fff::copyToAlternateFolder(copyLeft, copyRight, + globalCfg_.mainDlg.copyToCfg.targetFolderPath, + globalCfg_.mainDlg.copyToCfg.keepRelPaths, + globalCfg_.mainDlg.copyToCfg.overwriteIfExists, + globalCfg_.warnDlgs, + statusHandler); //throw CancelProcess + + //"clearSelection" not needed/desired + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + + //updateGui(); -> not needed +} + + +void MainDialog::deleteSelectedFiles(const std::vector& selectionL, + const std::vector& selectionR, bool moveToRecycler) +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + std::vector deleteLeft = selectionL; + std::vector deleteRight = selectionR; + + std::erase_if(deleteLeft, [](const FileSystemObject* fsObj) { return fsObj->isEmpty(); }); + std::erase_if(deleteRight, [](const FileSystemObject* fsObj) { return fsObj->isEmpty(); }); + + if (deleteLeft.empty() && deleteRight.empty()) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + + const int itemCount = static_cast(deleteLeft.size() + deleteRight.size()); + std::wstring itemList; + + for (const FileSystemObject* fsObj : deleteLeft) + itemList += AFS::getDisplayPath(fsObj->getAbstractPath()) + L'\n'; + + for (const FileSystemObject* fsObj : deleteRight) + itemList += AFS::getDisplayPath(fsObj->getAbstractPath()) + L'\n'; + //------------------------------------------------------------------ + + FocusPreserver fp; + + if (showDeleteDialog(this, itemList, itemCount, + moveToRecycler) != ConfirmationButton::accept) + return; + + //wxBusyCursor dummy; -> redundant: progress already shown in status bar! + const auto& guiCfg = getConfig(); + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + try + { + deleteFiles(deleteLeft, deleteRight, + extractDirectionCfg(folderCmp_, getConfig().mainCfg), + moveToRecycler, + globalCfg_.warnDlgs.warnRecyclerMissing, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + + append(fullSyncLog_->log, r.errorLog.ref()); + fullSyncLog_->totalTime += r.summary.totalTime; + + //remove rows that are empty: just a beautification, invalid rows shouldn't cause issues + filegrid::getDataView(*m_gridMainC).removeInvalidRows(); + + updateGui(); +} + + +void MainDialog::renameSelectedFiles(const std::vector& selectionL, + const std::vector& selectionR) +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + std::vector renameLeft = selectionL; + std::vector renameRight = selectionR; + + std::erase_if(renameLeft, [](const FileSystemObject* fsObj) { return fsObj->isEmpty(); }); + std::erase_if(renameRight, [](const FileSystemObject* fsObj) { return fsObj->isEmpty(); }); + + if (renameLeft.empty() && renameRight.empty()) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + //------------------------------------------------------------------ + + std::vector fileNamesOld; + for (const FileSystemObject* fsObj : renameLeft) + fileNamesOld.push_back(fsObj->getItemName()); + + for (const FileSystemObject* fsObj : renameRight) + fileNamesOld.push_back(fsObj->getItemName()); + + FocusPreserver fp; + + std::vector fileNamesNew; + if (showRenameDialog(this, fileNamesOld, fileNamesNew) != ConfirmationButton::accept) + return; + + //wxBusyCursor dummy; -> redundant: progress already shown in status bar! + const auto& guiCfg = getConfig(); + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + try + { + renameItems(renameLeft, {fileNamesNew.data(), renameLeft.size()}, + renameRight, {fileNamesNew.data() + renameLeft.size(), fileNamesNew.size() - renameLeft.size()}, + extractDirectionCfg(folderCmp_, getConfig().mainCfg), + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + + append(fullSyncLog_->log, r.errorLog.ref()); + fullSyncLog_->totalTime += r.summary.totalTime; + + updateGui(); +} + + +namespace +{ +template +AbstractPath getExistingParentFolder(const FileSystemObject& fsObj) +{ + auto folder = dynamic_cast(&fsObj); + if (!folder) + folder = dynamic_cast(&fsObj.parent()); + + while (folder) + { + if (!folder->isEmpty()) + return folder->getAbstractPath(); + + folder = dynamic_cast(&folder->parent()); + } + return fsObj.base().getAbstractPath(); +} + + +template +void extractFileDescriptor(const FileSystemObject& fsObj, Function onDescriptor) +{ + if (!fsObj.isEmpty()) + visitFSObject(fsObj, [](const FolderPair& folder) {}, + [&](const FilePair& file) + { + onDescriptor(FileDescriptor{file.getAbstractPath(), file.getAttributes()}); + }, + [](const SymlinkPair& symlink) {}); +} + + +template +void collectNonNativeFiles(const std::vector& selectedRows, const TempFileBuffer& tempFileBuf, + std::set& workLoad) +{ + for (const FileSystemObject* fsObj : selectedRows) + extractFileDescriptor(*fsObj, [&](const FileDescriptor& descr) + { + if (getNativeItemPath(descr.path).empty() && + tempFileBuf.getTempPath(descr).empty()) //TempFileBuffer::createTempFiles() contract! + workLoad.insert(descr); + }); +} + + +struct ItemPathInfo +{ + Zstring itemPath; + Zstring itemPath2; + Zstring itemName; + Zstring itemName2; + Zstring parentPath; + Zstring parentPath2; + Zstring localPath; + Zstring localPath2; +}; +template +std::vector getItemPathInfo(const std::vector& selection, const TempFileBuffer& tempFileBuf) +{ + constexpr SelectSide side2 = getOtherSide; + + std::vector pathInfos; + + for (const FileSystemObject* fsObj : selection) //context menu calls this function only if selection is not empty! + { + const AbstractPath basePath = fsObj->base().getAbstractPath(); + const AbstractPath basePath2 = fsObj->base().getAbstractPath(); + + //return paths, even if item is not (yet) existing: + const Zstring itemPath = AFS::isNullPath(basePath ) ? Zstr("") : utfTo(AFS::getDisplayPath(fsObj-> getAbstractPath())); + const Zstring itemPath2 = AFS::isNullPath(basePath2) ? Zstr("") : utfTo(AFS::getDisplayPath(fsObj-> getAbstractPath())); + const Zstring itemName = AFS::isNullPath(basePath ) ? Zstr("") : AFS::getItemName (fsObj-> getAbstractPath()); + const Zstring itemName2 = AFS::isNullPath(basePath2) ? Zstr("") : AFS::getItemName (fsObj-> getAbstractPath()); + const Zstring parentPath = AFS::isNullPath(basePath ) ? Zstr("") : utfTo(AFS::getDisplayPath(fsObj->parent().getAbstractPath())); + const Zstring parentPath2 = AFS::isNullPath(basePath2) ? Zstr("") : utfTo(AFS::getDisplayPath(fsObj->parent().getAbstractPath())); + + Zstring localPath; + Zstring localPath2; + + if (const Zstring& nativePath = getNativeItemPath(fsObj->getAbstractPath()); + !nativePath.empty()) + localPath = nativePath; //no matter if item exists or not + else //returns empty if not available (item not existing, error during copy): + extractFileDescriptor(*fsObj, [&](const FileDescriptor& descr) { localPath = tempFileBuf.getTempPath(descr); }); + + if (const Zstring& nativePath = getNativeItemPath(fsObj->getAbstractPath()); + !nativePath.empty()) + localPath2 = nativePath; + else + extractFileDescriptor(*fsObj, [&](const FileDescriptor& descr) { localPath2 = tempFileBuf.getTempPath(descr); }); + + if (localPath .empty()) localPath = replaceCpy(utfTo(L"<" + _("Local path not available for %x.") + L">"), Zstr("%x"), itemPath ); + if (localPath2.empty()) localPath2 = replaceCpy(utfTo(L"<" + _("Local path not available for %x.") + L">"), Zstr("%x"), itemPath2); + + pathInfos.push_back( + { + itemPath, + itemPath2, + itemName, + itemName2, + parentPath, + parentPath2, + localPath, + localPath2, + }); + } + return pathInfos; +} +} + + +void MainDialog::openExternalApplication(const Zstring& commandLinePhrase, bool leftSide, + const std::vector& selectionL, + const std::vector& selectionR) +{ + //do not open more than one Explorer instance! + if (commandLinePhrase == extCommandFileManager.cmdLine) + if (selectionL.size() + selectionR.size() > 1) + { + if (( leftSide && !selectionL.empty()) || + (!leftSide && selectionR.empty())) + return openExternalApplication(commandLinePhrase, leftSide, {selectionL[0]}, {}); + else + return openExternalApplication(commandLinePhrase, leftSide, {}, {selectionR[0]}); + } + + //---------------------------------------------------------------- + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + try + { + //support fallback instead of an error in this special case + if (commandLinePhrase == extCommandFileManager.cmdLine) + { + //either left or right selection is filled with exactly one item (or no selection at all) + AbstractPath itemPath = getNullPath(); + if (!selectionL.empty()) + { + if (selectionL[0]->isEmpty()) + return openFolderInFileBrowser(getExistingParentFolder(*selectionL[0])); //throw FileError + + itemPath = selectionL[0]->getAbstractPath(); + } + else if (!selectionR.empty()) + { + if (selectionR[0]->isEmpty()) + return openFolderInFileBrowser(getExistingParentFolder(*selectionR[0])); //throw FileError + + itemPath = selectionR[0]->getAbstractPath(); + } + else + return openFolderInFileBrowser(leftSide ? //throw FileError + createAbstractPath(firstFolderPair_->getValues().folderPathPhraseLeft) : + createAbstractPath(firstFolderPair_->getValues().folderPathPhraseRight)); + + //itemPath != base folder in this context + if (const Zstring& gdriveUrl = getGoogleDriveFolderUrl(*AFS::getParentPath(itemPath)); //throw FileError + !gdriveUrl.empty()) + return openWithDefaultApp(gdriveUrl); //throw FileError + } + + std::vector cmdLines; + if (containsFileItemMacro(commandLinePhrase)) + { + //regular command evaluation: + const size_t invokeCount = selectionL.size() + selectionR.size(); + assert(invokeCount > 0); + if (invokeCount > EXT_APP_MASS_INVOKE_THRESHOLD) + if (globalCfg_.confirmDlgs.confirmCommandMassInvoke) + { + bool dontAskAgain = false; + switch (showConfirmationDialog(this, DialogInfoType::warning, PopupDialogCfg().setTitle(_("Confirm")). + setMainInstructions(replaceCpy(_P("Do you really want to execute the command %y for one item?", + "Do you really want to execute the command %y for %x items?", invokeCount), + L"%y", fmtPath(commandLinePhrase))). + setCheckBox(dontAskAgain, _("&Don't show this warning again")), + _("&Execute"))) + { + case ConfirmationButton::accept: + globalCfg_.confirmDlgs.confirmCommandMassInvoke = !dontAskAgain; + break; + case ConfirmationButton::cancel: + return; + } + } + + std::set nonNativeFiles; + if (contains(commandLinePhrase, macroNameLocalPath) || + contains(commandLinePhrase, macroNameLocalPaths)) + { + collectNonNativeFiles(selectionL, tempFileBuf_, nonNativeFiles); + collectNonNativeFiles(selectionR, tempFileBuf_, nonNativeFiles); + } + if (contains(commandLinePhrase, macroNameLocalPath2)) + { + collectNonNativeFiles(selectionL, tempFileBuf_, nonNativeFiles); + collectNonNativeFiles(selectionR, tempFileBuf_, nonNativeFiles); + } + + //##################### create temporary files for non-native paths ###################### + if (!nonNativeFiles.empty()) + { + const auto& guiCfg = getConfig(); + + FocusPreserver fp; + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + try + { + tempFileBuf_.createTempFiles(nonNativeFiles, statusHandler); //throw CancelProcess + //"clearSelection" not needed/desired + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + + if (r.summary.result == TaskResult::cancelled) + return; + + //updateGui(); -> not needed + } + //######################################################################################## + + std::vector pathInfos; + append(pathInfos, getItemPathInfo(selectionL, tempFileBuf_)); + append(pathInfos, getItemPathInfo(selectionR, tempFileBuf_)); + + Zstring cmdLineTmp = expandMacros(commandLinePhrase); + + //support path lists for a single command line: https://freefilesync.org/forum/viewtopic.php?t=10328#p39305 + auto replaceListMacro = [&](const ZstringView macroName, const Zstring ItemPathInfo::*itemPath) + { + replace(cmdLineTmp, Zstring() + Zstr('"') + macroName + Zstr('"'), macroName); //get rid of quotes if existing + + if (contains(cmdLineTmp, macroName)) + { + Zstring pathList; + for (const ItemPathInfo& pathInfo : pathInfos) + { + if (!pathList.empty()) + pathList += Zstr(' '); + pathList += escapeCommandArg(pathInfo.*itemPath); + } + replace(cmdLineTmp, macroName, pathList); + } + }; + replaceListMacro(macroNameItemPaths, &ItemPathInfo::itemPath); + replaceListMacro(macroNameLocalPaths, &ItemPathInfo::localPath); + replaceListMacro(macroNameItemNames, &ItemPathInfo::itemName); + replaceListMacro(macroNameParentPaths, &ItemPathInfo::parentPath); + + //generate multiple command lines per each selected item + for (const ItemPathInfo& pathInfo : pathInfos) + if (commandLinePhrase == extCommandOpenDefault.cmdLine) + //not strictly needed, but: 1. better error reporting (Windows) 2. not async => avoid zombies (Linux/macOS) + openWithDefaultApp(pathInfo.localPath); //throw FileError + else + { + Zstring cmdLineItem = cmdLineTmp; + + auto replaceMacro = [&](const ZstringView macroName, const Zstring& value) + { + replace(cmdLineItem, Zstring() + Zstr('"') + macroName + Zstr('"'), macroName); //get rid of quotes if existing + replace(cmdLineItem, macroName, escapeCommandArg(value)); + }; + + replaceMacro(macroNameItemPath, pathInfo.itemPath); + replaceMacro(macroNameItemPath2, pathInfo.itemPath2); + replaceMacro(macroNameLocalPath, pathInfo.localPath); + replaceMacro(macroNameLocalPath2, pathInfo.localPath2); + replaceMacro(macroNameItemName, pathInfo.itemName); + replaceMacro(macroNameItemName2, pathInfo.itemName2); + replaceMacro(macroNameParentPath, pathInfo.parentPath); + replaceMacro(macroNameParentPath2, pathInfo.parentPath2); + + cmdLines.push_back(std::move(cmdLineItem)); + } + + removeDuplicatesStable(cmdLines); + } + else + cmdLines.push_back(expandMacros(commandLinePhrase)); //add single entry (even if selection is empty!) + + for (const Zstring& cmdLine : cmdLines) + try + { + std::optional timeoutMs; + if (cmdLines.size() <= EXT_APP_MASS_INVOKE_THRESHOLD) + timeoutMs = EXT_APP_MAX_TOTAL_WAIT_TIME_MS / cmdLines.size(); //run async, but give consoleExecute() some "time to fail" + //else: run synchronously + + if (const auto& [exitCode, output] = consoleExecute(cmdLine, timeoutMs); //throw SysError, SysErrorTimeOut + exitCode != 0) + throw SysError(formatSystemError(utfTo(commandLinePhrase), + replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + } + catch (SysErrorTimeOut&) {} //child process not failed yet => probably fine :> + catch (const SysError& e) { throw FileError(replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLine)), e.toString()); } + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } +} + + +void MainDialog::setStatusInfo(const wxString& text, bool highlight) +{ + if (statusTxts_.empty()) + { + m_staticTextStatusCenter->SetFont((m_staticTextStatusCenter->GetFont().*(highlight ? &wxFont::Bold : &wxFont::GetBaseFont))()); + m_staticTextStatusCenter->SetForegroundColour(highlight ? getColorFlashStatusInfo() : wxNullColour); + + setText(*m_staticTextStatusCenter, text); + m_panelStatusBar->Layout(); + } + else + statusTxts_.front() = text; + + statusTxtHighlightFirst_ = highlight; +} + + +void MainDialog::flashStatusInfo(const wxString& text) +{ + if (statusTxts_.empty()) + { + statusTxts_.push_back(m_staticTextStatusCenter->GetLabelText()); + statusTxts_.push_back(text); + + m_staticTextStatusCenter->SetForegroundColour(getColorFlashStatusInfo()); + m_staticTextStatusCenter->SetFont(m_staticTextStatusCenter->GetFont().Bold()); + + popStatusInfo(); + } + else + statusTxts_.insert(statusTxts_.begin() + 1, text); +} + + +void MainDialog::popStatusInfo() +{ + assert(!statusTxts_.empty()); + if (!statusTxts_.empty()) + { + const wxString statusTxt = std::move(statusTxts_.back()); + statusTxts_.pop_back(); + + if (statusTxts_.empty()) + setStatusInfo(statusTxt, statusTxtHighlightFirst_); + else + { + guiQueue_.processAsync([] { std::this_thread::sleep_for(std::chrono::seconds(3)); }, [this] { popStatusInfo(); }); + + setText(*m_staticTextStatusCenter, statusTxt); + m_panelStatusBar->Layout(); + } + } +} + + +void MainDialog::onResizeTopButtonPanel(wxEvent& event) +{ + const double horizontalWeight = 0.3; + const int newOrientation = m_panelTopButtons->GetSize().GetWidth() * horizontalWeight > + m_panelTopButtons->GetSize().GetHeight() ? wxHORIZONTAL : wxVERTICAL; //check window, NOT sizer width! + + assert(m_buttonCompare->GetContainingSizer()->GetItem(static_cast(0))->IsSpacer()); + + if (bSizerTopButtons->GetOrientation() != newOrientation) + { + bSizerTopButtons->SetOrientation(newOrientation); + + m_buttonCompare->GetContainingSizer()->GetItem(static_cast(0))->SetProportion(newOrientation == wxHORIZONTAL ? 1 : 0); + m_buttonCancel ->GetContainingSizer()->GetItem(m_buttonCancel) ->SetProportion(newOrientation == wxHORIZONTAL ? 0 : 1); + m_buttonCompare->GetContainingSizer()->GetItem(m_buttonCompare) ->SetProportion(newOrientation == wxHORIZONTAL ? 0 : 1); + m_buttonSync ->GetContainingSizer()->GetItem(m_buttonSync) ->SetProportion(newOrientation == wxHORIZONTAL ? 0 : 1); + + m_panelTopButtons->Layout(); + } + event.Skip(); +} + + +void MainDialog::onResizeConfigPanel(wxEvent& event) +{ + const double horizontalWeight = 0.75; + const int newOrientation = m_panelConfig->GetSize().GetWidth() * horizontalWeight > + m_panelConfig->GetSize().GetHeight() ? wxHORIZONTAL : wxVERTICAL; //check window, NOT sizer width! + if (bSizerConfig->GetOrientation() != newOrientation) + { + //hide button labels for horizontal layout + for (wxSizerItem* szItem : bSizerCfgHistoryButtons->GetChildren()) + if (auto sizerChild = dynamic_cast(szItem->GetSizer())) + for (wxSizerItem* szItem2 : sizerChild->GetChildren()) + if (auto btnLabel = dynamic_cast(szItem2->GetWindow())) + btnLabel->Show(newOrientation == wxVERTICAL); + + bSizerConfig->SetOrientation(newOrientation); + bSizerCfgHistoryButtons->SetOrientation(newOrientation == wxHORIZONTAL ? wxVERTICAL : wxHORIZONTAL); + bSizerSaveAs ->SetOrientation(newOrientation == wxHORIZONTAL ? wxVERTICAL : wxHORIZONTAL); + m_panelConfig->Layout(); + } + event.Skip(); +} + + +void MainDialog::onResizeViewPanel(wxEvent& event) +{ + const int newOrientation = m_panelViewFilter->GetSize().GetWidth() > + m_panelViewFilter->GetSize().GetHeight() ? wxHORIZONTAL : wxVERTICAL; //check window, NOT sizer width! + if (bSizerViewFilter->GetOrientation() != newOrientation) + { + bSizerStatistics ->SetOrientation(newOrientation); + bSizerViewButtons->SetOrientation(newOrientation); + bSizerViewFilter ->SetOrientation(newOrientation); + + //apply opposite orientation for child sizers + const int childOrient = newOrientation == wxHORIZONTAL ? wxVERTICAL : wxHORIZONTAL; + + for (wxSizerItem* szItem : bSizerStatistics->GetChildren()) + if (auto sizerChild = dynamic_cast(szItem->GetSizer())) + if (sizerChild->GetOrientation() != childOrient) + sizerChild->SetOrientation(childOrient); + + m_panelViewFilter->Layout(); + m_panelStatistics->Layout(); + } + event.Skip(); +} + + +void MainDialog::onResizeLeftFolderWidth(wxEvent& event) +{ + //adapt left-shift display distortion caused by scrollbars for multiple folder pairs + const int width = m_panelTopLeft->GetSize().GetWidth(); + for (FolderPairPanel* panel : additionalFolderPairs_) + panel->m_panelLeft->SetMinSize({width, -1}); + + event.Skip(); +} + + +void MainDialog::onTreeKeyEvent(wxKeyEvent& event) +{ + const std::vector selection = getTreeSelection(); + + int keyCode = event.GetKeyCode(); + if (m_gridOverview->GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + + if (event.ControlDown()) + switch (keyCode) + { + case 'C': + case WXK_INSERT: //CTRL + C || CTRL + INS + case WXK_NUMPAD_INSERT: + copyGridSelectionToClipboard(*m_gridOverview); + return; + } + + else if (event.AltDown()) + switch (keyCode) + { + case WXK_NUMPAD_LEFT: + case WXK_LEFT: //ALT + + setSyncDirManually(selection, SyncDirection::left); + return; + + case WXK_NUMPAD_RIGHT: + case WXK_RIGHT: //ALT + + setSyncDirManually(selection, SyncDirection::right); + return; + + case WXK_NUMPAD_UP: + case WXK_NUMPAD_DOWN: + case WXK_UP: //ALT + + case WXK_DOWN: //ALT + + setSyncDirManually(selection, SyncDirection::none); + return; + } + + else + switch (keyCode) + { + case WXK_F2: + case WXK_NUMPAD_F2: + renameSelectedFiles(selection, selection); + return; + + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + startSyncForSelecction(selection); + return; + + case WXK_SPACE: + case WXK_NUMPAD_SPACE: + if (!selection.empty()) + setIncludedManually(selection, m_bpButtonShowExcluded->isActive() && !selection[0]->isActive()); + //always exclude items if "m_bpButtonShowExcluded is unchecked" => yes, it's possible to have already unchecked items in selection, so we need to overwrite: + //e.g. select root node while the first item returned is not shown on grid! + return; + + case WXK_DELETE: + case WXK_NUMPAD_DELETE: + deleteSelectedFiles(selection, selection, !event.ShiftDown() /*moveToRecycler*/); + return; + } + + event.Skip(); //unknown keypress: propagate +} + + +void MainDialog::onGridKeyEvent(wxKeyEvent& event, Grid& grid, bool leftSide) +{ + const std::vector selection = getGridSelection(); + const std::vector selectionL = getGridSelection(true, false); + const std::vector selectionR = getGridSelection(false, true); + + int keyCode = event.GetKeyCode(); + if (grid.GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + + if (event.ControlDown()) + switch (keyCode) + { + case 'C': + case WXK_INSERT: //CTRL + C || CTRL + INS + case WXK_NUMPAD_INSERT: + copyPathsToClipboard(selectionL, selectionR); + return; // -> swallow event! don't allow default grid commands! + + case 'T': //CTRL + T + copyToAlternateFolder(selectionL, selectionR); + return; + } + + else if (event.AltDown()) + switch (keyCode) + { + case WXK_NUMPAD_LEFT: + case WXK_LEFT: //ALT + + setSyncDirManually(selection, SyncDirection::left); + return; + + case WXK_NUMPAD_RIGHT: + case WXK_RIGHT: //ALT + + setSyncDirManually(selection, SyncDirection::right); + return; + + case WXK_NUMPAD_UP: + case WXK_NUMPAD_DOWN: + case WXK_UP: //ALT + + case WXK_DOWN: //ALT + + setSyncDirManually(selection, SyncDirection::none); + return; + } + + else + { + //0 ... 9 + const size_t extAppPos = [&]() -> size_t + { + if ('0' <= keyCode && keyCode <= '9') + return keyCode - '0'; + if (WXK_NUMPAD0 <= keyCode && keyCode <= WXK_NUMPAD9) + return keyCode - WXK_NUMPAD0; + return static_cast(-1); + }(); + + if (extAppPos < globalCfg_.externalApps.size()) + { + openExternalApplication(globalCfg_.externalApps[extAppPos].cmdLine, leftSide, selectionL, selectionR); + return; + } + + switch (keyCode) + { + case WXK_F2: + case WXK_NUMPAD_F2: + renameSelectedFiles(selectionL, selectionR); + return; + + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + startSyncForSelecction(selection); + return; + + case WXK_SPACE: + case WXK_NUMPAD_SPACE: + if (!selection.empty()) + setIncludedManually(selection, m_bpButtonShowExcluded->isActive() && !selection[0]->isActive()); + return; + + case WXK_DELETE: + case WXK_NUMPAD_DELETE: + deleteSelectedFiles(selectionL, selectionR, !event.ShiftDown() /*moveToRecycler*/); + return; + } + } + + event.Skip(); //unknown keypress: propagate +} + + +void MainDialog::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + if (localKeyEventsEnabled_) //avoid recursion + { + localKeyEventsEnabled_ = false; + ZEN_ON_SCOPE_EXIT(localKeyEventsEnabled_ = true); + + const int keyCode = event.GetKeyCode(); + + //CTRL + X + /* if (event.ControlDown()) + switch (keyCode) + { + case 'F': //CTRL + F + showFindPanel(); + return; //-> swallow event! + } */ + + if (event.ControlDown()) + switch (keyCode) + { + case WXK_TAB: //CTRL + TAB + case WXK_NUMPAD_TAB: //don't use F10: avoid accidental clicks: https://freefilesync.org/forum/viewtopic.php?t=1663 + swapSides(); + return; //-> swallow event! + } + + switch (keyCode) + { + case WXK_F3: + case WXK_NUMPAD_F3: + startFindNext(!event.ShiftDown() /*searchAscending*/); + return; //-> swallow event! + + //case WXK_F6: + //{ + // wxCommandEvent dummy2(wxEVT_COMMAND_BUTTON_CLICKED); + // m_bpButtonCmpConfig->Command(dummy2); //simulate click + //} + //return; //-> swallow event! + + //case WXK_F7: + //{ + // wxCommandEvent dummy2(wxEVT_COMMAND_BUTTON_CLICKED); + // m_bpButtonFilter->Command(dummy2); //simulate click + //} + //return; //-> swallow event! + + //case WXK_F8: + //{ + // wxCommandEvent dummy2(wxEVT_COMMAND_BUTTON_CLICKED); + // m_bpButtonSyncConfig->Command(dummy2); //simulate click + //} + //return; //-> swallow event! + + case WXK_F11: + setGridViewType(m_bpButtonViewType->isActive() ? GridViewType::difference : GridViewType::action); + return; //-> swallow event! + + //redirect certain (unhandled) keys directly to grid! + case WXK_UP: + case WXK_DOWN: + case WXK_LEFT: + case WXK_RIGHT: + case WXK_PAGEUP: + case WXK_PAGEDOWN: + case WXK_HOME: + case WXK_END: + + case WXK_NUMPAD_UP: + case WXK_NUMPAD_DOWN: + case WXK_NUMPAD_LEFT: + case WXK_NUMPAD_RIGHT: + case WXK_NUMPAD_PAGEUP: + case WXK_NUMPAD_PAGEDOWN: + case WXK_NUMPAD_HOME: + case WXK_NUMPAD_END: + { + const wxWindow* focus = wxWindow::FindFocus(); + if (!isComponentOf(focus, m_gridMainL ) && // + !isComponentOf(focus, m_gridMainC ) && //don't propagate keyboard commands if grid is already in focus + !isComponentOf(focus, m_gridMainR ) && // + !isComponentOf(focus, m_gridOverview ) && + !isComponentOf(focus, m_gridCfgHistory) && //don't propagate if selecting config + !isComponentOf(focus, m_panelSearch ) && + !isComponentOf(focus, m_panelLog ) && + !isComponentOf(focus, m_panelDirectoryPairs) && //don't propagate if changing directory fields + m_gridMainL->IsEnabled()) + { + m_gridMainL->SetFocus(); + + event.SetEventType(wxEVT_KEY_DOWN); //the grid event handler doesn't expect wxEVT_CHAR_HOOK! + m_gridMainL->getMainWin().GetEventHandler()->ProcessEvent(event); //propagating event to child lead to recursion with old key_event.h handling => still an issue? + event.Skip(false); //definitively handled now! + return; + } + } + break; + + case WXK_ESCAPE: //let's do something useful and hide the log panel + if (!isComponentOf(wxWindow::FindFocus(), m_panelSearch) && //search panel also handles ESC! + m_panelLog->IsEnabled()) + { + if (auiMgr_.GetPane(m_panelLog).IsShown()) //else: let it "ding" + return showLogPanel(false /*show*/); + } + break; + } + } + event.Skip(); +} + + +void MainDialog::onTreeGridSelection(GridSelectEvent& event) +{ + //scroll m_gridMain to user's new selection on m_gridOverview + ptrdiff_t leadRow = -1; + if (event.positive_ && event.rowFirst_ != event.rowLast_) + if (std::unique_ptr node = treegrid::getDataView(*m_gridOverview).getLine(event.rowFirst_)) + { + if (const TreeView::RootNode* root = dynamic_cast(node.get())) + leadRow = filegrid::getDataView(*m_gridMainC).findRowFirstChild(&(root->baseFolder)); + else if (const TreeView::DirNode* dir = dynamic_cast(node.get())) + { + leadRow = filegrid::getDataView(*m_gridMainC).findRowDirect(&(dir->folder)); + if (leadRow < 0) //directory was filtered out! still on tree view (but NOT on grid view) + leadRow = filegrid::getDataView(*m_gridMainC).findRowFirstChild(&(dir->folder)); + } + else if (const TreeView::FilesNode* files = dynamic_cast(node.get())) + { + assert(!files->filesAndLinks.empty()); + if (!files->filesAndLinks.empty()) + leadRow = filegrid::getDataView(*m_gridMainC).findRowDirect(files->filesAndLinks[0]); + } + } + + if (leadRow >= 0) + { + leadRow = std::max(0, leadRow - 1); //scroll one more row + + m_gridMainL->scrollTo(leadRow); // + m_gridMainC->scrollTo(leadRow); //scroll all of them (including "scroll master") + m_gridMainR->scrollTo(leadRow); // + + m_gridOverview->getMainWin().Update(); //draw cursor immediately rather than on next idle event (required for slow CPUs, netbook) + } + + //get selection on overview panel and set corresponding markers on main grid + std::unordered_set markedFilesAndLinks; //mark files/symlinks directly + std::unordered_set markedContainer; //mark full container including child-objects + + for (size_t row : m_gridOverview->getSelectedRows()) + if (std::unique_ptr node = treegrid::getDataView(*m_gridOverview).getLine(row)) + { + if (const TreeView::RootNode* root = dynamic_cast(node.get())) + markedContainer.insert(&(root->baseFolder)); + else if (const TreeView::DirNode* dir = dynamic_cast(node.get())) + markedContainer.insert(&(dir->folder)); + else if (const TreeView::FilesNode* files = dynamic_cast(node.get())) + markedFilesAndLinks.insert(files->filesAndLinks.begin(), files->filesAndLinks.end()); + } + + filegrid::setNavigationMarker(*m_gridMainL, *m_gridMainR, + std::move(markedFilesAndLinks), std::move(markedContainer)); + + //selecting overview should clear main grid selection (if any) but not the other way around: + m_gridMainL->clearSelection(GridEventPolicy::deny); + m_gridMainC->clearSelection(GridEventPolicy::deny); + m_gridMainR->clearSelection(GridEventPolicy::deny); + + event.Skip(); +} + + +namespace +{ +template +std::vector getFilterPhrasesRel(const std::vector& selection) +{ + std::vector output; + for (const FileSystemObject* fsObj : selection) + { + //#pragma warning(suppress: 6011) -> fsObj bound in this context! + Zstring phrase = FILE_NAME_SEPARATOR + fsObj->getRelativePath(); + + const bool isFolder = dynamic_cast(fsObj) != nullptr; + if (isFolder) + phrase += FILE_NAME_SEPARATOR; + + output.push_back(std::move(phrase)); + } + return output; +} + + +Zstring getFilterPhraseRel(const std::vector& selectionL, + const std::vector& selectionR) +{ + std::vector phrases; + append(phrases, getFilterPhrasesRel(selectionL)); + append(phrases, getFilterPhrasesRel(selectionR)); + + removeDuplicatesStable(phrases, [](const Zstring& lhs, const Zstring& rhs) { return compareNoCase(lhs, rhs) < 0; }); + //ignore case, just like path filter + + Zstring relPathPhrase; + for (const Zstring& phrase : phrases) + { + relPathPhrase += phrase; + relPathPhrase += Zstr('\n'); + } + + return trimCpy(relPathPhrase); +} +} + + +void MainDialog::onTreeGridContext(GridContextMenuEvent& event) +{ + const std::vector& selection = getTreeSelection(); //referenced by lambdas! + ContextMenu menu; + + //---------------------------------------------------------------------------------------------------- + auto getImage = [&](SyncDirection dir, SyncOperation soDefault) + { + return mirrorIfRtl(getSyncOpImage(!selection.empty() && selection[0]->getSyncOperation() != SO_EQUAL ? + selection[0]->testSyncOperation(dir) : soDefault)); + }; + const wxImage opRight = getImage(SyncDirection::right, SO_OVERWRITE_RIGHT); + const wxImage opNone = getImage(SyncDirection::none, SO_DO_NOTHING ); + const wxImage opLeft = getImage(SyncDirection::left, SO_OVERWRITE_LEFT ); + + wxString shortcutLeft = L"\tAlt+Left"; + wxString shortcutRight = L"\tAlt+Right"; + if (m_gridOverview->GetLayoutDirection() == wxLayout_RightToLeft) + std::swap(shortcutLeft, shortcutRight); + + const bool nonEqualSelected = selectionIncludesNonEqualItem(selection); + menu.addItem(_("Set direction:") + L" ->" + shortcutRight, [this, &selection] { setSyncDirManually(selection, SyncDirection::right); }, opRight, nonEqualSelected); + menu.addItem(_("Set direction:") + L" -" L"\tAlt+Down", [this, &selection] { setSyncDirManually(selection, SyncDirection::none); }, opNone, nonEqualSelected); + menu.addItem(_("Set direction:") + L" <-" + shortcutLeft, [this, &selection] { setSyncDirManually(selection, SyncDirection::left); }, opLeft, nonEqualSelected); + //Gtk needs a direction, "<-", because it has no context menu icons! + //Gtk requires "no spaces" for shortcut identifiers! + menu.addSeparator(); + //---------------------------------------------------------------------------------------------------- + auto addFilterMenu = [&](const std::wstring& label, const wxImage& img, bool include) + { + if (selection.empty()) + menu.addItem(label, nullptr, img, false /*enabled*/); + else if (selection.size() == 1) + { + ContextMenu submenu; + + const bool isFolder = dynamic_cast(selection[0]) != nullptr; + + const Zstring& relPathL = selection[0]->getRelativePath(); + const Zstring& relPathR = selection[0]->getRelativePath(); + + //by extension + const Zstring extensionL = getFileExtension(relPathL); + const Zstring extensionR = getFileExtension(relPathR); + if (!extensionL.empty()) + submenu.addItem(L"*." + utfTo(extensionL), + [this, extensionL, include] { addFilterPhrase(Zstr("*.") + extensionL, include, false /*requireNewLine*/); }); + + if (!extensionR.empty() && !equalNoCase(extensionL, extensionR)) //rare, but possible (e.g. after manual rename) + submenu.addItem(L"*." + utfTo(extensionR), + [this, extensionR, include] { addFilterPhrase(Zstr("*.") + extensionR, include, false /*requireNewLine*/); }); + + //by file name + Zstring filterPhraseNameL = Zstring(Zstr("*")) + FILE_NAME_SEPARATOR + getItemName(relPathL); + Zstring filterPhraseNameR = Zstring(Zstr("*")) + FILE_NAME_SEPARATOR + getItemName(relPathR); + if (isFolder) + { + filterPhraseNameL += FILE_NAME_SEPARATOR; + filterPhraseNameR += FILE_NAME_SEPARATOR; + } + + submenu.addItem(utfTo(filterPhraseNameL), + [this, filterPhraseNameL, include] { addFilterPhrase(filterPhraseNameL, include, true /*requireNewLine*/); }); + + if (!equalNoCase(filterPhraseNameL, filterPhraseNameR)) //rare, but possible (ignore case, just like path filter) + submenu.addItem(utfTo(filterPhraseNameR), + [this, filterPhraseNameR, include] { addFilterPhrase(filterPhraseNameR, include, true /*requireNewLine*/); }); + + //by relative path + Zstring filterPhraseRelL = FILE_NAME_SEPARATOR + relPathL; + Zstring filterPhraseRelR = FILE_NAME_SEPARATOR + relPathR; + if (isFolder) + { + filterPhraseRelL += FILE_NAME_SEPARATOR; + filterPhraseRelR += FILE_NAME_SEPARATOR; + } + submenu.addItem(utfTo(filterPhraseRelL), [this, filterPhraseRelL, include] { addFilterPhrase(filterPhraseRelL, include, true /*requireNewLine*/); }); + + if (!equalNoCase(filterPhraseRelL, filterPhraseRelR)) //rare, but possible + submenu.addItem(utfTo(filterPhraseRelR), [this, filterPhraseRelR, include] { addFilterPhrase(filterPhraseRelR, include, true /*requireNewLine*/); }); + + menu.addSubmenu(label, submenu, img); + } + else //by relative path + menu.addItem(label + L" <" + _("multiple selection") + L">", + [this, &selection, include] { addFilterPhrase(getFilterPhraseRel(selection, selection), include, true /*requireNewLine*/); }, img); + }; + addFilterMenu(_("&Include via filter:"), loadImage("filter_include", dipToScreen(getMenuIconDipSize())), true); + addFilterMenu(_("&Exclude via filter:"), loadImage("filter_exclude", dipToScreen(getMenuIconDipSize())), false); + //---------------------------------------------------------------------------------------------------- + if (m_bpButtonShowExcluded->isActive() && !selection.empty() && !selection[0]->isActive()) + menu.addItem(_("Include temporarily") + L"\tSpace", [this, &selection] { setIncludedManually(selection, true); }, loadImage("checkbox_true")); + else + menu.addItem(_("Exclude temporarily") + L"\tSpace", [this, &selection] { setIncludedManually(selection, false); }, loadImage("checkbox_false"), !selection.empty()); + //---------------------------------------------------------------------------------------------------- + const bool selectionContainsItemsToSync = [&] + { + for (FileSystemObject* fsObj : expandSelectionForPartialSync(selection)) + if (getEffectiveSyncDir(fsObj->getSyncOperation()) != SyncDirection::none) + return true; + return false; + }(); + menu.addSeparator(); + menu.addItem(_("&Synchronize selection") + L"\tEnter", [&] { startSyncForSelecction(selection); }, + loadImage("start_sync_selection", dipToScreen(getMenuIconDipSize())), selectionContainsItemsToSync); + //---------------------------------------------------------------------------------------------------- + const ptrdiff_t itemsSelected = + std::count_if(selection.begin(), selection.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); }) + + std::count_if(selection.begin(), selection.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); }); + + //menu.addSeparator(); + //menu.addItem(_("&Copy to...") + L"\tCtrl+T", [&] { copyToAlternateFolder(selection, selection); }, wxNullImage, itemsSelected > 0); + //---------------------------------------------------------------------------------------------------- + menu.addSeparator(); + + menu.addItem((itemsSelected > 1 ? _("Multi-&Rename") : _("&Rename")) + L"\tF2", + [&] { renameSelectedFiles(selection, selection); }, loadImage("rename", dipToScreen(getMenuIconDipSize())), itemsSelected > 0); + + menu.addItem(_("&Delete") + L"\t(Shift+)Del", [&] { deleteSelectedFiles(selection, selection, true /*moveToRecycler*/); }, imgTrashSmall_, itemsSelected > 0); + + menu.popup(*m_gridOverview, event.mousePos_); +} + + +void MainDialog::onGridContextRim(GridContextMenuEvent& event, bool leftSide) +{ + const std::vector selection = getGridSelection(); //referenced by lambdas! + const std::vector selectionL = getGridSelection(true, false); + const std::vector selectionR = getGridSelection(false, true); + + onGridContextRim(getGridSelection(), + getGridSelection(true, false), + getGridSelection(false, true), leftSide, event.mousePos_); +} + + +void MainDialog::onGridGroupContextRim(GridClickEvent& event, bool leftSide) +{ + if (static_cast(event.hoverArea_) == HoverAreaGroup::groupName) + if (const FileView::PathDrawInfo pdi = filegrid::getDataView(*m_gridMainC).getDrawInfo(event.row_); + pdi.folderGroupObj) + { + m_gridMainL->clearSelection(GridEventPolicy::deny); + m_gridMainC->clearSelection(GridEventPolicy::deny); + m_gridMainR->clearSelection(GridEventPolicy::deny); + + std::vector selectionL; + std::vector selectionR; + (leftSide ? selectionL : selectionR).push_back(pdi.folderGroupObj); + + onGridContextRim({pdi.folderGroupObj}, + selectionL, selectionR, leftSide, event.mousePos_); + return; //"swallow" event => suppress default context menu handling + } + + assert(static_cast(event.hoverArea_) != HoverAreaGroup::groupName); + event.Skip(); +} + + +void MainDialog::onGridContextRim(const std::vector& selection, + const std::vector& selectionL, + const std::vector& selectionR, bool leftSide, wxPoint mousePos) +{ + ContextMenu menu; + + auto getImage = [&](SyncDirection dir, SyncOperation soDefault) + { + return mirrorIfRtl(getSyncOpImage(!selection.empty() && selection[0]->getSyncOperation() != SO_EQUAL ? + selection[0]->testSyncOperation(dir) : soDefault)); + }; + const wxImage opLeft = getImage(SyncDirection::left, SO_OVERWRITE_LEFT ); + const wxImage opRight = getImage(SyncDirection::right, SO_OVERWRITE_RIGHT); + const wxImage opNone = getImage(SyncDirection::none, SO_DO_NOTHING ); + + wxString shortcutLeft = L"\tAlt+Left"; + wxString shortcutRight = L"\tAlt+Right"; + if (m_gridMainL->GetLayoutDirection() == wxLayout_RightToLeft) + std::swap(shortcutLeft, shortcutRight); + + const bool nonEqualSelected = selectionIncludesNonEqualItem(selection); + menu.addItem(_("Set direction:") + L" ->" + shortcutRight, [this, &selection] { setSyncDirManually(selection, SyncDirection::right); }, opRight, nonEqualSelected); + menu.addItem(_("Set direction:") + L" -" L"\tAlt+Down", [this, &selection] { setSyncDirManually(selection, SyncDirection::none); }, opNone, nonEqualSelected); + menu.addItem(_("Set direction:") + L" <-" + shortcutLeft, [this, &selection] { setSyncDirManually(selection, SyncDirection::left); }, opLeft, nonEqualSelected); + //GTK needs a direction, "<-", because it has no context menu icons! + //GTK does not allow spaces in shortcut identifiers! + menu.addSeparator(); + //---------------------------------------------------------------------------------------------------- + auto addFilterMenu = [&](const wxString& label, const wxImage& img, bool include) + { + if (selectionL.empty() && selectionR.empty()) + menu.addItem(label, nullptr, img, false /*enabled*/); + else if (selectionL.size() + selectionR.size() == 1) + { + ContextMenu submenu; + + const bool isFolder = dynamic_cast((!selectionL.empty() ? selectionL : selectionR)[0]) != nullptr; + + const Zstring& relPath = !selectionL.empty() ? + selectionL[0]->getRelativePath() : + selectionR[0]->getRelativePath(); + //by extension + if (const Zstring extension = getFileExtension(relPath); + !extension.empty()) + submenu.addItem(L"*." + utfTo(extension), [this, extension, include] + { + addFilterPhrase(Zstr("*.") + extension, include, false /*requireNewLine*/); + }); + + //by file name + Zstring filterPhraseName = Zstring(Zstr("*")) + FILE_NAME_SEPARATOR + getItemName(relPath); + if (isFolder) + filterPhraseName += FILE_NAME_SEPARATOR; + + submenu.addItem(utfTo(filterPhraseName), [this, filterPhraseName, include] + { + addFilterPhrase(filterPhraseName, include, true /*requireNewLine*/); + }); + + //by relative path + Zstring filterPhraseRel = FILE_NAME_SEPARATOR + relPath; + if (isFolder) + filterPhraseRel += FILE_NAME_SEPARATOR; + submenu.addItem(utfTo(filterPhraseRel), [this, filterPhraseRel, include] { addFilterPhrase(filterPhraseRel, include, true /*requireNewLine*/); }); + + menu.addSubmenu(label, submenu, img); + } + else //by relative path + menu.addItem(label + L" <" + _("multiple selection") + L">", + [this, &selectionL, &selectionR, include] { addFilterPhrase(getFilterPhraseRel(selectionL, selectionR), include, true /*requireNewLine*/); }, img); + }; + addFilterMenu(_("&Include via filter:"), loadImage("filter_include", dipToScreen(getMenuIconDipSize())), true); + addFilterMenu(_("&Exclude via filter:"), loadImage("filter_exclude", dipToScreen(getMenuIconDipSize())), false); + //---------------------------------------------------------------------------------------------------- + if (m_bpButtonShowExcluded->isActive() && !selection.empty() && !selection[0]->isActive()) + menu.addItem(_("Include temporarily") + L"\tSpace", [this, &selection] { setIncludedManually(selection, true); }, loadImage("checkbox_true")); + else + menu.addItem(_("Exclude temporarily") + L"\tSpace", [this, &selection] { setIncludedManually(selection, false); }, loadImage("checkbox_false"), !selection.empty()); + //---------------------------------------------------------------------------------------------------- + const bool selectionContainsItemsToSync = [&] + { + for (FileSystemObject* fsObj : expandSelectionForPartialSync(selection)) + if (getEffectiveSyncDir(fsObj->getSyncOperation()) != SyncDirection::none) + return true; + return false; + }(); + menu.addSeparator(); + menu.addItem(_("&Synchronize selection") + L"\tEnter", [&] { startSyncForSelecction(selection); }, + loadImage("start_sync_selection", dipToScreen(getMenuIconDipSize())), selectionContainsItemsToSync); + //---------------------------------------------------------------------------------------------------- + if (!globalCfg_.externalApps.empty()) + { + menu.addSeparator(); + + for (auto it = globalCfg_.externalApps.begin(); + it != globalCfg_.externalApps.end(); + ++it) + { + //translate default external apps on the fly: 1. "Show in Explorer" 2. "Open with default application" + wxString description = translate(it->description); + + if (const size_t pos = it - globalCfg_.externalApps.begin(); + pos == 0) + description += L"\tD-Click, 0"; + else if (pos < 9) + description += L"\t" + numberTo(pos); + + auto openApp = [this, command = it->cmdLine, leftSide, &selectionL, &selectionR] { openExternalApplication(command, leftSide, selectionL, selectionR); }; + + menu.addItem(description, openApp, it->cmdLine == extCommandFileManager.cmdLine ? imgFileManagerSmall_ : wxNullImage, + it->cmdLine == extCommandFileManager.cmdLine || + !containsFileItemMacro(it->cmdLine) || + !selectionL.empty() || !selectionR.empty()); + } + } + //---------------------------------------------------------------------------------------------------- + const ptrdiff_t itemsSelected = + std::count_if(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); }) + + std::count_if(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty(); }); + + menu.addSeparator(); + menu.addItem(_("&Copy to...") + L"\tCtrl+T", [&] { copyToAlternateFolder(selectionL, selectionR); }, wxNullImage, itemsSelected > 0); + //---------------------------------------------------------------------------------------------------- + menu.addSeparator(); + + menu.addItem((itemsSelected > 1 ? _("Multi-&Rename") : _("&Rename")) + L"\tF2", + [&] { renameSelectedFiles(selectionL, selectionR); }, loadImage("rename", dipToScreen(getMenuIconDipSize())), itemsSelected > 0); + + menu.addItem(_("&Delete") + L"\t(Shift+)Del", [&] { deleteSelectedFiles(selectionL, selectionR, true /*moveToRecycler*/); }, imgTrashSmall_, itemsSelected > 0); + + menu.popup(leftSide ? *m_gridMainL : *m_gridMainR, mousePos); +} + + +void MainDialog::addFilterPhrase(const Zstring& phrase, bool include, bool requireNewLine) +{ + Zstring& filterString = [&]() -> Zstring& + { + if (include) + { + Zstring& includeFilter = currentCfg_.mainCfg.globalFilter.includeFilter; + if (NameFilter::isNull(includeFilter, Zstring())) //fancy way of checking for "*" include + includeFilter.clear(); + return includeFilter; + } + else + return currentCfg_.mainCfg.globalFilter.excludeFilter; + }(); + + if (requireNewLine) + { + trim(filterString, TrimSide::right, [](Zchar c) { return c == FILTER_ITEM_SEPARATOR || c == Zstr('\n') || c == Zstr(' '); }); + if (!filterString.empty()) + filterString += Zstr('\n'); + filterString += phrase; + } + else + { + trim(filterString, TrimSide::right, [](Zchar c) { return c == Zstr('\n') || c == Zstr(' '); }); + + if (contains(afterLast(filterString, Zstr('\n'), IfNotFoundReturn::all), FILTER_ITEM_SEPARATOR)) + { + if (!endsWith(filterString, FILTER_ITEM_SEPARATOR)) + filterString += Zstring() + Zstr(' ') + FILTER_ITEM_SEPARATOR; + + filterString += Zstr(' ') + phrase; + } + else + { + if (!filterString.empty()) + filterString += Zstr('\n'); + + filterString += phrase + Zstr(' ') + FILTER_ITEM_SEPARATOR; //append FILTER_ITEM_SEPARATOR to 'mark' that next extension exclude should write to same line + } + } + + updateGlobalFilterButton(); + if (include) + applyFilterConfig(); //user's temporary exclusions lost! + else //do not fully apply filter, just exclude new items: preserve user's temporary exclusions + { + for (BaseFolderPair& baseFolder : asRange(folderCmp_)) + addHardFiltering(baseFolder, phrase); + updateGui(); + } +} + + +void MainDialog::onGridLabelContextC(GridLabelClickEvent& event) +{ + ContextMenu menu; + + const GridViewType viewType = m_bpButtonViewType->isActive() ? GridViewType::action : GridViewType::difference; + menu.addItem(_("Difference") + (viewType != GridViewType::difference ? L"\tF11" : L""), + [&] { setGridViewType(GridViewType::difference); }, greyScaleIfDisabled(loadImage("compare", dipToScreen(getMenuIconDipSize())), viewType == GridViewType::difference)); + + menu.addItem(_("Action") + (viewType != GridViewType::action ? L"\tF11" : L""), + [&] { setGridViewType(GridViewType::action); }, greyScaleIfDisabled(loadImage("start_sync", dipToScreen(getMenuIconDipSize())), viewType == GridViewType::action)); + menu.popup(*m_gridMainC, {event.mousePos_.x, m_gridMainC->getColumnLabelHeight()}); +} + + +void MainDialog::onGridLabelContextRim(GridLabelClickEvent& event, bool leftSide) +{ + ContextMenu menu; + //-------------------------------------------------------------------------------------------------------- + Grid& grid = leftSide ? *m_gridMainL : *m_gridMainR; + //const ColumnTypeRim colType = static_cast(event.colType_); + + auto toggleColumn = [&](ColumnType ct) + { + auto colAttr = grid.getColumnConfig(); + + Grid::ColAttributes* caItemPath = nullptr; + Grid::ColAttributes* caToggle = nullptr; + + for (Grid::ColAttributes& ca : colAttr) + if (ca.type == static_cast(ColumnTypeRim::path)) + caItemPath = &ca; + else if (ca.type == ct) + caToggle = &ca; + + assert(caItemPath && caItemPath->stretch > 0 && caItemPath->visible); + assert(caToggle && caToggle ->stretch == 0); + + if (caItemPath && caToggle) + { + caToggle->visible = !caToggle->visible; + + //take width of newly visible column from stretched item path column + caItemPath->offset -= caToggle->visible ? caToggle->offset : -caToggle->offset; + + grid.setColumnConfig(colAttr); + } + }; + + if (const GridData* prov = grid.getDataProvider()) + for (const Grid::ColAttributes& ca : grid.getColumnConfig()) + menu.addCheckBox(prov->getColumnLabel(ca.type), [ct = ca.type, toggleColumn] { toggleColumn(ct); }, + ca.visible, ca.type != static_cast(ColumnTypeRim::path)); //do not allow user to hide this column! + //---------------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto& itemPathFormat = leftSide ? globalCfg_.mainDlg.itemPathFormatLeftGrid : globalCfg_.mainDlg.itemPathFormatRightGrid; + + auto setItemPathFormat = [&](ItemPathFormat fmt) + { + itemPathFormat = fmt; + filegrid::setItemPathForm(grid, fmt); + }; + auto addFormatEntry = [&](const wxString& label, ItemPathFormat fmt) + { + menu.addRadio(label, [fmt, &setItemPathFormat] { setItemPathFormat(fmt); }, itemPathFormat == fmt); + }; + addFormatEntry(_("Item name" ), ItemPathFormat::name); + addFormatEntry(_("Relative path"), ItemPathFormat::relative); + addFormatEntry(_("Full path" ), ItemPathFormat::full); + + //---------------------------------------------------------------------------------------------- + auto setIconSize = [&](GridIconSize sz, bool showIcons) + { + globalCfg_.mainDlg.iconSize = sz; + globalCfg_.mainDlg.showIcons = showIcons; + filegrid::setupIcons(*m_gridMainL, *m_gridMainC, *m_gridMainR, globalCfg_.mainDlg.showIcons, convert(globalCfg_.mainDlg.iconSize)); + }; + + menu.addSeparator(); + menu.addCheckBox(_("Show icons:"), [&] { setIconSize(globalCfg_.mainDlg.iconSize, !globalCfg_.mainDlg.showIcons); }, globalCfg_.mainDlg.showIcons); + + auto addSizeEntry = [&](const wxString& label, GridIconSize sz) + { + menu.addRadio(label, [sz, &setIconSize] { setIconSize(sz, true /*showIcons*/); }, globalCfg_.mainDlg.iconSize == sz, globalCfg_.mainDlg.showIcons); + }; + addSizeEntry(TAB_SPACE + _("Small" ), GridIconSize::small ); + addSizeEntry(TAB_SPACE + _("Medium"), GridIconSize::medium); + addSizeEntry(TAB_SPACE + _("Large" ), GridIconSize::large ); + + //---------------------------------------------------------------------------------------------- + auto setDefault = [&] + { + grid.setColumnConfig(convertColAttributes(leftSide ? getFileGridDefaultColAttribsLeft() : getFileGridDefaultColAttribsRight(), getFileGridDefaultColAttribsLeft())); + + const GlobalConfig defaultCfg; + setItemPathFormat(leftSide ? defaultCfg.mainDlg.itemPathFormatLeftGrid : defaultCfg.mainDlg.itemPathFormatRightGrid); + setIconSize(defaultCfg.mainDlg.iconSize, defaultCfg.mainDlg.showIcons); + }; + + menu.addSeparator(); + menu.addItem(_("&Default"), setDefault, loadImage("reset_sicon")); + + // if (type == ColumnTypeRim::date) + { + auto selectTimeSpan = [&] + { + if (showSelectTimespanDlg(this, manualTimeSpanFrom_, manualTimeSpanTo_) == ConfirmationButton::accept) + { + applyTimeSpanFilter(folderCmp_, manualTimeSpanFrom_, manualTimeSpanTo_); //overwrite current active/inactive settings + //updateGuiDelayedIf(!m_bpButtonShowExcluded->isActive()); //show update GUI before removing rows + updateGui(); + } + }; + + menu.addSeparator(); + menu.addItem(_("Select time span..."), selectTimeSpan); + } + //-------------------------------------------------------------------------------------------------------- + menu.popup(grid, {event.mousePos_.x, grid.getColumnLabelHeight()}); + //event.Skip(); +} + + +void MainDialog::onOpenMenuTools(wxMenuEvent& event) +{ + //each layout menu item is either shown and owned by m_menuTools OR detached from m_menuTools and owned by detachedMenuItems_: + auto filterLayoutItems = [&](wxMenuItem* menuItem, wxWindow* panelWindow) + { + wxAuiPaneInfo& paneInfo = this->auiMgr_.GetPane(panelWindow); + if (paneInfo.IsShown()) + { + if (!detachedMenuItems_.contains(menuItem)) + detachedMenuItems_.insert(m_menuTools->Remove(menuItem)); //pass ownership + } + else if (detachedMenuItems_.contains(menuItem)) + { + detachedMenuItems_.erase(menuItem); //pass ownership + m_menuTools->Append(menuItem); // + } + }; + filterLayoutItems(m_menuItemShowMain, m_panelTopButtons); + filterLayoutItems(m_menuItemShowFolders, m_panelDirectoryPairs); + filterLayoutItems(m_menuItemShowViewFilter, m_panelViewFilter); + filterLayoutItems(m_menuItemShowConfig, m_panelConfig); + filterLayoutItems(m_menuItemShowOverview, m_gridOverview); + event.Skip(); +} + + +void MainDialog::resetLayout() +{ + m_splitterMain->setSashOffset(0); + auiMgr_.LoadPerspective(defaultPerspective_, false /*don't call wxAuiManager::Update() => already done in updateGuiForFolderPair() */); + updateGuiForFolderPair(); + + //progress dialog size: + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.size = std::nullopt; + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized = false; +} + + +void MainDialog::onSetLayoutContext(wxMouseEvent& event) +{ + ContextMenu menu; + + menu.addItem(_("&Reset layout"), [&] { resetLayout(); }, loadImage("reset_sicon")); + //---------------------------------------------------------------------------------------- + + bool addedSeparator = false; + + for (wxAuiPaneInfo& paneInfo : auiMgr_.GetAllPanes()) + if (!paneInfo.IsShown() && + paneInfo.window != compareStatus_->getAsWindow() && + paneInfo.window != m_panelLog && + paneInfo.window != m_panelSearch) + { + if (!addedSeparator) + { + menu.addSeparator(); + addedSeparator = true; + } + + menu.addItem(replaceCpy(_("Show \"%x\""), L"%x", paneInfo.caption), [this, &paneInfo] + { + paneInfo.Show(); + this->auiMgr_.Update(); + }); + } + + menu.popup(*this); +} + + +void MainDialog::onCompSettingsContext(wxEvent& event) +{ + ContextMenu menu; + + auto setVariant = [&](CompareVariant var) + { + currentCfg_.mainCfg.cmpCfg.compareVar = var; + applyCompareConfig(true /*setDefaultViewType*/); + }; + + const CompareVariant activeCmpVar = getConfig().mainCfg.cmpCfg.compareVar; + + auto addVariantItem = [&](CompareVariant cmpVar, const char* iconName) + { + const wxImage imgSel = loadImage(iconName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize())); + + menu.addItem(getVariantName(cmpVar), [&setVariant, cmpVar] { setVariant(cmpVar); }, greyScaleIfDisabled(imgSel, activeCmpVar == cmpVar)); + }; + addVariantItem(CompareVariant::timeSize, "cmp_time"); + addVariantItem(CompareVariant::content, "cmp_content"); + addVariantItem(CompareVariant::size, "cmp_size"); + + menu.popup(*m_bpButtonCmpContext, {m_bpButtonCmpContext->GetSize().x, 0}); +} + + +void MainDialog::onSyncSettingsContext(wxEvent& event) +{ + ContextMenu menu; + + auto setVariant = [&](SyncVariant var) + { + currentCfg_.mainCfg.syncCfg.directionCfg = getDefaultSyncCfg(var); + applySyncDirections(); + }; + + const SyncVariant activeSyncVar = getSyncVariant(getConfig().mainCfg.syncCfg.directionCfg); + + auto addVariantItem = [&](SyncVariant syncVar, const char* iconName) + { + const wxImage imgSel = mirrorIfRtl(loadImage(iconName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize()))); + + menu.addItem(getVariantName(syncVar), [&setVariant, syncVar] { setVariant(syncVar); }, greyScaleIfDisabled(imgSel, activeSyncVar == syncVar)); + }; + addVariantItem(SyncVariant::twoWay, "sync_twoway"); + addVariantItem(SyncVariant::mirror, "sync_mirror"); + addVariantItem(SyncVariant::update, "sync_update"); + //addVariantItem(SyncVariant::custom, "sync_custom"); -> doesn't make sense, does it? + + menu.popup(*m_bpButtonSyncContext, {m_bpButtonSyncContext->GetSize().x, 0}); +} + + +void MainDialog::onDialogFilesDropped(FileDropEvent& event) +{ + assert(!event.itemPaths_.empty()); + loadConfiguration(event.itemPaths_); + //event.Skip(); +} + + +void MainDialog::onFolderSelected(wxCommandEvent& event) +{ + if (!folderCmp_.empty()) + clearGrid(); //+ update GUI! + else + updateUnsavedCfgStatus(); + + event.Skip(); +} + + +void MainDialog::cfgHistoryRemoveObsolete(const std::vector& filePaths) +{ + auto getUnavailableCfgFilesAsync = [filePaths] //don't use wxString: NOT thread-safe! (e.g. non-atomic ref-count) + { + std::vector> keepFile; //check all config files in parallel! + + for (const Zstring& filePath : filePaths) + keepFile.push_back(runAsync([=] + { + try + { + getItemType(filePath); //throw FileError + return true; + } + catch (FileError&) { return false; } //not-existing/access error? e.g. not accessible network share or USB stick => remove cfg + })); + + //potentially slow network access => limit maximum wait time! + waitForAllTimed(keepFile.begin(), keepFile.end(), std::chrono::seconds(2)); + + std::vector pathsToRemove; + + auto itFut = keepFile.begin(); + for (auto it = filePaths.begin(); it != filePaths.end(); ++it, ++itFut) + if (isReady(*itFut) && !itFut->get()) //not ready? maybe HDD that is just spinning up => better keep it + pathsToRemove.push_back(*it); + + return pathsToRemove; + }; + + guiQueue_.processAsync(getUnavailableCfgFilesAsync, [this](const std::vector& filePaths2) + { + if (!filePaths2.empty()) + { + cfggrid::getDataView(*m_gridCfgHistory).removeItems(filePaths2); + + //restore grid selection (after rows were removed) + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + } + }); +} + + +void MainDialog::cfgHistoryUpdateNotes(const std::vector& filePaths) +{ + //load per-config user notes (let's not keep stale copy in GlobalSettings.xml) + for (const Zstring& filePath : filePaths) + { + auto getCfgNotes = [filePath] + { + try + { + const auto& [newGuiCfg, warningMsg] = readAnyConfig({filePath}); //throw FileError + return newGuiCfg.notes; + } + catch (FileError&) { return std::wstring(); } + }; + + guiQueue_.processAsync(getCfgNotes, [this, filePath](const std::wstring& notes) + { + if (const auto& [item, row] = cfggrid::getDataView(*m_gridCfgHistory).getItem(filePath); + item) + if (item->notes != notes) + { + cfggrid::getDataView(*m_gridCfgHistory).setNotes(filePath, notes); + m_gridCfgHistory->Refresh(); + } + }); + } +} + + +std::vector MainDialog::getJobNames() const +{ + std::vector jobNames; + for (const Zstring& cfgFilePath : activeConfigFiles_) + jobNames.push_back(equalNativePath(cfgFilePath, lastRunConfigPath_) ? + L'[' + _("Last session") + L']' : + extractJobName(cfgFilePath)); + return jobNames; +} + + +void MainDialog::updateUnsavedCfgStatus() +{ + const FfsGuiConfig guiCfg = getConfig(); + + auto makeBrightGrey = [](wxImage img) + { + img = img.ConvertToGreyscale(1.0/3, 1.0/3, 1.0/3); //treat all channels equally! + brighten(img, 80); + return img; + }; + + //update new config button + const bool allowNew = guiCfg != getDefaultGuiConfig(globalCfg_.defaultFilter); + + if (m_bpButtonNew->IsEnabled() != allowNew || !m_bpButtonNew->GetBitmap().IsOk()) //support polling + { + setImage(*m_bpButtonNew, allowNew ? loadImage("cfg_new") : makeBrightGrey(loadImage("cfg_new"))); + m_bpButtonNew->Enable(allowNew); + m_menuItemNew->Enable(allowNew); + } + + //update save config button + const bool haveUnsavedCfg = lastSavedCfg_ != guiCfg; + + const bool allowSave = haveUnsavedCfg || + activeConfigFiles_.size() > 1; + + const Zstring activeCfgFilePath = activeConfigFiles_.size() == 1 && !equalNativePath(activeConfigFiles_[0], lastRunConfigPath_) ? activeConfigFiles_[0] : Zstring(); + + if (m_bpButtonSave->IsEnabled() != allowSave || !m_bpButtonSave->GetBitmap().IsOk()) //support polling + { + setImage(*m_bpButtonSave, allowSave ? loadImage("cfg_save") : makeBrightGrey(loadImage("cfg_save"))); + m_bpButtonSave->Enable(allowSave); + m_menuItemSave->Enable(allowSave); //bitmap is automatically greyscaled on Win7 (introducing a crappy looking shift), but not on XP + } + + //set main dialog title + wxString title; + if (haveUnsavedCfg) + title += L'*'; + bool showingConfigName = true; + if (!activeCfgFilePath.empty()) + { + title += extractJobName(activeCfgFilePath); + if (const std::optional& parentPath = getParentFolderPath(activeCfgFilePath)) + title += L" [" + utfTo(*parentPath) + L']'; + } + else if (activeConfigFiles_.size() > 1) + { + for (const std::wstring& jobName : getJobNames()) + title += jobName + L" + "; + if (endsWith(title, L" + ")) + title.resize(title.size() - 3); + } + else + showingConfigName = false; + + if (showingConfigName) + title += SPACED_DASH; + + title += L"FreeFileSync " + utfTo(ffsVersion); + try + { + if (runningElevated()) //throw FileError + title += L" (root)"; + } + catch (FileError&) { assert(false); } + + if (!showingConfigName) + title += SPACED_DASH + _("Folder Comparison and Synchronization"); + + + SetTitle(title); + + //macOS-only: + OSXSetModified(haveUnsavedCfg); + SetRepresentedFilename(utfTo(activeCfgFilePath)); +} + + +void MainDialog::onConfigSave(wxCommandEvent& event) +{ + const Zstring activeCfgFilePath = activeConfigFiles_.size() == 1 && !equalNativePath(activeConfigFiles_[0], lastRunConfigPath_) ? activeConfigFiles_[0] : Zstring(); + + //if we work on a single named configuration document: save directly if changed + //else: always show file dialog + if (activeCfgFilePath.empty()) + trySaveConfig(nullptr); + else + { + if (endsWithAsciiNoCase(activeCfgFilePath, Zstr(".ffs_gui"))) + trySaveConfig(&activeCfgFilePath); + else if (endsWithAsciiNoCase(activeCfgFilePath, Zstr(".ffs_batch"))) + trySaveBatchConfig(&activeCfgFilePath); + else + showNotificationDialog(this, DialogInfoType::error, + PopupDialogCfg().setDetailInstructions(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(activeCfgFilePath)) + + L"\n\n" + _("Unexpected file extension:") + L' ' + fmtPath(getFileExtension(activeCfgFilePath)) + L'\n' + + _("Expected:") + L" ffs_gui, ffs_batch")); + } +} + + +bool MainDialog::trySaveConfig(const Zstring* guiCfgPath) //"false": error/cancel +{ + Zstring cfgFilePath; + + if (guiCfgPath) + { + cfgFilePath = *guiCfgPath; + assert(endsWithAsciiNoCase(cfgFilePath, Zstr(".ffs_gui"))); + } + else + { + const Zstring activeCfgFilePath = activeConfigFiles_.size() == 1 && !equalNativePath(activeConfigFiles_[0], lastRunConfigPath_) ? activeConfigFiles_[0] : Zstring(); + + const std::optional defaultFolderPath = !activeCfgFilePath.empty() ? + getParentFolderPath(activeCfgFilePath) : + getParentFolderPath(globalCfg_.mainDlg.config.lastSelectedFile); + + Zstring defaultFileName = !activeCfgFilePath.empty() ? + getItemName(activeCfgFilePath) : + Zstr("SyncSettings.ffs_gui"); + + //attention: activeConfigFiles_ may be an imported ffs_batch file! We don't want to overwrite it with a GUI config! + defaultFileName = beforeLast(defaultFileName, Zstr('.'), IfNotFoundReturn::all) + Zstr(".ffs_gui"); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), utfTo(defaultFileName), + wxString(L"FreeFileSync (*.ffs_gui)|*.ffs_gui") + L"|" +_("All files") + L" (*.*)|*", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (fileSelector.ShowModal() != wxID_OK) + return false; + + cfgFilePath = utfTo(fileSelector.GetPath()); + if (!endsWithAsciiNoCase(cfgFilePath, Zstr(".ffs_gui"))) //no weird shit! + cfgFilePath += Zstr(".ffs_gui"); //https://freefilesync.org/forum/viewtopic.php?t=9451#p34724 + + globalCfg_.mainDlg.config.lastSelectedFile = cfgFilePath; + } + + const FfsGuiConfig guiCfg = getConfig(); + + try + { + writeConfig(guiCfg, cfgFilePath); //throw FileError + + setLastUsedConfig(guiCfg, {cfgFilePath}); + + flashStatusInfo(_("Configuration saved")); + return true; + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return false; + } +} + + +bool MainDialog::trySaveBatchConfig(const Zstring* batchCfgPath) //"false": error/cancel +{ + //essentially behave like trySaveConfig(): the collateral damage of not saving GUI-only settings "m_bpButtonViewType" is negligible + + const Zstring activeCfgFilePath = activeConfigFiles_.size() == 1 && !equalNativePath(activeConfigFiles_[0], lastRunConfigPath_) ? activeConfigFiles_[0] : Zstring(); + + //prepare batch config: reuse existing batch-specific settings from file if available + BatchExclusiveConfig batchExCfg; + try + { + Zstring referenceBatchFile; + if (batchCfgPath) + referenceBatchFile = *batchCfgPath; + else if (!activeCfgFilePath.empty() && endsWithAsciiNoCase(activeCfgFilePath, Zstr(".ffs_batch"))) + referenceBatchFile = activeCfgFilePath; + + if (!referenceBatchFile.empty()) + batchExCfg = readBatchConfig(referenceBatchFile).first.batchExCfg; //throw FileError + //=> ignore warnings altogether: user has seen them already when loading the config file! + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return false; + } + + Zstring cfgFilePath; + if (batchCfgPath) + { + cfgFilePath = *batchCfgPath; + assert(endsWithAsciiNoCase(cfgFilePath, Zstr(".ffs_batch"))); + } + else + { + //let user update batch config: this should change batch-exclusive settings only, else the "setLastUsedConfig" below would be somewhat of a lie + if (showBatchConfigDialog(this, + batchExCfg, + currentCfg_.mainCfg.ignoreErrors) != ConfirmationButton::accept) + return false; + updateUnsavedCfgStatus(); //nothing else to update on GUI! + + const std::optional defaultFolderPath = !activeCfgFilePath.empty() ? + getParentFolderPath(activeCfgFilePath) : + getParentFolderPath(globalCfg_.mainDlg.config.lastSelectedFile); + + Zstring defaultFileName = !activeCfgFilePath.empty() ? + getItemName(activeCfgFilePath) : + Zstr("BatchRun.ffs_batch"); + + //attention: activeConfigFiles_ may be an ffs_gui file! We don't want to overwrite it with a BATCH config! + defaultFileName = beforeLast(defaultFileName, Zstr('.'), IfNotFoundReturn::all) + Zstr(".ffs_batch"); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), utfTo(defaultFileName), + _("FreeFileSync batch") + L" (*.ffs_batch)|*.ffs_batch" + L"|" +_("All files") + L" (*.*)|*", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (fileSelector.ShowModal() != wxID_OK) + return false; + + cfgFilePath = utfTo(fileSelector.GetPath()); + if (!endsWithAsciiNoCase(cfgFilePath, Zstr(".ffs_batch"))) //no weird shit! + cfgFilePath += Zstr(".ffs_batch"); //https://freefilesync.org/forum/viewtopic.php?t=9451#p34724 + + globalCfg_.mainDlg.config.lastSelectedFile = cfgFilePath; + } + + const FfsGuiConfig guiCfg = getConfig(); + try + { + writeConfig({guiCfg, batchExCfg}, cfgFilePath); //throw FileError + + setLastUsedConfig(guiCfg, {cfgFilePath}); //[!] behave as if we had saved guiCfg + + flashStatusInfo(_("Configuration saved")); + return true; + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return false; + } +} + + +bool MainDialog::saveOldConfig() //"false": error/cancel +{ + const FfsGuiConfig guiCfg = getConfig(); + + if (lastSavedCfg_ != guiCfg) + { + const Zstring activeCfgFilePath = activeConfigFiles_.size() == 1 && !equalNativePath(activeConfigFiles_[0], lastRunConfigPath_) ? activeConfigFiles_[0] : Zstring(); + + //notify user about changed settings + if (globalCfg_.confirmDlgs.confirmSaveConfig) + if (!activeCfgFilePath.empty()) + //only if check is active and non-default config file loaded + { + bool neverSaveChanges = false; + switch (showQuestionDialog(this, DialogInfoType::info, PopupDialogCfg().setTitle(utfTo(activeCfgFilePath)). + setMainInstructions(replaceCpy(_("Do you want to save changes to %x?"), L"%x", fmtPath(getItemName(activeCfgFilePath)))). + setCheckBox(neverSaveChanges, _("Never save &changes"), static_cast(QuestionButton2::yes)), + _("&Save"), _("Do&n't save"))) + { + case QuestionButton2::yes: //save + if (endsWithAsciiNoCase(activeCfgFilePath, Zstr(".ffs_gui"))) + return trySaveConfig(&activeCfgFilePath); //"false": error/cancel + else if (endsWithAsciiNoCase(activeCfgFilePath, Zstr(".ffs_batch"))) + return trySaveBatchConfig(&activeCfgFilePath); //"false": error/cancel + else + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg(). + setDetailInstructions(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(activeCfgFilePath)) + + L"\n\n" + _("Unexpected file extension:") + L' ' + fmtPath(getFileExtension(activeCfgFilePath)) + L'\n' + + _("Expected:") + L" ffs_gui, ffs_batch")); + return false; + } + break; + + case QuestionButton2::no: //don't save + globalCfg_.confirmDlgs.confirmSaveConfig = !neverSaveChanges; + break; + + case QuestionButton2::cancel: + return false; + } + } + //user doesn't save changes => + //discard current reference file(s), this ensures next app start will load [Last session] instead of the original non-modified config selection + setLastUsedConfig(guiCfg, {} /*cfgFilePaths*/); + //this seems to make theoretical sense also: the job of this function is to make sure, current (volatile) config and reference file name are in sync + // => if user does not save cfg, it is not attached to a physical file anymore! + } + return true; +} + + +void MainDialog::onConfigLoad(wxCommandEvent& event) +{ + std::optional defaultFolderPath = getParentFolderPath(globalCfg_.mainDlg.config.lastSelectedFile); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), wxString() /*default file name*/, + wxString(L"FreeFileSync (*.ffs_gui; *.ffs_batch)|*.ffs_gui;*.ffs_batch") + L"|" +_("All files") + L" (*.*)|*", + wxFD_OPEN | wxFD_MULTIPLE); + if (fileSelector.ShowModal() != wxID_OK) + return; + + wxArrayString tmp; + fileSelector.GetPaths(tmp); + + std::vector filePaths; + for (const wxString& path : tmp) + filePaths.push_back(utfTo(path)); + + if (!filePaths.empty()) + globalCfg_.mainDlg.config.lastSelectedFile = filePaths[0]; + + assert(!filePaths.empty()); + loadConfiguration(filePaths); +} + + +void MainDialog::onCfgGridSelection(GridSelectEvent& event) +{ + std::vector filePaths; + for (size_t row : m_gridCfgHistory->getSelectedRows()) + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(row)) + filePaths.push_back(cfg->cfgItem.cfgFilePath); + else + assert(false); + + //clicking on already selected config should not clear comparison results: + const bool skipSelection = [&] //what about multi-selection? a second selection probably *should* clear results + { + return filePaths.size() == 1 && activeConfigFiles_.size() == 1 && + filePaths[0] == activeConfigFiles_[0]; + }(); + + if (!skipSelection) + if (filePaths.empty() || //ignore accidental clicks in empty space of configuration panel + !loadConfiguration(filePaths, true /*ignoreBrokenConfig*/)) //=> allow user to delete broken config entry! + //user changed m_gridCfgHistory selection so it's this method's responsibility to synchronize with activeConfigFiles: + //- if user cancelled saving old config + //- there's an error loading new config + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + + event.Skip(); +} + + +void MainDialog::onCfgGridDoubleClick(GridClickEvent& event) +{ + if (!activeConfigFiles_.empty()) + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonCompare->Command(dummy); //simulate click + } +} + + +void MainDialog::onConfigNew(wxCommandEvent& event) +{ + loadConfiguration({}); +} + + +bool MainDialog::loadConfiguration(const std::vector& filePaths, bool ignoreBrokenConfig) //"false": error/cancel +{ + FfsGuiConfig newGuiCfg = getDefaultGuiConfig(globalCfg_.defaultFilter); + std::wstring warningMsg; + + if (!filePaths.empty()) //empty cfg file list means "use default" + try + { + std::tie(newGuiCfg, warningMsg) = readAnyConfig(filePaths); //throw FileError + //allow reading batch configurations, too + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + if (!ignoreBrokenConfig) + return false; + } + + if (!saveOldConfig()) //=> error/cancel + return false; + + setConfig(newGuiCfg, filePaths); + + if (!warningMsg.empty()) + { + showNotificationDialog(this, DialogInfoType::warning, PopupDialogCfg().setDetailInstructions(warningMsg)); + setLastUsedConfig(FfsGuiConfig(), filePaths); //simulate changed config due to parsing errors + } + + //flashStatusInfo("Configuration loaded"); -> irrelevant!? + return true; +} + + +void MainDialog::removeSelectedCfgHistoryItems(bool deleteFromDisk) +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + const std::vector selectedRows = m_gridCfgHistory->getSelectedRows(); + if (!selectedRows.empty()) + { + std::vector filePaths; + for (size_t row : selectedRows) + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(row)) + filePaths.push_back(cfg->cfgItem.cfgFilePath); + else + assert(false); + + if (deleteFromDisk) + { + //=========================================================================== + std::wstring fileList; + for (const Zstring& filePath : filePaths) + fileList += utfTo(filePath) + L'\n'; + + FocusPreserver fp; + + bool moveToRecycler = true; + if (showDeleteDialog(this, fileList, static_cast(filePaths.size()), + moveToRecycler) != ConfirmationButton::accept) + return; + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + 0 /*autoRetryCount*/, + std::chrono::seconds(0) /*autoRetryDelay*/, + globalCfg_.soundFileAlertPending); + std::vector deletedPaths; + try + { + deleteListOfFiles(filePaths, deletedPaths, moveToRecycler, globalCfg_.warnDlgs.warnRecyclerMissing, statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + + filePaths = deletedPaths; + //=========================================================================== + } + + cfggrid::getDataView(*m_gridCfgHistory).removeItems(filePaths); + m_gridCfgHistory->Refresh(); //grid size changed => clears selection! + + //discard unsaved changes => no point in saving before loading next config, right? + //- bonus: clear activeConfigFiles_ if loadConfiguration() fails so that old configs don't reappear after restart + setLastUsedConfig(getConfig(), {} /*cfgFilePaths*/); + + //set active selection on next item to allow "batch-deletion" by holding down DEL key + //user expects that selected config is also loaded: https://freefilesync.org/forum/viewtopic.php?t=5723 + // => deleteFromDisk failed? still select selectedRows.front()! + std::vector nextCfgPaths; + if (m_gridCfgHistory->getRowCount() > 0) + { + const size_t nextRow = std::min(selectedRows.front(), m_gridCfgHistory->getRowCount() - 1); + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(nextRow)) + { + nextCfgPaths.push_back(cfg->cfgItem.cfgFilePath); + + m_gridCfgHistory->setGridCursor(nextRow, GridEventPolicy::deny); + //= Grid::makeRowVisible(redundant) + set grid cursor + select cursor row(redundant) + } + } + + loadConfiguration(nextCfgPaths); //=> error/(cancel) + } +} + + +void MainDialog::renameSelectedCfgHistoryItem() +{ + const std::vector selectedRows = m_gridCfgHistory->getSelectedRows(); + if (!selectedRows.empty()) + { + const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(selectedRows[0]); + assert(cfg); + if (!cfg) + return; + + if (cfg->isLastRunCfg) + return showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions( + replaceCpy(_("%x cannot be renamed."), L"%x", fmtPath(cfg->name)))); + + const Zstring cfgPathOld = cfg->cfgItem.cfgFilePath; + + //FIRST: 1. consolidate unsaved changes using the *old* config file name, if any! + //2. get rid of multiple-selection if exists 3. load cfg to allow non-failing(!) setLastUsedConfig() below + if (!loadConfiguration({cfgPathOld})) //=> error/cancel + return; + + const Zstring fileName = getItemName(cfgPathOld); + /**/ Zstring folderPathPf = beforeLast(cfgPathOld, FILE_NAME_SEPARATOR, IfNotFoundReturn::none); + if (!folderPathPf.empty()) + folderPathPf += FILE_NAME_SEPARATOR; + + const Zstring cfgNameOld = beforeLast(fileName, Zstr('.'), IfNotFoundReturn::all); + /**/ Zstring cfgDotExt = afterLast(fileName, Zstr('.'), IfNotFoundReturn::none); + if (!cfgDotExt.empty()) + cfgDotExt = Zstr('.') + cfgDotExt; + + wxString cfgNameTmp = utfTo(cfgNameOld); + for (;;) + { + wxTextEntryDialog cfgRenameDlg(this, _("New name:"), _("Rename Configuration"), cfgNameTmp); + + wxTextValidator inputValidator(wxFILTER_EXCLUDE_CHAR_LIST); + inputValidator.SetCharExcludes(L"/\\"); //let's not silently forbid "fileNameForbiddenChars", but let it fail explicitly! + cfgRenameDlg.SetTextValidator(inputValidator); + + if (cfgRenameDlg.ShowModal() != wxID_OK) + return; + cfgNameTmp = cfgRenameDlg.GetValue(); + + const Zstring cfgNameNew = utfTo(trimCpy(cfgNameTmp)); + if (cfgNameNew == cfgNameOld) + return; + + const Zstring cfgPathNew = folderPathPf + cfgNameNew + cfgDotExt; + try + { + if (cfgNameNew.empty()) //better error message + check than wxFILTER_EMPTY, e.g. trimCpy()! + throw FileError(_("Configuration name must not be empty.")); + + moveAndRenameItem(cfgPathOld, cfgPathNew, false /*replaceExisting*/); //throw FileError, (ErrorMoveUnsupported), ErrorTargetExisting + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + continue; + } + + cfggrid::getDataView(*m_gridCfgHistory).renameItem(cfgPathOld, cfgPathNew); + m_gridCfgHistory->Refresh(); //grid size changed => clears selection! + + const auto& [item, row] = cfggrid::getDataView(*m_gridCfgHistory).getItem(cfgPathNew); + assert(item); + m_gridCfgHistory->setGridCursor(row, GridEventPolicy::deny); + //= Grid::makeRowVisible(redundant) + set grid cursor + select cursor row(redundant) + // + //keep current cfg and just swap the file name: see previous "loadConfiguration({cfgPathOld}"! + setLastUsedConfig(lastSavedCfg_, {cfgPathNew}); + return; + } + } +} + + +void MainDialog::onCfgGridKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + switch (keyCode) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (!activeConfigFiles_.empty()) + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + (folderCmp_.empty() ? m_buttonCompare : m_buttonSync)->Command(dummy); //simulate click + } + break; + + case WXK_DELETE: + case WXK_NUMPAD_DELETE: + removeSelectedCfgHistoryItems(event.ShiftDown() /*deleteFromDisk*/); + return; //"swallow" event + + case WXK_F2: + case WXK_NUMPAD_F2: + renameSelectedCfgHistoryItem(); + return; //"swallow" event + } + event.Skip(); +} + + +void MainDialog::onCfgGridContext(GridContextMenuEvent& event) +{ + ContextMenu menu; + const std::vector selectedRows = m_gridCfgHistory->getSelectedRows(); + + std::vector cfgFilePaths; + for (size_t row : selectedRows) + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(row)) + cfgFilePaths.push_back(cfg->cfgItem.cfgFilePath); + else + assert(false); + + //-------------------------------------------------------------------------------------------------------- + ContextMenu submenu; + + auto applyBackColor = [this, &cfgFilePaths](const wxColor& col) + { + cfggrid::getDataView(*m_gridCfgHistory).setBackColor(cfgFilePaths, col); + + //re-apply selection (after sorting by color tags): + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + //m_gridCfgHistory->Refresh(); <- implicit in last call + }; + + const wxSize colSize{this->GetCharHeight(), this->GetCharHeight()}; + + auto addColorOption = [&](const wxColor& col, const wxString& name) + { + submenu.addItem(name, [&, col] { applyBackColor(col); }, + rectangleImage({wxsizeToScreen(colSize.x), + wxsizeToScreen(colSize.y)}, + col.Ok() ? col : wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), + {0xdd, 0xdd, 0xdd} /*light grey*/, dipToScreen(1)), + !selectedRows.empty()); + }; + const auto defaultColors = []() -> std::vector> + { + if (wxSystemSettings::GetAppearance().IsDark()) //=> offer darker colors + return {{wxNullColour /*=> !wxColor::IsOk()*/, L'(' + _("&Default") + L')'}, //meta options should be enclosed in parentheses + {{0xfe, 0x59, 0x48}, _("Red")}, + {{0xfe, 0xff, 0x31}, _("Yellow")}, + {{0x5a, 0xff, 0x00}, _("Green")}, + {{0x5a, 0xff, 0xff}, _("Cyan")}, + {{0x48, 0x47, 0xff}, _("Blue")}, + {{0xc1, 0x7e, 0xfe}, _("Purple")}, + {{0xb7, 0xb7, 0xb7}, _("Gray")}, + }; + else //=> offer lighter colors + return {{wxNullColour /*=> !wxColor::IsOk()*/, L'(' + _("&Default") + L')'}, //meta options should be enclosed in parentheses + {{0xff, 0xd8, 0xcb}, _("Red")}, + {{0xff, 0xf9, 0x99}, _("Yellow")}, + {{0xcc, 0xff, 0x99}, _("Green")}, + {{0xcc, 0xff, 0xff}, _("Cyan")}, + {{0xcc, 0xcc, 0xff}, _("Blue")}, + {{0xf2, 0xcb, 0xff}, _("Purple")}, + {{0xdd, 0xdd, 0xdd}, _("Gray")}, + }; + }(); + + std::unordered_set addedColorCodes; + + //add default colors + for (const auto& [color, name] : defaultColors) + { + addColorOption(color, name); + if (color.IsOk()) + addedColorCodes.insert(color.GetRGBA()); + } + + //add user-defined colors + for (const ConfigFileItem& item : cfggrid::getDataView(*m_gridCfgHistory).get()) + if (item.backColor.IsOk()) + if (const auto [it, inserted] = addedColorCodes.insert(item.backColor.GetRGBA()); + inserted) + addColorOption(item.backColor, item.backColor.GetAsString(wxC2S_HTML_SYNTAX)); //#RRGGBB + + //show color picker + wxBitmap bmpColorPicker(wxsizeToScreen(colSize.x), + wxsizeToScreen(colSize.y)); //seems we don't need to pass 24-bit depth here even for high-contrast color schemes + bmpColorPicker.SetScaleFactor(getScreenDpiScale()); + { + wxMemoryDC dc(bmpColorPicker); + const wxColor borderCol(0xdd, 0xdd, 0xdd); //light grey + drawFilledRectangle(dc, wxRect(colSize), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), borderCol, dipToWxsize(1)); + + dc.SetFont(dc.GetFont().Bold()); + dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + dc.DrawText(L"?", wxPoint() + (colSize - dc.GetTextExtent(L"?")) / 2); + } + + submenu.addItem(_("Different color..."), [&] + { + wxColourData colCfg; + colCfg.SetChooseFull(true); + colCfg.SetChooseAlpha(false); + colCfg.SetColour(defaultColors[1].first); //tentative + + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(selectedRows[0])) + if (cfg->cfgItem.backColor.IsOk()) + colCfg.SetColour(cfg->cfgItem.backColor); + + int i = 0; + for (const auto& [color, name] : defaultColors) + if (color.IsOk() && i < static_cast(wxColourData::NUM_CUSTOM)) + colCfg.SetCustomColour(i++, color); + + auto fixColorPickerColor = [](const wxColor& col) + { + assert(col.Alpha() == 255); + return col; + }; + wxColourDialog dlg(this, &colCfg); + dlg.Center(); + + dlg.Bind(wxEVT_COLOUR_CHANGED, [&](wxColourDialogEvent& event2) + { + //show preview during color selection (Windows-only atm) + cfggrid::getDataView(*m_gridCfgHistory).setBackColor(cfgFilePaths, fixColorPickerColor(event2.GetColour()), true /*previewOnly*/); + m_gridCfgHistory->Refresh(); + }); + + if (dlg.ShowModal() == wxID_OK) + applyBackColor(fixColorPickerColor(dlg.GetColourData().GetColour())); + else //shut off color preview + { + cfggrid::getDataView(*m_gridCfgHistory).setBackColor(cfgFilePaths, wxNullColour, true /*previewOnly*/); + m_gridCfgHistory->Refresh(); + } + }, bmpColorPicker.ConvertToImage()); + + menu.addSubmenu(_("Background color"), submenu, loadImage("color", dipToScreen(getMenuIconDipSize())), !selectedRows.empty()); + menu.addSeparator(); + //-------------------------------------------------------------------------------------------------------- + + auto showInFileManager = [&] + { + if (!selectedRows.empty()) + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(selectedRows[0])) + { + const Zstring cmdLine = replaceCpy(expandMacros(extCommandFileManager.cmdLine), Zstr("%local_path%"), escapeCommandArg(cfg->cfgItem.cfgFilePath)); + try + { + if (const auto& [exitCode, output] = consoleExecute(cmdLine, EXT_APP_MAX_TOTAL_WAIT_TIME_MS); //throw SysError, SysErrorTimeOut + exitCode != 0) + throw SysError(formatSystemError(utfTo(extCommandFileManager.cmdLine), + replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + } + catch (SysErrorTimeOut&) {} //child process not failed yet => probably fine :> + catch (const SysError& e) + { + const std::wstring errorMsg = replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLine)) + L"\n\n" + e.toString(); + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(errorMsg)); + } + return; + } + assert(false); + }; + menu.addItem(translate(extCommandFileManager.description), //translate default external apps on the fly: "Show in Explorer" + showInFileManager, imgFileManagerSmall_, !selectedRows.empty()); + menu.addSeparator(); + //-------------------------------------------------------------------------------------------------------- + const bool renameEnabled = [&] + { + if (!selectedRows.empty()) + if (const ConfigView::Details* cfg = cfggrid::getDataView(*m_gridCfgHistory).getItem(selectedRows[0])) + return !cfg->isLastRunCfg; + return false; + }(); + menu.addItem(_("&Rename") + L"\tF2", [this] { renameSelectedCfgHistoryItem (); }, loadImage("rename", dipToScreen(getMenuIconDipSize())), renameEnabled); + + //-------------------------------------------------------------------------------------------------------- + menu.addItem(_("&Hide") + L"\tDel", [this] { removeSelectedCfgHistoryItems(false /*deleteFromDisk*/); }, wxNullImage, !selectedRows.empty()); + menu.addItem(_("&Delete") + L"\tShift+Del", [this] { removeSelectedCfgHistoryItems(true /*deleteFromDisk*/); }, imgTrashSmall_, !selectedRows.empty()); + //-------------------------------------------------------------------------------------------------------- + menu.popup(*m_gridCfgHistory, event.mousePos_); + //event.Skip(); +} + + +void MainDialog::onCfgGridLabelContext(GridLabelClickEvent& event) +{ + ContextMenu menu; + //-------------------------------------------------------------------------------------------------------- + auto toggleColumn = [&](ColumnType ct) + { + auto colAttr = m_gridCfgHistory->getColumnConfig(); + + Grid::ColAttributes* caName = nullptr; + Grid::ColAttributes* caToggle = nullptr; + + for (Grid::ColAttributes& ca : colAttr) + if (ca.type == static_cast(ColumnTypeCfg::name)) + caName = &ca; + else if (ca.type == ct) + caToggle = &ca; + + assert(caName && caName->stretch > 0 && caName->visible); + assert(caToggle && caToggle->stretch == 0); + + if (caName && caToggle) + { + caToggle->visible = !caToggle->visible; + + //take width of newly visible column from stretched folder name column + caName->offset -= caToggle->visible ? caToggle->offset : -caToggle->offset; + + m_gridCfgHistory->setColumnConfig(colAttr); + } + }; + + if (auto prov = m_gridCfgHistory->getDataProvider()) + for (const Grid::ColAttributes& ca : m_gridCfgHistory->getColumnConfig()) + menu.addCheckBox(prov->getColumnLabel(ca.type), [ct = ca.type, toggleColumn] { toggleColumn(ct); }, + ca.visible, ca.type != static_cast(ColumnTypeCfg::name)); //do not allow user to hide name column! + else assert(false); + //-------------------------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto setDefault = [&] + { + const DpiLayout defaultLayout; + m_gridCfgHistory->setColumnConfig(convertColAttributes(defaultLayout.configColumnAttribs, getCfgGridDefaultColAttribs())); + }; + menu.addItem(_("&Default"), setDefault, loadImage("reset_sicon")); //'&' -> reuse text from "default" buttons elsewhere + //-------------------------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto setCfgHighlight = [&] + { + int cfgGridSyncOverdueDays = cfggrid::getSyncOverdueDays(*m_gridCfgHistory); + + if (showCfgHighlightDlg(this, cfgGridSyncOverdueDays) == ConfirmationButton::accept) + cfggrid::setSyncOverdueDays(*m_gridCfgHistory, cfgGridSyncOverdueDays); + }; + menu.addItem(_("Highlight..."), setCfgHighlight); + //-------------------------------------------------------------------------------------------------------- + + menu.popup(*m_gridCfgHistory, {event.mousePos_.x, m_gridCfgHistory->getColumnLabelHeight()}); + //event.Skip(); +} + + +void MainDialog::onCfgGridLabelLeftClick(GridLabelClickEvent& event) +{ + const auto colType = static_cast(event.colType_); + bool sortAscending = getDefaultSortDirection(colType); + + const auto [sortCol, ascending] = cfggrid::getDataView(*m_gridCfgHistory).getSortDirection(); + if (sortCol == colType) + sortAscending = !ascending; + + cfggrid::getDataView(*m_gridCfgHistory).setSortDirection(colType, sortAscending); + m_gridCfgHistory->Refresh(); + + //re-apply selection: + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); +} + + +void MainDialog::onCheckRows(CheckRowsEvent& event) +{ + std::vector selectedRows; + + const size_t rowLast = std::min(event.rowLast_, filegrid::getDataView(*m_gridMainC).rowsOnView()); //consider dummy rows + for (size_t i = event.rowFirst_; i < rowLast; ++i) + selectedRows.push_back(i); + + if (!selectedRows.empty()) + { + std::vector objects = filegrid::getDataView(*m_gridMainC).getAllFileRef(selectedRows); + setIncludedManually(objects, event.setActive_); + } +} + + +void MainDialog::onSetSyncDirection(SyncDirectionEvent& event) +{ + std::vector selectedRows; + + const size_t rowLast = std::min(event.rowLast_, filegrid::getDataView(*m_gridMainC).rowsOnView()); //consider dummy rows + for (size_t i = event.rowFirst_; i < rowLast; ++i) + selectedRows.push_back(i); + + if (!selectedRows.empty()) + { + std::vector objects = filegrid::getDataView(*m_gridMainC).getAllFileRef(selectedRows); + setSyncDirManually(objects, event.direction_); + } +} + + +void MainDialog::setLastUsedConfig(const FfsGuiConfig& guiConfig, const std::vector& cfgFilePaths) +{ + activeConfigFiles_ = cfgFilePaths; + lastSavedCfg_ = guiConfig; + + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, true /*scrollToSelection*/); //put file paths on list of last used config files + + //update notes after save + for newly loaded files => BUT: superfluous when loading already known config files! + cfgHistoryUpdateNotes(cfgFilePaths); + + updateUnsavedCfgStatus(); +} + + +void MainDialog::setConfig(const FfsGuiConfig& newGuiCfg, const std::vector& cfgFilePaths) +{ + currentCfg_ = newGuiCfg; + + //(re-)set view filter buttons + setViewFilterDefault(); + + updateGlobalFilterButton(); + + //set first folder pair + firstFolderPair_->setValues(currentCfg_.mainCfg.firstPair); + + setAddFolderPairs(currentCfg_.mainCfg.additionalPairs); + + setGridViewType(currentCfg_.gridViewType); + + //clearGrid(); //+ update GUI! -> already called by setAddFolderPairs() + + setLastUsedConfig(newGuiCfg, cfgFilePaths); +} + + +FfsGuiConfig MainDialog::getConfig() const +{ + FfsGuiConfig guiCfg = currentCfg_; + + //load settings whose ownership lies not in currentCfg: + + //first folder pair + guiCfg.mainCfg.firstPair = firstFolderPair_->getValues(); + + //add additional pairs + guiCfg.mainCfg.additionalPairs.clear(); + + for (const FolderPairPanel* panel : additionalFolderPairs_) + guiCfg.mainCfg.additionalPairs.push_back(panel->getValues()); + + //sync preview + guiCfg.gridViewType = m_bpButtonViewType->isActive() ? GridViewType::action : GridViewType::difference; + + return guiCfg; +} + + +void MainDialog::updateGuiDelayedIf(bool condition) +{ + if (condition) + { + filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR); + m_gridMainL->Update(); + m_gridMainC->Update(); + m_gridMainR->Update(); + + //some delay to show the changed GUI before removing rows from sight + std::this_thread::sleep_for(FILE_GRID_POST_UPDATE_DELAY); + } + + updateGui(); +} + + +void MainDialog::showConfigDialog(SyncConfigPanel panelToShow, int localPairIndexToShow) +{ + GlobalPairConfig globalPairCfg; + globalPairCfg.cmpCfg = currentCfg_.mainCfg.cmpCfg; + globalPairCfg.syncCfg = currentCfg_.mainCfg.syncCfg; + globalPairCfg.filter = currentCfg_.mainCfg.globalFilter; + + globalPairCfg.miscCfg.deviceParallelOps = currentCfg_.mainCfg.deviceParallelOps; + globalPairCfg.miscCfg.ignoreErrors = currentCfg_.mainCfg.ignoreErrors; + globalPairCfg.miscCfg.autoRetryCount = currentCfg_.mainCfg.autoRetryCount; + globalPairCfg.miscCfg.autoRetryDelay = currentCfg_.mainCfg.autoRetryDelay; + globalPairCfg.miscCfg.postSyncCommand = currentCfg_.mainCfg.postSyncCommand; + globalPairCfg.miscCfg.postSyncCondition = currentCfg_.mainCfg.postSyncCondition; + globalPairCfg.miscCfg.altLogFolderPathPhrase = currentCfg_.mainCfg.altLogFolderPathPhrase; + globalPairCfg.miscCfg.emailNotifyAddress = currentCfg_.mainCfg.emailNotifyAddress; + globalPairCfg.miscCfg.emailNotifyCondition = currentCfg_.mainCfg.emailNotifyCondition; + globalPairCfg.miscCfg.notes = currentCfg_.notes; + + //don't recalculate value but consider current screen status!!! + //e.g. it's possible that the first folder pair local config is shown with all config initial if user just removed local config via mouse context menu! + const bool showMultipleCfgs = m_bpButtonLocalCompCfg->IsShown(); + //harmonize with MainDialog::updateGuiForFolderPair()! + + assert(showMultipleCfgs || localPairIndexToShow == -1); + assert(m_bpButtonLocalCompCfg->IsShown() == m_bpButtonLocalSyncCfg->IsShown() && + m_bpButtonLocalCompCfg->IsShown() == m_bpButtonLocalFilter ->IsShown()); + + std::vector localCfgs; //showSyncConfigDlg() needs *all* folder pairs for deviceParallelOps update + localCfgs.push_back(firstFolderPair_->getValues()); + + for (const FolderPairPanel* panel : additionalFolderPairs_) + localCfgs.push_back(panel->getValues()); + + //------------------------------------------------------------------------------------ + const GlobalPairConfig globalPairCfgOld = globalPairCfg; + const std::vector localPairCfgOld = localCfgs; + + if (showSyncConfigDlg(this, + panelToShow, + showMultipleCfgs ? localPairIndexToShow : -1, + showMultipleCfgs, + globalPairCfg, + localCfgs, + globalCfg_.defaultFilter, + globalCfg_.versioningFolderHistory, globalCfg_.versioningFolderLastSelected, + globalCfg_.logFolderHistory, globalCfg_.logFolderLastSelected, globalCfg_.logFolderPhrase, + globalCfg_.folderHistoryMax, + globalCfg_.sftpKeyFileLastSelected, + globalCfg_.emailHistory, globalCfg_.emailHistoryMax, + globalCfg_.commandHistory, globalCfg_.commandHistoryMax) == ConfirmationButton::accept) + { + assert(localCfgs.size() == localPairCfgOld.size()); + + currentCfg_.mainCfg.cmpCfg = globalPairCfg.cmpCfg; + currentCfg_.mainCfg.syncCfg = globalPairCfg.syncCfg; + currentCfg_.mainCfg.globalFilter = globalPairCfg.filter; + + currentCfg_.mainCfg.deviceParallelOps = globalPairCfg.miscCfg.deviceParallelOps; + currentCfg_.mainCfg.ignoreErrors = globalPairCfg.miscCfg.ignoreErrors; + currentCfg_.mainCfg.autoRetryCount = globalPairCfg.miscCfg.autoRetryCount; + currentCfg_.mainCfg.autoRetryDelay = globalPairCfg.miscCfg.autoRetryDelay; + currentCfg_.mainCfg.postSyncCommand = globalPairCfg.miscCfg.postSyncCommand; + currentCfg_.mainCfg.postSyncCondition = globalPairCfg.miscCfg.postSyncCondition; + currentCfg_.mainCfg.altLogFolderPathPhrase = globalPairCfg.miscCfg.altLogFolderPathPhrase; + currentCfg_.mainCfg.emailNotifyAddress = globalPairCfg.miscCfg.emailNotifyAddress; + currentCfg_.mainCfg.emailNotifyCondition = globalPairCfg.miscCfg.emailNotifyCondition; + currentCfg_.notes = globalPairCfg.miscCfg.notes; + + firstFolderPair_->setValues(localCfgs[0]); + + for (size_t i = 1; i < localCfgs.size(); ++i) + additionalFolderPairs_[i - 1]->setValues(localCfgs[i]); + + //------------------------------------------------------------------------------------ + + const bool cmpConfigChanged = globalPairCfg.cmpCfg != globalPairCfgOld.cmpCfg || [&] + { + for (size_t i = 0; i < localCfgs.size(); ++i) + if (localCfgs[i].localCmpCfg != localPairCfgOld[i].localCmpCfg) + return true; + return false; + }(); + + //[!] don't redetermine sync directions if only options for deletion handling or versioning are changed!!! + const bool syncDirectionsChanged = globalPairCfg.syncCfg.directionCfg != globalPairCfgOld.syncCfg.directionCfg || [&] + { + for (size_t i = 0; i < localCfgs.size(); ++i) + if (static_cast(localCfgs[i].localSyncCfg) != static_cast(localPairCfgOld[i].localSyncCfg) || + (localCfgs[i].localSyncCfg && localCfgs[i].localSyncCfg->directionCfg != localPairCfgOld[i].localSyncCfg->directionCfg)) + return true; + return false; + }(); + + const bool filterConfigChanged = globalPairCfg.filter != globalPairCfgOld.filter || [&] + { + for (size_t i = 0; i < localCfgs.size(); ++i) + if (localCfgs[i].localFilter != localPairCfgOld[i].localFilter) + return true; + return false; + }(); + + //const bool miscConfigChanged = globalPairCfg.miscCfg.deviceParallelOps != globalPairCfgOld.miscCfg.deviceParallelOps || + // globalPairCfg.miscCfg.ignoreErrors != globalPairCfgOld.miscCfg.ignoreErrors || + // globalPairCfg.miscCfg.autoRetryCount != globalPairCfgOld.miscCfg.autoRetryCount || + // globalPairCfg.miscCfg.autoRetryDelay != globalPairCfgOld.miscCfg.autoRetryDelay || + // globalPairCfg.miscCfg.postSyncCommand != globalPairCfgOld.miscCfg.postSyncCommand || + // globalPairCfg.miscCfg.postSyncCondition != globalPairCfgOld.miscCfg.postSyncCondition || + // globalPairCfg.miscCfg.altLogFolderPathPhrase != globalPairCfgOld.miscCfg.altLogFolderPathPhrase || + // globalPairCfg.miscCfg.emailNotifyAddress != globalPairCfgOld.miscCfg.emailNotifyAddress || + // globalPairCfg.miscCfg.emailNotifyCondition != globalPairCfgOld.miscCfg.emailNotifyCondition; + // globalPairCfg.miscCfg.notes != globalPairCfgOld.miscCfg.notes; + + if (cmpConfigChanged) + applyCompareConfig(globalPairCfg.cmpCfg.compareVar != globalPairCfgOld.cmpCfg.compareVar /*setDefaultViewType*/); + + if (syncDirectionsChanged) + applySyncDirections(); + + if (filterConfigChanged) + { + updateGlobalFilterButton(); //refresh global filter icon + applyFilterConfig(); //re-apply filter + } + } + //else: possible but obscure: default filter changed => impact on "New config" enabled/disabled! + + updateUnsavedCfgStatus(); //also included by updateGui(); +} + + +void MainDialog::onGlobalFilterContext(wxEvent& event) +{ + std::optional filterCfgOnClipboard; + if (std::optional clipTxt = getClipboardText()) + filterCfgOnClipboard = parseFilterBuf(utfTo(*clipTxt)); + + auto cutFilter = [&] + { + setClipboardText(utfTo(serializeFilter(currentCfg_.mainCfg.globalFilter))); + currentCfg_.mainCfg.globalFilter = FilterConfig(); + updateGlobalFilterButton(); + applyFilterConfig(); + }; + + auto copyFilter = [&] { setClipboardText(utfTo(serializeFilter(currentCfg_.mainCfg.globalFilter))); }; + + auto pasteFilter = [&] + { + currentCfg_.mainCfg.globalFilter = *filterCfgOnClipboard; + updateGlobalFilterButton(); + applyFilterConfig(); + }; + + ContextMenu menu; + menu.addItem( _("&Copy"), copyFilter, loadImage("item_copy_sicon"), !isNullFilter(currentCfg_.mainCfg.globalFilter)); + menu.addItem( _("&Paste"), pasteFilter, loadImage("item_paste_sicon"), filterCfgOnClipboard.has_value()); + menu.addSeparator(); + menu.addItem( _("Cu&t"), cutFilter, loadImage("item_cut_sicon"), !isNullFilter(currentCfg_.mainCfg.globalFilter)); + + menu.popup(*m_bpButtonFilterContext, {m_bpButtonFilterContext->GetSize().x, 0}); +} + + +void MainDialog::onToggleViewType(wxCommandEvent& event) +{ + setGridViewType(m_bpButtonViewType->isActive() ? GridViewType::difference : GridViewType::action); +} + + +void MainDialog::onToggleViewButton(wxCommandEvent& event) +{ + if (auto button = dynamic_cast(event.GetEventObject())) + { + button->toggle(); + updateGui(); + + //consistency: toggling view buttons should *always* clear selections, not only implicitly when row count changes: + // + //m_gridMainL->clearSelection(GridEventPolicy::deny); + //m_gridMainC->clearSelection(GridEventPolicy::deny); -> implicitly called by onTreeGridSelection() + //m_gridMainR->clearSelection(GridEventPolicy::deny); + m_gridOverview->clearSelection(GridEventPolicy::allow); + } + else + assert(false); +} + + +void MainDialog::setViewFilterDefault() +{ + auto setButton = [](ToggleButton& tb, bool value) { tb.setActive(value); }; + + const auto& def = globalCfg_.mainDlg.viewFilterDefault; + setButton(*m_bpButtonShowExcluded, def.excluded); + setButton(*m_bpButtonShowEqual, def.equal); + setButton(*m_bpButtonShowConflict, def.conflict); + + setButton(*m_bpButtonShowLeftOnly, def.leftOnly); + setButton(*m_bpButtonShowRightOnly, def.rightOnly); + setButton(*m_bpButtonShowLeftNewer, def.leftNewer); + setButton(*m_bpButtonShowRightNewer, def.rightNewer); + setButton(*m_bpButtonShowDifferent, def.different); + + setButton(*m_bpButtonShowCreateLeft, def.createLeft); + setButton(*m_bpButtonShowCreateRight, def.createRight); + setButton(*m_bpButtonShowUpdateLeft, def.updateLeft); + setButton(*m_bpButtonShowUpdateRight, def.updateRight); + setButton(*m_bpButtonShowDeleteLeft, def.deleteLeft); + setButton(*m_bpButtonShowDeleteRight, def.deleteRight); + setButton(*m_bpButtonShowDoNothing, def.doNothing); +} + + +void MainDialog::onViewTypeContextMouse(wxMouseEvent& event) +{ + ContextMenu menu; + + const GridViewType viewType = m_bpButtonViewType->isActive() ? GridViewType::action : GridViewType::difference; + + menu.addItem(_("Difference") + (viewType != GridViewType::difference ? L"\tF11" : L""), + [&] { setGridViewType(GridViewType::difference); }, greyScaleIfDisabled(loadImage("compare", dipToScreen(getMenuIconDipSize())), viewType == GridViewType::difference)); + + menu.addItem(_("Action") + (viewType != GridViewType::action ? L"\tF11" : L""), + [&] { setGridViewType(GridViewType::action); }, greyScaleIfDisabled(loadImage("start_sync", dipToScreen(getMenuIconDipSize())), viewType == GridViewType::action)); + + menu.popup(*m_bpButtonViewType, {m_bpButtonViewType->GetSize().x, 0}); +} + + +void MainDialog::onViewFilterContext(wxEvent& event) +{ + ContextMenu menu; + + auto saveButtonDefault = [](const ToggleButton& tb, bool& defaultValue) + { + if (tb.IsShown()) + defaultValue = tb.isActive(); + }; + + auto saveDefault = [&] + { + auto& def = globalCfg_.mainDlg.viewFilterDefault; + saveButtonDefault(*m_bpButtonShowExcluded, def.excluded); + saveButtonDefault(*m_bpButtonShowEqual, def.equal); + saveButtonDefault(*m_bpButtonShowConflict, def.conflict); + + saveButtonDefault(*m_bpButtonShowLeftOnly, def.leftOnly); + saveButtonDefault(*m_bpButtonShowRightOnly, def.rightOnly); + saveButtonDefault(*m_bpButtonShowLeftNewer, def.leftNewer); + saveButtonDefault(*m_bpButtonShowRightNewer, def.rightNewer); + saveButtonDefault(*m_bpButtonShowDifferent, def.different); + + saveButtonDefault(*m_bpButtonShowCreateLeft, def.createLeft); + saveButtonDefault(*m_bpButtonShowCreateRight, def.createRight); + saveButtonDefault(*m_bpButtonShowDeleteLeft, def.deleteLeft); + saveButtonDefault(*m_bpButtonShowDeleteRight, def.deleteRight); + saveButtonDefault(*m_bpButtonShowUpdateLeft, def.updateLeft); + saveButtonDefault(*m_bpButtonShowUpdateRight, def.updateRight); + saveButtonDefault(*m_bpButtonShowDoNothing, def.doNothing); + + flashStatusInfo(_("View settings saved")); + }; + + menu.addItem(_("&Save as default"), saveDefault, loadImage("cfg_save", dipToScreen(getMenuIconDipSize()))); + menu.popup(*m_bpButtonViewFilterContext, {m_bpButtonViewFilterContext->GetSize().x, 0}); +} + + +void MainDialog::updateGlobalFilterButton() +{ + //global filter: test for Null-filter + setImage(*m_bpButtonFilter, greyScaleIfDisabled(loadImage("options_filter"), !isNullFilter(currentCfg_.mainCfg.globalFilter))); + + m_bpButtonFilter->SetToolTip(_("Filter") + L" (F7)" + getFilterSummaryForTooltip(currentCfg_.mainCfg.globalFilter)); + //m_bpButtonFilterContext->SetToolTip(m_bpButtonFilter->GetToolTipText()); +} + + +void MainDialog::onCompare(wxCommandEvent& event) +{ + /* mitigate unwanted reentrancy caused by wxApp::Yield(): + disabling GUI elements is NOT enough! e.g. reentrancy when there's a second click event *already* in the Windows message queue + + CAVEAT: This doesn't block all theoretically possible Window events that were queued *before* disableGuiElementsImpl() takes effect, + but at least the 90% case of (rare!) crashes caused by a duplicate click event on comparison or sync button. */ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + //wxBusyCursor dummy; -> redundant: progress already shown in progress dialog! + + FocusPreserver fp; //e.g. keep focus on config panel after pressing F5 + + //give nice hint on what's next to do if user manually clicked on compare + assert(m_buttonCompare->GetId() != wxID_ANY); + if (fp.getFocusId() == m_buttonCompare->GetId()) + fp.setFocus(m_buttonSync); + + int scrollPosX = 0; + int scrollPosY = 0; + m_gridMainL->GetViewStart(&scrollPosX, &scrollPosY); //preserve current scroll position + ZEN_ON_SCOPE_EXIT(m_gridMainL->Scroll(scrollPosX, scrollPosY); // + m_gridMainR->Scroll(scrollPosX, scrollPosY); //restore + m_gridMainC->Scroll(-1, scrollPosY); ); // + + clearGrid(); //avoid memory peak by clearing old data first + + const auto& guiCfg = getConfig(); + + const std::vector& fpCfgList = extractCompareCfg(guiCfg.mainCfg); + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + //handle status display and error messages + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now(), + guiCfg.mainCfg.ignoreErrors, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + + auto requestPassword = [&, password = Zstring()](const std::wstring& msg, const std::wstring& lastErrorMsg) mutable //throw CancelProcess + { + assert(runningOnMainThread()); + if (showPasswordPrompt(this, msg, lastErrorMsg, password) != ConfirmationButton::accept) + statusHandler.cancelProcessNow(CancelReason::user); //throw CancelProcess + + return password; + }; + try + { + //GUI mode: place directory locks on directories isolated(!) during both comparison and synchronization + + std::unique_ptr dirLocks; + folderCmp_ = compare(globalCfg_.warnDlgs, + globalCfg_.fileTimeTolerance, + requestPassword, + globalCfg_.runWithBackgroundPriority, + globalCfg_.createLockFile, + dirLocks, + fpCfgList, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + //--------------------------------------------------------------------------- + setLastOperationLog(r.summary, r.errorLog.ptr()); + + fullSyncLog_ = {r.errorLog.ref(), r.summary.startTime, r.summary.totalTime}; + + if (r.summary.result == TaskResult::cancelled) + return updateGui(); //refresh grid in ANY case! (also on abort) + + + filegrid::setData(*m_gridMainC, folderCmp_); // + treegrid::setData(*m_gridOverview, folderCmp_); //update view on data + updateGui(); // + + //play (optional) sound notification + if (!globalCfg_.soundFileCompareFinished.empty()) + { + //wxWidgets shows modal error dialog by default => "no, wxWidgets, NO!" + wxLog* oldLogTarget = wxLog::SetActiveTarget(new wxLogStderr); //transfer and receive ownership! + ZEN_ON_SCOPE_EXIT(delete wxLog::SetActiveTarget(oldLogTarget)); + + wxSound::Play(utfTo(globalCfg_.soundFileCompareFinished), wxSOUND_ASYNC); + } + + if (!IsActive()) + RequestUserAttention(); //this == toplevel win, so we also get the taskbar flash! + + //remember folder history (except when cancelled by user) + for (const FolderPairCfg& fpCfg : fpCfgList) + { + folderHistoryLeft_ ->addItem(fpCfg.folderPathPhraseLeft_); + folderHistoryRight_->addItem(fpCfg.folderPathPhraseRight_); + } + + //mark selected cfg files as "in sync" when there is nothing to do: https://freefilesync.org/forum/viewtopic.php?t=4991 + if (r.summary.result == TaskResult::success) + if (getCUD(SyncStatistics(folderCmp_)) == 0) + { + setStatusInfo(_("No files to synchronize"), true /*highlight*/); //user might be AFK: don't flashStatusInfo() + //overwrites status info already set in updateGui() above + + cfggrid::getDataView(*m_gridCfgHistory).setLastInSyncTime(activeConfigFiles_, std::chrono::system_clock::to_time_t(r.summary.startTime)); + //re-apply selection: sort order changed if sorted by last sync time, or log + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + //m_gridCfgHistory->Refresh(); <- implicit in last call + } + + //reset icon cache (IconBuffer) after *each* comparison! + filegrid::setupIcons(*m_gridMainL, *m_gridMainC, *m_gridMainR, globalCfg_.mainDlg.showIcons, convert(globalCfg_.mainDlg.iconSize)); +} + + +void MainDialog::updateGui() +{ + updateGridViewData(); //update gridDataView and write status information + + const SyncStatistics st(folderCmp_); + updateStatistics(st); + + updateUnsavedCfgStatus(); + + const auto& mainCfg = getConfig().mainCfg; + const std::optional cmpVar = getCommonCompVariant(mainCfg); + const std::optional syncVar = getCommonSyncVariant(mainCfg); + + const char* cmpVarIconName = nullptr; + if (cmpVar) + switch (*cmpVar) + { + case CompareVariant::timeSize: cmpVarIconName = "cmp_time"; break; + case CompareVariant::content: cmpVarIconName = "cmp_content"; break; + case CompareVariant::size: cmpVarIconName = "cmp_size"; break; + } + const char* syncVarIconName = nullptr; + if (syncVar) + switch (*syncVar) + { + case SyncVariant::twoWay: syncVarIconName = "sync_twoway"; break; + case SyncVariant::mirror: syncVarIconName = "sync_mirror"; break; + case SyncVariant::update: syncVarIconName = "sync_update"; break; + case SyncVariant::custom: syncVarIconName = "sync_custom"; break; + } + + const bool useDbFile = [&] + { + for (const FolderPairCfg& fpCfg : extractCompareCfg(mainCfg)) + if (std::get_if(&fpCfg.directionCfg.dirs)) + return true; + return false; + }(); + + updateTopButton(*m_buttonCompare, loadImage("compare"), + getVariantName(cmpVar), cmpVarIconName, + nullptr /*extraIconName*/, + folderCmp_.empty() ? getColorHighlightCompareButton() : wxNullColour); + + updateTopButton(*m_buttonSync, loadImage("start_sync"), + getVariantName(syncVar), syncVarIconName, + useDbFile ? "database" : nullptr, + getCUD(st) != 0 ? getColorHighlightSyncButton() : wxNullColour); + + m_panelTopButtons->Layout(); + + m_menuItemExportList->Enable(!folderCmp_.empty()); //empty CSV confuses users: https://freefilesync.org/forum/viewtopic.php?t=4787 + + //auiMgr_.Update(); -> doesn't seem to be needed +} + + +void MainDialog::clearGrid(ptrdiff_t pos) +{ + if (!folderCmp_.empty()) + { + assert(pos < makeSigned(folderCmp_.size())); + if (pos < 0) + folderCmp_.clear(); + else + folderCmp_.erase(folderCmp_.begin() + pos); + } + + if (folderCmp_.empty()) + fullSyncLog_.reset(); + + filegrid::setData(*m_gridMainC, folderCmp_); + treegrid::setData(*m_gridOverview, folderCmp_); + updateGui(); +} + + +void MainDialog::updateStatistics(const SyncStatistics& st) +{ + auto setValue = [](wxStaticText& txtControl, bool isZeroValue, const wxString& valueAsString, wxStaticBitmap& bmpControl, const char* imageName) + { + if (txtControl.GetLabel() != valueAsString) + { + wxFont fnt = txtControl.GetFont(); + fnt.SetWeight(isZeroValue ? wxFONTWEIGHT_NORMAL : wxFONTWEIGHT_BOLD); + txtControl.SetFont(fnt); + + txtControl.SetLabelText(valueAsString); + setImage(bmpControl, greyScaleIfDisabled(mirrorIfRtl(loadImage(imageName)), !isZeroValue)); + } + }; + + auto setIntValue = [&setValue](wxStaticText& txtControl, int value, wxStaticBitmap& bmpControl, const char* imageName) + { + setValue(txtControl, value == 0, formatNumber(value), bmpControl, imageName); + }; + + //update preview of item count and bytes to be transferred: + setValue(*m_staticTextData, st.getBytesToProcess() == 0, formatFilesizeShort(st.getBytesToProcess()), *m_bitmapData, "data"); + setIntValue(*m_staticTextCreateLeft, st.createCount(), *m_bitmapCreateLeft, "so_create_left_sicon"); + setIntValue(*m_staticTextUpdateLeft, st.updateCount(), *m_bitmapUpdateLeft, "so_update_left_sicon"); + setIntValue(*m_staticTextDeleteLeft, st.deleteCount(), *m_bitmapDeleteLeft, "so_delete_left_sicon"); + setIntValue(*m_staticTextCreateRight, st.createCount(), *m_bitmapCreateRight, "so_create_right_sicon"); + setIntValue(*m_staticTextUpdateRight, st.updateCount(), *m_bitmapUpdateRight, "so_update_right_sicon"); + setIntValue(*m_staticTextDeleteRight, st.deleteCount(), *m_bitmapDeleteRight, "so_delete_right_sicon"); + + m_panelViewFilter->Layout(); //[!] statistics panel size changed, so this is needed + m_panelStatistics->Layout(); + m_panelStatistics->Refresh(); //fix small mess up on RTL layout +} + + +void MainDialog::applyCompareConfig(bool setDefaultViewType) +{ + clearGrid(); //+ GUI update + + //convenience: change sync view + if (setDefaultViewType) + switch (currentCfg_.mainCfg.cmpCfg.compareVar) + { + case CompareVariant::timeSize: + case CompareVariant::size: + setGridViewType(GridViewType::action); + break; + + case CompareVariant::content: + setGridViewType(GridViewType::difference); + break; + } +} + + +void MainDialog::onStartSync(wxCommandEvent& event) +{ + FocusPreserver fp; //e.g. keep focus on config panel after pressing F9 + + if (folderCmp_.empty()) + { + //quick sync: simulate button click on "compare" + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonCompare->Command(dummy); //simulate click + + if (folderCmp_.empty()) //check if user aborted or error occurred, etc... + return; + } + + if (std::exchange(operationInProgress_, true)) //*after* simluated comparison button click! + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + //------------------------------------------------------------------ + + const auto& guiCfg = getConfig(); + + //show sync preview/confirmation dialog + if (globalCfg_.confirmDlgs.confirmSyncStart) + { + bool dontShowAgain = false; + + if (showSyncConfirmationDlg(this, false /*syncSelection*/, + getCommonSyncVariant(guiCfg.mainCfg), + SyncStatistics(folderCmp_), + dontShowAgain) != ConfirmationButton::accept) + return; + globalCfg_.confirmDlgs.confirmSyncStart = !dontShowAgain; + } + + const std::chrono::system_clock::time_point syncStartTime = std::chrono::system_clock::now(); + + const WindowLayout::Dimensions progressDim + { + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.size, + std::nullopt /*pos*/, + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized + }; + + UiInputDisabler uiBlock(*this, false /*enableAbort*/); //StatusHandlerFloatingDialog will internally process Window messages, so avoid unexpected callbacks! + + //class handling status updates and error messages + StatusHandlerFloatingDialog statusHandler(this, getJobNames(), syncStartTime, + guiCfg.mainCfg.ignoreErrors, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileSyncFinished, + globalCfg_.soundFileAlertPending, + progressDim, + globalCfg_.progressDlgAutoClose); + try + { + //PERF_START; + + //let's report here rather than before comparison (user might have changed global settings in the meantime!) + logNonDefaultSettings(globalCfg_, statusHandler); //throw CancelProcess + + //wxBusyCursor dummy; -> redundant: progress already shown in progress dialog! + + //GUI mode: end directory lock lifetime after comparion and start new locking right before sync + std::unique_ptr dirLocks; + if (globalCfg_.createLockFile) + { + std::set folderPathsToLock; + for (const BaseFolderPair& baseFolder : asRange(folderCmp_)) + { + if (baseFolder.getFolderStatus() == BaseFolderStatus::existing) //do NOT check directory existence again! + if (const Zstring& nativePath = getNativeItemPath(baseFolder.getAbstractPath()); //restrict directory locking to native paths until further + !nativePath.empty()) + folderPathsToLock.insert(nativePath); + + if (baseFolder.getFolderStatus() == BaseFolderStatus::existing) + if (const Zstring& nativePath = getNativeItemPath(baseFolder.getAbstractPath()); + !nativePath.empty()) + folderPathsToLock.insert(nativePath); + } + dirLocks = std::make_unique(folderPathsToLock, globalCfg_.warnDlgs.warnDirectoryLockFailed, statusHandler); //throw CancelProcess + } + + synchronize(syncStartTime, + globalCfg_.verifyFileCopy, + globalCfg_.copyLockedFiles, + globalCfg_.copyFilePermissions, + globalCfg_.failSafeFileCopy, + globalCfg_.runWithBackgroundPriority, + extractSyncCfg(guiCfg.mainCfg), + folderCmp_, + globalCfg_.warnDlgs, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) { assert(statusHandler.taskCancelled() == CancelReason::user); } + + //------------------------------------------------------------------- + StatusHandlerFloatingDialog::Result r = statusHandler.prepareResult(); + + //merge logs of comparison, manual operations, sync + append(fullSyncLog_->log, r.errorLog.ref()); + fullSyncLog_->totalTime += r.summary.totalTime; + + + //"consume" fullSyncLog_, but don't reset: there may be items remaining for manual operations or re-sync! + ProcessSummary fullSummary = r.summary; + fullSummary.startTime = std::exchange(fullSyncLog_->startTime, std::chrono::system_clock::now()); + fullSummary.totalTime = std::exchange(fullSyncLog_->totalTime, {}); + //let's *not* redetermine "ProcessSummary::result", even if errors occured during manual operations! + + ErrorLog fullLog = std::exchange(fullSyncLog_->log, {}); + + auto logMsg2 =[&](const std::wstring& msg, MessageType type) + { + logMsg(fullLog, msg, type); + logMsg(r.errorLog.ref(), msg, type); + }; + + AbstractPath logFolderPath = createAbstractPath(guiCfg.mainCfg.altLogFolderPathPhrase); //optional + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(globalCfg_.logFolderPhrase); + assert(!AFS::isNullPath(logFolderPath)); //mandatory! but still: let's include fall back + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(getLogFolderDefaultPath()); + + AbstractPath logFilePath = AFS::appendRelPath(logFolderPath, generateLogFileName(globalCfg_.logFormat, fullSummary)); + //e.g. %AppData%\FreeFileSync\Logs\Backup FreeFileSync 2013-09-15 015052.123 [Error].log + + auto notifyStatusNoThrow = [&](std::wstring&& msg) { try { statusHandler.updateStatus(std::move(msg)); /*throw CancelProcess*/ } catch (CancelProcess&) {} }; + + + if (statusHandler.taskCancelled()) + /* user cancelled => don't run post sync command + => don't run post sync action + => don't send email notification + => don't play sound notification + (=> DO save log file: sync attempt is more than just a "manual operation") + (=> DO update last sync stats for the selected cfg files) */ + assert(statusHandler.taskCancelled() == CancelReason::user); //"stop on first error" is only for ffs_batch + else + { + //--------------------- post sync command ---------------------- + if (const Zstring cmdLine = trimCpy(expandMacros(guiCfg.mainCfg.postSyncCommand)); + !cmdLine.empty()) + if (guiCfg.mainCfg.postSyncCondition == PostSyncCondition::completion || + (guiCfg.mainCfg.postSyncCondition == PostSyncCondition::errors) == (r.summary.result == TaskResult::cancelled || + r.summary.result == TaskResult::error)) + try + { + //give consoleExecute() some "time to fail", but not too long to hang our process + const int DEFAULT_APP_TIMEOUT_MS = 100; + + if (const auto& [exitCode, output] = consoleExecute(cmdLine, DEFAULT_APP_TIMEOUT_MS); //throw SysError, SysErrorTimeOut + exitCode != 0) + throw SysError(formatSystemError("", replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + + logMsg2(_("Executing command:") + L' ' + utfTo(cmdLine) + L" [" + replaceCpy(_("Exit code %x"), L"%x", L"0") + L']', MSG_TYPE_INFO); + } + catch (SysErrorTimeOut&) //child process not failed yet => probably fine :> + { + logMsg2(_("Executing command:") + L' ' + utfTo(cmdLine), MSG_TYPE_INFO); + } + catch (const SysError& e) + { + logMsg2(replaceCpy(_("Command %x failed."), L"%x", fmtPath(cmdLine)) + L"\n\n" + e.toString(), MSG_TYPE_ERROR); + } + + //--------------------- email notification ---------------------- + if (const std::string notifyEmail = trimCpy(guiCfg.mainCfg.emailNotifyAddress); + !notifyEmail.empty()) + if (guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::always || + (guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::errorWarning && (fullSummary.result == TaskResult::cancelled || + fullSummary.result == TaskResult::error || + fullSummary.result == TaskResult::warning)) || + (guiCfg.mainCfg.emailNotifyCondition == ResultsNotification::errorOnly && (fullSummary.result == TaskResult::cancelled || + fullSummary.result == TaskResult::error))) + try + { + logMsg2(replaceCpy(_("Sending email notification to %x"), L"%x", utfTo(notifyEmail)), MSG_TYPE_INFO); + sendLogAsEmail(notifyEmail, fullSummary, fullLog, logFilePath, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e) { logMsg2(e.toString(), MSG_TYPE_ERROR); } + } + + //--------------------- save log file ---------------------- + std::set logsToKeepPaths; + { + const std::set activeCfgSorted(activeConfigFiles_.begin(), activeConfigFiles_.end()); + + for (const ConfigFileItem& cfi : cfggrid::getDataView(*m_gridCfgHistory).get()) + if (!activeCfgSorted.contains(cfi.cfgFilePath)) //exception: don't keep old logs for the selected cfg files! + logsToKeepPaths.insert(cfi.lastRunStats.logFilePath); + } + try //create not before destruction: 1. avoid issues with FFS trying to sync open log file 2. include status in log file name without extra rename + { + //do NOT use tryReportingError()! saving log files should not be cancellable! + saveLogFile(logFilePath, fullSummary, fullLog, globalCfg_.logfilesMaxAgeDays, globalCfg_.logFormat, logsToKeepPaths, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e) + { + try //fallback: log file *must* be saved no matter what! + { + const AbstractPath logFileDefaultPath = AFS::appendRelPath(createAbstractPath(getLogFolderDefaultPath()), generateLogFileName(globalCfg_.logFormat, fullSummary)); + if (logFilePath == logFileDefaultPath) + throw; + + logMsg2(e.toString(), MSG_TYPE_ERROR); + + logFilePath = logFileDefaultPath; + saveLogFile(logFileDefaultPath, fullSummary, fullLog, globalCfg_.logfilesMaxAgeDays, globalCfg_.logFormat, logsToKeepPaths, notifyStatusNoThrow); //throw FileError + } + catch (const FileError& e2) { logMsg2(e2.toString(), MSG_TYPE_ERROR); logExtraError(e2.toString()); } //should never happen!!! + } + + //--------- update last sync stats for the selected cfg files --------- + const ErrorLogStats& fullLogStats = getStats(fullLog); + + cfggrid::getDataView(*m_gridCfgHistory).setLastRunStats(activeConfigFiles_, + { + std::chrono::system_clock::to_time_t(fullSummary.startTime), + logFilePath, + fullSummary.result, + fullSummary.statsProcessed.items, + fullSummary.statsProcessed.bytes, + fullSummary.totalTime, + fullLogStats.errors, + fullLogStats.warnings, + }); + //re-apply selection: sort order changed if sorted by last sync time + cfggrid::addAndSelect(*m_gridCfgHistory, activeConfigFiles_, false /*scrollToSelection*/); + //m_gridCfgHistory->Refresh(); <- implicit in last call + + //--------------------------------------------------------------------------- + setLastOperationLog(r.summary, r.errorLog.ptr()); + + //remove empty rows: just a beautification, invalid rows shouldn't cause issues + filegrid::getDataView(*m_gridMainC).removeInvalidRows(); + + //--------------------------------------------------------------------------- + const StatusHandlerFloatingDialog::DlgOptions dlgOpt = statusHandler.showResult(); + + globalCfg_.progressDlgAutoClose = dlgOpt.autoCloseSelected; + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.size = dlgOpt.dim.size; //=> ignore dim.pos + globalCfg_.dpiLayouts[getDpiScalePercent()].progressDlg.isMaximized = dlgOpt.dim.isMaximized; + + updateGui(); //let's update *after* showResult(): some users are interested in seeing the old statistics dialog even after sync + + //--------------------------------------------------------------------------- + //run shutdown *after* last sync stats were updated! they will be saved via onBeforeSystemShutdownCookie_: https://freefilesync.org/forum/viewtopic.php?t=5761 + using FinalRequest = StatusHandlerFloatingDialog::FinalRequest; + switch (dlgOpt.finalRequest) + { + case FinalRequest::none: + break; + + case FinalRequest::exit: + //don't Close() which prompts to save current config in onClose() + Destroy(); //for top-level windows this employs delayed destruction (wxPendingDelete) + uiBlock.dismiss(); //...or else: crash when ~UiInputDisabler() calls Yield() + enableGuiElementsImpl()! + fp .dismiss(); + break; + + case FinalRequest::shutdown: + try + { + shutdownSystem(); //throw FileError + terminateProcess(static_cast(FfsExitCode::success)); + //no point in continuing and saving cfg again in ~MainDialog()/onBeforeSystemShutdown() while the OS will kill us any time! + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } + //[!] ignores current error handling setting, BUT this is not a sync error! + break; + } +} + + +namespace +{ +void appendInactive(ContainerObject& conObj, std::vector& inactiveItems) +{ + for (FilePair& file : conObj.files()) + if (!file.isActive()) + inactiveItems.push_back(&file); + for (SymlinkPair& symlink : conObj.symlinks()) + if (!symlink.isActive()) + inactiveItems.push_back(&symlink); + for (FolderPair& folder : conObj.subfolders()) + { + if (!folder.isActive()) + inactiveItems.push_back(&folder); + appendInactive(folder, inactiveItems); //recurse + } +} +} + + +void MainDialog::startSyncForSelecction(const std::vector& selection) +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + //------------------ analyze selection ------------------ + std::unordered_set basePairsSelect; + std::vector selectedActive; + + for (FileSystemObject* fsObj : expandSelectionForPartialSync(selection)) + { + switch (fsObj->getSyncOperation()) + { + case SO_CREATE_LEFT: + case SO_CREATE_RIGHT: + case SO_DELETE_LEFT: + case SO_DELETE_RIGHT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + case SO_OVERWRITE_LEFT: + case SO_OVERWRITE_RIGHT: + case SO_RENAME_LEFT: + case SO_RENAME_RIGHT: + basePairsSelect.insert(&fsObj->base()); + break; + + case SO_UNRESOLVED_CONFLICT: + case SO_DO_NOTHING: + case SO_EQUAL: + break; + } + if (fsObj->isActive()) + selectedActive.push_back(fsObj); + } + + if (basePairsSelect.empty()) + return; //harmonize with onGridContextRim(): this function should be a no-op iff context menu option is disabled! + + FocusPreserver fp; + { + //--------------------------------------------------------------- + //simulate partial sync by temporarily excluding all other items: + std::vector inactiveItems; //remember inactive (assuming a smaller number than active items) + for (BaseFolderPair& baseFolder : asRange(folderCmp_)) + appendInactive(baseFolder, inactiveItems); + + setActiveStatus(false, folderCmp_); //limit to folderCmpSelect? => no, let's also activate non-participating folder pairs, if only to visually match user selection + + for (FileSystemObject* fsObj : selectedActive) + fsObj->setActive(true); + + //don't run a full updateGui() (which would remove excluded rows) since we're only temporarily excluding: + filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR); + m_gridOverview->Refresh(); + + ZEN_ON_SCOPE_EXIT( + setActiveStatus(true, folderCmp_); + + //inactive items are expected to still exist after sync! => no need for FileSystemObject::ObjectId + for (FileSystemObject* fsObj : inactiveItems) + fsObj->setActive(false); + + filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR); //e.g. if user cancels confirmation popup + m_gridOverview->Refresh(); + ); + //--------------------------------------------------------------- + const auto& guiCfg = getConfig(); + const std::vector fpCfg = extractSyncCfg(guiCfg.mainCfg); + + //only apply partial sync to base pairs that contain at least one item to sync (e.g. avoid needless sync.ffs_db updates) + std::vector> folderCmpSelect; + std::vector fpCfgSelect; + + for (size_t i = 0; i < folderCmp_.size(); ++i) + if (basePairsSelect.contains(&folderCmp_[i].ref())) + { + folderCmpSelect.push_back(folderCmp_[i]); + fpCfgSelect .push_back( fpCfg[i]); + } + + //show sync preview/confirmation dialog + if (globalCfg_.confirmDlgs.confirmSyncStart) + { + bool dontShowAgain = false; + + if (showSyncConfirmationDlg(this, + true /*syncSelection*/, + getCommonSyncVariant(guiCfg.mainCfg), + SyncStatistics(folderCmpSelect), + dontShowAgain) != ConfirmationButton::accept) + return; + globalCfg_.confirmDlgs.confirmSyncStart = !dontShowAgain; + } + + const std::chrono::system_clock::time_point syncStartTime = std::chrono::system_clock::now(); + + //last sync log file? => let's go without; same behavior as manual deletion + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, syncStartTime, + guiCfg.mainCfg.ignoreErrors, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + globalCfg_.soundFileAlertPending); + try + { + //let's report here rather than before comparison (user might have changed global settings in the meantime!) + logNonDefaultSettings(globalCfg_, statusHandler); //throw CancelProcess + + //LockHolder? => let's go without; same behavior as manual deletion + + synchronize(syncStartTime, + globalCfg_.verifyFileCopy, + globalCfg_.copyLockedFiles, + globalCfg_.copyFilePermissions, + globalCfg_.failSafeFileCopy, + globalCfg_.runWithBackgroundPriority, + fpCfgSelect, + folderCmpSelect, + globalCfg_.warnDlgs, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + + setLastOperationLog(r.summary, r.errorLog.ptr()); + + append(fullSyncLog_->log, r.errorLog.ref()); + fullSyncLog_->totalTime += r.summary.totalTime; + + } //run updateGui() *after* reverting our temporary exclusions + + //remove empty rows: just a beautification, invalid rows shouldn't cause issues + filegrid::getDataView(*m_gridMainC).removeInvalidRows(); + + updateGui(); +} + + +void MainDialog::setLastOperationLog(const ProcessSummary& summary, const std::shared_ptr& errorLog) +{ + const wxImage syncResultImage = [&] + { + switch (summary.result) + { + case TaskResult::success: + return loadImage("result_success"); + case TaskResult::warning: + return loadImage("result_warning"); + case TaskResult::error: + case TaskResult::cancelled: + return loadImage("result_error"); + } + assert(false); + return wxNullImage; + }(); + + const wxImage logOverlayImage = [&] + { + //don't use "syncResult": There may be errors after sync, e.g. failure to save log file/send email! + if (errorLog) + { + const ErrorLogStats logCount = getStats(*errorLog); + if (logCount.errors > 0) + return loadImage("msg_error", dipToScreen(getMenuIconDipSize())); + if (logCount.warnings > 0) + return loadImage("msg_warning", dipToScreen(getMenuIconDipSize())); + + //return loadImage("msg_success", dipToScreen(getMenuIconDipSize())); -> too noisy? + } + return wxNullImage; + }(); + + setImage(*m_bitmapSyncResult, syncResultImage); + m_staticTextSyncResult->SetLabelText(getSyncResultLabel(summary.result)); + + + m_staticTextItemsProcessed->SetLabelText(formatNumber(summary.statsProcessed.items)); + m_staticTextBytesProcessed->SetLabelText(L'(' + formatFilesizeShort(summary.statsProcessed.bytes) + L')'); + + const bool hideRemainingStats = (summary.statsTotal.items < 0 && summary.statsTotal.bytes < 0) || //no total items/bytes: e.g. for pure folder comparison + summary.statsProcessed == summary.statsTotal; //...if everything was processed successfully + + m_staticTextProcessed ->Show(!hideRemainingStats); + m_staticTextRemaining ->Show(!hideRemainingStats); + m_staticTextItemsRemaining->Show(!hideRemainingStats); + m_staticTextBytesRemaining->Show(!hideRemainingStats); + + if (!hideRemainingStats) + { + m_staticTextItemsRemaining->SetLabelText( formatNumber(summary.statsTotal.items - summary.statsProcessed.items)); + m_staticTextBytesRemaining->SetLabelText(L'(' + formatFilesizeShort(summary.statsTotal.bytes - summary.statsProcessed.bytes) + L')'); + } + + const int64_t totalTimeSec = std::chrono::duration_cast(summary.totalTime).count(); + m_staticTextTimeElapsed->SetLabelText(utfTo(formatTimeSpan(totalTimeSec, true /*hourRequired*/))); + //include "hour" => let's use full precision for max. clarity: https://freefilesync.org/forum/viewtopic.php?t=6308 + + logPanel_->setLog(errorLog); + + m_panelLog->Layout(); + //m_panelItemStats->Dimensions(); //needed? + //m_panelTimeStats->Dimensions(); // + + const wxImage& logBtnImg = layOver(loadImage("log_file"), logOverlayImage, wxALIGN_BOTTOM | wxALIGN_RIGHT); + m_bpButtonToggleLog->init(layOver(generatePressedButtonBack(logBtnImg.GetSize() + wxSize(dipToScreen(10), dipToScreen(10))), logBtnImg), logBtnImg); + + const int logBtnSize = m_bpButtonViewType->GetSize().GetHeight(); + m_bpButtonToggleLog->SetMinSize({logBtnSize, logBtnSize}); + + m_bpButtonToggleLog->Show(static_cast(errorLog)); +} + + +void MainDialog::onToggleLog(wxCommandEvent& event) +{ + showLogPanel(!m_bpButtonToggleLog->isActive()); +} + + +void MainDialog::showLogPanel(bool show) +{ + m_bpButtonToggleLog->setActive(show); + + if (wxAuiPaneInfo& logPane = auiMgr_.GetPane(m_panelLog); + logPane.IsShown() != show) + { + if (!show) + { + if (logPane.IsMaximized()) + auiMgr_.RestorePane(logPane); //!= wxAuiPaneInfo::Restore() which does not un-hide other panels (WTF!?) + else //ensure current window sizes will be used when pane is shown again: + logPane.best_size = logPane.rect.GetSize(); + } + + logPane.Show(show); + auiMgr_.Update(); + m_panelLog->Refresh(); //macOS: fix background corruption for the statistics boxes; call *after* wxAuiManager::Update() + } + + if (show) + { + if (wxWindow* focus = wxWindow::FindFocus()) //restore when closing panel! + if (!isComponentOf(focus, m_panelLog)) + focusAfterCloseLog_ = focus->GetId(); + + logPanel_->SetFocus(); + } + else + { + if (isComponentOf(wxWindow::FindFocus(), m_panelLog)) + if (wxWindow* oldFocusWin = wxWindow::FindWindowById(focusAfterCloseLog_)) + oldFocusWin->SetFocus(); + focusAfterCloseLog_ = wxID_ANY; + } +} + + +void MainDialog::onGridDoubleClickRim(GridClickEvent& event, bool leftSide) +{ + if (!globalCfg_.externalApps.empty()) + { + std::vector selectionL; + std::vector selectionR; + if (FileSystemObject* fsObj = filegrid::getDataView(*m_gridMainC).getFsObject(event.row_)) //selection must be a list of BOUND pointers! + (leftSide ? selectionL: selectionR) = {fsObj}; + + openExternalApplication(globalCfg_.externalApps[0].cmdLine, leftSide, selectionL, selectionR); + } +} + + +void MainDialog::onGridLabelLeftClickRim(GridLabelClickEvent& event, bool leftSide) +{ + const ColumnTypeRim colType = static_cast(event.colType_); + + bool sortAscending = getDefaultSortDirection(colType); + + if (auto sortInfo = filegrid::getDataView(*m_gridMainC).getSortConfig()) + if (const ColumnTypeRim* sortType = std::get_if(&sortInfo->sortCol)) + if (*sortType == colType && sortInfo->onLeft == leftSide) + sortAscending = !sortInfo->ascending; + + const ItemPathFormat itemPathFormat = leftSide ? globalCfg_.mainDlg.itemPathFormatLeftGrid : globalCfg_.mainDlg.itemPathFormatRightGrid; + + filegrid::getDataView(*m_gridMainC).sortView(colType, itemPathFormat, leftSide, sortAscending); + updateGui(); //refresh gridDataView + + m_gridMainL->clearSelection(GridEventPolicy::deny); //call *after* updateGui/updateGridViewData() has restored FileView::viewRef_ + m_gridMainC->clearSelection(GridEventPolicy::deny); + m_gridMainR->clearSelection(GridEventPolicy::deny); +} + + +void MainDialog::onGridLabelLeftClickC(GridLabelClickEvent& event) +{ + const ColumnTypeCenter colType = static_cast(event.colType_); + if (colType != ColumnTypeCenter::checkbox) + { + bool sortAscending = getDefaultSortDirection(colType); + + if (auto sortInfo = filegrid::getDataView(*m_gridMainC).getSortConfig()) + if (const ColumnTypeCenter* sortType = std::get_if(&sortInfo->sortCol)) + if (*sortType == colType) + sortAscending = !sortInfo->ascending; + + filegrid::getDataView(*m_gridMainC).sortView(colType, sortAscending); + updateGui(); //refresh gridDataView + + m_gridMainL->clearSelection(GridEventPolicy::deny); + m_gridMainC->clearSelection(GridEventPolicy::deny); + m_gridMainR->clearSelection(GridEventPolicy::deny); + } +} + + +void MainDialog::swapSides() +{ + if (std::exchange(operationInProgress_, true)) + return; + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + FocusPreserver fp; + + if (!folderCmp_.empty() && //require confirmation only *after* comparison + globalCfg_.confirmDlgs.confirmSwapSides) + { + bool dontWarnAgain = false; + switch (showConfirmationDialog(this, DialogInfoType::info, + PopupDialogCfg().setMainInstructions(_("Please confirm you want to swap sides.")). + setCheckBox(dontWarnAgain, _("&Don't show this dialog again")), + _("&Swap"))) + { + case ConfirmationButton::accept: //swap + globalCfg_.confirmDlgs.confirmSwapSides = !dontWarnAgain; + break; + case ConfirmationButton::cancel: + return; + } + } + //------------------------------------------------------ + + //swap directory names: + LocalPairConfig lpc1st = firstFolderPair_->getValues(); + std::swap(lpc1st.folderPathPhraseLeft, lpc1st.folderPathPhraseRight); + firstFolderPair_->setValues(lpc1st); + + for (FolderPairPanel* panel : additionalFolderPairs_) + { + LocalPairConfig lpc = panel->getValues(); + std::swap(lpc.folderPathPhraseLeft, lpc.folderPathPhraseRight); + panel->setValues(lpc); + } + + //swap view filter + bool tmp = m_bpButtonShowLeftOnly->isActive(); + m_bpButtonShowLeftOnly->setActive(m_bpButtonShowRightOnly->isActive()); + m_bpButtonShowRightOnly->setActive(tmp); + + tmp = m_bpButtonShowLeftNewer->isActive(); + m_bpButtonShowLeftNewer->setActive(m_bpButtonShowRightNewer->isActive()); + m_bpButtonShowRightNewer->setActive(tmp); + + /* for sync preview and "mirror" variant swapping may create strange effect: + tmp = m_bpButtonShowCreateLeft->isActive(); + m_bpButtonShowCreateLeft->setActive(m_bpButtonShowCreateRight->isActive()); + m_bpButtonShowCreateRight->setActive(tmp); + + tmp = m_bpButtonShowDeleteLeft->isActive(); + m_bpButtonShowDeleteLeft->setActive(m_bpButtonShowDeleteRight->isActive()); + m_bpButtonShowDeleteRight->setActive(tmp); + + tmp = m_bpButtonShowUpdateLeft->isActive(); + m_bpButtonShowUpdateLeft->setActive(m_bpButtonShowUpdateRight->isActive()); + m_bpButtonShowUpdateRight->setActive(tmp); + */ + //---------------------------------------------------------------------- + + if (!folderCmp_.empty()) + { + const auto& guiCfg = getConfig(); + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + Zstr("") /*soundFileAlertPending*/); + try + { + statusHandler.initNewPhase(-1, -1, ProcessPhase::none); + swapGrids(getConfig().mainCfg, folderCmp_, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + } + + updateGui(); //e.g. unsaved changes + + flashStatusInfo(_("Left and right sides have been swapped")); +} + + +void MainDialog::updateGridViewData() +{ + auto updateFilterButton = [&](ToggleButton& btn, const char* imgName, int itemCount) + { + const bool show = itemCount > 0; + if (show) + { + int& itemCountDrawn = buttonLabelItemCount_[&btn]; + assert(itemCount != 0); //itemCountDrawn defaults to 0! + if (itemCountDrawn != itemCount) //perf: only regenerate button labels when needed! + { + itemCountDrawn = itemCount; + + //accessibility: always set both foreground AND background colors! + wxImage imgCountPressed = mirrorIfRtl(createImageFromText(formatNumber(itemCount), btn.GetFont().Bold(), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT))); + wxImage imgCountReleased = mirrorIfRtl(createImageFromText(formatNumber(itemCount), btn.GetFont(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNTEXT))); + imgCountReleased = resizeCanvas(imgCountReleased, imgCountPressed.GetSize(), wxALIGN_CENTER); //match with imgCountPressed's bold font + + //add bottom/right border space + imgCountPressed = resizeCanvas(imgCountPressed, imgCountPressed .GetSize() + wxSize(dipToScreen(5), dipToScreen(5)), wxALIGN_TOP | wxALIGN_LEFT); + imgCountReleased = resizeCanvas(imgCountReleased, imgCountReleased.GetSize() + wxSize(dipToScreen(5), dipToScreen(5)), wxALIGN_TOP | wxALIGN_LEFT); + + wxImage imgCategory = loadImage(imgName); + imgCategory = resizeCanvas(imgCategory, imgCategory.GetSize() + wxSize(dipToScreen(5), dipToScreen(2)), wxALIGN_CENTER); + + wxImage imgIconReleased = imgCategory.ConvertToGreyscale(1.0/3, 1.0/3, 1.0/3); //treat all channels equally! + brighten(imgIconReleased, 80); + + wxImage imgButtonPressed = stackImages(imgCategory, imgCountPressed, ImageStackLayout::horizontal, ImageStackAlignment::bottom); + wxImage imgButtonReleased = stackImages(imgIconReleased, imgCountReleased, ImageStackLayout::horizontal, ImageStackAlignment::bottom); + + btn.init(mirrorIfRtl(layOver(generatePressedButtonBack(imgButtonPressed.GetSize()), imgButtonPressed)), + mirrorIfRtl(imgButtonReleased)); + } + } + + if (btn.IsShown() != show) + btn.Show(show); + }; + + FileView::FileStats fileStatsLeft; + FileView::FileStats fileStatsRight; + + if (m_bpButtonViewType->isActive()) + { + const FileView::ActionViewStats viewStats = filegrid::getDataView(*m_gridMainC).applyActionFilter(m_bpButtonShowExcluded->isActive(), + m_bpButtonShowCreateLeft ->isActive(), + m_bpButtonShowCreateRight->isActive(), + m_bpButtonShowDeleteLeft ->isActive(), + m_bpButtonShowDeleteRight->isActive(), + m_bpButtonShowUpdateLeft ->isActive(), + m_bpButtonShowUpdateRight->isActive(), + m_bpButtonShowDoNothing ->isActive(), + m_bpButtonShowEqual ->isActive(), + m_bpButtonShowConflict ->isActive()); + fileStatsLeft = viewStats.fileStatsLeft; + fileStatsRight = viewStats.fileStatsRight; + + //sync preview buttons + updateFilterButton(*m_bpButtonShowExcluded, "cat_excluded", viewStats.excluded); + updateFilterButton(*m_bpButtonShowEqual, "cat_equal", viewStats.equal); + updateFilterButton(*m_bpButtonShowConflict, "cat_conflict", viewStats.conflict); + + updateFilterButton(*m_bpButtonShowCreateLeft, "so_create_left", viewStats.createLeft); + updateFilterButton(*m_bpButtonShowCreateRight, "so_create_right", viewStats.createRight); + updateFilterButton(*m_bpButtonShowDeleteLeft, "so_delete_left", viewStats.deleteLeft); + updateFilterButton(*m_bpButtonShowDeleteRight, "so_delete_right", viewStats.deleteRight); + updateFilterButton(*m_bpButtonShowUpdateLeft, "so_update_left", viewStats.updateLeft); + updateFilterButton(*m_bpButtonShowUpdateRight, "so_update_right", viewStats.updateRight); + updateFilterButton(*m_bpButtonShowDoNothing, "so_none", viewStats.updateNone); + + m_bpButtonShowLeftOnly ->Hide(); + m_bpButtonShowRightOnly ->Hide(); + m_bpButtonShowLeftNewer ->Hide(); + m_bpButtonShowRightNewer->Hide(); + m_bpButtonShowDifferent ->Hide(); + } + else + { + const FileView::DifferenceViewStats viewStats = filegrid::getDataView(*m_gridMainC).applyDifferenceFilter(m_bpButtonShowExcluded->isActive(), + m_bpButtonShowLeftOnly ->isActive(), + m_bpButtonShowRightOnly ->isActive(), + m_bpButtonShowLeftNewer ->isActive(), + m_bpButtonShowRightNewer->isActive(), + m_bpButtonShowDifferent ->isActive(), + m_bpButtonShowEqual ->isActive(), + m_bpButtonShowConflict ->isActive()); + fileStatsLeft = viewStats.fileStatsLeft; + fileStatsRight = viewStats.fileStatsRight; + + //comparison result view buttons + updateFilterButton(*m_bpButtonShowExcluded, "cat_excluded", viewStats.excluded); + updateFilterButton(*m_bpButtonShowEqual, "cat_equal", viewStats.equal); + updateFilterButton(*m_bpButtonShowConflict, "cat_conflict", viewStats.conflict); + + m_bpButtonShowCreateLeft ->Hide(); + m_bpButtonShowCreateRight->Hide(); + m_bpButtonShowDeleteLeft ->Hide(); + m_bpButtonShowDeleteRight->Hide(); + m_bpButtonShowUpdateLeft ->Hide(); + m_bpButtonShowUpdateRight->Hide(); + m_bpButtonShowDoNothing ->Hide(); + + updateFilterButton(*m_bpButtonShowLeftOnly, "cat_left_only", viewStats.leftOnly); + updateFilterButton(*m_bpButtonShowRightOnly, "cat_right_only", viewStats.rightOnly); + updateFilterButton(*m_bpButtonShowLeftNewer, "cat_left_newer", viewStats.leftNewer); + updateFilterButton(*m_bpButtonShowRightNewer, "cat_right_newer", viewStats.rightNewer); + updateFilterButton(*m_bpButtonShowDifferent, "cat_different", viewStats.different); + } + + const bool anyViewButtonShown = m_bpButtonShowExcluded ->IsShown() || + m_bpButtonShowEqual ->IsShown() || + m_bpButtonShowConflict ->IsShown() || + + m_bpButtonShowCreateLeft ->IsShown() || + m_bpButtonShowCreateRight->IsShown() || + m_bpButtonShowDeleteLeft ->IsShown() || + m_bpButtonShowDeleteRight->IsShown() || + m_bpButtonShowUpdateLeft ->IsShown() || + m_bpButtonShowUpdateRight->IsShown() || + m_bpButtonShowDoNothing ->IsShown() || + + m_bpButtonShowLeftOnly ->IsShown() || + m_bpButtonShowRightOnly ->IsShown() || + m_bpButtonShowLeftNewer ->IsShown() || + m_bpButtonShowRightNewer->IsShown() || + m_bpButtonShowDifferent ->IsShown(); + + m_bpButtonViewType ->Show(anyViewButtonShown); + m_bpButtonViewFilterContext->Show(anyViewButtonShown); + + //m_panelViewFilter->Dimensions(); -> yes, needed, but will also be called in updateStatistics(); + + //all three grids retrieve their data directly via gridDataView + filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR); + + //overview panel + if (m_bpButtonViewType->isActive()) + treegrid::getDataView(*m_gridOverview).applyActionFilter(m_bpButtonShowExcluded ->isActive(), + m_bpButtonShowCreateLeft ->isActive(), + m_bpButtonShowCreateRight->isActive(), + m_bpButtonShowDeleteLeft ->isActive(), + m_bpButtonShowDeleteRight->isActive(), + m_bpButtonShowUpdateLeft ->isActive(), + m_bpButtonShowUpdateRight->isActive(), + m_bpButtonShowDoNothing ->isActive(), + m_bpButtonShowEqual ->isActive(), + m_bpButtonShowConflict ->isActive()); + else + treegrid::getDataView(*m_gridOverview).applyDifferenceFilter(m_bpButtonShowExcluded ->isActive(), + m_bpButtonShowLeftOnly ->isActive(), + m_bpButtonShowRightOnly ->isActive(), + m_bpButtonShowLeftNewer ->isActive(), + m_bpButtonShowRightNewer->isActive(), + m_bpButtonShowDifferent ->isActive(), + m_bpButtonShowEqual ->isActive(), + m_bpButtonShowConflict ->isActive()); + m_gridOverview->Refresh(); + + //update status bar information + setStatusBarFileStats(fileStatsLeft, fileStatsRight); +} + + +void MainDialog::setStatusBarFileStats(FileView::FileStats statsLeft, + FileView::FileStats statsRight) +{ + //update status information + bSizerStatusLeftDirectories->Show(statsLeft.folderCount > 0); + bSizerStatusLeftFiles ->Show(statsLeft.fileCount > 0); + + setText(*m_staticTextStatusLeftDirs, _P("1 directory", "%x directories", statsLeft.folderCount)); + setText(*m_staticTextStatusLeftFiles, _P("1 file", "%x files", statsLeft.fileCount)); + setText(*m_staticTextStatusLeftBytes, L'(' + formatFilesizeShort(statsLeft.bytes) + L')'); + //------------------------------------------------------------------------------ + bSizerStatusRightDirectories->Show(statsRight.folderCount > 0); + bSizerStatusRightFiles ->Show(statsRight.fileCount > 0); + + setText(*m_staticTextStatusRightDirs, _P("1 directory", "%x directories", statsRight.folderCount)); + setText(*m_staticTextStatusRightFiles, _P("1 file", "%x files", statsRight.fileCount)); + setText(*m_staticTextStatusRightBytes, L'(' + formatFilesizeShort(statsRight.bytes) + L')'); + //------------------------------------------------------------------------------ + wxString statusCenterNew; + if (filegrid::getDataView(*m_gridMainC).rowsTotal() > 0) + { + statusCenterNew = _P("Showing %y of 1 item", "Showing %y of %x items", filegrid::getDataView(*m_gridMainC).rowsTotal()); + replace(statusCenterNew, L"%y", formatNumber(filegrid::getDataView(*m_gridMainC).rowsOnView())); //%x used as plural form placeholder! + } + + setStatusInfo(statusCenterNew, false /*highlight*/); +} + + +void MainDialog::applyFilterConfig() +{ + applyFiltering(folderCmp_, getConfig().mainCfg); + updateGui(); + //updateGuiDelayedIf(currentCfg.hideExcludedItems); //show update GUI before removing rows +} + + +void MainDialog::applySyncDirections() +{ + if (!folderCmp_.empty()) + { + if (std::exchange(operationInProgress_, true)) + //can't just skip:t now's a really bad time! Hopefully never happens!? + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Sync direction changed while other operation running."); + ZEN_ON_SCOPE_EXIT(operationInProgress_ = false); + + FocusPreserver fp; + + UiInputDisabler uiBlock(*this, true /*enableAbort*/); //StatusHandlerTemporaryPanel calls wxApp::Yield(), so avoid unexpected callbacks! + + const auto& guiCfg = getConfig(); + const auto& directCfgs = extractDirectionCfg(folderCmp_, getConfig().mainCfg); + + StatusHandlerTemporaryPanel statusHandler(*this, std::chrono::system_clock::now() /*startTime*/, + false /*ignoreErrors*/, + guiCfg.mainCfg.autoRetryCount, + guiCfg.mainCfg.autoRetryDelay, + Zstr("") /*soundFileAlertPending*/); + try + { + statusHandler.initNewPhase(-1, -1, ProcessPhase::none); + redetermineSyncDirection(directCfgs, + statusHandler); //throw CancelProcess + } + catch (CancelProcess&) {} + + const StatusHandlerTemporaryPanel::Result r = statusHandler.prepareResult(); //noexcept + setLastOperationLog(r.summary, r.errorLog.ptr()); + } + + updateGui(); //e.g. unsaved changes +} + + +void MainDialog::onSearchGridEnter(wxCommandEvent& event) +{ + startFindNext(true /*searchAscending*/); +} + + +void MainDialog::onHideSearchPanel(wxCommandEvent& event) +{ + showFindPanel(false /*show*/); +} + + +void MainDialog::onSearchPanelKeyPressed(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: //catches ENTER keys while focus is on *any* part of m_panelSearch! Seems to obsolete onSearchGridEnter()! + startFindNext(true /*searchAscending*/); + return; + case WXK_ESCAPE: + showFindPanel(false /*show*/); + return; + } + event.Skip(); +} + + +void MainDialog::showFindPanel(bool show) //CTRL + F or F3 with empty search phrase +{ + if (auiMgr_.GetPane(m_panelSearch).IsShown() != show) + { + auiMgr_.GetPane(m_panelSearch).Show(show); + auiMgr_.Update(); + } + + if (show) + { + m_textCtrlSearchTxt->SelectAll(); + + if (wxWindow* focus = wxWindow::FindFocus()) //restore when closing panel! + if (!isComponentOf(focus, m_panelSearch)) + focusAfterCloseSearch_ = focus->GetId(); + + m_textCtrlSearchTxt->SetFocus(); + } + else + { + if (isComponentOf(wxWindow::FindFocus(), m_panelSearch)) + if (wxWindow* oldFocusWin = wxWindow::FindWindowById(focusAfterCloseSearch_)) + oldFocusWin->SetFocus(); + + focusAfterCloseSearch_ = wxID_ANY; + } +} + + +void MainDialog::startFindNext(bool searchAscending) //F3 or ENTER in m_textCtrlSearchTxt +{ + const std::wstring& searchString = utfTo(trimCpy(m_textCtrlSearchTxt->GetValue())); + + if (searchString.empty()) + showFindPanel(true /*show*/); + else + { + Grid* grid1 = m_gridMainL; + Grid* grid2 = m_gridMainR; + + wxWindow* focus = wxWindow::FindFocus(); + if ((isComponentOf(focus, m_panelSearch) ? focusAfterCloseSearch_ : focus->GetId()) == m_gridMainR->getMainWin().GetId()) + std::swap(grid1, grid2); //select side to start search at grid cursor position + + wxBeginBusyCursor(wxHOURGLASS_CURSOR); + const std::pair result = findGridMatch(*grid1, *grid2, utfTo(searchString), + m_checkBoxMatchCase->GetValue(), searchAscending); + //parameter owned by GUI, *not* globalCfg structure! => we should better implement a getGlocalCfg()! + wxEndBusyCursor(); + + if (Grid* grid = const_cast(result.first)) //grid wasn't const when passing to findAndSelectNext(), so this is legal + { + assert(result.second >= 0); + + filegrid::setScrollMaster(*grid); + grid->setGridCursor(result.second, GridEventPolicy::allow); + + focusAfterCloseSearch_ = grid->getMainWin().GetId(); + + if (!isComponentOf(wxWindow::FindFocus(), m_panelSearch)) + grid->getMainWin().SetFocus(); + } + else + { + showFindPanel(true /*show*/); + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setTitle(_("Find")). + setMainInstructions(replaceCpy(_("Cannot find %x"), L"%x", fmtPath(searchString)))); + } + } +} + + +void MainDialog::onTopFolderPairAdd(wxCommandEvent& event) +{ + insertAddFolderPair({LocalPairConfig()}, 0); + moveAddFolderPairUp(0); +} + + +void MainDialog::onTopFolderPairRemove(wxCommandEvent& event) +{ + assert(!additionalFolderPairs_.empty()); + if (!additionalFolderPairs_.empty()) + { + moveAddFolderPairUp(0); + removeAddFolderPair(0); + } +} + + +void MainDialog::onLocalCompCfg(wxCommandEvent& event) +{ + const wxObject* const eventObj = event.GetEventObject(); //find folder pair originating the event + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (eventObj == (*it)->m_bpButtonLocalCompCfg) + { + showConfigDialog(SyncConfigPanel::compare, (it - additionalFolderPairs_.begin()) + 1); + break; + } +} + + +void MainDialog::onLocalSyncCfg(wxCommandEvent& event) +{ + const wxObject* const eventObj = event.GetEventObject(); //find folder pair originating the event + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (eventObj == (*it)->m_bpButtonLocalSyncCfg) + { + showConfigDialog(SyncConfigPanel::sync, (it - additionalFolderPairs_.begin()) + 1); + break; + } +} + + +void MainDialog::onLocalFilterCfg(wxCommandEvent& event) +{ + const wxObject* const eventObj = event.GetEventObject(); //find folder pair originating the event + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (eventObj == (*it)->m_bpButtonLocalFilter) + { + showConfigDialog(SyncConfigPanel::filter, (it - additionalFolderPairs_.begin()) + 1); + break; + } +} + + +void MainDialog::onRemoveFolderPair(wxCommandEvent& event) +{ + const wxObject* const eventObj = event.GetEventObject(); //find folder pair originating the event + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (eventObj == (*it)->m_bpButtonRemovePair) + { + removeAddFolderPair(it - additionalFolderPairs_.begin()); + break; + } +} + + +void MainDialog::onShowFolderPairOptions(wxEvent& event) +{ + const wxObject* const eventObj = event.GetEventObject(); //find folder pair originating the event + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (eventObj == (*it)->m_bpButtonFolderPairOptions) + { + const ptrdiff_t pos = it - additionalFolderPairs_.begin(); + + ContextMenu menu; + menu.addItem(_("Add folder pair"), [this, pos] { insertAddFolderPair({LocalPairConfig()}, pos); }, loadImage("item_add_sicon")); + menu.addSeparator(); + menu.addItem(_("Move up" ) + L"\tAlt+Page Up", [this, pos] { moveAddFolderPairUp(pos); }, loadImage("move_up_sicon")); + menu.addItem(_("Move down") + L"\tAlt+Page Down", [this, pos] { moveAddFolderPairUp(pos + 1); }, loadImage("move_down_sicon"), + pos + 1 < makeSigned(additionalFolderPairs_.size())); + + menu.popup(*(*it)->m_bpButtonFolderPairOptions, {(*it)->m_bpButtonFolderPairOptions->GetSize().x, 0}); + break; + } +} + + +void MainDialog::onTopFolderPairKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + + if (event.AltDown()) + switch (keyCode) + { + case WXK_PAGEDOWN: //Alt + Page Down + case WXK_NUMPAD_PAGEDOWN: + if (!additionalFolderPairs_.empty()) + { + moveAddFolderPairUp(0); + additionalFolderPairs_[0]->m_folderPathLeft->SetFocus(); + } + return; + } + + event.Skip(); +} + + +void MainDialog::onAddFolderPairKeyEvent(wxKeyEvent& event) +{ + const int keyCode = event.GetKeyCode(); + + auto getAddFolderPairPos = [&]() -> ptrdiff_t //find folder pair originating the event + { + if (auto eventObj = dynamic_cast(event.GetEventObject())) + for (auto it = additionalFolderPairs_.begin(); it != additionalFolderPairs_.end(); ++it) + if (isComponentOf(eventObj, *it)) + return it - additionalFolderPairs_.begin(); + return -1; + }; + + if (event.AltDown()) + switch (keyCode) + { + case WXK_PAGEUP: //Alt + Page Up + case WXK_NUMPAD_PAGEUP: + if (const ptrdiff_t pos = getAddFolderPairPos(); + pos >= 0) + { + moveAddFolderPairUp(pos); + (pos == 0 ? m_folderPathLeft : additionalFolderPairs_[pos - 1]->m_folderPathLeft)->SetFocus(); + } + return; + + case WXK_PAGEDOWN: //Alt + Page Down + case WXK_NUMPAD_PAGEDOWN: + if (const ptrdiff_t pos = getAddFolderPairPos(); + 0 <= pos && pos + 1 < makeSigned(additionalFolderPairs_.size())) + { + moveAddFolderPairUp(pos + 1); + additionalFolderPairs_[pos + 1]->m_folderPathLeft->SetFocus(); + } + return; + } + + event.Skip(); +} + + +void MainDialog::updateGuiForFolderPair() +{ + recalcMaxFolderPairsVisible(); + + //adapt delete top folder pair button + m_bpButtonRemovePair->Show(!additionalFolderPairs_.empty()); + m_panelTopLeft->Layout(); + + //adapt local filter and sync cfg for first folder pair + const bool showLocalCfgFirstPair = !additionalFolderPairs_.empty() || + firstFolderPair_->getCompConfig() || + firstFolderPair_->getSyncConfig() || + !isNullFilter(firstFolderPair_->getFilterConfig()); + //harmonize with MainDialog::showConfigDialog()! + + m_bpButtonLocalCompCfg->Show(showLocalCfgFirstPair); + m_bpButtonLocalSyncCfg->Show(showLocalCfgFirstPair); + m_bpButtonLocalFilter ->Show(showLocalCfgFirstPair); + setImage(*m_bpButtonSwapSides, loadImage(showLocalCfgFirstPair ? "swap_slim" : "swap")); + + //update sub-panel sizes for calculations below!!! + m_panelTopCenter->GetSizer()->SetSizeHints(m_panelTopCenter); //~=Fit() + SetMinSize() + + const int firstPairHeight = std::max(m_panelDirectoryPairs->ClientToWindowSize(m_panelTopLeft ->GetSize()).y, //include m_panelDirectoryPairs window borders! + m_panelDirectoryPairs->ClientToWindowSize(m_panelTopCenter->GetSize()).y); // + const int addPairHeight = !additionalFolderPairs_.empty() ? additionalFolderPairs_[0]->GetSize().y : 0; + + const double addPairCountMax = std::max(globalCfg_.mainDlg.folderPairsVisibleMax - 1 + 0.5, 1.5); + + const double addPairCountMin = std::min(1.5, additionalFolderPairs_.size()); //add 0.5 to indicate additional folders + const double addPairCountOpt = std::min(addPairCountMax, additionalFolderPairs_.size()); // + addPairCountLast_ = addPairCountOpt; + + wxAuiPaneInfo& dirPane = auiMgr_.GetPane(m_panelDirectoryPairs); + + //make sure user cannot fully shrink additional folder pairs + dirPane.MinSize(dipToWxsize(100), firstPairHeight + addPairCountMin * addPairHeight); + dirPane.BestSize(-1, firstPairHeight + addPairCountOpt * addPairHeight); + + //######################################################################################################################## + //wxAUI hack: call wxAuiPaneInfo::Fixed() to apply best size: + dirPane.Fixed(); + auiMgr_.Update(); + + //now make resizable again + dirPane.Resizable(); + auiMgr_.Update(); + //alternative: dirPane.Hide() + .Show() seems to work equally well + + //######################################################################################################################## + + //it seems there is no GetSizer()->SetSizeHints(this)/Fit() required due to wxAui "magic" + //=> *massive* perf improvement on OS X! +} + + +void MainDialog::recalcMaxFolderPairsVisible() +{ + const int firstPairHeight = std::max(m_panelDirectoryPairs->ClientToWindowSize(m_panelTopLeft ->GetSize()).y, //include m_panelDirectoryPairs window borders! + m_panelDirectoryPairs->ClientToWindowSize(m_panelTopCenter->GetSize()).y); // + const int addPairHeight = !additionalFolderPairs_.empty() ? additionalFolderPairs_[0]->GetSize().y : + m_bpButtonAddPair->GetSize().y; //an educated guess + + //assert(firstPairHeight > 0 && addPairHeight > 0); -> wxWindows::GetSize() returns 0 if main window is minimized during sync! Test with "When finished: Exit" + + if (addPairCountLast_ && firstPairHeight > 0 && addPairHeight > 0) + { + const double addPairCountCurrent = (m_panelDirectoryPairs->GetSize().y - firstPairHeight) / (1.0 * addPairHeight); //include m_panelDirectoryPairs window borders! + + if (std::abs(addPairCountCurrent - *addPairCountLast_) > 0.4) //=> presumely changed by user! + { + globalCfg_.mainDlg.folderPairsVisibleMax = std::round(addPairCountCurrent) + 1; + } + } +} + + +void MainDialog::insertAddFolderPair(const std::vector& newPairs, size_t pos) +{ + assert(pos <= additionalFolderPairs_.size() && additionalFolderPairs_.size() == bSizerAddFolderPairs->GetItemCount()); + pos = std::min(pos, additionalFolderPairs_.size()); + + for (size_t i = 0; i < newPairs.size(); ++i) + { + FolderPairPanel* newPair = nullptr; + if (!folderPairScrapyard_.empty()) //construct cheaply from "spare parts" + { + newPair = folderPairScrapyard_.back().release(); //transfer ownership + folderPairScrapyard_.pop_back(); + newPair->Show(); + } + else + { + newPair = new FolderPairPanel(m_scrolledWindowFolderPairs, *this, + globalCfg_.mainDlg.folderLastSelectedLeft, + globalCfg_.mainDlg.folderLastSelectedRight, + globalCfg_.sftpKeyFileLastSelected); + + //setHistory dropdown history + newPair->m_folderPathLeft ->setHistory(folderHistoryLeft_ ); + newPair->m_folderPathRight->setHistory(folderHistoryRight_); + + const wxSize optionsIconSize = loadImage("item_add").GetSize(); + setImage(*(newPair->m_bpButtonFolderPairOptions), resizeCanvas(mirrorIfRtl(loadImage("button_arrow_right")), optionsIconSize, wxALIGN_CENTER)); + + //set width of left folder panel + const int width = m_panelTopLeft->GetSize().GetWidth(); + newPair->m_panelLeft->SetMinSize({width, -1}); + + //register events + newPair->m_bpButtonFolderPairOptions->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onShowFolderPairOptions(event); }); + newPair->m_bpButtonFolderPairOptions->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onShowFolderPairOptions(event); }); + newPair->m_bpButtonRemovePair ->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onRemoveFolderPair (event); }); + + static_cast(newPair)->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onAddFolderPairKeyEvent(event); }); + + newPair->m_bpButtonLocalCompCfg->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onLocalCompCfg (event); }); + newPair->m_bpButtonLocalSyncCfg->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onLocalSyncCfg (event); }); + newPair->m_bpButtonLocalFilter ->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onLocalFilterCfg(event); }); + + //important: make sure panel has proper default height! + newPair->GetSizer()->SetSizeHints(newPair); //~=Fit() + SetMinSize() + } + + bSizerAddFolderPairs->Insert(pos + i, newPair, 0, wxEXPAND); + additionalFolderPairs_.insert(additionalFolderPairs_.begin() + pos + i, newPair); + + //wxComboBox screws up miserably if width/height is smaller than the magic number 4! Problem occurs when trying to set tooltip + //so we have to update window sizes before setting configuration: + newPair->setValues(newPairs[i]); + } + + updateGuiForFolderPair(); + + clearGrid(); //+ GUI update +} + + +void MainDialog::moveAddFolderPairUp(size_t pos) +{ + assert(pos < additionalFolderPairs_.size()); + if (pos < additionalFolderPairs_.size()) + { + const LocalPairConfig cfgTmp = additionalFolderPairs_[pos]->getValues(); + if (pos == 0) + { + additionalFolderPairs_[pos]->setValues(firstFolderPair_->getValues()); + firstFolderPair_->setValues(cfgTmp); + } + else + { + additionalFolderPairs_[pos]->setValues(additionalFolderPairs_[pos - 1]->getValues()); + additionalFolderPairs_[pos - 1]->setValues(cfgTmp); + } + + //move comparison results, too! + if (!folderCmp_.empty()) + std::swap(folderCmp_[pos], folderCmp_[pos + 1]); //invariant: folderCmp is empty or matches number of all folder pairs + + filegrid::setData(*m_gridMainC, folderCmp_); + treegrid::setData(*m_gridOverview, folderCmp_); + updateGui(); + } +} + + +void MainDialog::removeAddFolderPair(size_t pos) +{ + assert(pos < additionalFolderPairs_.size()); + if (pos < additionalFolderPairs_.size()) + { + FolderPairPanel* panel = additionalFolderPairs_[pos]; + + additionalFolderPairs_.erase(additionalFolderPairs_.begin() + pos); + bSizerAddFolderPairs->Detach(panel); //Remove() does not work on wxWindow*, so do it manually + //more (non-portable) wxWidgets bullshit: on OS X wxWindow::Destroy() screws up and calls "operator delete" directly rather than + //the deferred deletion it is expected to do (and which is implemented correctly on Windows and Linux) + //http://bb10.com/python-wxpython-devel/2012-09/msg00004.html + //=> since we're in a mouse button callback of a sub-component of "panel" we need to delay deletion ourselves: + panel->Hide(); + folderPairScrapyard_.emplace_back(panel); //transfer ownership + + updateGuiForFolderPair(); + clearGrid(pos + 1); //+ GUI update + } +} + + +void MainDialog::setAddFolderPairs(const std::vector& newPairs) +{ + //FolderPairPanel are too expensive to casually throw away and recreate! + for (FolderPairPanel* panel : additionalFolderPairs_) + { + panel->Hide(); + folderPairScrapyard_.emplace_back(panel); //transfer ownership + } + additionalFolderPairs_.clear(); + bSizerAddFolderPairs->Clear(false /*delete_windows*/); //release ownership + + insertAddFolderPair(newPairs, 0); +} + + +//######################################################################################################## + + +void MainDialog::onMenuOptions(wxCommandEvent& event) +{ + const ColorTheme colorThemeOld = globalCfg_.appColorTheme; + + showOptionsDlg(this, globalCfg_); + + if (!equalAppearance(globalCfg_.appColorTheme, colorThemeOld)) + { + if (!folderCmp_.empty()) //otherwise: why bother the user? + switch (showConfirmationDialog(this, DialogInfoType::warning, PopupDialogCfg().setTitle(_("Confirm")). + setMainInstructions(_("The application must restart to change the color theme.") + L"\n\n" + + _("Restart now?")), _("&Restart"))) + { + case ConfirmationButton::accept: + break; + case ConfirmationButton::cancel: + return; + } + try + { + changeColorTheme(globalCfg_.appColorTheme); //throw FileError + //should work on macOS/Linux, but not on Windows (until wxWidgets fixes their s...) + + //show new dialog, then delete old one + MainDialog::create(getConfig(), activeConfigFiles_, getGlobalCfgBeforeExit(), globalCfgFilePath_, false /*startComparison*/); + Destroy(); + } + catch (FileError&) //changing color scheme failed => restart app + { + onSystemShutdownRunTasks(); //LastRun.ffs_gui + GlobalSettings.xml +... + try + { + const Zstring ffsLaunchPath = getProcessPath(); //throw FileError + try + { + //run async, but give consoleExecute() some "time to fail" + const auto& [exitCode, output] = consoleExecute(ffsLaunchPath, 100 /*timeoutMs*/); //throw SysError, SysErrorTimeOut + if (exitCode != 0) + throw SysError(formatSystemError("", replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + } + catch (SysErrorTimeOut&) {} + catch (const SysError& e) { throw FileError(replaceCpy(_("Command %x failed."), L"%x", fmtPath(ffsLaunchPath)), e.toString()); } + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } + + //don't continue after having called onSystemShutdownRunTasks() + // => also avoid ~MainDialog() calling getGlobalCfgBeforeExit() a second time and saving cfg needlessly + terminateProcess(static_cast(FfsExitCode::success)); + } + } +} + + +void MainDialog::onMenuExportFileList(wxCommandEvent& event) +{ + wxBusyCursor dummy; + + //https://en.wikipedia.org/wiki/Comma-separated_values + const lconv* localInfo = ::localeconv(); //always bound according to doc + const bool haveCommaAsDecimalSep = std::string(localInfo->decimal_point) == ","; + + const char CSV_SEP = haveCommaAsDecimalSep ? ';' : ','; + + auto fmtValue = [&](const std::wstring& val) -> std::string + { + std::string&& tmp = utfTo(val); + + if (contains(tmp, CSV_SEP)) + return '"' + tmp + '"'; + else + return std::move(tmp); + }; + + //generate header + std::string header; + header += BYTE_ORDER_MARK_UTF8; + + header += fmtValue(_("Folder Pairs")) + LINE_BREAK; + for (const BaseFolderPair& baseFolder : asRange(folderCmp_)) + { + header += fmtValue(AFS::getDisplayPath(baseFolder.getAbstractPath())) + CSV_SEP; + header += fmtValue(AFS::getDisplayPath(baseFolder.getAbstractPath())) + LINE_BREAK; + } + header += LINE_BREAK; + + auto provLeft = m_gridMainL->getDataProvider(); + auto provCenter = m_gridMainC->getDataProvider(); + auto provRight = m_gridMainR->getDataProvider(); + + auto colAttrLeft = m_gridMainL->getColumnConfig(); + auto colAttrCenter = m_gridMainC->getColumnConfig(); + auto colAttrRight = m_gridMainR->getColumnConfig(); + + std::erase_if(colAttrLeft, [](const Grid::ColAttributes& ca) { return !ca.visible; }); + std::erase_if(colAttrCenter, [](const Grid::ColAttributes& ca) { return !ca.visible || static_cast(ca.type) == ColumnTypeCenter::checkbox; }); + std::erase_if(colAttrRight, [](const Grid::ColAttributes& ca) { return !ca.visible; }); + + if (provLeft && provCenter && provRight) + { + for (const Grid::ColAttributes& ca : colAttrLeft) + { + header += fmtValue(provLeft->getColumnLabel(ca.type)); + header += CSV_SEP; + } + for (const Grid::ColAttributes& ca : colAttrCenter) + { + header += fmtValue(provCenter->getColumnLabel(ca.type)); + header += CSV_SEP; + } + if (!colAttrRight.empty()) + { + std::for_each(colAttrRight.begin(), colAttrRight.end() - 1, + [&](const Grid::ColAttributes& ca) + { + header += fmtValue(provRight->getColumnLabel(ca.type)); + header += CSV_SEP; + }); + header += fmtValue(provRight->getColumnLabel(colAttrRight.back().type)); + } + header += LINE_BREAK; + + try + { + Zstring title = Zstr("FreeFileSync"); + if (const std::vector& jobNames = getJobNames(); + !jobNames.empty()) + { + title = utfTo(jobNames[0]); + std::for_each(jobNames.begin() + 1, jobNames.end(), [&](const std::wstring& jobName) + { title += Zstr(" + ") + utfTo(jobName); }); + } + + const Zstring shortGuid = printNumber(Zstr("%04x"), static_cast(getCrc16(generateGUID()))); + const Zstring csvFilePath = appendPath(tempFileBuf_.getAndCreateFolderPath(), //throw FileError + title + Zstr('~') + shortGuid + Zstr(".csv")); + + const Zstring tmpFilePath = getPathWithTempName(csvFilePath); + + FileOutputBuffered tmpFile(tmpFilePath, nullptr /*notifyUnbufferedIO*/); //throw FileError, (ErrorTargetExisting) + + auto writeString = [&](const std::string& str) { tmpFile.write(str.data(), str.size()); }; //throw FileError + + //main grid: write rows one after the other instead of creating one big string: memory allocation might fail; think 1 million rows! + writeString(header); //throw FileError + + const size_t rowCount = m_gridMainL->getRowCount(); + for (size_t row = 0; row < rowCount; ++row) + { + for (const Grid::ColAttributes& ca : colAttrLeft) + writeString(fmtValue(provLeft->getValue(row, ca.type)) += CSV_SEP); //throw FileError + + for (const Grid::ColAttributes& ca : colAttrCenter) + writeString(fmtValue(provCenter->getValue(row, ca.type)) += CSV_SEP); //throw FileError + + for (const Grid::ColAttributes& ca : colAttrRight) + writeString(fmtValue(provRight->getValue(row, ca.type)) += CSV_SEP); //throw FileError + + writeString(LINE_BREAK); //throw FileError + } + + tmpFile.finalize(); //throw FileError + //take over ownership: + ZEN_ON_SCOPE_FAIL( try { removeFilePlain(tmpFilePath); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + //operation finished: move temp file transactionally + moveAndRenameItem(tmpFilePath, csvFilePath, true /*replaceExisting*/); //throw FileError, (ErrorMoveUnsupported), (ErrorTargetExisting) + + openWithDefaultApp(csvFilePath); //throw FileError + + flashStatusInfo(_("File list exported")); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } + } +} + + +void MainDialog::onMenuCheckVersion(wxCommandEvent& event) +{ + checkForUpdateNow(*this, globalCfg_.lastOnlineVersion); +} + + +void MainDialog::onStartupUpdateCheck(wxIdleEvent& event) +{ + //execute just once per startup! + [[maybe_unused]] bool ubOk = Unbind(wxEVT_IDLE, &MainDialog::onStartupUpdateCheck, this); + assert(ubOk); + + auto showNewVersionReminder = [this] + { + if (haveNewerVersionOnline(globalCfg_.lastOnlineVersion)) + { + auto menu = new wxMenu(); + wxMenuItem* newItem = new wxMenuItem(menu, wxID_ANY, _("&Show details")); + Bind(wxEVT_COMMAND_MENU_SELECTED, [this](wxCommandEvent&) { checkForUpdateNow(*this, globalCfg_.lastOnlineVersion); }, newItem->GetId()); + //show changelog + handle Supporter Edition auto-updater (including expiration) + menu->Append(newItem); //pass ownership + + const std::wstring& blackStar = utfTo("★"); + m_menubar->Append(menu, blackStar + L' ' + replaceCpy(_("FreeFileSync %x is available!"), L"%x", utfTo(globalCfg_.lastOnlineVersion)) + L' ' + blackStar); + } + }; + + if (automaticUpdateCheckDue(globalCfg_.lastUpdateCheck)) + { + flashStatusInfo(_("Searching for software updates...")); + + guiQueue_.processAsync([resultPrep = automaticUpdateCheckPrepare(*this) /*prepare on main thread*/] + { return automaticUpdateCheckRunAsync(resultPrep.ref()); }, //run on worker thread: (long-running part of the check) + [this, showNewVersionReminder] (SharedRef&& resultAsync) + { + const time_t lastUpdateCheckOld = globalCfg_.lastUpdateCheck; + + automaticUpdateCheckEval(*this, globalCfg_.lastUpdateCheck, globalCfg_.lastOnlineVersion, + resultAsync.ref()); //run on main thread: + showNewVersionReminder(); + + if (globalCfg_.lastUpdateCheck == lastUpdateCheckOld) + flashStatusInfo(_("Software update check failed!")); + }); + } + else + showNewVersionReminder(); +} + + +void MainDialog::onLayoutWindowAsync(wxIdleEvent& event) +{ + //execute just once per startup! + [[maybe_unused]] bool ubOk = Unbind(wxEVT_IDLE, &MainDialog::onLayoutWindowAsync, this); + assert(ubOk); + + //adjust folder pair distortion on startup + for (FolderPairPanel* panel : additionalFolderPairs_) + panel->Layout(); + + Layout(); //strangely this layout call works if called in next idle event only + m_panelTopButtons->Layout(); + + //auiMgr_.Update(); fix view filter distortion; 2021-02-01: apparently not needed anymore! +} + + +void MainDialog::onMenuAbout(wxCommandEvent& event) +{ + showAboutDialog(this); +} + + +void MainDialog::switchProgramLanguage(wxLanguage langId) +{ + try + { + //set language *before* creating MainDialog! + setLanguage(langId); //throw FileError + } + catch (const FileError& e) + { + showNotificationDialog(nullptr, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return; + } + + //create new dialog with respect to new language + GlobalConfig newGlobalCfg = getGlobalCfgBeforeExit(); + newGlobalCfg.programLanguage = langId; + + //show new dialog, then delete old one + MainDialog::create(getConfig(), activeConfigFiles_, newGlobalCfg, globalCfgFilePath_, false /*startComparison*/); + + //don't use Close(): + //1. we don't want to show the prompt to save current config in onClose() + //2. after getGlobalCfgBeforeExit() the old main dialog is invalid so we want to force deletion + Destroy(); //alternative: Close(true /*force*/) +} + + +void MainDialog::setGridViewType(GridViewType vt) +{ + //if (m_bpButtonViewType->isActive() == value) return; support polling -> what about initialization? + + m_bpButtonViewType->setActive(vt == GridViewType::action); + m_bpButtonViewType->SetToolTip((vt == GridViewType::action ? _("Action") : _("Difference")) + L" (F11)"); + + //toggle display of sync preview in middle grid + filegrid::setViewType(*m_gridMainC, vt); + + updateGui(); +} diff --git a/FreeFileSync/Source/ui/main_dlg.h b/FreeFileSync/Source/ui/main_dlg.h new file mode 100644 index 0000000..2ceb5a6 --- /dev/null +++ b/FreeFileSync/Source/ui/main_dlg.h @@ -0,0 +1,370 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef MAIN_DLG_H_8910481324545644545 +#define MAIN_DLG_H_8910481324545644545 + +//#include +#include +#include +#include +#include "gui_generated.h" +#include "file_grid.h" +#include "progress_indicator.h" +#include "sync_cfg.h" +#include "log_panel.h" +#include "folder_history_box.h" +#include "../config.h" +//#include "../status_handler.h" +#include "../base/algorithm.h" +#include "../base/synchronization.h" + + +namespace fff +{ +class FolderPairFirst; +class FolderPairPanel; +template class FolderPairCallback; + + +class MainDialog : public MainDialogGenerated +{ +public: + //default behavior, application start, restores last used config + static void create(const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath); + + //- when loading dynamically assembled config + //- when switching language + //- switching from batch run to GUI on warnings dialog + static void create(const FfsGuiConfig& guiCfg, const std::vector& cfgFilePaths, + const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath, //take over ownership => save on exit + bool startComparison); + +private: + MainDialog(const FfsGuiConfig& guiCfg, const std::vector& cfgFilePaths, + const GlobalConfig& globalCfg, const Zstring& globalCfgFilePath); //take over ownership => save on exit + ~MainDialog(); + + void onBeforeSystemShutdown(); //last chance to do something useful before killing the application! + + friend class StatusHandlerTemporaryPanel; + friend class StatusHandlerFloatingDialog; + friend class FolderPairFirst; + friend class FolderPairPanel; + template + friend class FolderPairCallback; + friend class PanelMoveWindow; + + class UiInputDisabler; + + //configuration load/save + void setLastUsedConfig(const FfsGuiConfig& guiConfig, const std::vector& cfgFilePaths); + + FfsGuiConfig getConfig() const; + void setConfig(const FfsGuiConfig& newGuiCfg, const std::vector& cfgFilePaths); + + void setGlobalCfgOnInit(const GlobalConfig& globalCfg); //messes with Maximize(), window sizes, so call just once! + GlobalConfig getGlobalCfgBeforeExit(); //destructive "get" thanks to "Iconize(false), Maximize(false)" + + bool loadConfiguration(const std::vector& filepaths, bool ignoreBrokenConfig = false); //"false": error/cancel + + bool trySaveConfig (const Zstring* guiCfgPath); // + bool trySaveBatchConfig(const Zstring* batchCfgPath); //"false": error/cancel + bool saveOldConfig(); // + + void updateGlobalFilterButton(); + + void setViewFilterDefault(); + + void cfgHistoryRemoveObsolete(const std::vector& filepaths); + void cfgHistoryUpdateNotes (const std::vector& filepaths); + + void insertAddFolderPair(const std::vector& newPairs, size_t pos); + void moveAddFolderPairUp(size_t pos); + void removeAddFolderPair(size_t pos); + void setAddFolderPairs(const std::vector& newPairs); + + void updateGuiForFolderPair(); //helper method: add usability by showing/hiding buttons related to folder pairs + void recalcMaxFolderPairsVisible(); + + //main method for putting gridDataView on UI: updates data respecting current view settings + void updateGui(); //kitchen-sink update + void updateGuiDelayedIf(bool condition); //400 ms delay + + void updateGridViewData(); // + void updateStatistics(const SyncStatistics& st); // more fine-grained updaters + void updateUnsavedCfgStatus(); // + + std::vector getJobNames() const; + + //context menu functions + std::vector getGridSelection(bool fromLeft = true, bool fromRight = true) const; + std::vector getTreeSelection() const; + + void setSyncDirManually (const std::vector& selection, SyncDirection direction); + void setIncludedManually(const std::vector& selection, bool setIncluded); + void copyGridSelectionToClipboard(const zen::Grid& grid); + void copyPathsToClipboard(const std::vector& selectionL, + const std::vector& selectionR); + + void copyToAlternateFolder(const std::vector& selectionL, + const std::vector& selectionR); + + void deleteSelectedFiles(const std::vector& selectionL, + const std::vector& selectionR, bool moveToRecycler); + + void renameSelectedFiles(const std::vector& selectionL, + const std::vector& selectionR); + + void openExternalApplication(const Zstring& commandLinePhrase, bool leftSide, + const std::vector& selectionL, + const std::vector& selectionR); //selection may be empty + + void setStatusBarFileStats(FileView::FileStats statsLeft, FileView::FileStats statsRight); + + void setStatusInfo(const wxString& text, bool highlight); //(permanently) set status bar center text + void flashStatusInfo(const wxString& text); //temporarily show different status + void popStatusInfo(); + + //events + void onGridKeyEvent(wxKeyEvent& event, zen::Grid& grid, bool leftSide); + + void onTreeKeyEvent (wxKeyEvent& event); + void onSetLayoutContext(wxMouseEvent& event); + void onLocalKeyEvent (wxKeyEvent& event); + + void applyCompareConfig(bool setDefaultViewType); + + //context menu handler methods + void onGridContextRim(zen::GridContextMenuEvent& event, bool leftSide); + + void onGridGroupContextRim(zen::GridClickEvent& event, bool leftSide); + + void onGridContextRim(const std::vector& selection, + const std::vector& selectionL, + const std::vector& selectionR, bool leftSide, wxPoint mousePos); + + void onTreeGridContext(zen::GridContextMenuEvent& event); + + void onTreeGridSelection(zen::GridSelectEvent& event); + + void onDialogFilesDropped(zen::FileDropEvent& event); + + void onFolderSelected(wxCommandEvent& event); + + void onCheckRows (CheckRowsEvent& event); + void onSetSyncDirection(SyncDirectionEvent& event); + + void swapSides(); + + void onGridDoubleClickRim(zen::GridClickEvent& event, bool leftSide); + + void onGridLabelLeftClickRim(zen::GridLabelClickEvent& event, bool onLeft); + void onGridLabelLeftClickC (zen::GridLabelClickEvent& event); + + void onGridLabelContextRim(zen::GridLabelClickEvent& event, bool leftSide); + void onGridLabelContextC (zen::GridLabelClickEvent& event); + + void onToggleViewType (wxCommandEvent& event) override; + void onToggleViewButton(wxCommandEvent& event) override; + + void onViewTypeContextMouse (wxMouseEvent& event) override; + void onViewFilterContext (wxCommandEvent& event) override { onViewFilterContext(static_cast(event)); } + void onViewFilterContextMouse(wxMouseEvent& event) override { onViewFilterContext(static_cast(event)); } + void onViewFilterContext(wxEvent& event); + + void onConfigNew (wxCommandEvent& event) override; + void onConfigSave (wxCommandEvent& event) override; + void onConfigSaveAs (wxCommandEvent& event) override { trySaveConfig(nullptr); } + void onSaveAsBatchJob(wxCommandEvent& event) override { trySaveBatchConfig(nullptr); } + void onConfigLoad (wxCommandEvent& event) override; + + void onCfgGridSelection (zen::GridSelectEvent& event); + void onCfgGridDoubleClick(zen::GridClickEvent& event); + void onCfgGridKeyEvent (wxKeyEvent& event); + void onCfgGridContext (zen::GridContextMenuEvent& event); + void onCfgGridLabelContext (zen::GridLabelClickEvent& event); + void onCfgGridLabelLeftClick(zen::GridLabelClickEvent& event); + + void removeSelectedCfgHistoryItems(bool removeFromDisk); + void renameSelectedCfgHistoryItem(); + + void onStartupUpdateCheck(wxIdleEvent& event); + void onLayoutWindowAsync (wxIdleEvent& event); + + void onResizeLeftFolderWidth(wxEvent& event); + void onResizeTopButtonPanel (wxEvent& event); + void onResizeConfigPanel (wxEvent& event); + void onResizeViewPanel (wxEvent& event); + void onToggleLog (wxCommandEvent& event) override; + void onCompare (wxCommandEvent& event) override; + void onStartSync (wxCommandEvent& event) override; + void onClose (wxCloseEvent& event) override; + void onSwapSides (wxCommandEvent& event) override { swapSides(); } + + void startSyncForSelecction(const std::vector& selection); + + void onCmpSettings (wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::compare, -1); } + void onSyncSettings (wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::sync, -1); } + void onConfigureFilter(wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::filter, -1); } + + void onCompSettingsContext (wxCommandEvent& event) override { onCompSettingsContext(static_cast(event)); } + void onCompSettingsContextMouse(wxMouseEvent& event) override { onCompSettingsContext(static_cast(event)); } + void onSyncSettingsContext (wxCommandEvent& event) override { onSyncSettingsContext(static_cast(event)); } + void onSyncSettingsContextMouse(wxMouseEvent& event) override { onSyncSettingsContext(static_cast(event)); } + void onGlobalFilterContext (wxCommandEvent& event) override { onGlobalFilterContext(static_cast(event)); } + void onGlobalFilterContextMouse(wxMouseEvent& event) override { onGlobalFilterContext(static_cast(event)); } + + void onCompSettingsContext(wxEvent& event); + void onSyncSettingsContext(wxEvent& event); + void onGlobalFilterContext(wxEvent& event); + + void showConfigDialog(SyncConfigPanel panelToShow, int localPairIndexToShow); + + void setLastOperationLog(const ProcessSummary& summary, const std::shared_ptr& errorLog); + void showLogPanel(bool show); + + void addFilterPhrase(const Zstring& phrase, bool include, bool requireNewLine); + + void onTopFolderPairAdd (wxCommandEvent& event) override; + void onTopFolderPairRemove(wxCommandEvent& event) override; + void onRemoveFolderPair (wxCommandEvent& event); + void onShowFolderPairOptions(wxEvent& event); + + void onTopLocalCompCfg (wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::compare, 0); } + void onTopLocalSyncCfg (wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::sync, 0); } + void onTopLocalFilterCfg(wxCommandEvent& event) override { showConfigDialog(SyncConfigPanel::filter, 0); } + + void onLocalCompCfg (wxCommandEvent& event); + void onLocalSyncCfg (wxCommandEvent& event); + void onLocalFilterCfg(wxCommandEvent& event); + + void onTopFolderPairKeyEvent(wxKeyEvent& event); + void onAddFolderPairKeyEvent(wxKeyEvent& event); + + void applyFilterConfig(); + void applySyncDirections(); + + void showFindPanel(bool show); //CTRL + F + void startFindNext(bool searchAscending); //F3 + + void resetLayout(); + + void onSearchGridEnter(wxCommandEvent& event) override; + void onHideSearchPanel(wxCommandEvent& event) override; + void onSearchPanelKeyPressed(wxKeyEvent& event); + + //menu events + void onOpenMenuTools(wxMenuEvent& event); + void onMenuOptions (wxCommandEvent& event) override; + void onMenuExportFileList (wxCommandEvent& event) override; + void onMenuResetLayout (wxCommandEvent& event) override { resetLayout(); } + void onMenuFindItem (wxCommandEvent& event) override { showFindPanel(true /*show*/); } //CTRL + F + void onMenuCheckVersion (wxCommandEvent& event) override; + void onMenuAbout (wxCommandEvent& event) override; + void onShowHelp (wxCommandEvent& event) override { wxLaunchDefaultBrowser(L"https://freefilesync.org/manual.php?topic=freefilesync"); } + void onMenuQuit (wxCommandEvent& event) override { Close(); } + + void switchProgramLanguage(wxLanguage langId); + + std::set detachedMenuItems_; //owning pointers!!! + //alternatives: 1. std::set>? key is const => no support for moving items out! 2. std::map>: redundant info, inconvenient use + + void clearGrid(ptrdiff_t pos = -1); + + //*********************************************** + //application variables are stored here: + + //global settings shared by GUI and batch mode + GlobalConfig globalCfg_; + + const Zstring globalCfgFilePath_; + + //------------------------------------- + //program configuration + FfsGuiConfig currentCfg_; //caveat: some parts are owned by GUI controls! see setConfig() + + //used when saving configuration + std::vector activeConfigFiles_; //name of currently loaded config files: NOT owned by m_gridCfgHistory, see onCfgGridSelection() + + FfsGuiConfig lastSavedCfg_; //support for: "Save changed configuration?" dialog + + const Zstring lastRunConfigPath_ = getLastRunConfigPath(); //let's not use another global... + //------------------------------------- + + //the prime data structure of this tool *bling*: + FolderComparison folderCmp_; //optional!: sync button not available if empty + + //merge logs of individual steps (comparison, manual operations, sync) into a combined result (just as for ffs_batch jobs) + struct FullSyncLog + { + zen::ErrorLog log; + std::chrono::system_clock::time_point startTime; + std::chrono::milliseconds totalTime{}; + }; + std::optional fullSyncLog_; + + //folder pairs: + std::unique_ptr firstFolderPair_; //always bound!!! + std::vector additionalFolderPairs_; //additional pairs to the first pair + + std::optional addPairCountLast_; + + //------------------------------------- + //fight sluggish GUI: FolderPairPanel are too expensive to casually throw away and recreate! + struct DeleteWxWindow { void operator()(wxWindow* win) const { win->Destroy(); } }; + + std::vector> folderPairScrapyard_; + //------------------------------------- + + //*********************************************** + //status bar center text + std::vector statusTxts_; //the first one is the original/non-flash status message + bool statusTxtHighlightFirst_ = false; + + //compare status panel (hidden on start, shown during comparison) + std::optional compareStatus_; //always bound + + LogPanel* logPanel_ = nullptr; + + //toggle to display configuration preview instead of comparison result: + //for read access use: m_bpButtonViewType->isActive() + //when changing value use: + void setGridViewType(GridViewType vt); + + wxAuiManager auiMgr_; //implement dockable GUI design + + wxString defaultPerspective_; + + time_t manualTimeSpanFrom_ = 0; + time_t manualTimeSpanTo_ = 0; //buffer manual time span selection at session level + + //recreate view filter button labels only when necessary: + std::unordered_map buttonLabelItemCount_; + + const std::shared_ptr folderHistoryLeft_; //shared by all wxComboBox dropdown controls + const std::shared_ptr folderHistoryRight_; // + + zen::AsyncGuiQueue guiQueue_; //schedule and run long-running tasks asynchronously, but process results on GUI queue + + wxWindowID focusAfterCloseLog_ = wxID_ANY; // + wxWindowID focusAfterCloseSearch_ = wxID_ANY; //restore focus after panel is closed + //don't save wxWindow* to arbitrary window: might not exist anymore when hideFindPanel() uses it!!! (e.g. some folder pair panel) + + //mitigate reentrancy: + bool localKeyEventsEnabled_ = true; + bool operationInProgress_ = false; //see SingleOperationBlocker; e.g. do NOT allow dialog exit while sync is running => crash!!! + + TempFileBuffer tempFileBuf_; //buffer temporary copies of non-native files for %local_path% + + const wxImage imgTrashSmall_; + const wxImage imgFileManagerSmall_; + + const zen::SharedRef> onBeforeSystemShutdownCookie_ = zen::makeSharedRef>([this] { onBeforeSystemShutdown(); }); +}; +} + +#endif //MAIN_DLG_H_8910481324545644545 diff --git a/FreeFileSync/Source/ui/progress_indicator.cpp b/FreeFileSync/Source/ui/progress_indicator.cpp new file mode 100644 index 0000000..64e16dd --- /dev/null +++ b/FreeFileSync/Source/ui/progress_indicator.cpp @@ -0,0 +1,1746 @@ +// ***************************************************************************** +// * 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 "progress_indicator.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "wx+/taskbar.h" +#include "gui_generated.h" +#include "tray_icon.h" +#include "log_panel.h" +#include "app_icon.h" +#include "../icon_buffer.h" +#include "../base/speed_test.h" + + +using namespace zen; +using namespace fff; + + +namespace +{ +constexpr std::chrono::seconds PERF_WINDOW_BYTES_PER_SEC (4); //window size used for statistics +constexpr std::chrono::seconds PERF_WINDOW_REMAINING_TIME(60); //USB memory stick can have 40-second-hangs +constexpr std::chrono::seconds SPEED_ESTIMATE_SAMPLE_SKIP(1); +constexpr std::chrono::milliseconds SPEED_ESTIMATE_UPDATE_INTERVAL(500); +constexpr std::chrono::seconds GRAPH_TOTAL_TIME_UPDATE_INTERVAL(2); + +const size_t PROGRESS_GRAPH_SAMPLE_SIZE_MAX = 2'500'000; //sizeof(CurveDataStatistics::Sample) == 16 byte key/value + +wxColor getColorBytes () { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x16, 0xd2, 0x02} /*medium green*/ : wxColor{111, 255, 99} /*light green*/; } +wxColor getColorItems () { return wxSystemSettings::GetAppearance().IsDark() ? wxColor{0x53, 0x71, 0xfb} /*medium blue*/ : wxColor{127, 147, 255} /*light blue*/; } +wxColor getColorEstimate() { return wxSystemSettings::GetAppearance().IsDark() ? 0xc4c4c4 /*medium grey*/ : 0xf0f0f0 /*light grey*/; } +wxColor getColorEstimateText() { return 0x000000UL; } + + +std::wstring getDialogPhaseText(const Statistics& syncStat, bool paused) +{ + if (paused) + return _("Paused"); + + if (syncStat.taskCancelled()) + return _("Stop requested..."); + + switch (syncStat.currentPhase()) + { + case ProcessPhase::none: + return _("Initializing..."); //dialog is shown *before* sync starts, so this text may be visible! + case ProcessPhase::scan: + return _("Scanning..."); + case ProcessPhase::binaryCompare: + return _("Comparing content..."); + case ProcessPhase::sync: + return _("Synchronizing..."); + } + assert(false); + return std::wstring(); +} + + +class CurveDataProgressBar : public CurveData +{ +public: + CurveDataProgressBar(bool drawTop) : drawTop_(drawTop) {} + + void setFraction(double fraction) { fraction_ = fraction; } //value between [0, 1] + +private: + std::pair getRangeX() const override { return {0, 1}; } + + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override + { + const double yLow = drawTop_ ? 1 : -1; //draw partially out of vertical bounds to not render top/bottom borders of the bars + const double yHigh = drawTop_ ? 3 : 1; // + + return + { + {0, yHigh}, + {fraction_, yHigh}, + {fraction_, yLow }, + {0, yLow }, + }; + } + + double fraction_ = 0; + const bool drawTop_; +}; + +class CurveDataProgressSeparatorLine : public CurveData +{ + std::pair getRangeX() const override { return {0, 1}; } + + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override + { + return + { + {0, 1}, + {1, 1}, + }; + } +}; +} + + +class CompareProgressPanel::Impl : public CompareProgressDlgGenerated +{ +public: + explicit Impl(wxFrame& parentWindow); + + void init(const Statistics& syncStat, bool ignoreErrors, size_t autoRetryCount); //constructor/destructor semantics, but underlying Window is reused + void teardown(); // + + void initNewPhase(); + void updateProgressGui(bool allowYield); + + bool getOptionIgnoreErrors() const { return ignoreErrors_; } + void setOptionIgnoreErrors(bool ignoreErrors) { ignoreErrors_ = ignoreErrors; updateStaticGui(); } + + void timerSetStatus(bool active) + { + if (active) + stopWatch_.resume(); + else + stopWatch_.pause(); + } + bool timerIsRunning() const { return !stopWatch_.isPaused(); } + + std::chrono::milliseconds pauseAndGetTotalTime() + { + stopWatch_.pause(); + return std::chrono::duration_cast(stopWatch_.elapsed()); + } + +private: + //void onToggleIgnoreErrors(wxCommandEvent& event) override { updateStaticGui(); } + + void updateStaticGui(); + + wxFrame& parentWindow_; + wxString parentTitleBackup_; + + StopWatch stopWatch_; + std::chrono::nanoseconds phaseStart_{}; //begin of current phase + + const Statistics* syncStat_ = nullptr; //only bound while sync is running + + std::optional taskbar_; + SpeedTest remTimeTest_{PERF_WINDOW_REMAINING_TIME}; + SpeedTest speedTest_ {PERF_WINDOW_BYTES_PER_SEC}; + + std::chrono::nanoseconds timeLastSpeedEstimate_ = std::chrono::seconds(-100); //used for calculating intervals between showing and collecting perf samples + //initial value: just some big number + + SharedRef curveDataBytes_{makeSharedRef(true /*drawTop*/)}; + SharedRef curveDataItems_{makeSharedRef(false /*drawTop*/)}; + + bool ignoreErrors_ = false; +}; + + +CompareProgressPanel::Impl::Impl(wxFrame& parentWindow) : + CompareProgressDlgGenerated(&parentWindow), + parentWindow_(parentWindow) +{ + setImage(*m_bitmapItemStat, IconBuffer::genericFileIcon(IconBuffer::IconSize::small)); + setImage(*m_bitmapTimeStat, loadImage("time", -1 /*maxWidth*/, IconBuffer::getPixSize(IconBuffer::IconSize::small))); + m_bitmapTimeStat->SetMinSize({-1, screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small))}); + + setImage(*m_bitmapErrors, loadImage("msg_error", dipToScreen(getMenuIconDipSize()))); + setImage(*m_bitmapWarnings, loadImage("msg_warning", dipToScreen(getMenuIconDipSize()))); + + setImage(*m_bitmapIgnoreErrors, loadImage("error_ignore_active", dipToScreen(getMenuIconDipSize()))); + setImage(*m_bitmapRetryErrors, loadImage("error_retry", dipToScreen(getMenuIconDipSize()))); + + //make sure standard height matches ProcessPhase::binaryCompare statistics layout (== largest) + + //init graph + m_panelProgressGraph->setAttributes(Graph2D::MainAttributes().setMinY(0).setMaxY(2). + setLabelX(XLabelPos::none). + setLabelY(YLabelPos::none). + setBaseColors(getColorEstimateText(), getColorEstimate()). + setSelectionMode(GraphSelMode::none)); + + const wxColor gridLineColor = m_panelProgressGraph->getAttributes().getGridLineColor(); + m_panelProgressGraph->addCurve(curveDataBytes_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillPolygonArea(getColorBytes()).setColor(gridLineColor)); + m_panelProgressGraph->addCurve(curveDataItems_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillPolygonArea(getColorItems()).setColor(gridLineColor)); + + m_panelProgressGraph->addCurve(makeSharedRef(), Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).setColor(gridLineColor)); + + Layout(); + m_panelItemStats->Layout(); + m_panelTimeStats->Layout(); + m_panelErrorStats->Layout(); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + //Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif +} + + +void CompareProgressPanel::Impl::init(const Statistics& syncStat, bool ignoreErrors, size_t autoRetryCount) +{ + assert(!syncStat_); + syncStat_ = &syncStat; + parentTitleBackup_ = parentWindow_.GetTitle(); + + try //try to get access to Windows 7/Ubuntu taskbar + { + taskbar_.emplace(this); //throw TaskbarNotAvailable + } + catch (const TaskbarNotAvailable&) {} + + stopWatch_ = StopWatch(); //reset to measure total time + + setText(*m_staticTextRetryCount, L'(' + formatNumber(autoRetryCount) + MULT_SIGN + L')'); + bSizerErrorsRetry->Show(autoRetryCount > 0); + + //allow changing a few options dynamically during sync + ignoreErrors_ = ignoreErrors; + + updateStaticGui(); + + initNewPhase(); +} + + +void CompareProgressPanel::Impl::teardown() +{ + assert(stopWatch_.isPaused()); //why wasn't pauseAndGetTotalTime() called? + + syncStat_ = nullptr; + parentWindow_.SetTitle(parentTitleBackup_); + taskbar_.reset(); +} + + +void CompareProgressPanel::Impl::initNewPhase() +{ + //start new measurement + remTimeTest_.clear(); + speedTest_ .clear(); + timeLastSpeedEstimate_ = std::chrono::seconds(-100); //make sure estimate is updated upon next check + phaseStart_ = stopWatch_.elapsed(); + + const int itemsTotal = syncStat_->getTotalStats().items; + const int64_t bytesTotal = syncStat_->getTotalStats().bytes; + + const bool haveTotalStats = itemsTotal >= 0 || bytesTotal >= 0; + + if (taskbar_) taskbar_->setStatus(haveTotalStats ? Taskbar::Status::normal : Taskbar::Status::indeterminate); + + m_staticTextProcessed ->Show(haveTotalStats); + m_staticTextRemaining ->Show(haveTotalStats); + m_staticTextItemsRemaining->Show(haveTotalStats); + m_staticTextBytesRemaining->Show(haveTotalStats); + m_staticTextTimeRemaining ->Show(haveTotalStats); + bSizerProgressGraph ->Show(haveTotalStats); + + Layout(); // + m_panelItemStats->Layout(); //redundant? can we trust updateProgressGui() to do the same after detecting "layoutChanged"? + m_panelTimeStats->Layout(); // + + updateProgressGui(false /*allowYield*/); +} + + +void CompareProgressPanel::Impl::updateStaticGui() +{ + bSizerErrorsIgnore->Show(ignoreErrors_); + Layout(); +} + + +void CompareProgressPanel::Impl::updateProgressGui(bool allowYield) +{ + assert(syncStat_); + if (!syncStat_) //no comparison running!? + return; + + auto setTitle = [&](const wxString& title) + { + if (parentWindow_.GetTitle() != title) + parentWindow_.SetTitle(title); + }; + + bool layoutChanged = false; //avoid screen flicker by calling layout() only if necessary + const std::chrono::nanoseconds timeElapsed = stopWatch_.elapsed(); + + const int itemsCurrent = syncStat_->getCurrentStats().items; + const int64_t bytesCurrent = syncStat_->getCurrentStats().bytes; + const int itemsTotal = syncStat_->getTotalStats ().items; + const int64_t bytesTotal = syncStat_->getTotalStats ().bytes; + + const bool haveTotalStats = itemsTotal >= 0 || bytesTotal >= 0; + + //status texts + setText(*m_staticTextStatus, replaceCpy(syncStat_->currentStatusText(), L'\n', L' ')); //no layout update for status texts! + + if (!haveTotalStats) + { + //dialog caption, taskbar + setTitle(formatNumber(itemsCurrent) + L' ' + getDialogPhaseText(*syncStat_, false /*paused*/)); + + //progress indicators + //taskbar_ already set to STATUS_INDETERMINATE by initNewPhase() + } + else + { + //add both bytes + item count, to handle "deletion-only" cases + const double fractionTotal = bytesTotal + itemsTotal == 0 ? 0 : 1.0 * (bytesCurrent + itemsCurrent) / (bytesTotal + itemsTotal); + const double fractionBytes = bytesTotal == 0 ? 0 : 1.0 * bytesCurrent / bytesTotal; + const double fractionItems = itemsTotal == 0 ? 0 : 1.0 * itemsCurrent / itemsTotal; + + //dialog caption, taskbar + setTitle(formatProgressPercent(fractionTotal) + L' ' + getDialogPhaseText(*syncStat_, false /*paused*/)); + + //progress indicators + if (taskbar_) taskbar_->setProgress(fractionTotal); + + curveDataBytes_.ref().setFraction(fractionBytes); + curveDataItems_.ref().setFraction(fractionItems); + } + + //item and data stats + if (!haveTotalStats) + { + setText(*m_staticTextItemsProcessed, formatNumber(itemsCurrent), &layoutChanged); + setText(*m_staticTextBytesProcessed, L"", &layoutChanged); + } + else + { + setText(*m_staticTextItemsProcessed, formatNumber(itemsCurrent), &layoutChanged); + setText(*m_staticTextBytesProcessed, L'(' + formatFilesizeShort(bytesCurrent) + L')', &layoutChanged); + + setText(*m_staticTextItemsRemaining, formatNumber(itemsTotal - itemsCurrent), &layoutChanged); + setText(*m_staticTextBytesRemaining, L'(' + formatFilesizeShort(bytesTotal - bytesCurrent) + L')', &layoutChanged); + } + + auto showIfNeeded = [&](wxWindow& wnd, bool show) + { + if (wnd.IsShown() != show) + { + wnd.Show(show); + layoutChanged = true; + } + }; + + //errors and warnings (pop up dynamically) + const Statistics::ErrorStats errorStats = syncStat_->getErrorStats(); + + showIfNeeded(*m_staticTextErrors, errorStats.errorCount != 0); + showIfNeeded(*m_staticTextWarnings, errorStats.warningCount != 0); + showIfNeeded(*m_panelErrorStats, errorStats.errorCount != 0 || errorStats.warningCount != 0); + + if (m_panelErrorStats->IsShown()) + { + showIfNeeded(*m_bitmapErrors, errorStats.errorCount != 0); + showIfNeeded(*m_staticTextErrorCount, errorStats.errorCount != 0); + + if (m_staticTextErrorCount->IsShown()) + setText(*m_staticTextErrorCount, formatNumber(errorStats.errorCount), &layoutChanged); + + showIfNeeded(*m_bitmapWarnings, errorStats.warningCount != 0); + showIfNeeded(*m_staticTextWarningCount, errorStats.warningCount != 0); + + if (m_staticTextWarningCount->IsShown()) + setText(*m_staticTextWarningCount, formatNumber(errorStats.warningCount), &layoutChanged); + } + + //current time elapsed + const int64_t timeElapSec = std::chrono::duration_cast(timeElapsed).count(); + + setText(*m_staticTextTimeElapsed, utfTo(formatTimeSpan(timeElapSec, false /*hourRequired*/)), &layoutChanged); + + if (haveTotalStats) //remaining time and speed: only visible during binary comparison + if (numeric::dist(timeLastSpeedEstimate_, timeElapsed) >= SPEED_ESTIMATE_UPDATE_INTERVAL) + { + timeLastSpeedEstimate_ = timeElapsed; + + if (numeric::dist(phaseStart_, timeElapsed) >= SPEED_ESTIMATE_SAMPLE_SKIP) //discard stats for first second: probably messy + { + remTimeTest_.addSample(timeElapsed, itemsCurrent, bytesCurrent); + speedTest_ .addSample(timeElapsed, itemsCurrent, bytesCurrent); + } + + //current speed -> Win 7 copy uses 1 sec update interval instead + m_panelProgressGraph->setAttributes(m_panelProgressGraph->getAttributes().setCornerText(speedTest_.getBytesPerSecFmt(), GraphCorner::topL)); + m_panelProgressGraph->setAttributes(m_panelProgressGraph->getAttributes().setCornerText(speedTest_.getItemsPerSecFmt(), GraphCorner::bottomL)); + + //remaining time: display with relative error of 10% - based on samples taken every 0.5 sec only + //-> call more often than once per second to correctly show last few seconds countdown, but don't call too often to avoid occasional jitter + std::optional remTimeSec = remTimeTest_.getRemainingSec(itemsTotal - itemsCurrent, bytesTotal - bytesCurrent); + setText(*m_staticTextTimeRemaining, remTimeSec ? formatRemainingTime(*remTimeSec) : std::wstring(1, EM_DASH), &layoutChanged); + } + + if (haveTotalStats) + m_panelProgressGraph->Refresh(); + + //adapt layout after content changes above + if (layoutChanged) + { + Layout(); + m_panelItemStats->Layout(); + m_panelTimeStats->Layout(); + if (m_panelErrorStats->IsShown()) + m_panelErrorStats->Layout(); + } + + //do the ui update + if (allowYield) + wxTheApp->Yield(); //pump GUI messages + else + this->Update(); //don't wait until next idle event (who knows what blocking process comes next?) +} + +//######################################################################################## + +//redirect to implementation +CompareProgressPanel::CompareProgressPanel(wxFrame& parentWindow) : pimpl_(new Impl(parentWindow)) {} //owned by parentWindow +wxWindow* CompareProgressPanel::getAsWindow() { return pimpl_; } +void CompareProgressPanel::init(const Statistics& syncStat, bool ignoreErrors, size_t autoRetryCount) { pimpl_->init(syncStat, ignoreErrors, autoRetryCount); } +void CompareProgressPanel::teardown() { pimpl_->teardown(); } +void CompareProgressPanel::initNewPhase() { pimpl_->initNewPhase(); } +void CompareProgressPanel::updateGui() { pimpl_->updateProgressGui(true /*allowYield*/); } +bool CompareProgressPanel::getOptionIgnoreErrors() const { return pimpl_->getOptionIgnoreErrors(); } +void CompareProgressPanel::setOptionIgnoreErrors(bool ignoreErrors) { pimpl_->setOptionIgnoreErrors(ignoreErrors); } +void CompareProgressPanel::timerSetStatus(bool active) { pimpl_->timerSetStatus(active); } +bool CompareProgressPanel::timerIsRunning() const { return pimpl_->timerIsRunning(); } +std::chrono::milliseconds CompareProgressPanel::pauseAndGetTotalTime() { return pimpl_->pauseAndGetTotalTime(); } +//######################################################################################## + +namespace +{ +class CurveDataStatistics : public SparseCurveData +{ +public: + CurveDataStatistics() : SparseCurveData(true /*addSteps*/) {} + + void clear() { samples_.clear(); lastSample_ = {}; } + + void addSample(double timeElapsed /*[sec]*/, double value /*[items|bytes]*/) + { + assert(( samples_.empty() && lastSample_.x == 0 && lastSample_.y == 0) || + (!samples_.empty() && samples_.back().x <= lastSample_.x)); + + if (timeElapsed < lastSample_.x) //time *required* to be monotonously ascending for std::partition_point + { + assert(false); + return; + } + + lastSample_ = {timeElapsed, value}; + + //allow for at most one sample per 100ms (handles duplicate inserts, too!) => unrelated to UI_UPDATE_INTERVAL! + if (!samples_.empty() && timeElapsed - samples_.back().x < 0.1) + return; + + samples_.push_back(CurvePoint{timeElapsed, value}); + + if (samples_.size() > PROGRESS_GRAPH_SAMPLE_SIZE_MAX) //limit buffer size + samples_.pop_front(); + } + +private: + std::pair getRangeX() const override + { + if (samples_.empty()) + return {}; + /* + //report some additional width by 5% elapsed time to make graph recalibrate before hitting the right border + //caveat: graph for batch mode binary comparison does NOT start at elapsed time 0!! ProcessPhase::binaryCompare and ProcessPhase::sync! + //=> consider width of current sample set! + upperEndMs += 0.05 *(upperEndMs - samples.begin()->first); + */ + return {samples_.front().x, //need not start with 0, e.g. "binary comparison, graph reset, followed by sync" + lastSample_.x}; + } + + std::optional getLessEq(double x) const override //x: seconds since begin + { + //--------- add artifical last sample value -------- + if (!samples_.empty() && lastSample_.x <= x) + return lastSample_; + //-------------------------------------------------- + + //find first item > x, then go one step back: + auto it = std::partition_point(samples_.begin(), samples_.end(), + /*find first item for which "!pred"*/ [x](const CurvePoint& p) { return p.x <= x; }); + if (it == samples_.begin()) + return std::nullopt; + --it; //bound! + return *it; + } + + std::optional getGreaterEq(double x) const override + { + //find first item >= x + const auto it = std::partition_point(samples_.begin(), samples_.end(), + /*find first item for which "!pred"*/ [x](const CurvePoint& p) { return p.x < x; }); + if (it != samples_.end()) + return *it; + + //--------- add artifical last sample value -------- + if (!samples_.empty() && x <= lastSample_.x) + return lastSample_; + //-------------------------------------------------- + return std::nullopt; + } + + RingBuffer samples_; //x: monotonously ascending with time! + CurvePoint lastSample_; //artificial record after end of samples to visualize current time! +}; + + +class CurveDataEstimate : public CurveData +{ +public: + void setValue(double x1, double x2, double y1, double y2) { x1_ = x1; x2_ = x2; y1_ = y1; y2_ = y2; } + void setTotalTime(double x2) { x2_ = x2; } + double getTotalTime() const { return x2_; } + +private: + std::pair getRangeX() const override { return {x1_, x2_}; } + + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override + { + return + { + {x1_, y1_}, + {x2_, y2_}, + }; + } + + double x1_ = 0; //elapsed time [s] + double x2_ = 0; //total time [s] (estimated) + double y1_ = 0; //items/bytes processed + double y2_ = 0; //items/bytes total +}; + + +class CurveDataTimeMarker : public CurveData +{ +public: + void setValue(double x, double y) { x_ = x; y_ = y; } + void setTime(double x) { x_ = x; } + +private: + std::pair getRangeX() const override { return {x_, x_}; } + + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override + { + return + { + {x_, y_}, + {x_, 0 }, + }; + } + + double x_ = 0; //time [s] + double y_ = 0; //items/bytes +}; + + +const double stretchDefaultBlockSize = 1.4; //enlarge block default size + + +struct LabelFormatterBytes : public LabelFormatter +{ + double getOptimalBlockSize(double bytesProposed) const override + { + bytesProposed *= stretchDefaultBlockSize; //enlarge block default size + + if (bytesProposed <= 1) //never smaller than 1 byte + return 1; + + //round to next number which is a convenient to read block size + const double k = std::floor(std::log(bytesProposed) / std::numbers::ln2); + const double e = std::pow(2.0, k); + if (numeric::isNull(e)) + return 0; + const double a = bytesProposed / e; //bytesProposed = a * 2^k with a in [1, 2) + assert(1 <= a && a < 2); + const double steps[] = {1, 2}; + return e * numeric::roundToGrid(a, std::begin(steps), std::end(steps)); + } + + wxString formatText(double value, double optimalBlockSize) const override { return formatFilesizeShort(static_cast(value)); } +}; + + +struct LabelFormatterItemCount : public LabelFormatter +{ + double getOptimalBlockSize(double itemsProposed) const override + { + itemsProposed *= stretchDefaultBlockSize; //enlarge block default size + + const double steps[] = {1, 2, 5, 10}; + if (itemsProposed <= 10) + return numeric::roundToGrid(itemsProposed, std::begin(steps), std::end(steps)); //like nextNiceNumber(), but without the 2.5 step! + return nextNiceNumber(itemsProposed); + } + + wxString formatText(double value, double optimalBlockSize) const override + { + return formatNumber(std::round(value)); //not enough room for a "%x items" representation + } +}; + + +struct LabelFormatterTimeElapsed : public LabelFormatter +{ + double getOptimalBlockSize(double secProposed) const override + { + //5 sec minimum block size + const double stepsSec[] = {5, 10, 20, 30, 60}; //nice numbers for seconds + if (secProposed <= 60) + return numeric::roundToGrid(secProposed, std::begin(stepsSec), std::end(stepsSec)); + + const double stepsMin[] = {1, 2, 5, 10, 15, 20, 30, 60}; //nice numbers for minutes + if (secProposed <= 3600) + return 60 * numeric::roundToGrid(secProposed / 60, std::begin(stepsMin), std::end(stepsMin)); + + if (secProposed <= 3600 * 24) + return 3600 * nextNiceNumber(secProposed / 3600); //round to full hours + + return 24 * 3600 * nextNiceNumber(secProposed / (24 * 3600)); //round to full days + } + + wxString formatText(double timeElapsed, double optimalBlockSize) const override + { + const int64_t timeElapsedSec = std::round(timeElapsed); + if (timeElapsedSec < 60) + return _P("1 sec", "%x sec", timeElapsedSec); + + return utfTo(formatTimeSpan(timeElapsedSec, false /*hourRequired*/)); + } +}; +} + + +template //can be a wxFrame or wxDialog +class SyncProgressDialogImpl : public TopLevelDialog, public SyncProgressDialog +/* we need derivation, not composition: + 1. SyncProgressDialogImpl IS a wxFrame/wxDialog + 2. implement virtual ~wxFrame() + 3. event handling below assumes lifetime is larger-equal than wxFrame's */ +{ +public: + SyncProgressDialogImpl(long style, //wxFrame/wxDialog style + const WindowLayout::Dimensions& dim, + const std::function& userRequestCancel, + const Statistics& syncStat, + wxFrame* parentFrame, + bool showProgress, + bool autoCloseDialog, + const std::vector& jobNames, + time_t syncStartTime, + bool ignoreErrors, + size_t autoRetryCount, + PostSyncAction postSyncAction); + + Result destroy(bool autoClose, bool restoreParentFrame, TaskResult syncResult, const SharedRef& log) override; + + wxWindow* getWindowIfVisible() override { return this->IsShown() ? this : nullptr; } + //workaround macOS bug: if "this" is used as parent window for a modal dialog then this dialog will erroneously un-hide its parent! + + void initNewPhase () override; + void notifyProgressChange() override; + void updateGui () override { updateProgressGui(true /*allowYield*/); } + + bool getOptionIgnoreErrors() const override { return ignoreErrors_; } + void setOptionIgnoreErrors(bool ignoreErrors) override { ignoreErrors_ = ignoreErrors; updateStaticGui(); } + PostSyncAction getAndFreezePostSyncAction() const override + { + pnl_.m_choicePostSyncAction->Disable(); + return enumPostSyncAction_.get(); + } + bool getOptionAutoCloseDialog() const override { return pnl_.m_checkBoxAutoClose->GetValue(); } + + void timerSetStatus(bool active) override + { + if (active) + stopWatch_.resume(); + else + stopWatch_.pause(); + } + + bool timerIsRunning() const override { return !stopWatch_.isPaused(); } + + std::chrono::milliseconds pauseAndGetTotalTime() override + { + stopWatch_.pause(); + return std::chrono::duration_cast(stopWatch_.elapsed()); + } + +private: + void onLocalKeyEvent (wxKeyEvent& event); + void onParentKeyEvent(wxKeyEvent& event); + void onPause (wxCommandEvent& event); + void onCancel (wxCommandEvent& event); + void onClose(wxCloseEvent& event); + void onIconize(wxIconizeEvent& event); + //void onToggleIgnoreErrors(wxCommandEvent& event) { updateStaticGui(); } + + void showSummary(TaskResult syncResult, const SharedRef& log); + + void minimizeToTray(); + void resumeFromSystray(bool userRequested); + + void updateStaticGui(); + void updateProgressGui(bool allowYield); + + void setExternalStatus(const wxString& status, const wxString& progress); //progress may be empty! + + SyncProgressPanelGenerated& pnl_; //wxPanel containing the GUI controls of *this + + const TimeComp syncStartTime_; + const wxString jobName_; + StopWatch stopWatch_; + + wxFrame* parentFrame_; //optional + + const std::function userRequestAbort_; //cancel button or dialog close + + //status variables + const Statistics* syncStat_; //valid only while sync is running + bool paused_ = false; + bool closePressed_ = false; + + //remaining time + SpeedTest remTimeTest_{PERF_WINDOW_REMAINING_TIME}; + SpeedTest speedTest_ {PERF_WINDOW_BYTES_PER_SEC}; + std::chrono::nanoseconds timeLastSpeedEstimate_ = std::chrono::seconds(-100); //used for calculating intervals between collecting perf samples + std::chrono::nanoseconds timeLastGraphTotalUpdate_ = std::chrono::seconds(-100); + + //help calculate total speed + std::chrono::nanoseconds phaseStart_{}; //begin of current phase + + SharedRef curveBytes_ = makeSharedRef(); + SharedRef curveItems_ = makeSharedRef(); + SharedRef curveBytesEstim_ = makeSharedRef(); + SharedRef curveItemsEstim_ = makeSharedRef(); + SharedRef curveBytesTimeNow_ = makeSharedRef(); + SharedRef curveItemsTimeNow_ = makeSharedRef(); + SharedRef curveBytesTimeEstim_ = makeSharedRef(); + SharedRef curveItemsTimeEstim_ = makeSharedRef(); + + const wxColor colorBytesRim_ = enhanceContrast(getColorBytes(), + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 4.5 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text + const wxColor colorItemsRim_ = enhanceContrast(getColorItems(), + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 4.5 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text + const wxColor colorEstimRim_ = enhanceContrast(getColorEstimate(), + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 4.5 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text + const wxColor colorBytesNow_ = enhanceContrast(getColorBytes(), getColorEstimate(), 4.5 /*contrastRatioMin*/); + const wxColor colorItemsNow_ = enhanceContrast(getColorItems(), getColorEstimate(), 4.5 /*contrastRatioMin*/); + + wxString parentTitleBackup_; + std::optional trayIcon_; //optional: if filled all other windows should be hidden and conversely + std::optional taskbar_; + + bool ignoreErrors_ = false; + EnumDescrList enumPostSyncAction_ + { + *pnl_.m_choicePostSyncAction, [this] + { + std::vector::DescrItem> descr; + descr.push_back({PostSyncAction::none, L"", {}}); + if (parentFrame_) //enable EXIT option for gui mode sync + descr.push_back({PostSyncAction::exit, wxControl::RemoveMnemonics(_("E&xit")), {}}); + descr.push_back({PostSyncAction::sleep, _("System: Sleep"), {}}); + descr.push_back({PostSyncAction::shutdown, _("System: Shut down"), {}}); + return descr; + }() + }; +}; + + +template +SyncProgressDialogImpl::SyncProgressDialogImpl(long style, //wxFrame/wxDialog style + const WindowLayout::Dimensions& dim, + const std::function& userRequestCancel, + const Statistics& syncStat, + wxFrame* parentFrame, + bool showProgress, + bool autoCloseDialog, + const std::vector& jobNames, + time_t syncStartTime, + bool ignoreErrors, + size_t autoRetryCount, + PostSyncAction postSyncAction) : + TopLevelDialog(parentFrame, wxID_ANY, wxString(), wxDefaultPosition, wxDefaultSize, style), //title is overwritten anyway in setExternalStatus() + pnl_(*new SyncProgressPanelGenerated(this)), //ownership passed to "this" + syncStartTime_(getLocalTime(syncStartTime)), //returns TimeComp() on error + jobName_([&] +{ + std::wstring tmp; + if (!jobNames.empty()) + { + tmp = jobNames[0]; + std::for_each(jobNames.begin() + 1, jobNames.end(), [&](const std::wstring& jobName) + { tmp += L" + " + jobName; }); + } + return tmp; +} +()), +parentFrame_(parentFrame), +userRequestAbort_(userRequestCancel), +syncStat_(&syncStat) +{ + static_assert(std::is_same_v || + std::is_same_v); + assert((std::is_same_v == !parentFrame)); + //finish construction of this dialog: + this->pnl_.m_panelProgress->SetMinSize({dipToWxsize(550), dipToWxsize(340)}); + + wxBoxSizer* bSizer170 = new wxBoxSizer(wxVERTICAL); + bSizer170->Add(&pnl_, 1, wxEXPAND); + this->SetSizer(bSizer170); //pass ownership + + //lifetime of event sources is subset of this instance's lifetime => no wxEvtHandler::Unbind() needed + this->Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& event) { onClose(event); }); + this->Bind(wxEVT_ICONIZE, [this](wxIconizeEvent& event) { onIconize(event); }); + this->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); + + pnl_.m_buttonClose ->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { closePressed_ = true; }); + pnl_.m_buttonPause ->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onPause(event); }); + pnl_.m_buttonStop ->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { onCancel(event); }); + pnl_.m_bpButtonMinimizeToTray->Bind(wxEVT_COMMAND_BUTTON_CLICKED, [this](wxCommandEvent& event) { minimizeToTray(); }); + + if (parentFrame_) + parentFrame_->Bind(wxEVT_CHAR_HOOK, &SyncProgressDialogImpl::onParentKeyEvent, this); + + + assert(pnl_.m_buttonClose->GetId() == wxID_OK); //we cannot use wxID_CLOSE else ESC key won't work: yet another wxWidgets bug?? + + setRelativeFontSize(*pnl_.m_staticTextPhase, 1.5); + setRelativeFontSize(*pnl_.m_staticTextPercentTotal, 1.5); + + if (parentFrame_) + parentTitleBackup_ = parentFrame_->GetTitle(); //save old title (will be used as progress indicator) + + //pnl.m_animCtrlSyncing->SetAnimation(getResourceAnimation(L"working")); + //pnl.m_animCtrlSyncing->Play(); + + //this->EnableCloseButton(false); //this is NOT honored on OS X or with ALT+F4 on Windows! -> why disable button at all?? + + try //try to get access to Windows 7/Ubuntu taskbar + { + taskbar_.emplace(this); //throw TaskbarNotAvailable + } + catch (const TaskbarNotAvailable&) {} + + //hide until end of process: + pnl_.m_notebookResult ->Hide(); + pnl_.m_buttonClose ->Show(false); + //set std order after button visibility was set + setStandardButtonLayout(*pnl_.bSizerStdButtons, StdButtons().setAffirmative(pnl_.m_buttonPause).setCancel(pnl_.m_buttonStop)); + + setImage(*pnl_.m_bpButtonMinimizeToTray, loadImage("minimize_to_tray")); + + setImage(*pnl_.m_bitmapItemStat, IconBuffer::genericFileIcon(IconBuffer::IconSize::small)); + setImage(*pnl_.m_bitmapTimeStat, loadImage("time", -1 /*maxWidth*/, IconBuffer::getPixSize(IconBuffer::IconSize::small))); + pnl_.m_bitmapTimeStat->SetMinSize({-1, screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small))}); + + setImage(*pnl_.m_bitmapErrors, loadImage("msg_error", dipToScreen(getMenuIconDipSize()))); + setImage(*pnl_.m_bitmapWarnings, loadImage("msg_warning", dipToScreen(getMenuIconDipSize()))); + + setImage(*pnl_.m_bitmapIgnoreErrors, loadImage("error_ignore_active", dipToScreen(getMenuIconDipSize()))); + setImage(*pnl_.m_bitmapRetryErrors, loadImage("error_retry", dipToScreen(getMenuIconDipSize()))); + + //init graph + const int xLabelHeight = this->GetCharHeight() + dipToWxsize(2) /*margin*/; //use same height for both graphs to make sure they stretch evenly + const int yLabelWidth = dipToWxsize(70); + pnl_.m_panelGraphBytes->setAttributes(Graph2D::MainAttributes(). + setLabelX(XLabelPos::top, xLabelHeight, std::make_shared()). + setLabelY(YLabelPos::right, yLabelWidth, std::make_shared()). + setBaseColors(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)). + setSelectionMode(GraphSelMode::none)); + + pnl_.m_panelGraphItems->setAttributes(Graph2D::MainAttributes(). + setLabelX(XLabelPos::bottom, xLabelHeight, std::make_shared()). + setLabelY(YLabelPos::right, yLabelWidth, std::make_shared()). + setBaseColors(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)). + setSelectionMode(GraphSelMode::none)); + + pnl_.m_panelGraphBytes->addCurve(curveBytes_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillCurveArea(getColorBytes()).setColor(colorBytesRim_)); + pnl_.m_panelGraphItems->addCurve(curveItems_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillCurveArea(getColorItems()).setColor(colorItemsRim_)); + + pnl_.m_panelGraphBytes->addCurve(curveBytesEstim_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillCurveArea(getColorEstimate()).setColor(colorEstimRim_)); + pnl_.m_panelGraphItems->addCurve(curveItemsEstim_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(1)).fillCurveArea(getColorEstimate()).setColor(colorEstimRim_)); + + pnl_.m_panelGraphBytes->addCurve(curveBytesTimeNow_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(2)).setColor(colorBytesNow_)); + pnl_.m_panelGraphItems->addCurve(curveItemsTimeNow_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(2)).setColor(colorItemsNow_)); + + pnl_.m_panelGraphBytes->addCurve(curveBytesTimeEstim_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(2)).setColor(colorEstimRim_)); + pnl_.m_panelGraphItems->addCurve(curveItemsTimeEstim_, Graph2D::CurveAttributes().setLineWidth(dipToWxsize(2)).setColor(colorEstimRim_)); + + //graph legend: + const wxSize squareSize{this->GetCharHeight(), this->GetCharHeight()}; + setImage(*pnl_.m_bitmapGraphKeyBytes, rectangleImage({wxsizeToScreen(squareSize.x), wxsizeToScreen(squareSize.y)}, getColorBytes(), colorBytesRim_, dipToScreen(1))); + setImage(*pnl_.m_bitmapGraphKeyItems, rectangleImage({wxsizeToScreen(squareSize.x), wxsizeToScreen(squareSize.y)}, getColorItems(), colorItemsRim_, dipToScreen(1))); + + pnl_.bSizerDynSpace->SetMinSize(yLabelWidth, -1); //ensure item/time stats are nicely centered + + setText(*pnl_.m_staticTextRetryCount, L'(' + formatNumber(autoRetryCount) + MULT_SIGN + L')'); + pnl_.bSizerErrorsRetry->Show(autoRetryCount > 0); + + //allow changing a few options dynamically during sync + ignoreErrors_ = ignoreErrors; + + enumPostSyncAction_.set(postSyncAction); + + pnl_.m_checkBoxAutoClose->SetValue(autoCloseDialog); + + updateStaticGui(); //null-status will be shown while waiting for dir locks + + //make sure that standard height matches ProcessPhase::binaryCompare statistics layout (== largest) + + this->GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + this->Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + pnl_.Layout(); + this->Center(); //call *after* dialog layout update and *before* wxWindow::Show()! + + WindowLayout::setInitial(*this, dim, this->GetSize() /*defaultSize*/); + + pnl_.m_buttonStop->SetDefault(); + + if (showProgress) + { + this->Show(); + //clear gui flicker, remove dummy texts: window must be visible to make this work! + updateProgressGui(true /*allowYield*/); //at least on OS X a real Yield() is required to flush pending GUI updates; Update() is not enough + + setFocusIfActive(*pnl_.m_buttonStop); //don't steal focus when starting in sys-tray! + } + else + minimizeToTray(); +} + + +template +void SyncProgressDialogImpl::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_ESCAPE: + { + wxButton& activeButton = pnl_.m_buttonStop->IsShown() ? *pnl_.m_buttonStop : *pnl_.m_buttonClose; + + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + activeButton.Command(dummy); //simulate click + return; + } + } + + event.Skip(); +} + + +template +void SyncProgressDialogImpl::onParentKeyEvent(wxKeyEvent& event) +{ + //redirect keys from main dialog to progress dialog + switch (event.GetKeyCode()) + { + case WXK_ESCAPE: + this->SetFocus(); + this->onLocalKeyEvent(event); //event will be handled => no event recursion to parent dialog! + return; + } + + event.Skip(); +} + + +template +void SyncProgressDialogImpl::initNewPhase() +{ + updateStaticGui(); //evaluates "syncStat_->currentPhase()" + + //reset graphs (e.g. after binary comparison) + curveBytes_ .ref().clear(); + curveItems_ .ref().clear(); + curveBytesEstim_ .ref().setValue(0, 0, 0, 0); + curveItemsEstim_ .ref().setValue(0, 0, 0, 0); + curveBytesTimeNow_ .ref().setValue(0, 0); + curveItemsTimeNow_ .ref().setValue(0, 0); + curveBytesTimeEstim_.ref().setValue(0, 0); + curveItemsTimeEstim_.ref().setValue(0, 0); + + notifyProgressChange(); //make sure graphs get initial values + + //start new measurement + remTimeTest_.clear(); + speedTest_ .clear(); + timeLastGraphTotalUpdate_ = timeLastSpeedEstimate_ = std::chrono::seconds(-100); //make sure estimate is updated upon next check + phaseStart_ = stopWatch_.elapsed(); + + updateProgressGui(false /*allowYield*/); +} + + +template +void SyncProgressDialogImpl::notifyProgressChange() //noexcept! +{ + if (syncStat_) //sync running + { + const double timeElapsedDouble = std::chrono::duration(stopWatch_.elapsed()).count(); + const ProgressStats stats = syncStat_->getCurrentStats(); + curveBytes_.ref().addSample(timeElapsedDouble, stats.bytes); + curveItems_.ref().addSample(timeElapsedDouble, stats.items); + } +} + + +namespace +{ +} + + +template +void SyncProgressDialogImpl::setExternalStatus(const wxString& status, const wxString& progress) //progress may be empty! +{ + //sys tray: order "top-down": jobname, status, progress + wxString tooltip = L"FreeFileSync"; + if (!jobName_.empty()) + tooltip += SPACED_DASH + jobName_; + + tooltip += L'\n' + status; + + if (!progress.empty()) + tooltip += L' ' + progress; + + //window caption/taskbar; inverse order: progress, status, jobname + wxString title; + if (!progress.empty()) + title += progress + L' '; + + title += status; + + if (!jobName_.empty() && !parentFrame_ /*job name already visible in sync config panel, unlike with batch jobs*/) + title += SPACED_DASH + jobName_; + +#if 0 //why again does start time have to be visible in the title!? + const Zchar* format = [&tc = syncStartTime_] + { + if (const TimeComp& tcNow = getLocalTime(); + tc.day == tcNow.day && + tc.month == tcNow.month && + tc.year == tcNow.year) + return formatTimeTag; + return formatDateTimeTag; + }(); + title += SPACED_DASH + utfTo(formatTime(format, syncStartTime_)); +#endif + //--------------------------------------------------------------------------- + + //systray tooltip, if window is minimized + if (trayIcon_) + trayIcon_->setToolTip(tooltip); + + //top level dialog title also shows in Windows taskbar! + if (parentFrame_) + { + if (parentFrame_->GetTitle() != title) + parentFrame_->SetTitle(title); + } + else if (this->GetTitle() != title) + this->SetTitle(title); +} + + +template +void SyncProgressDialogImpl::updateProgressGui(bool allowYield) +{ + assert(syncStat_); + if (!syncStat_) //sync not running!? + return; + + //normally we don't update the "static" GUI components here, but we have to make an exception + //if sync is cancelled (by user or error handling option) + if (syncStat_->taskCancelled()) + updateStaticGui(); //called more than once after cancel... ok + + + const std::chrono::nanoseconds timeElapsed = stopWatch_.elapsed(); + const double timeElapsedDouble = std::chrono::duration(timeElapsed).count(); + + const int itemsCurrent = syncStat_->getCurrentStats().items; + const int64_t bytesCurrent = syncStat_->getCurrentStats().bytes; + const int itemsTotal = syncStat_->getTotalStats ().items; + const int64_t bytesTotal = syncStat_->getTotalStats ().bytes; + + const bool haveTotalStats = itemsTotal >= 0 || bytesTotal >= 0; + + bool headerLayoutChanged = false; + + //status texts + setText(*pnl_.m_staticTextStatus, replaceCpy(syncStat_->currentStatusText(), L'\n', L' ')); //no layout update for status texts! + + if (!haveTotalStats) + { + //dialog caption, taskbar, systray tooltip + setExternalStatus(getDialogPhaseText(*syncStat_, paused_), formatNumber(itemsCurrent)); //status text may be "paused"! + + //progress indicators + setText(*pnl_.m_staticTextPercentTotal, L"", &headerLayoutChanged); + + if (trayIcon_) trayIcon_->setProgress(1); //100% = fully visible FFS logo + //taskbar_ already set to STATUS_INDETERMINATE by initNewPhase() + } + else + { + //dialog caption, taskbar, systray tooltip + + const double fractionTotal = bytesTotal + itemsTotal == 0 ? 0 : 1.0 * (bytesCurrent + itemsCurrent) / (bytesTotal + itemsTotal); + //add both data + obj-count, to handle "deletion-only" cases + + const std::wstring percentTotal = formatProgressPercent(fractionTotal); + + setExternalStatus(getDialogPhaseText(*syncStat_, paused_), percentTotal); //status text may be "paused"! + + //progress indicators + setText(*pnl_.m_staticTextPercentTotal, L' ' + percentTotal, &headerLayoutChanged); + + if (trayIcon_) trayIcon_->setProgress(fractionTotal); + if (taskbar_ ) taskbar_ ->setProgress(fractionTotal); + + const double timeTotalSecTentative = bytesCurrent == bytesTotal ? timeElapsedDouble : std::max(curveBytesEstim_.ref().getTotalTime(), timeElapsedDouble); + + curveBytesEstim_.ref().setValue(timeElapsedDouble, timeTotalSecTentative, bytesCurrent, bytesTotal); + curveItemsEstim_.ref().setValue(timeElapsedDouble, timeTotalSecTentative, itemsCurrent, itemsTotal); + + //tentatively update total time, may be improved on below: + curveBytesTimeNow_.ref().setValue(timeElapsedDouble, bytesCurrent); + curveItemsTimeNow_.ref().setValue(timeElapsedDouble, itemsCurrent); + + curveBytesTimeEstim_.ref().setValue(timeTotalSecTentative, bytesTotal); + curveItemsTimeEstim_.ref().setValue(timeTotalSecTentative, itemsTotal); + } + + //even though notifyProgressChange() already set the latest data, let's add another sample to have all curves consider "timeNowMs" + //no problem with adding too many records: CurveDataStatistics will remove duplicate entries! + curveBytes_.ref().addSample(timeElapsedDouble, bytesCurrent); + curveItems_.ref().addSample(timeElapsedDouble, itemsCurrent); + + bool layoutChanged = false; //avoid screen flicker by calling layout() only if necessary + auto showIfNeeded = [&](wxWindow& wnd, bool show) + { + if (wnd.IsShown() != show) + { + wnd.Show(show); + layoutChanged = true; + } + }; + + //item and data stats + if (!haveTotalStats) + { + setText(*pnl_.m_staticTextItemsProcessed, formatNumber(itemsCurrent), &layoutChanged); + setText(*pnl_.m_staticTextBytesProcessed, L"", &layoutChanged); + + setText(*pnl_.m_staticTextItemsRemaining, std::wstring(1, EM_DASH), &layoutChanged); + setText(*pnl_.m_staticTextBytesRemaining, L"", &layoutChanged); + } + else + { + setText(*pnl_.m_staticTextItemsProcessed, formatNumber(itemsCurrent), &layoutChanged); + setText(*pnl_.m_staticTextBytesProcessed, L'(' + formatFilesizeShort(bytesCurrent) + L')', &layoutChanged); + + setText(*pnl_.m_staticTextItemsRemaining, formatNumber(itemsTotal - itemsCurrent), &layoutChanged); + setText(*pnl_.m_staticTextBytesRemaining, L'(' + formatFilesizeShort(bytesTotal - bytesCurrent) + L')', &layoutChanged); + //it's possible data remaining becomes shortly negative if last file synced has ADS data and the bytesTotal was not yet corrected! + } + + + //errors and warnings (pop up dynamically) + const Statistics::ErrorStats errorStats = syncStat_->getErrorStats(); + + showIfNeeded(*pnl_.m_staticTextErrors, errorStats.errorCount != 0); + showIfNeeded(*pnl_.m_staticTextWarnings, errorStats.warningCount != 0); + showIfNeeded(*pnl_.m_panelErrorStats, errorStats.errorCount != 0 || errorStats.warningCount != 0); + + if (pnl_.m_panelErrorStats->IsShown()) + { + showIfNeeded(*pnl_.m_bitmapErrors, errorStats.errorCount != 0); + showIfNeeded(*pnl_.m_staticTextErrorCount, errorStats.errorCount != 0); + + if (pnl_.m_staticTextErrorCount->IsShown()) + setText(*pnl_.m_staticTextErrorCount, formatNumber(errorStats.errorCount), &layoutChanged); + + showIfNeeded(*pnl_.m_bitmapWarnings, errorStats.warningCount != 0); + showIfNeeded(*pnl_.m_staticTextWarningCount, errorStats.warningCount != 0); + + if (pnl_.m_staticTextWarningCount->IsShown()) + setText(*pnl_.m_staticTextWarningCount, formatNumber(errorStats.warningCount), &layoutChanged); + } + + //current time elapsed + const int64_t timeElapSec = std::chrono::duration_cast(timeElapsed).count(); + + setText(*pnl_.m_staticTextTimeElapsed, utfTo(formatTimeSpan(timeElapSec, false /*hourRequired*/)), &layoutChanged); + + //remaining time and speed + if (numeric::dist(timeLastSpeedEstimate_, timeElapsed) >= SPEED_ESTIMATE_UPDATE_INTERVAL) + { + timeLastSpeedEstimate_ = timeElapsed; + + if (numeric::dist(phaseStart_, timeElapsed) >= SPEED_ESTIMATE_SAMPLE_SKIP) //discard stats for first second: probably messy + { + remTimeTest_.addSample(timeElapsed, itemsCurrent, bytesCurrent); + speedTest_ .addSample(timeElapsed, itemsCurrent, bytesCurrent); + } + + //current speed -> Win 7 copy uses 1 sec update interval instead + pnl_.m_panelGraphBytes->setAttributes(pnl_.m_panelGraphBytes->getAttributes().setCornerText(speedTest_.getBytesPerSecFmt(), GraphCorner::topL)); + pnl_.m_panelGraphItems->setAttributes(pnl_.m_panelGraphItems->getAttributes().setCornerText(speedTest_.getItemsPerSecFmt(), GraphCorner::topL)); + + //remaining time + if (!haveTotalStats) + { + setText(*pnl_.m_staticTextTimeRemaining, std::wstring(1, EM_DASH), &layoutChanged); + //ignore graphs: should already have been cleared in initNewPhase() + } + else + { + //remaining time: display with relative error of 10% - based on samples taken every 0.5 sec only + //-> call more often than once per second to correctly show last few seconds countdown, but don't call too often to avoid occasional jitter + std::optional remTimeSec = remTimeTest_.getRemainingSec(itemsTotal - itemsCurrent, bytesTotal - bytesCurrent); + setText(*pnl_.m_staticTextTimeRemaining, remTimeSec ? formatRemainingTime(*remTimeSec) : std::wstring(1, EM_DASH), &layoutChanged); + + const double timeRemainingSec = remTimeSec ? *remTimeSec : 0; + const double timeTotalSec = timeElapsedDouble + timeRemainingSec; + //update estimated total time marker only with precision of "20% remaining time" to avoid needless jumping around: + if (numeric::dist(curveBytesEstim_.ref().getTotalTime(), timeTotalSec) > 0.2 * timeRemainingSec) + { + //avoid needless flicker and don't update total time graph too often: + static_assert(std::chrono::duration_cast(GRAPH_TOTAL_TIME_UPDATE_INTERVAL).count() % SPEED_ESTIMATE_UPDATE_INTERVAL.count() == 0); + if (numeric::dist(timeLastGraphTotalUpdate_, timeElapsed) >= GRAPH_TOTAL_TIME_UPDATE_INTERVAL) + { + timeLastGraphTotalUpdate_ = timeElapsed; + + curveBytesEstim_.ref().setTotalTime(timeTotalSec); + curveItemsEstim_.ref().setTotalTime(timeTotalSec); + + curveBytesTimeEstim_.ref().setTime(timeTotalSec); + curveItemsTimeEstim_.ref().setTime(timeTotalSec); + } + } + } + } + + pnl_.m_panelGraphBytes->Refresh(); + pnl_.m_panelGraphItems->Refresh(); + + //adapt layout after content changes above + if (headerLayoutChanged) + pnl_.Layout(); + + if (layoutChanged) + { + pnl_.m_panelProgress->Layout(); + //small statistics panels: + pnl_.m_panelItemStats->Layout(); + pnl_.m_panelTimeStats->Layout(); + if (pnl_.m_panelErrorStats->IsShown()) + pnl_.m_panelErrorStats->Layout(); + } + + + if (allowYield) + { + if (paused_) //support for pause button + { + PauseTimers dummy(*this); + + while (paused_) + { + wxTheApp->Yield(); //receive UI message that ends pause + //*first* refresh GUI (removing flicker) before sleeping! + std::this_thread::sleep_for(UI_UPDATE_INTERVAL); + } + } + else + /* + /|\ + | keep this sequence to ensure one full progress update before entering pause mode! + \|/ + */ + wxTheApp->Yield(); //receive UI message that sets pause status OR forceful termination! + } + else + this->Update(); //don't wait until next idle event (who knows what blocking process comes next?) +} + + +template +void SyncProgressDialogImpl::updateStaticGui() //depends on "syncStat_, paused_" +{ + assert(syncStat_); + if (!syncStat_) + return; + + pnl_.m_staticTextPhase->SetLabelText(getDialogPhaseText(*syncStat_, paused_)); + //pnl_.m_bitmapStatus->SetToolTip(); -> redundant + + const wxImage statusImage = [&] + { + if (paused_) + return loadImage("status_pause"); + + if (syncStat_->taskCancelled()) + return loadImage("result_error"); + + switch (syncStat_->currentPhase()) + { + case ProcessPhase::none: + case ProcessPhase::scan: + return loadImage("status_scanning"); + case ProcessPhase::binaryCompare: + return loadImage("status_binary_compare"); + case ProcessPhase::sync: + return loadImage("status_syncing"); + } + assert(false); + return wxNullImage; + }(); + setImage(*pnl_.m_bitmapStatus, statusImage); + + //show status on Windows 7 taskbar + if (taskbar_) + { + if (paused_) + taskbar_->setStatus(Taskbar::Status::paused); + else + { + const int itemsTotal = syncStat_->getTotalStats().items; + const int64_t bytesTotal = syncStat_->getTotalStats().bytes; + + const bool haveTotalStats = itemsTotal >= 0 || bytesTotal >= 0; + + taskbar_->setStatus(haveTotalStats ? Taskbar::Status::normal : Taskbar::Status::indeterminate); + } + } + + //pause button + pnl_.m_buttonPause->SetLabel(paused_ ? _("&Continue") : _("&Pause")); + + pnl_.bSizerErrorsIgnore->Show(ignoreErrors_); + + pnl_.Layout(); + pnl_.m_panelProgress->Layout(); //for bSizerErrorsIgnore + //this->Refresh(); //a few pixels below the status text need refreshing -> still needed? +} + + +template +void SyncProgressDialogImpl::showSummary(TaskResult syncResult, const SharedRef& log) +{ + assert(syncStat_); + //at the LATEST(!) to prevent access to currentStatusHandler + //enable okay and close events; may be set in this method ONLY + + paused_ = false; //you never know? + + //update numbers one last time (as if sync were still running) + notifyProgressChange(); //make one last graph entry at the *current* time + updateProgressGui(false /*allowYield*/); + //=================================================================================== + + const int itemsProcessed = syncStat_->getCurrentStats().items; + const int64_t bytesProcessed = syncStat_->getCurrentStats().bytes; + const int itemsTotal = syncStat_->getTotalStats ().items; + const int64_t bytesTotal = syncStat_->getTotalStats ().bytes; + + //set overall speed (instead of current speed) + const double timeDelta = std::chrono::duration(stopWatch_.elapsed() - phaseStart_).count(); + //we need to consider "time within current phase" not total "timeElapsed"! + + const wxString overallBytesPerSecond = numeric::isNull(timeDelta) ? std::wstring() : + replaceCpy(_("%x/sec"), L"%x", formatFilesizeShort(std::round(bytesProcessed / timeDelta))); + const wxString overallItemsPerSecond = numeric::isNull(timeDelta) ? std::wstring() : + replaceCpy(_("%x/sec"), L"%x", replaceCpy(_("%x items"), L"%x", formatThreeDigitPrecision(itemsProcessed / timeDelta))); + + pnl_.m_panelGraphBytes->setAttributes(pnl_.m_panelGraphBytes->getAttributes().setCornerText(overallBytesPerSecond, GraphCorner::topL)); + pnl_.m_panelGraphItems->setAttributes(pnl_.m_panelGraphItems->getAttributes().setCornerText(overallItemsPerSecond, GraphCorner::topL)); + + //...if everything was processed successfully + if (itemsTotal >= 0 && bytesTotal >= 0 && //itemsTotal < 0 && bytesTotal < 0 => e.g. cancel during folder comparison + itemsProcessed == itemsTotal && + bytesProcessed == bytesTotal) + { + pnl_.m_staticTextPercentTotal->Hide(); + + pnl_.m_staticTextProcessed ->Hide(); + pnl_.m_staticTextRemaining ->Hide(); + pnl_.m_staticTextItemsRemaining->Hide(); + pnl_.m_staticTextBytesRemaining->Hide(); + pnl_.m_staticTextTimeRemaining ->Hide(); + } + + //generally not interesting anymore (e.g. items > 0 due to skipped errors) + pnl_.m_staticTextTimeRemaining->Hide(); + + const int64_t totalTimeSec = std::chrono::duration_cast(stopWatch_.elapsed()).count(); + pnl_.m_staticTextTimeElapsed->SetLabelText(utfTo(formatTimeSpan(totalTimeSec))); + //hourOptional? -> let's use full precision for max. clarity: https://freefilesync.org/forum/viewtopic.php?t=6308 + + + resumeFromSystray(false /*userRequested*/); //if in tray mode... + + //------- change class state ------- + syncStat_ = nullptr; + //---------------------------------- + + const wxImage statusImage = [&] + { + switch (syncResult) + { + case TaskResult::success: + return loadImage("result_success"); + case TaskResult::warning: + return loadImage("result_warning"); + case TaskResult::error: + case TaskResult::cancelled: + return loadImage("result_error"); + } + assert(false); + return wxNullImage; + }(); + setImage(*pnl_.m_bitmapStatus, statusImage); + + pnl_.m_staticTextPhase->SetLabelText(getSyncResultLabel(syncResult)); + + //pnl_.m_bitmapStatus->SetToolTip(); -> redundant + + //show status on Windows 7 taskbar + if (taskbar_) + switch (syncResult) + { + case TaskResult::success: + taskbar_->setStatus(Taskbar::Status::normal); + break; + + case TaskResult::warning: + taskbar_->setStatus(Taskbar::Status::warning); + break; + + case TaskResult::error: + case TaskResult::cancelled: + taskbar_->setStatus(Taskbar::Status::error); + break; + } + //---------------------------------- + + setExternalStatus(getSyncResultLabel(syncResult), wxString()); + + //this->EnableCloseButton(true); + + pnl_.m_bpButtonMinimizeToTray->Hide(); + pnl_.m_buttonStop->Disable(); + pnl_.m_buttonStop->Hide(); + pnl_.m_buttonPause->Disable(); + pnl_.m_buttonPause->Hide(); + pnl_.m_buttonClose->Show(); + pnl_.m_buttonClose->Enable(); + + pnl_.bSizerProgressFooter->Show(false); + + if (!parentFrame_) //hide checkbox for batch mode sync (where value won't be retrieved after close) + pnl_.m_checkBoxAutoClose->Hide(); + + //set std order after button visibility was set + setStandardButtonLayout(*pnl_.bSizerStdButtons, StdButtons().setAffirmative(pnl_.m_buttonClose)); + + //hide current operation status + pnl_.bSizerStatusText->Show(false); + + pnl_.m_staticlineFooter->Hide(); //win: m_notebookResult already has a window frame + + //------------------------------------------------------------- + + pnl_.m_notebookResult->SetPadding(wxSize(dipToWxsize(2), 0)); //height cannot be changed + + //1. re-arrange graph into results listbook + const size_t pagePosProgress = 0; + const size_t pagePosLog = 1; + + [[maybe_unused]] const bool wasDetached = pnl_.bSizerRoot->Detach(pnl_.m_panelProgress); + assert(wasDetached); + pnl_.m_panelProgress->Reparent(pnl_.m_notebookResult); + pnl_.m_notebookResult->AddPage(pnl_.m_panelProgress, _("Progress"), true /*bSelect*/); + + //2. log file + assert(pnl_.m_notebookResult->GetPageCount() == 1); + LogPanel* logPanel = new LogPanel(pnl_.m_notebookResult); //owned by m_notebookResult + logPanel->setLog(log.ptr()); + pnl_.m_notebookResult->AddPage(logPanel, _("Log"), false /*bSelect*/); + + //show log instead of graph if errors occurred! (not required for ignored warnings) + const ErrorLogStats logCount = getStats(log.ref()); + if (logCount.errors > 0) + pnl_.m_notebookResult->ChangeSelection(pagePosLog); + + //fill image list to cope with wxNotebook image setting design desaster... + const int imgListSize = dipToWxsize(16); //also required by GTK => don't use getMenuIconDipSize() + auto imgList = std::make_unique(imgListSize, imgListSize); + + imgList->Add(toScaledBitmap(loadImage("progress", wxsizeToScreen(imgListSize)))); + imgList->Add(toScaledBitmap(loadImage("log_file", wxsizeToScreen(imgListSize)))); + + pnl_.m_notebookResult->AssignImageList(imgList.release()); //pass ownership + + pnl_.m_notebookResult->SetPageImage(pagePosProgress, pagePosProgress); + pnl_.m_notebookResult->SetPageImage(pagePosLog, pagePosLog); + + //Caveat: we need "Show()" *after" the above wxNotebook::ChangeSelection() to get the correct selection on Linux + pnl_.m_notebookResult->Show(); + + //GetSizer()->SetSizeHints(this); //~=Fit() //not a good idea: will shrink even if window is maximized or was enlarged by the user + pnl_.Layout(); + + pnl_.m_panelProgress->Layout(); + //small statistics panels: + pnl_.m_panelItemStats->Layout(); + pnl_.m_panelTimeStats->Layout(); + if (pnl_.m_panelErrorStats->IsShown()) + pnl_.m_panelErrorStats->Layout(); + + //this->Raise(); -> don't! user may be watching a movie in the meantime ;) + + pnl_.m_buttonClose->SetDefault(); + setFocusIfActive(*pnl_.m_buttonClose); +} + + +template +auto SyncProgressDialogImpl::destroy(bool autoClose, bool restoreParentFrame, TaskResult syncResult, const SharedRef& log) -> Result +{ + assert(stopWatch_.isPaused()); //why wasn't pauseAndGetTotalTime() called? + + if (autoClose) + { + assert(syncStat_); + + //ATTENTION: dialog may live a little longer, so watch callbacks! + //e.g. wxGTK calls onIconize after wxWindow::Close() (better not ask why) and before physical destruction! => indirectly calls updateStaticGui(), which reads syncStat_!!! + syncStat_ = nullptr; + } + else + { + showSummary(syncResult, log); + + //wait until user closes the dialog by pressing "Close" + while (!closePressed_) + { + wxTheApp->Yield(); //refresh GUI *first* before sleeping! (remove flicker) + std::this_thread::sleep_for(UI_UPDATE_INTERVAL); + } + restoreParentFrame = true; + } + //------------------------------------------------------------------------ + + if (parentFrame_) + { + [[maybe_unused]] bool ubOk = parentFrame_->Unbind(wxEVT_CHAR_HOOK, &SyncProgressDialogImpl::onParentKeyEvent, this); + assert(ubOk); + + parentFrame_->SetTitle(parentTitleBackup_); //restore title text + + if (restoreParentFrame) + { + //make sure main dialog is shown again if still "minimized to systray"! + parentFrame_->Show(); + //if (parentFrame_->IsIconized()) //caveat: if window is maximized calling Iconize(false) will erroneously un-maximize! + // parentFrame_->Iconize(false); + } + } + //else: don't call transformAppType(): consider "switch to main dialog" option during silent batch run + + //------------------------------------------------------------------------ + const bool autoCloseDialog = getOptionAutoCloseDialog(); + + const WindowLayout::Dimensions dims = WindowLayout::getBeforeClose(*this); + + this->Destroy(); //wxWidgets macOS: simple "delete"!!!!!!! + + return {autoCloseDialog, dims}; +} + + +template +void SyncProgressDialogImpl::onClose(wxCloseEvent& event) +{ + assert(event.CanVeto()); //this better be true: if "this" is parent of a modal error dialog, there is NO way (in hell) we allow destruction here!!! + //wxEVT_END_SESSION is already handled by application.cpp::onSystemShutdown()! + event.Veto(); + + closePressed_ = true; //"temporary" auto-close: preempt closing results dialog + + if (syncStat_) + { + //user closing dialog => cancel sync + auto-close dialog + userRequestAbort_(); + + paused_ = false; //[!] we could be pausing here! + updateStaticGui(); //update status + pause button + } +} + + +template +void SyncProgressDialogImpl::onCancel(wxCommandEvent& event) +{ + userRequestAbort_(); + + paused_ = false; + updateStaticGui(); //update status + pause button + //no UI-update here to avoid cascaded Yield()-call! +} + + +template +void SyncProgressDialogImpl::onPause(wxCommandEvent& event) +{ + paused_ = !paused_; + updateStaticGui(); //update status + pause button +} + + +template +void SyncProgressDialogImpl::onIconize(wxIconizeEvent& event) +{ + /* propagate progress dialog minimize/maximize to parent + ----------------------------------------------------- + Fedora/Debian/Ubuntu: + - wxDialog cannot be minimized + - worse, wxGTK sends stray iconize events *after* wxDialog::Destroy() + - worse, on Fedora an iconize event is issued directly after calling Close() + - worse, even wxDialog::Hide() causes iconize event! + => nothing to do + SUSE: + - wxDialog can be minimized (it just vanishes!) and in general also minimizes parent: except for our progress wxDialog!!! + - worse, wxDialog::Hide() causes iconize event + - probably the same issues with stray iconize events like Fedora/Debian/Ubuntu + - minimize button is always shown, even if wxMINIMIZE_BOX is omitted! + => nothing to do + macOS: + - wxDialog can be minimized but does not also minimize parent + => propagate event to parent + Windows: + - wxDialog can be minimized but does not also minimize parent + - iconize events only seen for manual minimize + => propagate event to parent */ + event.Skip(); +} + + +template +void SyncProgressDialogImpl::minimizeToTray() +{ + if (!trayIcon_) + { + trayIcon_.emplace([this] { this->resumeFromSystray(true /*userRequested*/); }); //FfsTrayIcon lifetime is a subset of "this"'s lifetime! + //we may destroy FfsTrayIcon even while in the FfsTrayIcon callback!!!! + + updateProgressGui(false /*allowYield*/); //set tray tooltip + progress: e.g. no updates while paused + + +#warning("need delay for minimize animation to play out?") + //std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + + this->Hide(); + if (parentFrame_) + parentFrame_->Hide(); + } +} + + +template +void SyncProgressDialogImpl::resumeFromSystray(bool userRequested) +{ + if (trayIcon_) + { + trayIcon_.reset(); + + if (parentFrame_) + parentFrame_->Show(); + this->Show(); + + updateStaticGui(); //restore Windows 7 task bar status (e.g. required in pause mode) + updateProgressGui(false /*allowYield*/); //restore Windows 7 task bar progress (e.g. required in pause mode) + + if (userRequested) + { + if (parentFrame_) + parentFrame_->Raise(); + this->Raise(); + pnl_.m_bpButtonMinimizeToTray->SetFocus(); + } + } +} + +//######################################################################################## + +SyncProgressDialog* SyncProgressDialog::create(const WindowLayout::Dimensions& dim, + const std::function& userRequestCancel, + const Statistics& syncStat, + wxFrame* parentWindow, //may be nullptr + bool showProgress, + bool autoCloseDialog, + const std::vector& jobNames, + time_t syncStartTime, + bool ignoreErrors, + size_t autoRetryCount, + PostSyncAction postSyncAction) +{ + if (parentWindow) //FFS GUI sync + return new SyncProgressDialogImpl(wxDEFAULT_DIALOG_STYLE | wxMAXIMIZE_BOX | wxMINIMIZE_BOX | wxRESIZE_BORDER, + dim, userRequestCancel, syncStat, parentWindow, showProgress, + autoCloseDialog, jobNames, syncStartTime, ignoreErrors, autoRetryCount, postSyncAction); + else //FFS batch job + { + auto dlg = new SyncProgressDialogImpl(wxDEFAULT_FRAME_STYLE, + dim, userRequestCancel, syncStat, parentWindow, showProgress, + autoCloseDialog, jobNames, syncStartTime, ignoreErrors, autoRetryCount, postSyncAction); + dlg->SetIcon(getFfsIcon()); //only top level windows should have an icon + return dlg; + } +} diff --git a/FreeFileSync/Source/ui/progress_indicator.h b/FreeFileSync/Source/ui/progress_indicator.h new file mode 100644 index 0000000..5fae12f --- /dev/null +++ b/FreeFileSync/Source/ui/progress_indicator.h @@ -0,0 +1,110 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PROGRESS_INDICATOR_H_8037493452348 +#define PROGRESS_INDICATOR_H_8037493452348 + +#include +#include +#include "wx+/window_tools.h" +#include "../status_handler.h" + + +namespace fff +{ +class CompareProgressPanel +{ +public: + explicit CompareProgressPanel(wxFrame& parentWindow); //CompareProgressPanel will be owned by parentWindow! + + wxWindow* getAsWindow(); //convenience! don't abuse! + + void init(const Statistics& syncStat, bool ignoreErrors, size_t autoRetryCount); //begin of sync: make visible, set pointer to "syncStat", initialize all status values + void teardown(); //end of sync: hide again, clear pointer to "syncStat" + + void initNewPhase(); //call after "StatusHandler::initNewPhase" + + void updateGui(); + + //allow changing a few options dynamically during sync + bool getOptionIgnoreErrors() const; + void setOptionIgnoreErrors(bool ignoreError); + + void timerSetStatus(bool active); //start/stop all internal timers! + bool timerIsRunning() const; + std::chrono::milliseconds pauseAndGetTotalTime(); + +private: + class Impl; + Impl* const pimpl_; +}; + + +//StatusHandlerFloatingDialog will internally process Window messages => disable GUI controls to avoid unexpected callbacks! + +enum class PostSyncAction +{ + none, + exit, + sleep, + shutdown +}; + +struct SyncProgressDialog +{ + static SyncProgressDialog* create(const zen::WindowLayout::Dimensions& dim, + const std::function& userRequestCancel, + const Statistics& syncStat, + wxFrame* parentWindow, //may be nullptr + bool showProgress, + bool autoCloseDialog, + const std::vector& jobNames, + time_t syncStartTime, + bool ignoreErrors, + size_t autoRetryCount, + PostSyncAction postSyncAction); + struct Result + { + bool autoCloseDialog; + zen::WindowLayout::Dimensions dim; + }; + virtual Result destroy(bool autoClose, bool restoreParentFrame, TaskResult syncResult, const zen::SharedRef& log) = 0; + //--------------------------------------------------------------------------- + + virtual wxWindow* getWindowIfVisible() = 0; //may be nullptr; don't abuse, use as parent for modal dialogs only! + + virtual void initNewPhase () = 0; //call after "StatusHandler::initNewPhase" + virtual void notifyProgressChange() = 0; //noexcept, required by graph! + virtual void updateGui () = 0; //update GUI and process Window messages + + //allow changing a few options dynamically during sync + virtual bool getOptionIgnoreErrors() const = 0; + virtual void setOptionIgnoreErrors(bool ignoreError) = 0; + virtual PostSyncAction getAndFreezePostSyncAction() const = 0; + virtual bool getOptionAutoCloseDialog() const = 0; + + virtual void timerSetStatus(bool active) = 0; //start/stop all internal timers! + virtual bool timerIsRunning() const = 0; + virtual std::chrono::milliseconds pauseAndGetTotalTime() = 0; + +protected: + ~SyncProgressDialog() {} +}; + + +template +class PauseTimers +{ +public: + explicit PauseTimers(ProgressDlg& ss) : ss_(ss), timerWasRunning_(ss.timerIsRunning()) { ss_.timerSetStatus(false); } + ~PauseTimers() { ss_.timerSetStatus(timerWasRunning_); } //restore previous state: support recursive calls +private: + ProgressDlg& ss_; + const bool timerWasRunning_; +}; +} + +#endif //PROGRESS_INDICATOR_H_8037493452348 diff --git a/FreeFileSync/Source/ui/rename_dlg.cpp b/FreeFileSync/Source/ui/rename_dlg.cpp new file mode 100644 index 0000000..64051a6 --- /dev/null +++ b/FreeFileSync/Source/ui/rename_dlg.cpp @@ -0,0 +1,437 @@ +// ***************************************************************************** +// * 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 "rename_dlg.h" +#include +//#include +#include +#include +#include +#include "gui_generated.h" +#include "../base/multi_rename.h" + + +using namespace zen; +using namespace fff; + + +namespace +{ +enum class ColumnTypeRename +{ + oldName, + newName, +}; + + +class GridDataRename : public GridData +{ +public: + GridDataRename(const std::vector& fileNamesOld, + const SharedRef& renameBuf) : + fileNamesOld_(fileNamesOld), + renameBuf_(renameBuf) {} + + bool updatePreview(std::wstring_view renamePhrase, size_t selectBegin, size_t selectEnd) //support polling + { + //normalize input: trim and adapt selection + { + const std::wstring_view renamePhraseTrm = trimCpy(renamePhrase); + + if (selectBegin <= selectEnd && selectEnd <= renamePhrase.size()) + { + selectBegin -= std::min(selectBegin, makeUnsigned(renamePhraseTrm.data() - renamePhrase.data())); //careful: + selectEnd -= std::min(selectEnd, makeUnsigned(renamePhraseTrm.data() - renamePhrase.data())); //avoid underflow + + selectBegin = std::min(selectBegin, renamePhraseTrm.size()); + selectEnd = std::min(selectEnd, renamePhraseTrm.size()); + } + else + { + assert(false); + selectBegin = selectEnd = 0; + } + + renamePhrase = renamePhraseTrm; + } + + auto currentPhrase = std::make_tuple(renamePhrase, selectBegin, selectEnd); + if (currentPhrase != lastUsedPhrase_) //only update when needed + { + lastUsedPhrase_ = currentPhrase; + + fileNamesNewSelectBefore_ = resolvePlaceholderPhrase(renamePhrase.substr(0, selectBegin), renameBuf_.ref()); + fileNamesNewSelected_ = resolvePlaceholderPhrase(renamePhrase.substr(selectBegin, selectEnd - selectBegin), renameBuf_.ref()); + fileNamesNewSelectAfter_ = resolvePlaceholderPhrase(renamePhrase.substr(selectEnd), renameBuf_.ref()); + + assert(fileNamesNewSelectBefore_.size() == fileNamesOld_.size()); + assert(fileNamesNewSelected_ .size() == fileNamesOld_.size()); + assert(fileNamesNewSelectAfter_ .size() == fileNamesOld_.size()); + + previewChangeTime_ = std::chrono::steady_clock::now(); + return true; + } + else + return false; + } + + std::vector getNewNames() const { return resolvePlaceholderPhrase(std::get(lastUsedPhrase_), renameBuf_.ref()); } + + size_t getRowCount() const override { return fileNamesOld_.size(); } + + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (row < fileNamesOld_.size()) + switch (static_cast(colType)) + { + case ColumnTypeRename::oldName: + return fileNamesOld_[row]; + + case ColumnTypeRename::newName: + return fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row] + fileNamesNewSelectAfter_[row]; + } + return std::wstring(); + } + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + //clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default + } + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + //draw border on right + clearArea(dc, {rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height}, wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + wxRect rectTmp = rect; + rectTmp.x += getColumnGapLeft(); + rectTmp.width -= getColumnGapLeft() + dipToWxsize(1); + + switch (static_cast(colType)) + { + case ColumnTypeRename::oldName: + drawCellText(dc, rectTmp, getValue(row, colType)); + break; + + case ColumnTypeRename::newName: + { + const std::wstring& fulltext = fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row] + fileNamesNewSelectAfter_[row]; + //macOS: drawCellText() is not accurate for partial strings => draw full text + calculate deltas: + const wxSize extentBefore = dc.GetTextExtent(fileNamesNewSelectBefore_[row]); + const wxSize extentFullText = dc.GetTextExtent(fulltext); + + drawCellText(dc, rectTmp, fulltext, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &extentFullText); + + if (!fileNamesNewSelected_[row].empty()) //highlight text selection: + { + const wxSize extentBeforeAndSel = dc.GetTextExtent(fileNamesNewSelectBefore_[row] + fileNamesNewSelected_[row]); + + const wxRect rectSel{rectTmp.x + extentBefore.GetWidth(), + rectTmp.y, + extentBeforeAndSel.GetWidth() - extentBefore.GetWidth(), + rectTmp.height}; + + clearArea(dc, rectSel, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT)); + + RecursiveDcClipper dummy(dc, rectSel); + + wxDCTextColourChanger textColor(dc, wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHTTEXT)); //accessibility: always set both foreground AND background colors! + drawCellText(dc, rectTmp, fulltext, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, &extentFullText); //draw everything: might fix partially cleared character + } + else //draw input cursor + if (showCursor_ || std::chrono::steady_clock::now() < previewChangeTime_ + std::chrono::milliseconds(400)) + { + const wxRect rectLine{rectTmp.x + extentBefore.GetWidth(), + rectTmp.y, + dipToWxsize(1), + rectTmp.height}; + clearArea(dc, rectLine, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + } + } + break; + } + } + + int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override + { + // -> synchronize renderCell() <-> getBestSize() + return dc.GetTextExtent(getValue(row, colType)).GetWidth() + 2 * getColumnGapLeft() + dipToWxsize(1); //gap on left and right side + border + } + + std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override { return std::wstring(); } + + std::wstring getColumnLabel(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeRename::oldName: + return _("Old name"); + case ColumnTypeRename::newName: + return _("New name"); + } + //assert(false); may be ColumnType::none + return std::wstring(); + } + + void setCursorShown(bool show) { showCursor_ = show; } + +private: + const std::vector fileNamesOld_; + + std::tuple lastUsedPhrase_; + + std::vector fileNamesNewSelectBefore_{fileNamesOld_.size()}; + std::vector fileNamesNewSelected_ {fileNamesOld_.size()}; + std::vector fileNamesNewSelectAfter_ {fileNamesOld_.size()}; + + bool showCursor_ = false; + std::chrono::steady_clock::time_point previewChangeTime_ = std::chrono::steady_clock::now(); + + const SharedRef renameBuf_; +}; + + +class RenameDialog : public RenameDlgGenerated +{ +public: + RenameDialog(wxWindow* parent, const std::vector& fileNamesOld, std::vector& fileNamesNew); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onLocalKeyEvent(wxKeyEvent& event); + + void updatePreview() + { + const std::wstring renamePhrase = copyStringTo(m_textCtrlNewName->GetValue()); + + long selectBegin = 0; + long selectEnd = 0; + m_textCtrlNewName->GetSelection(&selectBegin, &selectEnd); + + assert(selectBegin == m_textCtrlNewName->GetInsertionPoint()); //apparently this is true for all Win/macOS/Linux + + if (getDataView().updatePreview(renamePhrase, selectBegin, selectEnd)) + m_gridRenamePreview->Refresh(); + } + + GridDataRename& getDataView() + { + if (auto* prov = dynamic_cast(m_gridRenamePreview->getDataProvider())) + return *prov; + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] m_gridRenamePreview was not initialized."); + } + + wxTimer timer_; //poll for text selection changes + wxTimer timerCursor_; //second timer just for cursor blinking + + //output-only parameters: + std::vector& fileNamesNewOut_; +}; + + +RenameDialog::RenameDialog(wxWindow* parent, + const std::vector& fileNamesOld, + std::vector& fileNamesNew) : + RenameDlgGenerated(parent), + fileNamesNewOut_(fileNamesNew) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + setMainInstructionFont(*m_staticTextHeader); + + setImage(*m_bitmapRename, loadImage("rename")); + + m_staticTextHeader->SetLabelText(_P("Do you really want to rename the following item?", + "Do you really want to rename the following %x items?", fileNamesOld.size())); + + m_buttonOK->SetLabelText(wxControl::RemoveMnemonics(_("&Rename"))); //no access key needed: use ENTER! + + auto [renamePhrase, renameBuf] = getPlaceholderPhrase(fileNamesOld); + const std::wstring renamePhraseOld = renamePhrase; //save copy *before* trimming + + trim(renamePhrase); //leading/trailing whitespace makes no sense for file names + + + std::wstring placeholders; + for (const wchar_t c : renamePhrase) + if (isRenamePlaceholderChar(c)) + placeholders += c; + + m_staticTextPlaceholderDescription->SetLabelText(placeholders + L": " + m_staticTextPlaceholderDescription->GetLabelText()); + + //----------------------------------------------------------- + m_gridRenamePreview->setDataProvider(std::make_shared(fileNamesOld, renameBuf)); + m_gridRenamePreview->showRowLabel(false); + m_gridRenamePreview->setRowHeight(m_gridRenamePreview->getMainWin().GetCharHeight() + dipToWxsize(1) /*extra space*/); + + //----------------------------------------------------------- + if (fileNamesOld.size() > 1) //calculate reasonable default preview grid size + { + //quick and dirty: get (likely) maximum string width while avoiding excessive wxDC::GetTextExtent() calls + std::vector longNames = fileNamesOld; + if (longNames.size() > 10) //find the 10 longest strings according to std::wstring::size() + { + std::nth_element(longNames.begin(), longNames.begin() + 9, longNames.end(), + /**/[](const std::wstring& lhs, const std::wstring& rhs) { return lhs.size() > rhs.size(); }); //complexity: O(n) + longNames.resize(10); + } + + wxInfoDC infoDc(m_gridRenamePreview); + infoDc.SetFont(m_gridRenamePreview->GetFont()); //the font parameter of GetTextExtent() is not evaluated on OS X, wxWidgets 2.9.5, so apply it to the DC directly! + + int maxStringWidth = 0; + for (const std::wstring& str : longNames) + maxStringWidth = std::max(maxStringWidth, infoDc.GetTextExtent(str).GetWidth()); + + const int defaultColWidthOld = maxStringWidth + 2 * GridData::getColumnGapLeft() + dipToWxsize(1) /*border*/ + dipToWxsize(10) /*extra space: less cramped*/; + const int defaultColWidthNew = maxStringWidth + 2 * GridData::getColumnGapLeft() + dipToWxsize(1) /*border*/ + dipToWxsize(50) /*extra space: for longer new name*/; + + m_gridRenamePreview->setColumnConfig( + { + {static_cast(ColumnTypeRename::oldName), defaultColWidthOld, 0, true}, //"old name" is fixed => + {static_cast(ColumnTypeRename::newName), -defaultColWidthOld, 1, true}, //stretch "new name" only + }); + + const int previewDefaultWidth = std::min(defaultColWidthOld + defaultColWidthNew + dipToWxsize(25), //scroll bar width (guess!) + dipToWxsize(900)); + + const int previewDefaultHeight = std::min(m_gridRenamePreview->getColumnLabelHeight() + + static_cast(fileNamesOld.size()) * m_gridRenamePreview->getRowHeight(), + dipToWxsize(400)); + + m_gridRenamePreview->SetMinSize({previewDefaultWidth, previewDefaultHeight}); + + m_staticTextHeader->Wrap(std::max(previewDefaultWidth, dipToWxsize(400))); //needs to be reapplied after SetLabel() + } + else //renaming single file + { + m_gridRenamePreview ->Hide(); + m_staticlinePreview ->Hide(); + m_staticTextPlaceholderDescription->Hide(); + + wxInfoDC infoDc(m_textCtrlNewName); + infoDc.SetFont(m_textCtrlNewName->GetFont()); //the font parameter of GetTextExtent() is not evaluated on OS X, wxWidgets 2.9.5, so apply it to the DC directly! + + const int textCtrlDefaultWidth = std::min(infoDc.GetTextExtent(renamePhrase).GetWidth() + 20 /*borders (non-DIP!)*/ + + dipToWxsize(50) /*extra space: for longer new name*/, + dipToWxsize(900)); + m_textCtrlNewName->SetMinSize({textCtrlDefaultWidth, -1}); + + m_staticTextHeader->Wrap(std::max(textCtrlDefaultWidth, dipToWxsize(400))); //needs to be reapplied after SetLabel() + } + //----------------------------------------------------------- + + m_textCtrlNewName->Bind(wxEVT_COMMAND_TEXT_UPDATED, [this, renamePhraseOld, needPreview = fileNamesOld.size() > 1](wxCommandEvent& event) + { + if (needPreview) + updatePreview(); //(almost?) redundant, considering timer_ is doing the same!? + + //disable OK button, until user changes input + const std::wstring renamePhraseNew = trimCpy(copyStringTo(m_textCtrlNewName->GetValue())); + m_buttonOK->Enable(!renamePhraseNew.empty() && renamePhraseNew != renamePhraseOld); //supports polling + }); + + wxTextValidator inputValidator(wxFILTER_EXCLUDE_CHAR_LIST); + + inputValidator.SetCharExcludes(L"/\\"); //let's not silently forbid "fileNameForbiddenChars", but let it fail explicitly! + m_textCtrlNewName->SetValidator(inputValidator); + m_textCtrlNewName->SetValue(renamePhrase); //SetValue() generates a text change event, unlike ChangeValue() + + + if (fileNamesOld.size() > 1) + { + timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { updatePreview(); }); //poll to detect text selection changes + timer_.Start(100 /*unit: [ms]*/); + + timerCursor_.Bind(wxEVT_TIMER, [this, show = true](wxTimerEvent& event) mutable //trigger blinking cursor + { + getDataView().setCursorShown(show); + m_gridRenamePreview->Refresh(); + show = !show; + }); + timerCursor_.Start(wxCaret::GetBlinkTime() /*unit: [ms]*/); + } + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + //----------------------------------------------------------- + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_textCtrlNewName->SetFocus(); //[!] required *before* SetSelection() on wxGTK + //----------------------------------------------------------- + + //macOS issue: the *whole* text control is selected by default, unless we SetSelection() *after* wxDialog::Show()! + CallAfter([this, nameCount = fileNamesOld.size(), renamePhrase = renamePhrase] + { + //pre-select name part that user will most likely change + //assert(contains(renamePhrase, L'\u2776') == nameCount > 1); -> fails, if user selects same item on left and right grid + auto it = std::find_if(renamePhrase.begin(), renamePhrase.end(), isRenamePlaceholderChar); //❶ + if (it == renamePhrase.end()) + it = findLast(renamePhrase.begin(), renamePhrase.end(), L'.'); //select everything except file extension + + if (it == renamePhrase.end()) + m_textCtrlNewName->SelectAll(); + else + { + const long selectEnd = static_cast(it - renamePhrase.begin()); + m_textCtrlNewName->SetSelection(0, selectEnd); + } + + updatePreview(); //consider new selection + }); +} + + +void RenameDialog::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void RenameDialog::onOkay(wxCommandEvent& event) +{ + updatePreview(); //ensure GridDataRename::getNewNames() is current + + fileNamesNewOut_.clear(); + for (const std::wstring& newName : getDataView().getNewNames()) + fileNamesNewOut_.push_back(utfTo(newName)); + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + + +ConfirmationButton fff::showRenameDialog(wxWindow* parent, + const std::vector& fileNamesOld, + std::vector& fileNamesNew) +{ + std::vector namesOld; + for (const Zstring& name : fileNamesOld) + namesOld.push_back(utfTo(getUnicodeNormalForm(name))); //[!] don't care about Normalization form differences! + + RenameDialog dlg(parent, namesOld, fileNamesNew); + return static_cast(dlg.ShowModal()); +} diff --git a/FreeFileSync/Source/ui/rename_dlg.h b/FreeFileSync/Source/ui/rename_dlg.h new file mode 100644 index 0000000..b6be9cd --- /dev/null +++ b/FreeFileSync/Source/ui/rename_dlg.h @@ -0,0 +1,20 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RENAME_DLG_H_23487982347324 +#define RENAME_DLG_H_23487982347324 + +#include + + +namespace fff +{ +zen::ConfirmationButton showRenameDialog(wxWindow* parent, + const std::vector& fileNamesOld, + std::vector& fileNamesNew); +} + +#endif //RENAME_DLG_H_23487982347324 diff --git a/FreeFileSync/Source/ui/search_grid.cpp b/FreeFileSync/Source/ui/search_grid.cpp new file mode 100644 index 0000000..e17238e --- /dev/null +++ b/FreeFileSync/Source/ui/search_grid.cpp @@ -0,0 +1,145 @@ +// ***************************************************************************** +// * 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 "search_grid.h" +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +template +void normalizeForSearch(std::wstring& str); + +template <> inline +void normalizeForSearch(std::wstring& str) +{ + for (wchar_t& c : str) + if (!isAsciiChar(c)) + { + str = utfTo(getUnicodeNormalForm(utfTo(str))); + replace(str, L'\\', L'/'); + return; + } + else if (c == L'\\') + c = L'/'; +} + +template <> inline +void normalizeForSearch(std::wstring& str) +{ + for (wchar_t& c : str) + if (!isAsciiChar(c)) + { + str = utfTo(getUpperCase(utfTo(str))); //getUnicodeNormalForm() is implied by getUpperCase() + replace(str, L'\\', L'/'); + return; + } + else if (c == L'\\') + c = L'/'; + else + c = asciiToUpper(c); //caveat, decomposed Unicode form! c might be followed by combining character! Still, should be fine... +} + + +template +class MatchFound +{ +public: + explicit MatchFound(const std::wstring& textToFind) : textToFind_(textToFind) + { + normalizeForSearch(textToFind_); + } + + bool operator()(std::wstring&& phrase) const + { + normalizeForSearch(phrase); + return contains(phrase, textToFind_); + } + +private: + std::wstring textToFind_; +}; + +//########################################################################################### + +template +ptrdiff_t findRow(const Grid& grid, //return -1 if no matching row found + const std::wstring& searchString, + bool searchAscending, + size_t rowFirst, //range to search: + size_t rowLast) // [rowFirst, rowLast) +{ + if (auto prov = grid.getDataProvider()) + { + std::vector colAttr = grid.getColumnConfig(); + std::erase_if(colAttr, [](const Grid::ColAttributes& ca) { return !ca.visible; }); + if (!colAttr.empty()) + { + const MatchFound matchFound(searchString); + + if (searchAscending) + { + for (size_t row = rowFirst; row < rowLast; ++row) + for (const Grid::ColAttributes& ca : colAttr) + if (matchFound(prov->getValue(row, ca.type))) + return row; + } + else + for (size_t row = rowLast; row-- > rowFirst;) + for (const Grid::ColAttributes& ca : colAttr) + if (matchFound(prov->getValue(row, ca.type))) + return row; + } + } + return -1; +} +} + + +std::pair fff::findGridMatch(const Grid& grid1, const Grid& grid2, const std::wstring& searchString, bool respectCase, bool searchAscending) +{ + //PERF_START + + const size_t rowCount1 = grid1.getRowCount(); + const size_t rowCount2 = grid2.getRowCount(); + + size_t cursorRow1 = grid1.getGridCursor(); + if (cursorRow1 >= rowCount1) + cursorRow1 = 0; + + std::pair result(nullptr, -1); + + auto finishSearch = [&](const Grid& grid, size_t rowFirst, size_t rowLast) + { + const ptrdiff_t targetRow = respectCase ? + findRow(grid, searchString, searchAscending, rowFirst, rowLast) : + findRow(grid, searchString, searchAscending, rowFirst, rowLast); + if (targetRow >= 0) + { + result = {&grid, targetRow}; + return true; + } + return false; + }; + + if (searchAscending) + { + if (!finishSearch(grid1, cursorRow1 + 1, rowCount1)) + if (!finishSearch(grid2, 0, rowCount2)) + finishSearch(grid1, 0, cursorRow1 + 1); + } + else + { + if (!finishSearch(grid1, 0, cursorRow1)) + if (!finishSearch(grid2, 0, rowCount2)) + finishSearch(grid1, cursorRow1, rowCount1); + } + return result; +} diff --git a/FreeFileSync/Source/ui/search_grid.h b/FreeFileSync/Source/ui/search_grid.h new file mode 100644 index 0000000..81560f7 --- /dev/null +++ b/FreeFileSync/Source/ui/search_grid.h @@ -0,0 +1,19 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SEARCH_H_423905762345342526587 +#define SEARCH_H_423905762345342526587 + +#include + + +namespace fff +{ +std::pair findGridMatch(const zen::Grid& grid1, const zen::Grid& grid2, const std::wstring& searchString, bool respectCase, bool searchAscending); +//returns (grid/row) where the value was found, (nullptr, -1) if not found +} + +#endif //SEARCH_H_423905762345342526587 diff --git a/FreeFileSync/Source/ui/small_dlgs.cpp b/FreeFileSync/Source/ui/small_dlgs.cpp new file mode 100644 index 0000000..b50cdae --- /dev/null +++ b/FreeFileSync/Source/ui/small_dlgs.cpp @@ -0,0 +1,2446 @@ +// ***************************************************************************** +// * 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 "small_dlgs.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "gui_generated.h" +#include "folder_selector.h" +#include "abstract_folder_picker.h" +#include "../afs/concrete.h" +#include "../afs/gdrive.h" +#include "../afs/ftp.h" +#include "../afs/sftp.h" +#include "../base/synchronization.h" +#include "../base/icon_loader.h" +#include "../status_handler.h" //uiUpdateDue() +#include "../version/version.h" +#include "../ffs_paths.h" +#include "../icon_buffer.h" + + + +using namespace zen; +using namespace fff; + + +namespace +{ +class AboutDlg : public AboutDlgGenerated +{ +public: + AboutDlg(wxWindow* parent); + +private: + void onOkay (wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::accept)); } + void onClose(wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onOpenForum(wxCommandEvent& event) override { wxLaunchDefaultBrowser(L"https://freefilesync.org/forum"); } + void onDonate (wxCommandEvent& event) override { wxLaunchDefaultBrowser(L"https://freefilesync.org/donate"); } + void onSendEmail(wxCommandEvent& event) override + { + wxLaunchDefaultBrowser(wxString() + L"mailto:zenju" + L'@' + /*don't leave full email in either source or binary*/ L"freefilesync.org"); + } + + void onLocalKeyEvent(wxKeyEvent& event); +}; + + +AboutDlg::AboutDlg(wxWindow* parent) : AboutDlgGenerated(parent) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonClose)); + + assert(m_buttonClose->GetId() == wxID_OK); //we cannot use wxID_CLOSE else ESC key won't work: yet another wxWidgets bug?? + + const bool darkAppearance = wxSystemSettings::GetAppearance().IsDark(); //not "dark mode" necessarily! + + setImage(*m_bitmapLogo, loadImage(darkAppearance ? "ffs-header-dark" : "ffs-header-light")); + setImage(*m_bitmapLogoLeft, loadImage(darkAppearance ? "ffs-logo-dark" : "ffs-logo-light")); + + setBitmapTextLabel(*m_bpButtonForum, loadImage("ffs_forum"), L"FreeFileSync Forum"); + setBitmapTextLabel(*m_bpButtonEmail, loadImage("ffs_email"), wxString() + L"zenju" + L'@' + /*don't leave full email in either source or binary*/ L"freefilesync.org"); + m_bpButtonEmail->SetToolTip( wxString() + L"mailto:zenju" + L'@' + /*don't leave full email in either source or binary*/ L"freefilesync.org"); + + wxString build = utfTo(ffsVersion); + + const wchar_t* const SPACED_BULLET = L" \u2022 "; + build += SPACED_BULLET; + + build += LTR_MARK; //fix Arabic + build += utfTo(cpuArchName); + + build += SPACED_BULLET; + build += utfTo(formatTime(formatDateTag, getCompileTime())); + + m_staticFfsTextVersion->SetLabelText(replaceCpy(_("Version: %x"), L"%x", build)); + + wxString variantName; + m_staticTextFfsVariant->SetLabelText(variantName); + +#ifndef wxUSE_UNICODE +#error what is going on? +#endif + + { + m_bitmapAnimalBig->Hide(); + + setRelativeFontSize(*m_staticTextDonate, 1.20); + m_staticTextDonate->Hide(); //temporarily! => avoid impact to dialog width + + setRelativeFontSize(*m_buttonDonate1, 1.25); + setBitmapTextLabel(*m_buttonDonate1, loadImage("ffs_heart", dipToScreen(28)), m_buttonDonate1->GetLabelText()); + + m_buttonShowSupporterDetails->Hide(); + m_buttonDonate2->Hide(); + } + + //-------------------------------------------------------------------------- + m_staticTextThanksForLoc->SetMinSize({dipToWxsize(200), -1}); + m_staticTextThanksForLoc->Wrap(dipToWxsize(200)); + + const int scrollDelta = GetCharHeight(); + m_scrolledWindowTranslators->SetScrollRate(scrollDelta, scrollDelta); + + for (const TranslationInfo& ti : getAvailableTranslations()) + { + //country flag + wxStaticBitmap* staticBitmapFlag = new wxStaticBitmap(m_scrolledWindowTranslators, wxID_ANY, toScaledBitmap(loadImage(ti.languageFlag))); + fgSizerTranslators->Add(staticBitmapFlag, 0, wxALIGN_CENTER); + + //translator name + wxStaticText* staticTextTranslator = new wxStaticText(m_scrolledWindowTranslators, wxID_ANY, ti.translatorName, wxDefaultPosition, wxDefaultSize, 0); + fgSizerTranslators->Add(staticTextTranslator, 0, wxALIGN_CENTER_VERTICAL); + + staticBitmapFlag ->SetToolTip(ti.languageName); + staticTextTranslator->SetToolTip(ti.languageName); + } + fgSizerTranslators->Fit(m_scrolledWindowTranslators); + //-------------------------------------------------------------------------- + + wxImage::AddHandler(new wxJPEGHandler /*ownership passed*/); //activate support for .jpg files + + wxImage animalImg(utfTo(appendPath(getResourceDirPath(), Zstr("Animal.dat"))), wxBITMAP_TYPE_JPEG); + convertToVanillaImage(animalImg); + assert(animalImg.IsOk()); + + //-------------------------------------------------------------------------- + //have animal + text match *final* dialog width + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + + { + const int imageWidth = (m_panelDonate->GetSize().GetWidth() - 5 - 5 - 5 /* grey border*/) / 2; + const int textWidth = m_panelDonate->GetSize().GetWidth() - 5 - 5 - 5 - imageWidth; + + setImage(*m_bitmapAnimalSmall, shrinkImage(animalImg, wxsizeToScreen(imageWidth), -1 /*maxHeight*/)); + + m_staticTextDonate->Show(); + m_staticTextDonate->Wrap(textWidth - 10 /*left gap*/); //wrap *after* changing font size + } + //-------------------------------------------------------------------------- + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonClose->SetFocus(); //on GTK ESC is only associated with wxID_OK correctly if we set at least *any* focus at all!!! +} + + +void AboutDlg::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonClose->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} +} + +void fff::showAboutDialog(wxWindow* parent) +{ + AboutDlg dlg(parent); + dlg.ShowModal(); +} + +//######################################################################################## + +namespace +{ +class CloudSetupDlg : public CloudSetupDlgGenerated +{ +public: + CloudSetupDlg(wxWindow* parent, Zstring& folderPathPhrase, Zstring& sftpKeyFileLastSelected, size_t& parallelOps, bool canChangeParallelOp); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onGdriveUserAdd (wxCommandEvent& event) override; + void onGdriveUserRemove(wxCommandEvent& event) override; + void onGdriveUserSelect(wxCommandEvent& event) override; + void gdriveUpdateDrivesAndSelect(const std::string& accountEmail, const Zstring& locationToSelect); + + void onDetectServerChannelLimit(wxCommandEvent& event) override; + void onTypingPassword(wxCommandEvent& event) override; + void onToggleShowPassword(wxCommandEvent& event) override; + void onTogglePasswordPrompt(wxCommandEvent& event) override { updateGui(); } + void onBrowseCloudFolder (wxCommandEvent& event) override; + + void onConnectionGdrive(wxCommandEvent& event) override { type_ = CloudType::gdrive; updateGui(); } + void onConnectionSftp (wxCommandEvent& event) override { type_ = CloudType::sftp; updateGui(); } + void onConnectionFtp (wxCommandEvent& event) override { type_ = CloudType::ftp; updateGui(); } + + void onAuthPassword(wxCommandEvent& event) override { sftpAuthType_ = SftpAuthType::password; updateGui(); } + void onAuthKeyfile (wxCommandEvent& event) override { sftpAuthType_ = SftpAuthType::keyFile; updateGui(); } + void onAuthAgent (wxCommandEvent& event) override { sftpAuthType_ = SftpAuthType::agent; updateGui(); } + + void onSelectKeyfile(wxCommandEvent& event) override; + + void updateGui(); + + //work around defunct keyboard focus on macOS (or is it wxMac?) => not needed for this dialog! + //void onLocalKeyEvent(wxKeyEvent& event); + + static bool acceptFileDrop(const std::vector& shellItemPaths); + void onKeyFileDropped(FileDropEvent& event); + + bool validateParameters(); + AbstractPath getFolderPath() const; + + enum class CloudType + { + gdrive, + sftp, + ftp, + }; + CloudType type_ = CloudType::gdrive; + + const wxString txtLoading_ = L'(' + _("Loading...") + L')'; + const wxString txtMyDrive_ = _("My Drive"); + + const SftpLogin sftpDefault_; + + SftpAuthType sftpAuthType_ = sftpDefault_.authType; + + AsyncGuiQueue guiQueue_; + + Zstring& sftpKeyFileLastSelected_; + + //output-only parameters: + Zstring& folderPathPhraseOut_; + size_t& parallelOpsOut_; +}; + + +CloudSetupDlg::CloudSetupDlg(wxWindow* parent, Zstring& folderPathPhrase, Zstring& sftpKeyFileLastSelected, size_t& parallelOps, bool canChangeParallelOp) : + CloudSetupDlgGenerated(parent), + sftpKeyFileLastSelected_(sftpKeyFileLastSelected), + folderPathPhraseOut_(folderPathPhrase), + parallelOpsOut_(parallelOps) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + setImage(*m_toggleBtnGdrive, loadImage("google_drive")); + + setRelativeFontSize(*m_toggleBtnGdrive, 1.25); + setRelativeFontSize(*m_toggleBtnSftp, 1.25); + setRelativeFontSize(*m_toggleBtnFtp, 1.25); + + setBitmapTextLabel(*m_buttonGdriveAddUser, loadImage("user_add", dipToScreen(20)), m_buttonGdriveAddUser ->GetLabelText()); + setBitmapTextLabel(*m_buttonGdriveRemoveUser, loadImage("user_remove", dipToScreen(20)), m_buttonGdriveRemoveUser->GetLabelText()); + + setImage(*m_bitmapGdriveUser, loadImage("user", dipToScreen(20))); + setImage(*m_bitmapGdriveDrive, loadImage("drive", dipToScreen(20))); + setImage(*m_bitmapServer, loadImage("server", dipToScreen(20))); + setImage(*m_bitmapCloud, loadImage("cloud")); + setImage(*m_bitmapPerf, loadImage("speed")); + setImage(*m_bitmapServerDir, IconBuffer::genericDirIcon(IconBuffer::IconSize::small)); + m_checkBoxShowPassword ->SetValue(false); + m_checkBoxPasswordPrompt->SetValue(false); + + m_textCtrlServer->SetHint(_("Example:") + L" website.com 66.198.240.22"); + m_textCtrlServer->SetMinSize({dipToWxsize(260), -1}); + + m_textCtrlPort->SetMinSize({dipToWxsize(60), -1}); + setDefaultWidth(*m_spinCtrlConnectionCount); + setDefaultWidth(*m_spinCtrlChannelCountSftp); + setDefaultWidth(*m_spinCtrlTimeout); + + setupFileDrop(*m_panelAuth); + m_panelAuth->Bind(EVENT_DROP_FILE, [this](FileDropEvent& event) { onKeyFileDropped(event); }); + + m_staticTextConnectionsLabelSub->SetLabelText(L'(' + _("Connections") + L')'); + + //use spacer to keep dialog height stable, no matter if key file options are visible + bSizerAuthInner->Add(0, m_panelAuth->GetSize().y); + + //--------------------------------------------------------- + std::vector gdriveAccounts; + try + { + for (const std::string& loginEmail : gdriveListAccounts()) //throw FileError + gdriveAccounts.push_back(utfTo(loginEmail)); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } + m_listBoxGdriveUsers->Append(gdriveAccounts); + + //set default values for Google Drive: use first item of m_listBoxGdriveUsers + if (!gdriveAccounts.empty() && !acceptsItemPathPhraseGdrive(folderPathPhrase)) + { + m_listBoxGdriveUsers->SetSelection(0); + gdriveUpdateDrivesAndSelect(utfTo(gdriveAccounts[0]), Zstring() /*My Drive*/); + } + + m_spinCtrlTimeout->SetValue(sftpDefault_.timeoutSec); + assert(sftpDefault_.timeoutSec == FtpLogin().timeoutSec); //make sure the default values are in sync + + //--------------------------------------------------------- + if (acceptsItemPathPhraseGdrive(folderPathPhrase)) + { + type_ = CloudType::gdrive; + const AbstractPath folderPath = createItemPathGdrive(folderPathPhrase); + const GdriveLogin login = extractGdriveLogin(folderPath.afsDevice); //noexcept + + if (const int selPos = m_listBoxGdriveUsers->FindString(utfTo(login.email), false /*caseSensitive*/); + selPos != wxNOT_FOUND) + { + m_listBoxGdriveUsers->EnsureVisible(selPos); + m_listBoxGdriveUsers->SetSelection(selPos); + gdriveUpdateDrivesAndSelect(login.email, login.locationName); + } + else + { + m_listBoxGdriveUsers->DeselectAll(); + m_listBoxGdriveDrives->Clear(); + } + + m_textCtrlServerPath->ChangeValue(utfTo(FILE_NAME_SEPARATOR + folderPath.afsPath.value)); + m_spinCtrlTimeout->SetValue(login.timeoutSec); + } + else if (acceptsItemPathPhraseSftp(folderPathPhrase)) + { + type_ = CloudType::sftp; + const AbstractPath folderPath = createItemPathSftp(folderPathPhrase); + const SftpLogin login = extractSftpLogin(folderPath.afsDevice); //noexcept + + if (login.portCfg > 0) + m_textCtrlPort->ChangeValue(numberTo(login.portCfg)); + m_textCtrlServer ->ChangeValue(utfTo(login.server)); + m_textCtrlUserName ->ChangeValue(utfTo(login.username)); + sftpAuthType_ = login.authType; + if (login.password) + m_textCtrlPasswordHidden->ChangeValue(utfTo(*login.password)); + else + m_checkBoxPasswordPrompt->SetValue(true); + m_textCtrlKeyfilePath ->ChangeValue(utfTo(login.privateKeyFilePath)); + m_textCtrlServerPath ->ChangeValue(utfTo(FILE_NAME_SEPARATOR + folderPath.afsPath.value)); + m_checkBoxAllowZlib ->SetValue(login.allowZlib); + m_spinCtrlTimeout ->SetValue(login.timeoutSec); + m_spinCtrlChannelCountSftp->SetValue(login.traverserChannelsPerConnection); + } + else if (acceptsItemPathPhraseFtp(folderPathPhrase)) + { + type_ = CloudType::ftp; + const AbstractPath folderPath = createItemPathFtp(folderPathPhrase); + const FtpLogin login = extractFtpLogin(folderPath.afsDevice); //noexcept + + if (login.portCfg > 0) + m_textCtrlPort->ChangeValue(numberTo(login.portCfg)); + m_textCtrlServer ->ChangeValue(utfTo(login.server)); + m_textCtrlUserName->ChangeValue(utfTo(login.username)); + if (login.password) + m_textCtrlPasswordHidden ->ChangeValue(utfTo(*login.password)); + else + m_checkBoxPasswordPrompt->SetValue(true); + m_textCtrlServerPath->ChangeValue(utfTo(FILE_NAME_SEPARATOR + folderPath.afsPath.value)); + (login.useTls ? m_radioBtnEncryptSsl : m_radioBtnEncryptNone)->SetValue(true); + m_spinCtrlTimeout->SetValue(login.timeoutSec); + } + + m_spinCtrlConnectionCount->SetValue(parallelOps); + + m_spinCtrlConnectionCount->Disable(); + m_staticTextConnectionCountDescr->Hide(); + + m_spinCtrlChannelCountSftp->Disable(); + m_buttonChannelCountSftp ->Disable(); + //--------------------------------------------------------- + + //set up default view for dialog size calculation + bSizerGdrive->Show(false); + bSizerFtpEncrypt->Show(false); + m_textCtrlPasswordVisible->Hide(); + m_checkBoxPasswordPrompt->Hide(); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() + //=> works like a charm for GTK with window resizing problems and title bar corruption; e.g. Debian!!! +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + updateGui(); //*after* SetSizeHints when standard dialog height has been calculated + + m_buttonOK->SetFocus(); +} + + +void CloudSetupDlg::onGdriveUserAdd(wxCommandEvent& event) +{ + guiQueue_.processAsync([timeoutSec = extractGdriveLogin(getFolderPath().afsDevice).timeoutSec]() -> std::variant + { + try + { + return gdriveAddUser(nullptr /*updateGui*/, timeoutSec); //throw FileError + } + catch (const FileError& e) { return e; } + }, + [this](const std::variant& result) + { + if (const FileError* e = std::get_if(&result)) + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e->toString())); + else + { + const std::string& loginEmail = std::get(result); + + int selPos = m_listBoxGdriveUsers->FindString(utfTo(loginEmail), false /*caseSensitive*/); + if (selPos == wxNOT_FOUND) + selPos = m_listBoxGdriveUsers->Append(utfTo(loginEmail)); + + m_listBoxGdriveUsers->EnsureVisible(selPos); + m_listBoxGdriveUsers->SetSelection(selPos); + updateGui(); //enable remove user button + gdriveUpdateDrivesAndSelect(loginEmail, Zstring() /*My Drive*/); + } + }); +} + + +void CloudSetupDlg::onGdriveUserRemove(wxCommandEvent& event) +{ + const int selPos = m_listBoxGdriveUsers->GetSelection(); + assert(selPos != wxNOT_FOUND); + if (selPos != wxNOT_FOUND) + try + { + const std::string& loginEmail = utfTo(m_listBoxGdriveUsers->GetString(selPos)); + if (showConfirmationDialog(this, DialogInfoType::warning, PopupDialogCfg(). + setTitle(_("Confirm")). + setMainInstructions(replaceCpy(_("Do you really want to disconnect from user account %x?"), L"%x", utfTo(loginEmail))), + _("&Disconnect")) != ConfirmationButton::accept) + return; + + gdriveRemoveUser(loginEmail, extractGdriveLogin(getFolderPath().afsDevice).timeoutSec); //throw FileError + m_listBoxGdriveUsers->Delete(selPos); + updateGui(); //disable remove user button + m_listBoxGdriveDrives->Clear(); + } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } +} + + +void CloudSetupDlg::onGdriveUserSelect(wxCommandEvent& event) +{ + const int selPos = m_listBoxGdriveUsers->GetSelection(); + assert(selPos != wxNOT_FOUND); + if (selPos != wxNOT_FOUND) + { + const std::string& loginEmail = utfTo(m_listBoxGdriveUsers->GetString(selPos)); + updateGui(); //enable remove user button + gdriveUpdateDrivesAndSelect(loginEmail, Zstring() /*My Drive*/); + } +} + + +void CloudSetupDlg::gdriveUpdateDrivesAndSelect(const std::string& accountEmail, const Zstring& locationToSelect) +{ + m_listBoxGdriveDrives->Clear(); + m_listBoxGdriveDrives->Append(txtLoading_); + + guiQueue_.processAsync([accountEmail, timeoutSec = extractGdriveLogin(getFolderPath().afsDevice).timeoutSec]() -> + std::variant, FileError> + { + try + { + return gdriveListLocations(accountEmail, timeoutSec); //throw FileError + } + catch (const FileError& e) { return e; } + }, + [this, accountEmail, locationToSelect](std::variant, FileError>&& result) + { + if (const int selPos = m_listBoxGdriveUsers->GetSelection(); + selPos == wxNOT_FOUND || utfTo(m_listBoxGdriveUsers->GetString(selPos)) != accountEmail) + return; //different accountEmail selected in the meantime! + + m_listBoxGdriveDrives->Clear(); + + if (const FileError* e = std::get_if(&result)) + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e->toString())); + else + { + auto& locationNames = std::get>(result); + std::sort(locationNames.begin(), locationNames.end(), LessNaturalSort()); + + m_listBoxGdriveDrives->Append(txtMyDrive_); //sort locations, but keep "My Drive" at top + + for (const Zstring& itemLabel : locationNames) + m_listBoxGdriveDrives->Append(utfTo(itemLabel)); + + const wxString labelToSelect = locationToSelect.empty() ? txtMyDrive_ : utfTo(locationToSelect); + + if (const int selPos = m_listBoxGdriveDrives->FindString(labelToSelect, true /*caseSensitive*/); + selPos != wxNOT_FOUND) + { + m_listBoxGdriveDrives->EnsureVisible(selPos); + m_listBoxGdriveDrives->SetSelection (selPos); + } + } + }); +} + + +void CloudSetupDlg::onDetectServerChannelLimit(wxCommandEvent& event) +{ + assert (type_ == CloudType::sftp); + try + { + m_spinCtrlChannelCountSftp->SetSelection(0, 0); //some visual feedback: clear selection + m_spinCtrlChannelCountSftp->Refresh(); //both needed for wxGTK: meh! + m_spinCtrlChannelCountSftp->Update(); // + + AbstractPath folderPath = getFolderPath(); //noexcept + //------------------------------------------------------------------- + auto requestPassword = [&, password = Zstring()](const std::wstring& msg, const std::wstring& lastErrorMsg) mutable + { + assert(runningOnMainThread()); + if (showPasswordPrompt(this, msg, lastErrorMsg, password) != ConfirmationButton::accept) + throw CancelProcess(); + return password; + }; + AFS::authenticateAccess(folderPath.afsDevice, requestPassword); //throw FileError, CancelProcess + //------------------------------------------------------------------- + + const int channelCountMax = getServerMaxChannelsPerConnection(extractSftpLogin(folderPath.afsDevice)); //throw FileError + m_spinCtrlChannelCountSftp->SetValue(channelCountMax); + + m_spinCtrlChannelCountSftp->SetFocus(); //[!] otherwise selection is lost + m_spinCtrlChannelCountSftp->SetSelection(-1, -1); //some visual feedback: select all + } + catch (CancelProcess&) { return; } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + } +} + + +void CloudSetupDlg::onToggleShowPassword(wxCommandEvent& event) +{ + assert(type_ != CloudType::gdrive); + if (m_checkBoxShowPassword->GetValue()) + m_textCtrlPasswordVisible->ChangeValue(m_textCtrlPasswordHidden->GetValue()); + else + m_textCtrlPasswordHidden->ChangeValue(m_textCtrlPasswordVisible->GetValue()); + + updateGui(); + + wxTextCtrl& textCtrl = *(m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden); + textCtrl.SetFocus(); //macOS: selects text as unwanted side effect => *before* SetInsertionPointEnd() + textCtrl.SetInsertionPointEnd(); +} + + +void CloudSetupDlg::onTypingPassword(wxCommandEvent& event) +{ + assert(m_staticTextPassword->IsShown()); + const wxString password = (m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden)->GetValue(); + if (m_checkBoxShowPassword ->IsShown() != !password.empty() || //let's avoid some minor flicker + m_checkBoxPasswordPrompt->IsShown() != password.empty()) //in updateGui() Dimensions() + updateGui(); +} + + +bool CloudSetupDlg::acceptFileDrop(const std::vector& shellItemPaths) +{ + if (shellItemPaths.empty()) + return false; + + const Zstring ext = getFileExtension(shellItemPaths[0]); + return ext.empty() || + equalAsciiNoCase(ext, "pem") || + equalAsciiNoCase(ext, "ppk"); +} + + +void CloudSetupDlg::onKeyFileDropped(FileDropEvent& event) +{ + //assert (type_ == CloudType::SFTP); -> no big deal if false + if (!event.itemPaths_.empty()) + { + m_textCtrlKeyfilePath->ChangeValue(utfTo(event.itemPaths_[0])); + + sftpAuthType_ = SftpAuthType::keyFile; + updateGui(); + } +} + + +void CloudSetupDlg::onSelectKeyfile(wxCommandEvent& event) +{ + assert (type_ == CloudType::sftp && sftpAuthType_ == SftpAuthType::keyFile); + + std::optional defaultFolderPath = getParentFolderPath(sftpKeyFileLastSelected_); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(defaultFolderPath ? *defaultFolderPath : Zstr("")), wxString() /*default file name*/, + _("All files") + L" (*.*)|*" + + L"|" + L"OpenSSL PEM (*.pem)|*.pem" + + L"|" + L"PuTTY Private Key (*.ppk)|*.ppk", + wxFD_OPEN); + if (fileSelector.ShowModal() != wxID_OK) + return; + m_textCtrlKeyfilePath->ChangeValue(fileSelector.GetPath()); + sftpKeyFileLastSelected_ = utfTo(fileSelector.GetPath()); +} + + +void CloudSetupDlg::updateGui() +{ + m_toggleBtnGdrive->SetValue(type_ == CloudType::gdrive); + m_toggleBtnSftp ->SetValue(type_ == CloudType::sftp); + m_toggleBtnFtp ->SetValue(type_ == CloudType::ftp); + + bSizerGdrive->Show(type_ == CloudType::gdrive); + bSizerServer->Show(type_ == CloudType::ftp || type_ == CloudType::sftp); + bSizerAuth ->Show(type_ == CloudType::ftp || type_ == CloudType::sftp); + + bSizerFtpEncrypt->Show(type_ == CloudType:: ftp); + bSizerSftpAuth ->Show(type_ == CloudType::sftp); + + m_staticTextKeyfile->Show(type_ == CloudType::sftp && sftpAuthType_ == SftpAuthType::keyFile); + bSizerKeyFile ->Show(type_ == CloudType::sftp && sftpAuthType_ == SftpAuthType::keyFile); + + m_staticTextPassword->Show(type_ == CloudType::ftp || (type_ == CloudType::sftp && sftpAuthType_ != SftpAuthType::agent)); + bSizerPassword ->Show(type_ == CloudType::ftp || (type_ == CloudType::sftp && sftpAuthType_ != SftpAuthType::agent)); + if (m_staticTextPassword->IsShown()) + { + m_textCtrlPasswordVisible->Show( m_checkBoxShowPassword->GetValue()); + m_textCtrlPasswordHidden ->Show(!m_checkBoxShowPassword->GetValue()); + + m_textCtrlPasswordVisible->Enable(!m_checkBoxPasswordPrompt->GetValue()); + m_textCtrlPasswordHidden ->Enable(!m_checkBoxPasswordPrompt->GetValue()); + + const wxString password = (m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden)->GetValue(); + m_checkBoxShowPassword ->Show(!password.empty()); + m_checkBoxPasswordPrompt->Show( password.empty()); + } + + switch (type_) + { + case CloudType::gdrive: + m_buttonGdriveRemoveUser->Enable(m_listBoxGdriveUsers->GetSelection() != wxNOT_FOUND); + break; + + case CloudType::sftp: + m_radioBtnPassword->SetValue(false); + m_radioBtnKeyfile ->SetValue(false); + m_radioBtnAgent ->SetValue(false); + + m_textCtrlPort->SetHint(numberTo(DEFAULT_PORT_SFTP)); + + switch (sftpAuthType_) //*not* owned by GUI controls + { + case SftpAuthType::password: + m_radioBtnPassword->SetValue(true); + m_staticTextPassword->SetLabelText(_("Password:")); + break; + case SftpAuthType::keyFile: + m_radioBtnKeyfile->SetValue(true); + m_staticTextPassword->SetLabelText(_("Key passphrase:")); + break; + case SftpAuthType::agent: + m_radioBtnAgent->SetValue(true); + break; + } + break; + + case CloudType::ftp: + m_textCtrlPort->SetHint(numberTo(DEFAULT_PORT_FTP)); + m_staticTextPassword->SetLabelText(_("Password:")); + break; + } + + m_staticTextChannelCountSftp->Show(type_ == CloudType::sftp); + m_spinCtrlChannelCountSftp ->Show(type_ == CloudType::sftp); + m_buttonChannelCountSftp ->Show(type_ == CloudType::sftp); + m_checkBoxAllowZlib ->Show(type_ == CloudType::sftp); + m_staticTextZlibDescr ->Show(type_ == CloudType::sftp); + + Layout(); //needed! hidden items are not considered during resize + Refresh(); +} + + +bool CloudSetupDlg::validateParameters() +{ + if (type_ == CloudType::sftp || + type_ == CloudType::ftp) + { + if (trimCpy(m_textCtrlServer->GetValue()).empty()) + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Server name must not be empty."))); + m_textCtrlServer->SetFocus(); + return false; + } + } + + switch (type_) + { + case CloudType::gdrive: + if (m_listBoxGdriveUsers->GetSelection() == wxNOT_FOUND) + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please select a user account first."))); + return false; + } + break; + + case CloudType::sftp: + //username *required* for SFTP, but optional for FTP: libcurl will use "anonymous" + if (trimCpy(m_textCtrlUserName->GetValue()).empty()) + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a username."))); + m_textCtrlUserName->SetFocus(); + return false; + } + + if (sftpAuthType_ == SftpAuthType::keyFile) + if (trimCpy(m_textCtrlKeyfilePath->GetValue()).empty()) + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a file path."))); + //don't show error icon to follow "Windows' encouraging tone" + m_textCtrlKeyfilePath->SetFocus(); + return false; + } + break; + + case CloudType::ftp: + break; + } + return true; +} + + +AbstractPath CloudSetupDlg::getFolderPath() const +{ + //clean up (messy) user input, but no trim: support folders with trailing blanks! + const AfsPath serverRelPath = sanitizeDeviceRelativePath(utfTo(m_textCtrlServerPath->GetValue())); + + switch (type_) + { + case CloudType::gdrive: + { + GdriveLogin login; + if (const int selPos = m_listBoxGdriveUsers->GetSelection(); + selPos != wxNOT_FOUND) + { + login.email = utfTo(m_listBoxGdriveUsers->GetString(selPos)); + + if (const int selPos2 = m_listBoxGdriveDrives->GetSelection(); + selPos2 != wxNOT_FOUND) + { + if (const wxString& locationName = m_listBoxGdriveDrives->GetString(selPos2); + locationName != txtMyDrive_ && + locationName != txtLoading_) + login.locationName = utfTo(locationName); + } + } + login.timeoutSec = m_spinCtrlTimeout->GetValue(); + return AbstractPath(condenseToGdriveDevice(login), serverRelPath); //noexcept + } + + case CloudType::sftp: + { + SftpLogin login; + login.server = utfTo(m_textCtrlServer ->GetValue()); + login.portCfg = stringTo (m_textCtrlPort ->GetValue()); //0 if empty + login.username = utfTo(m_textCtrlUserName->GetValue()); + login.authType = sftpAuthType_; + login.privateKeyFilePath = utfTo(m_textCtrlKeyfilePath->GetValue()); + if (m_checkBoxPasswordPrompt->GetValue()) + login.password = std::nullopt; + else + login.password = utfTo((m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden)->GetValue()); + login.allowZlib = m_checkBoxAllowZlib->GetValue(); + login.timeoutSec = m_spinCtrlTimeout->GetValue(); + login.traverserChannelsPerConnection = m_spinCtrlChannelCountSftp->GetValue(); + return AbstractPath(condenseToSftpDevice(login), serverRelPath); //noexcept + } + + case CloudType::ftp: + { + FtpLogin login; + login.server = utfTo(m_textCtrlServer ->GetValue()); + login.portCfg = stringTo (m_textCtrlPort ->GetValue()); //0 if empty + login.username = utfTo(m_textCtrlUserName->GetValue()); + if (m_checkBoxPasswordPrompt->GetValue()) + login.password = std::nullopt; + else + login.password = utfTo((m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden)->GetValue()); + login.useTls = m_radioBtnEncryptSsl->GetValue(); + login.timeoutSec = m_spinCtrlTimeout->GetValue(); + return AbstractPath(condenseToFtpDevice(login), serverRelPath); //noexcept + } + } + assert(false); + return createAbstractPath(Zstr("")); +} + + +void CloudSetupDlg::onBrowseCloudFolder(wxCommandEvent& event) +{ + if (!validateParameters()) + return; + + AbstractPath folderPath = getFolderPath(); //noexcept + try + { + //------------------------------------------------------------------- + auto requestPassword = [&, password = Zstring()](const std::wstring& msg, const std::wstring& lastErrorMsg) mutable + { + assert(runningOnMainThread()); + if (showPasswordPrompt(this, msg, lastErrorMsg, password) != ConfirmationButton::accept) + throw CancelProcess(); + return password; + }; + AFS::authenticateAccess(folderPath.afsDevice, requestPassword); //throw FileError, CancelProcess + //caveat: this could block *indefinitely* for Google Drive, but luckily already authenticated in this context + //------------------------------------------------------------------- + // + //for (S)FTP it makes more sense to start with the home directory rather than root (which often denies access!) + if (!AFS::getParentPath(folderPath)) + { + if (type_ == CloudType::sftp) + folderPath.afsPath = getSftpHomePath(extractSftpLogin(folderPath.afsDevice)); //throw FileError + + if (type_ == CloudType::ftp) + folderPath.afsPath = getFtpHomePath(extractFtpLogin(folderPath.afsDevice)); //throw FileError + } + } + catch (CancelProcess&) { return; } + catch (const FileError& e) + { + showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); + return; + } + + if (showAbstractFolderPicker(this, folderPath) == ConfirmationButton::accept) + m_textCtrlServerPath->ChangeValue(utfTo(FILE_NAME_SEPARATOR + folderPath.afsPath.value)); +} + + +void CloudSetupDlg::onOkay(wxCommandEvent& event) +{ + //------- parameter validation (BEFORE writing output!) ------- + if (!validateParameters()) + return; + //------------------------------------------------------------- + + folderPathPhraseOut_ = AFS::getInitPathPhrase(getFolderPath()); + parallelOpsOut_ = m_spinCtrlConnectionCount->GetValue(); + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showCloudSetupDialog(wxWindow* parent, Zstring& folderPathPhrase, Zstring& sftpKeyFileLastSelected, size_t& parallelOps, bool canChangeParallelOp) +{ + CloudSetupDlg dlg(parent, folderPathPhrase, sftpKeyFileLastSelected, parallelOps, canChangeParallelOp); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class CopyToDialog : public CopyToDlgGenerated +{ +public: + CopyToDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + Zstring& targetFolderPath, Zstring& targetFolderLastSelected, + std::vector& folderHistory, size_t folderHistoryMax, + Zstring& sftpKeyFileLastSelected, + bool& keepRelPaths, + bool& overwriteIfExists); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onLocalKeyEvent(wxKeyEvent& event); + + std::optional targetFolder; //always bound + + //output-only parameters: + Zstring& targetFolderPathOut_; + bool& keepRelPathsOut_; + bool& overwriteIfExistsOut_; + std::vector& folderHistoryOut_; +}; + + +CopyToDialog::CopyToDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + Zstring& targetFolderPath, Zstring& targetFolderLastSelected, + std::vector& folderHistory, size_t folderHistoryMax, + Zstring& sftpKeyFileLastSelected, + bool& keepRelPaths, + bool& overwriteIfExists) : + CopyToDlgGenerated(parent), + targetFolderPathOut_(targetFolderPath), + keepRelPathsOut_(keepRelPaths), + overwriteIfExistsOut_(overwriteIfExists), + folderHistoryOut_(folderHistory) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + setMainInstructionFont(*m_staticTextHeader); + + setImage(*m_bitmapCopyTo, loadImage("copy_to")); + + targetFolder.emplace(this, *this, *m_buttonSelectTargetFolder, *m_bpButtonSelectAltTargetFolder, *m_targetFolderPath, + targetFolderLastSelected, sftpKeyFileLastSelected, nullptr /*staticText*/, nullptr /*wxWindow*/, nullptr /*droppedPathsFilter*/, + [](const Zstring& folderPathPhrase) { return 1; } /*getDeviceParallelOps*/, nullptr /*setDeviceParallelOps*/); + + m_targetFolderPath->setHistory(std::make_shared(folderHistory, folderHistoryMax)); + + m_textCtrlFileList->SetMinSize({dipToWxsize(500), dipToWxsize(200)}); + + /* There is a nasty bug on wxGTK under Ubuntu: If a multi-line wxTextCtrl contains so many lines that scrollbars are shown, + it re-enables all windows that are supposed to be disabled during the current modal loop! + This only affects Ubuntu/wxGTK! No such issue on Debian/wxGTK or Suse/wxGTK + => another Unity problem like the following? + https://github.com/wxWidgets/wxWidgets/issues/14823 "Menu not disabled when showing modal dialogs in wxGTK under Unity" */ + + m_staticTextHeader->SetLabelText(_P("Copy the following item to another folder?", + "Copy the following %x items to another folder?", itemCount)); + m_staticTextHeader->Wrap(dipToWxsize(460)); //needs to be reapplied after SetLabel() + + m_textCtrlFileList->ChangeValue(itemList); + + //----------------- set config --------------------------------- + targetFolder ->setPath(targetFolderPath); + m_checkBoxKeepRelPath ->SetValue(keepRelPaths); + m_checkBoxOverwriteIfExists->SetValue(overwriteIfExists); + //----------------- /set config -------------------------------- + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonOK->SetFocus(); +} + + +void CopyToDialog::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void CopyToDialog::onOkay(wxCommandEvent& event) +{ + //------- parameter validation (BEFORE writing output!) ------- + if (trimCpy(targetFolder->getPath()).empty()) + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a target folder."))); + //don't show error icon to follow "Windows' encouraging tone" + m_targetFolderPath->SetFocus(); + return; + } + m_targetFolderPath->getHistory()->addItem(targetFolder->getPath()); + //------------------------------------------------------------- + + targetFolderPathOut_ = targetFolder->getPath(); + keepRelPathsOut_ = m_checkBoxKeepRelPath->GetValue(); + overwriteIfExistsOut_ = m_checkBoxOverwriteIfExists->GetValue(); + folderHistoryOut_ = m_targetFolderPath->getHistory()->getList(); + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showCopyToDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + Zstring& targetFolderPath, Zstring& targetFolderLastSelected, + std::vector& folderHistory, size_t folderHistoryMax, + Zstring& sftpKeyFileLastSelected, + bool& keepRelPaths, + bool& overwriteIfExists) +{ + CopyToDialog dlg(parent, itemList, itemCount, targetFolderPath, targetFolderLastSelected, folderHistory, folderHistoryMax, sftpKeyFileLastSelected, keepRelPaths, overwriteIfExists); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class DeleteDialog : public DeleteDlgGenerated +{ +public: + DeleteDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + bool& useRecycleBin); + +private: + void onUseRecycler(wxCommandEvent& event) override { updateGui(); } + void onOkay (wxCommandEvent& event) override; + void onCancel (wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onLocalKeyEvent(wxKeyEvent& event); + + void updateGui(); + + const int itemCount_ = 0; + const std::chrono::steady_clock::time_point dlgStartTime_ = std::chrono::steady_clock::now(); + + const wxImage imgTrash_ = [] + { + wxImage imgDefault = loadImage("delete_recycler"); + + //use system icon if available (can fail on Linux??) + try { return extractWxImage(fff::getTrashIcon(imgDefault.GetHeight())); /*throw SysError*/ } + catch (SysError&) { assert(false); return imgDefault; } + }(); + + //output-only parameters: + bool& useRecycleBinOut_; +}; + + +DeleteDialog::DeleteDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + bool& useRecycleBin) : + DeleteDlgGenerated(parent), + itemCount_(itemCount), + useRecycleBinOut_(useRecycleBin) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + setMainInstructionFont(*m_staticTextHeader); + + m_textCtrlFileList->SetMinSize({dipToWxsize(500), dipToWxsize(200)}); + + wxString itemList2(itemList); + trim(itemList2); //remove trailing newline + m_textCtrlFileList->ChangeValue(itemList2); + /* There is a nasty bug on wxGTK under Ubuntu: If a multi-line wxTextCtrl contains so many lines that scrollbars are shown, + it re-enables all windows that are supposed to be disabled during the current modal loop! + This only affects Ubuntu/wxGTK! No such issue on Debian/wxGTK or Suse/wxGTK + => another Unity problem like the following? + https://github.com/wxWidgets/wxWidgets/issues/14823 "Menu not disabled when showing modal dialogs in wxGTK under Unity" */ + + m_checkBoxUseRecycler->SetValue(useRecycleBin); + + updateGui(); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonOK->SetFocus(); +} + + +void DeleteDialog::updateGui() +{ + if (m_checkBoxUseRecycler->GetValue()) + { + setImage(*m_bitmapDeleteType, imgTrash_); + m_staticTextHeader->SetLabelText(_P("Do you really want to move the following item to the recycle bin?", + "Do you really want to move the following %x items to the recycle bin?", itemCount_)); + m_buttonOK->SetLabelText(_("Move")); //no access key needed: use ENTER! + } + else + { + setImage(*m_bitmapDeleteType, loadImage("delete_permanently")); + m_staticTextHeader->SetLabelText(_P("Do you really want to delete the following item?", + "Do you really want to delete the following %x items?", itemCount_)); + m_buttonOK->SetLabelText(wxControl::RemoveMnemonics(_("&Delete"))); //no access key needed: use ENTER! + } + m_staticTextHeader->Wrap(dipToWxsize(460)); //needs to be reapplied after SetLabel() + + Layout(); + Refresh(); //needed after m_buttonOK label change +} + + +void DeleteDialog::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void DeleteDialog::onOkay(wxCommandEvent& event) +{ + //additional safety net, similar to File Explorer: time delta between DEL and ENTER must be at least 50ms to avoid accidental deletion! + if (std::chrono::steady_clock::now() < dlgStartTime_ + std::chrono::milliseconds(50)) //considers chrono-wrap-around! + return; + + useRecycleBinOut_ = m_checkBoxUseRecycler->GetValue(); + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showDeleteDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + bool& useRecycleBin) +{ + DeleteDialog dlg(parent, itemList, itemCount, useRecycleBin); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class SyncConfirmationDlg : public SyncConfirmationDlgGenerated +{ +public: + SyncConfirmationDlg(wxWindow* parent, + bool syncSelection, + std::optional syncVar, + const SyncStatistics& st, + bool& dontShowAgain); +private: + void onStartSync(wxCommandEvent& event) override; + void onCancel (wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onLocalKeyEvent(wxKeyEvent& event); + + //output-only parameters: + bool& dontShowAgainOut_; +}; + + +SyncConfirmationDlg::SyncConfirmationDlg(wxWindow* parent, + bool syncSelection, + std::optional syncVar, + const SyncStatistics& st, + bool& dontShowAgain) : + SyncConfirmationDlgGenerated(parent), + dontShowAgainOut_(dontShowAgain) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + setMainInstructionFont(*m_staticTextCaption); + setImage(*m_bitmapSync, loadImage(syncSelection ? "start_sync_selection" : "start_sync")); + + m_staticTextCaption->SetLabelText(syncSelection ?_("Start to synchronize the selection?") : _("Start synchronization now?")); + m_staticTextSyncVar->SetLabelText(getVariantName(syncVar)); + + const char* varImgName = nullptr; + if (syncVar) + switch (*syncVar) + { + case SyncVariant::twoWay: varImgName = "sync_twoway"; break; + case SyncVariant::mirror: varImgName = "sync_mirror"; break; + case SyncVariant::update: varImgName = "sync_update"; break; + case SyncVariant::custom: varImgName = "sync_custom"; break; + } + if (varImgName) + setImage(*m_bitmapSyncVar, loadImage(varImgName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize()))); + + m_checkBoxDontShowAgain->SetValue(dontShowAgain); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); + + //update preview of item count and bytes to be transferred: + auto setValue = [](wxStaticText& txtControl, bool isZeroValue, const wxString& valueAsString, wxStaticBitmap& bmpControl, const char* imageName) + { + wxFont fnt = txtControl.GetFont(); + fnt.SetWeight(isZeroValue ? wxFONTWEIGHT_NORMAL : wxFONTWEIGHT_BOLD); + txtControl.SetFont(fnt); + + setText(txtControl, valueAsString); + + setImage(bmpControl, greyScaleIfDisabled(mirrorIfRtl(loadImage(imageName)), !isZeroValue)); + }; + + auto setIntValue = [&setValue](wxStaticText& txtControl, int value, wxStaticBitmap& bmpControl, const char* imageName) + { + setValue(txtControl, value == 0, formatNumber(value), bmpControl, imageName); + }; + + setValue(*m_staticTextData, st.getBytesToProcess() == 0, formatFilesizeShort(st.getBytesToProcess()), *m_bitmapData, "data"); + setIntValue(*m_staticTextCreateLeft, st.createCount(), *m_bitmapCreateLeft, "so_create_left_sicon"); + setIntValue(*m_staticTextUpdateLeft, st.updateCount(), *m_bitmapUpdateLeft, "so_update_left_sicon"); + setIntValue(*m_staticTextDeleteLeft, st.deleteCount(), *m_bitmapDeleteLeft, "so_delete_left_sicon"); + setIntValue(*m_staticTextCreateRight, st.createCount(), *m_bitmapCreateRight, "so_create_right_sicon"); + setIntValue(*m_staticTextUpdateRight, st.updateCount(), *m_bitmapUpdateRight, "so_update_right_sicon"); + setIntValue(*m_staticTextDeleteRight, st.deleteCount(), *m_bitmapDeleteRight, "so_delete_right_sicon"); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonOK->SetFocus(); +} + + +void SyncConfirmationDlg::onLocalKeyEvent(wxKeyEvent& event) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void SyncConfirmationDlg::onStartSync(wxCommandEvent& event) +{ + dontShowAgainOut_ = m_checkBoxDontShowAgain->GetValue(); + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showSyncConfirmationDlg(wxWindow* parent, + bool syncSelection, + std::optional syncVar, + const SyncStatistics& statistics, + bool& dontShowAgain) +{ + SyncConfirmationDlg dlg(parent, + syncSelection, + syncVar, + statistics, + dontShowAgain); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class OptionsDlg : public OptionsDlgGenerated +{ +public: + OptionsDlg(wxWindow* parent, GlobalConfig& globalCfg); + +private: + void onOkay (wxCommandEvent& event) override; + void onShowHiddenDialogs (wxCommandEvent& event) override { expandConfigArea(ConfigArea::hidden); }; + void onShowContextCustomize(wxCommandEvent& event) override { expandConfigArea(ConfigArea::context); }; + void onDefault (wxCommandEvent& event) override; + void onCancel (wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onAddRow (wxCommandEvent& event) override; + void onRemoveRow (wxCommandEvent& event) override; + void onShowLogFolder (wxCommandEvent& event) override; + void onToggleLogfilesLimit(wxCommandEvent& event) override { updateGui(); } + void onToggleHiddenDialog (wxCommandEvent& event) override { updateGui(); } + + void onSelectSoundCompareDone (wxCommandEvent& event) override { selectSound(*m_textCtrlSoundPathCompareDone); } + void onSelectSoundSyncDone (wxCommandEvent& event) override { selectSound(*m_textCtrlSoundPathSyncDone); } + void onSelectSoundAlertPending(wxCommandEvent& event) override { selectSound(*m_textCtrlSoundPathAlertPending); } + void selectSound(wxTextCtrl& txtCtrl); + + void onChangeSoundFilePath(wxCommandEvent& event) override { updateGui(); } + void onChangeColorTheme (wxCommandEvent& event) override { updateGui(); } + + void onPlayCompareDone (wxCommandEvent& event) override { playSoundWithDiagnostics(trimCpy(m_textCtrlSoundPathCompareDone ->GetValue())); } + void onPlaySyncDone (wxCommandEvent& event) override { playSoundWithDiagnostics(trimCpy(m_textCtrlSoundPathSyncDone ->GetValue())); } + void onPlayAlertPending(wxCommandEvent& event) override { playSoundWithDiagnostics(trimCpy(m_textCtrlSoundPathAlertPending->GetValue())); } + void playSoundWithDiagnostics(const wxString& filePath); + + void onGridResize(wxEvent& event); + void onGridContext(wxGridEvent& event); + void copySelectionToClipboard() const; + + void updateGui(); + + void onLocalKeyEvent(wxKeyEvent& event); + + enum class ConfigArea + { + hidden, + context + }; + void expandConfigArea(ConfigArea area); + + //work around defunct keyboard focus on macOS (or is it wxMac?) => not needed for this dialog! + //void onLocalKeyEvent(wxKeyEvent& event); + + void setExtApp(const std::vector& extApp); + std::vector getExtApp() const; + + std::unordered_map descriptionTransToEng_; //mapping for external application config + + const GlobalConfig defaultCfg_; + + EnumDescrList enumColorTheme_ + { + *m_choiceColorTheme, + { + {ColorTheme::System, wxControl::RemoveMnemonics(_("&Default")), {}/*tooltip*/}, + {ColorTheme::Light, _("Light"), {}/*tooltip*/}, + {ColorTheme::Dark, _("Dark"), {}/*tooltip*/}, + } + }; + + std::optional colorThemeIcon_; + + std::vector /*get dialog shown status*/, + std::function /*set dialog shown status*/, + wxString /*dialog message*/>> hiddenDialogCfgMapping_ + { + //*INDENT-OFF* + {[](const GlobalConfig& cfg){ return cfg.confirmDlgs.confirmSyncStart; }, + []( GlobalConfig& cfg, bool show){ cfg.confirmDlgs.confirmSyncStart = show; }, _("Start synchronization now?")}, + {[](const GlobalConfig& cfg){ return cfg.confirmDlgs.confirmSaveConfig; }, + []( GlobalConfig& cfg, bool show){ cfg.confirmDlgs.confirmSaveConfig = show; }, _("Do you want to save changes to %x?")}, + {[](const GlobalConfig& cfg){ return !cfg.progressDlgAutoClose; }, + []( GlobalConfig& cfg, bool show){ cfg.progressDlgAutoClose = !show; }, _("Leave progress dialog open after synchronization. (don't auto-close)")}, + {[](const GlobalConfig& cfg){ return cfg.confirmDlgs.confirmSwapSides; }, + []( GlobalConfig& cfg, bool show){ cfg.confirmDlgs.confirmSwapSides = show; }, _("Please confirm you want to swap sides.")}, + {[](const GlobalConfig& cfg){ return cfg.confirmDlgs.confirmCommandMassInvoke; }, + []( GlobalConfig& cfg, bool show){ cfg.confirmDlgs.confirmCommandMassInvoke = show; }, _P("Do you really want to execute the command %y for one item?", + "Do you really want to execute the command %y for %x items?", 42)}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnFolderNotExisting; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnFolderNotExisting = show; }, _("The following folders do not yet exist:") + L" [...] " + _("The folders are created automatically when needed.")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnFoldersDifferInCase; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnFoldersDifferInCase = show; }, _("The following folder paths differ in case. Please use a single form in order to avoid duplicate accesses.")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnDependentFolderPair; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnDependentFolderPair = show; }, _("One folder of the folder pair is a subfolder of the other.") + L' ' + _("The folder should be excluded via filter.")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnDependentBaseFolders; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnDependentBaseFolders = show; }, _("Some files will be synchronized as part of multiple folder pairs.") + L' ' + _("To avoid conflicts, set up exclude filters so that each updated file is included by only one folder pair.")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnSignificantDifference; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnSignificantDifference = show; }, _("The following folders are significantly different. Please check that the correct folders are selected for synchronization.")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnNotEnoughDiskSpace; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnNotEnoughDiskSpace = show; }, _("Not enough free disk space available in:")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnUnresolvedConflicts; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnUnresolvedConflicts = show; }, _("The following items have unresolved conflicts and will not be synchronized:")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnRecyclerMissing; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnRecyclerMissing = show; }, _("The recycle bin is not available for %x.") + L' ' + _("Ignore and delete permanently each time recycle bin is unavailable?")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnDirectoryLockFailed; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnDirectoryLockFailed = show; }, _("Cannot set directory locks for the following folders:")}, + {[](const GlobalConfig& cfg){ return cfg.warnDlgs.warnVersioningFolderPartOfSync; }, + []( GlobalConfig& cfg, bool show){ cfg.warnDlgs.warnVersioningFolderPartOfSync = show; }, _("The versioning folder must not be part of the synchronization.") + L' ' + _("The folder should be excluded via filter.")}, + //*INDENT-ON* + }; + + FolderSelector logFolderSelector_; + + //output-only parameters: + GlobalConfig& globalCfgOut_; +}; + + +OptionsDlg::OptionsDlg(wxWindow* parent, GlobalConfig& globalCfg) : + OptionsDlgGenerated(parent), + + logFolderSelector_(this, *m_panelLogfile, *m_buttonSelectLogFolder, *m_bpButtonSelectAltLogFolder, *m_logFolderPath, globalCfg.logFolderLastSelected, globalCfg.sftpKeyFileLastSelected, + nullptr /*staticText*/, nullptr /*dropWindow2*/, nullptr /*droppedPathsFilter*/, + [](const Zstring& folderPathPhrase) { return 1; } /*getDeviceParallelOps_*/, nullptr /*setDeviceParallelOps_*/), + globalCfgOut_(globalCfg) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + //setMainInstructionFont(*m_staticTextHeader); + + const wxImage imgFileManagerSmall_([] + { + try { return extractWxImage(fff::getFileManagerIcon(dipToScreen(20))); /*throw SysError*/ } + catch (SysError&) { assert(false); return loadImage("file_manager", dipToScreen(20)); } + }()); + setImage(*m_bpButtonShowLogFolder, imgFileManagerSmall_); + m_bpButtonShowLogFolder->SetToolTip(translate(extCommandFileManager.description));//translate default external apps on the fly: "Show in Explorer" + + m_logFolderPath->SetHint(utfTo(defaultCfg_.logFolderPhrase)); + //1. no text shown when control is disabled! 2. apparently there's a refresh problem on GTK + + m_logFolderPath->setHistory(std::make_shared(globalCfg.logFolderHistory, globalCfg.folderHistoryMax)); + + logFolderSelector_.setPath(globalCfg.logFolderPhrase); + + setDefaultWidth(*m_spinCtrlLogFilesMaxAge); + + setImage(*m_bitmapSettings, loadImage("settings")); + setImage(*m_bitmapWarnings, loadImage("msg_warning", dipToScreen(20))); + setImage(*m_bitmapLogFile, loadImage("log_file", dipToScreen(20))); + setImage(*m_bitmapNotificationSounds, loadImage("notification_sounds")); + setImage(*m_bitmapConsole, loadImage("command_line", dipToScreen(20))); + setImage(*m_bitmapCompareDone, loadImage("compare", dipToScreen(20))); + setImage(*m_bitmapSyncDone, loadImage("start_sync", dipToScreen(20))); + setImage(*m_bitmapAlertPending, loadImage("msg_error", dipToScreen(20))); + setImage(*m_bpButtonPlayCompareDone, loadImage("play_sound")); + setImage(*m_bpButtonPlaySyncDone, loadImage("play_sound")); + setImage(*m_bpButtonPlayAlertPending, loadImage("play_sound")); + setImage(*m_bpButtonAddRow, loadImage("item_add")); + setImage(*m_bpButtonRemoveRow, loadImage("item_remove")); + + //-------------------------------------------------------------------------------- + m_checkListHiddenDialogs->Hide(); + m_buttonShowCtxCustomize->Hide(); + + //fix wxCheckListBox's stupid "per-item toggle" when multiple items are selected + m_checkListHiddenDialogs->Bind(wxEVT_KEY_DOWN, [&checklist = *m_checkListHiddenDialogs](wxKeyEvent& event) + { + switch (event.GetKeyCode()) + { + case WXK_SPACE: + case WXK_NUMPAD_SPACE: + assert(checklist.HasMultipleSelection()); + + if (wxArrayInt selection; + checklist.GetSelections(selection), !selection.empty()) + { + const bool checkedNew = !checklist.IsChecked(selection[0]); + + for (const int itemPos : selection) + checklist.Check(itemPos, checkedNew); + + wxCommandEvent chkEvent(wxEVT_CHECKLISTBOX); + chkEvent.SetInt(selection[0]); + checklist.GetEventHandler()->ProcessEvent(chkEvent); + } + return; + } + event.Skip(); + }); + + std::stable_partition(hiddenDialogCfgMapping_.begin(), hiddenDialogCfgMapping_.end(), [&](const auto& item) + { + const auto& [dlgShown, dlgSetShown, msg] = item; + return !dlgShown(globalCfg); //move hidden dialogs to the top + }); + + std::vector dialogMessages; + for (const auto& [dlgShown, dlgSetShown, msg] : hiddenDialogCfgMapping_) + dialogMessages.push_back(msg); + + m_checkListHiddenDialogs->Append(dialogMessages); + + unsigned int itemPos = 0; + for (const auto& [dlgShown, dlgSetShown, msg] : hiddenDialogCfgMapping_) + { + if (dlgShown(globalCfg)) + m_checkListHiddenDialogs->Check(itemPos); + ++itemPos; + } + + //-------------------------------------------------------------------------------- + m_checkBoxFailSafe ->SetValue(globalCfg.failSafeFileCopy); + m_checkBoxCopyLocked ->SetValue(globalCfg.copyLockedFiles); + m_checkBoxCopyPermissions->SetValue(globalCfg.copyFilePermissions); + + bSizerColorTheme->Show(darkModeAvailable()); + enumColorTheme_.set(globalCfg.appColorTheme); + + m_checkBoxLogFilesMaxAge->SetValue(globalCfg.logfilesMaxAgeDays > 0); + m_spinCtrlLogFilesMaxAge->SetValue(globalCfg.logfilesMaxAgeDays > 0 ? globalCfg.logfilesMaxAgeDays : GlobalConfig().logfilesMaxAgeDays); + + switch (globalCfg.logFormat) + { + case LogFileFormat::html: + m_radioBtnLogHtml->SetValue(true); + break; + case LogFileFormat::text: + m_radioBtnLogText->SetValue(true); + break; + } + + m_textCtrlSoundPathCompareDone ->ChangeValue(utfTo(globalCfg.soundFileCompareFinished)); + m_textCtrlSoundPathSyncDone ->ChangeValue(utfTo(globalCfg.soundFileSyncFinished)); + m_textCtrlSoundPathAlertPending->ChangeValue(utfTo(globalCfg.soundFileAlertPending)); + //-------------------------------------------------------------------------------- + + bSizerLockedFiles->Show(false); + m_gridCustomCommand->SetMargins(0, 0); //for onGridResize(): ensure GetClientSize() calculations are correct + m_gridCustomCommand->SetTabBehaviour(wxGrid::Tab_Leave); + m_gridCustomCommand->SetSelectionMode(wxGrid::wxGridSelectRows); + + //getting rid of column header highlight the stupid way but the alternative "wxGrid::DisableOverlaySelection()" looks like ass + class wxGridCellAttrProviderNoColHighlight : public wxGridCellAttrProvider + { + const wxGridColumnHeaderRenderer& GetColumnHeaderRenderer(int col) override { return colRenderNoHighlight_; } + + class : public wxGridColumnHeaderRendererDefault + { + void DrawHighlighted(const wxGrid& grid, wxDC& dc, wxRect& rect, int col, int flags) const override { DrawBorder(grid, dc, rect); } + } colRenderNoHighlight_; + }; + m_gridCustomCommand->GetTable()->SetAttrProvider(new wxGridCellAttrProviderNoColHighlight); + + m_gridCustomCommand->GetGridWindow()->Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { onGridResize(event); }); + m_gridCustomCommand->Bind(wxEVT_GRID_CELL_RIGHT_CLICK, [this](wxGridEvent& event) { onGridContext(event); }); + m_gridCustomCommand->Bind(wxEVT_GRID_LABEL_RIGHT_CLICK, [this](wxGridEvent& event) { onGridContext(event); }); + + m_gridCustomCommand->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) + { + switch (event.GetKeyCode()) + { + //fix \src\generic\grid.cpp calling wxGrid::MoveCursorDown() after pressing ENTER: 1. instead of showing edit control 2. after ending edit mode + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + m_gridCustomCommand->EnableCellEditControl(!m_gridCustomCommand->IsCellEditControlEnabled()); + return; + + case WXK_HOME: //make wxGrid behave like a list instead of a spreadsheet: + case WXK_END: //=> add fake CTRL to move up/down instead of left/right + { + assert(!m_gridCustomCommand->IsCellEditControlEnabled()); //cell edit already handles this event + event.m_controlDown = true; + break; + } + case 'A': //CTRL + A - select all + if (event.ControlDown()) + { + assert(!m_gridCustomCommand->IsCellEditControlEnabled()); //cell edit already handles this event + m_gridCustomCommand->SelectAll(); + return; + } + break; + + case 'C': + case WXK_INSERT: //CTRL + C || CTRL + INS + case WXK_NUMPAD_INSERT: + if (event.ControlDown()) + { + copySelectionToClipboard(); + return; + } + break; + } + event.Skip(); + }); + + m_gridCustomCommand->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) + { + switch (event.GetKeyCode()) + { + case WXK_ESCAPE: //exit cell edit mode + undo (instead of cancelling the whole dialog!!!) + if (m_gridCustomCommand->IsCellEditControlEnabled()) + { + const wxGridCellCoords coords = m_gridCustomCommand->GetGridCursorCoords(); + assert(coords != wxGridNoCellCoords); //otherwise what exactly are we editting??? + const wxString oldVal = m_gridCustomCommand->GetCellValue(coords); + + m_gridCustomCommand->DisableCellEditControl(); //saves editted value, unless wxEVT_GRID_CELL_CHANGED is vetoed + m_gridCustomCommand->SetCellValue(coords, oldVal); //=> instead of veto, restore old value manually + return; + } + break; + } + event.Skip(); + }); + + //temporarily set dummy value for window height calculations: + setExtApp(std::vector(globalCfg.externalApps.size() + 1)); + updateGui(); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + //restore actual value: + setExtApp(globalCfg.externalApps); + updateGui(); + + m_buttonOK->SetFocus(); +} + + +void OptionsDlg::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + m_gridCustomCommand->DisableCellEditControl(); + + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +//automatically fit column width to match total grid width +void OptionsDlg::onGridResize(wxEvent& event) +{ + const int widthTotal = m_gridCustomCommand->GetGridWindow()->GetClientSize().GetWidth(); + assert(m_gridCustomCommand->GetNumberCols() == 2); + + const int w0 = widthTotal * 2 / 5; //ratio 2 : 3 + const int w1 = widthTotal - w0; + m_gridCustomCommand->SetColSize(0, w0); + m_gridCustomCommand->SetColSize(1, w1); + + m_gridCustomCommand->Refresh(); //required on Ubuntu + event.Skip(); +} + + +void OptionsDlg::onGridContext(wxGridEvent& event) +{ + m_gridCustomCommand->SetFocus(); //ensure cell cursor is highlighted + + ContextMenu menu; + + const bool canCopy = m_gridCustomCommand->IsSelection() || m_gridCustomCommand->GetGridCursorCoords() != wxGridNoCellCoords; + menu.addItem(_("&Copy") + L"\tCtrl+C", [this] { copySelectionToClipboard(); }, loadImage("item_copy_sicon"), canCopy); + menu.addSeparator(); + + const int rowCount = m_gridCustomCommand->GetNumberRows(); + menu.addItem(_("Select all") + L"\tCtrl+A", [this] { m_gridCustomCommand->SelectAll(); }, wxNullImage, rowCount > 0); + + menu.popup(*m_gridCustomCommand, event.GetPosition()); +} + + +//why the fuck does wxGrid even allow multi-block selection and then fail in wxGrid::CopySelection()????????????? => fix [t... s...] +void OptionsDlg::copySelectionToClipboard() const +{ + const wxGridBlocks& selBlocks = m_gridCustomCommand->GetSelectedBlocks(); + + std::vector blocks(selBlocks.begin(), selBlocks.end()); + if (blocks.empty()) //=> select cursor position instead + if (const wxGridCellCoords curPos = m_gridCustomCommand->GetGridCursorCoords(); + curPos != wxGridNoCellCoords) + blocks.emplace_back(curPos.GetRow(), curPos.GetCol(), + curPos.GetRow(), curPos.GetCol()); + + wxString clipBuf; //perf: old wxString didn't model exponential growth, but now it's std::string-based: + static_assert(std::is_same_v); + + for (const wxGridBlockCoords& block : blocks) + for (int row = block.GetTopRow(); row <= block.GetBottomRow(); ++row) + for (int col = block.GetLeftCol(); col <= block.GetRightCol(); ++col) + { + clipBuf += m_gridCustomCommand->GetCellValue(row, col); + clipBuf += col == block.GetRightCol() ? L'\n' : L'\t'; + } + + setClipboardText(clipBuf); +} + + +void OptionsDlg::updateGui() +{ + if (!colorThemeIcon_ || *colorThemeIcon_ != enumColorTheme_.get()) //perf? don't update icon unless needed + switch (enumColorTheme_.get()) + { + case ColorTheme::System: + setImage(*m_bitmapColorTheme, loadImage("theme-default")); + break; + case ColorTheme::Light: + setImage(*m_bitmapColorTheme, loadImage("theme-light")); + break; + case ColorTheme::Dark: + setImage(*m_bitmapColorTheme, loadImage("theme-dark")); + break; + } + colorThemeIcon_ = enumColorTheme_.get(); + + m_spinCtrlLogFilesMaxAge->Enable(m_checkBoxLogFilesMaxAge->GetValue()); + + m_bpButtonPlayCompareDone ->Enable(!trimCpy(m_textCtrlSoundPathCompareDone ->GetValue()).empty()); + m_bpButtonPlaySyncDone ->Enable(!trimCpy(m_textCtrlSoundPathSyncDone ->GetValue()).empty()); + m_bpButtonPlayAlertPending->Enable(!trimCpy(m_textCtrlSoundPathAlertPending->GetValue()).empty()); + + int hiddenDialogs = 0; + for (unsigned int itemPos = 0; itemPos < hiddenDialogCfgMapping_.size(); ++itemPos) + if (!m_checkListHiddenDialogs->IsChecked(itemPos)) + ++hiddenDialogs; + assert(hiddenDialogCfgMapping_.size() == m_checkListHiddenDialogs->GetCount()); + + setText(*m_staticTextHiddenDialogsCount, L'(' + (hiddenDialogs == 0 ? _("No dialogs hidden") : + _P("1 dialog hidden", "%x dialogs hidden", hiddenDialogs)) + L')'); + Layout(); +} + + +void OptionsDlg::expandConfigArea(ConfigArea area) +{ + //only show one expanded area at a time (wxGTK even crashes when showing both: not worth debugging) + m_buttonShowHiddenDialogs->Show(area != ConfigArea::hidden); + m_buttonShowCtxCustomize ->Show(area != ConfigArea::context); + + m_checkListHiddenDialogs->Show(area == ConfigArea::hidden); + bSizerContextCustomize ->Show(area == ConfigArea::context); + + Layout(); + Refresh(); //required on Windows +} + + +void OptionsDlg::selectSound(wxTextCtrl& txtCtrl) +{ + std::optional defaultFolderPath = getParentFolderPath(utfTo(txtCtrl.GetValue())); + if (!defaultFolderPath) + defaultFolderPath = getResourceDirPath(); + + wxFileDialog fileSelector(this, wxString() /*message*/, utfTo(*defaultFolderPath), wxString() /*default file name*/, + wxString(L"WAVE (*.wav)|*.wav") + L"|" + _("All files") + L" (*.*)|*", + wxFD_OPEN); + if (fileSelector.ShowModal() != wxID_OK) + return; + + txtCtrl.ChangeValue(fileSelector.GetPath()); + updateGui(); +} + + +void OptionsDlg::playSoundWithDiagnostics(const wxString& filePath) +{ + try + { + //::PlaySound() on Windows does not set last error! + //wxSound::Play(..., wxSOUND_SYNC) can return "false", but also without details! + //=> check file access manually: + [[maybe_unused]] const std::string& stream = getFileContent(utfTo(filePath), nullptr /*notifyUnbufferedIO*/); //throw FileError + + if (!wxSound::Play(filePath, wxSOUND_ASYNC)) + throw FileError(L"Sound playback failed. No further diagnostics available."); + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } +} + + +void OptionsDlg::onDefault(wxCommandEvent& event) +{ + m_checkBoxFailSafe ->SetValue(defaultCfg_.failSafeFileCopy); + m_checkBoxCopyLocked ->SetValue(defaultCfg_.copyLockedFiles); + m_checkBoxCopyPermissions->SetValue(defaultCfg_.copyFilePermissions); + + enumColorTheme_.set(defaultCfg_.appColorTheme); + + unsigned int itemPos = 0; + for (const auto& [dlgShown, dlgSetShown, msg] : hiddenDialogCfgMapping_) + m_checkListHiddenDialogs->Check(itemPos++, dlgShown(defaultCfg_)); + + logFolderSelector_.setPath(defaultCfg_.logFolderPhrase); + + m_checkBoxLogFilesMaxAge->SetValue(defaultCfg_.logfilesMaxAgeDays > 0); + m_spinCtrlLogFilesMaxAge->SetValue(defaultCfg_.logfilesMaxAgeDays > 0 ? defaultCfg_.logfilesMaxAgeDays : 14); + + switch (defaultCfg_.logFormat) + { + case LogFileFormat::html: + m_radioBtnLogHtml->SetValue(true); + break; + case LogFileFormat::text: + m_radioBtnLogText->SetValue(true); + break; + } + + m_textCtrlSoundPathCompareDone ->ChangeValue(utfTo(defaultCfg_.soundFileCompareFinished)); + m_textCtrlSoundPathSyncDone ->ChangeValue(utfTo(defaultCfg_.soundFileSyncFinished)); + m_textCtrlSoundPathAlertPending->ChangeValue(utfTo(defaultCfg_.soundFileAlertPending)); + + setExtApp(defaultCfg_.externalApps); + + updateGui(); +} + + +void OptionsDlg::onOkay(wxCommandEvent& event) +{ + //------- parameter validation (BEFORE writing output!) ------- + Zstring logFolderPhrase = logFolderSelector_.getPath(); + if (AFS::isNullPath(createAbstractPath(logFolderPhrase))) //no need to show an error: just set default! + logFolderPhrase = defaultCfg_.logFolderPhrase; + //------------------------------------------------------------- + + //write settings only when okay-button is pressed (except hidden dialog reset)! + globalCfgOut_.failSafeFileCopy = m_checkBoxFailSafe->GetValue(); + globalCfgOut_.copyLockedFiles = m_checkBoxCopyLocked->GetValue(); + globalCfgOut_.copyFilePermissions = m_checkBoxCopyPermissions->GetValue(); + + globalCfgOut_.appColorTheme = enumColorTheme_.get(); + + globalCfgOut_.logFolderPhrase = logFolderPhrase; + m_logFolderPath->getHistory()->addItem(logFolderPhrase); + globalCfgOut_.logFolderHistory = m_logFolderPath->getHistory()->getList(); + globalCfgOut_.logfilesMaxAgeDays = m_checkBoxLogFilesMaxAge->GetValue() ? m_spinCtrlLogFilesMaxAge->GetValue() : -1; + globalCfgOut_.logFormat = m_radioBtnLogHtml->GetValue() ? LogFileFormat::html : LogFileFormat::text; + + globalCfgOut_.soundFileCompareFinished = utfTo(trimCpy(m_textCtrlSoundPathCompareDone ->GetValue())); + globalCfgOut_.soundFileSyncFinished = utfTo(trimCpy(m_textCtrlSoundPathSyncDone ->GetValue())); + globalCfgOut_.soundFileAlertPending = utfTo(trimCpy(m_textCtrlSoundPathAlertPending->GetValue())); + + globalCfgOut_.externalApps = getExtApp(); + + unsigned int itemPos = 0; + for (const auto& [dlgShown, dlgSetShown, msg] : hiddenDialogCfgMapping_) + dlgSetShown(globalCfgOut_, m_checkListHiddenDialogs->IsChecked(itemPos++)); + + EndModal(static_cast(ConfirmationButton::accept)); +} + + +void OptionsDlg::setExtApp(const std::vector& extApps) +{ + int rowDiff = static_cast(extApps.size()) - m_gridCustomCommand->GetNumberRows(); + ++rowDiff; //append empty row to facilitate insertions by user + + if (rowDiff >= 0) + m_gridCustomCommand->AppendRows(rowDiff); + else + m_gridCustomCommand->DeleteRows(0, -rowDiff); + + int row = 0; + for (const auto& [descriptionEng, cmdLine] : extApps) + { + const std::wstring description = translate(descriptionEng); + //remember english description to save in GlobalSettings.xml later rather than hard-code translation + descriptionTransToEng_[description] = descriptionEng; + + m_gridCustomCommand->SetCellValue(row, 0, description); + m_gridCustomCommand->SetCellValue(row, 1, utfTo(cmdLine)); + ++row; + } +} + + +std::vector OptionsDlg::getExtApp() const +{ + std::vector output; + for (int i = 0; i < m_gridCustomCommand->GetNumberRows(); ++i) + { + auto description = copyStringTo(m_gridCustomCommand->GetCellValue(i, 0)); + auto commandline = utfTo(m_gridCustomCommand->GetCellValue(i, 1)); + + //try to undo translation of description for GlobalSettings.xml + auto it = descriptionTransToEng_.find(description); + if (it != descriptionTransToEng_.end()) + description = it->second; + + if (!description.empty() || !commandline.empty()) + output.push_back({description, commandline}); + } + return output; +} + + +void OptionsDlg::onAddRow(wxCommandEvent& event) +{ + const int selectedRow = m_gridCustomCommand->GetGridCursorRow(); + if (0 <= selectedRow && selectedRow < m_gridCustomCommand->GetNumberRows()) + m_gridCustomCommand->InsertRows(selectedRow); + else + m_gridCustomCommand->AppendRows(); + + m_gridCustomCommand->SetFocus(); //make grid cursor visible +} + + +void OptionsDlg::onRemoveRow(wxCommandEvent& event) +{ + if (m_gridCustomCommand->GetNumberRows() > 0) + { + const int selectedRow = m_gridCustomCommand->GetGridCursorRow(); + if (0 <= selectedRow && selectedRow < m_gridCustomCommand->GetNumberRows()) + m_gridCustomCommand->DeleteRows(selectedRow); + else + m_gridCustomCommand->DeleteRows(m_gridCustomCommand->GetNumberRows() - 1); + + m_gridCustomCommand->SetFocus(); //make grid cursor visible + } +} + + +void OptionsDlg::onShowLogFolder(wxCommandEvent& event) +{ + try + { + AbstractPath logFolderPath = createAbstractPath(logFolderSelector_.getPath()); + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(defaultCfg_.logFolderPhrase); + + openFolderInFileBrowser(logFolderPath); //throw FileError + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } +} +} + +ConfirmationButton fff::showOptionsDlg(wxWindow* parent, GlobalConfig& globalCfg) +{ + OptionsDlg dlg(parent, globalCfg); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class SelectTimespanDlg : public SelectTimespanDlgGenerated +{ +public: + SelectTimespanDlg(wxWindow* parent, time_t& timeFrom, time_t& timeTo); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onChangeSelectionFrom(wxCalendarEvent& event) override + { + if (m_calendarFrom->GetDate() > m_calendarTo->GetDate()) + m_calendarTo->SetDate(m_calendarFrom->GetDate()); + } + void onChangeSelectionTo(wxCalendarEvent& event) override + { + if (m_calendarFrom->GetDate() > m_calendarTo->GetDate()) + m_calendarFrom->SetDate(m_calendarTo->GetDate()); + } + + void onLocalKeyEvent(wxKeyEvent& event); + + //output-only parameters: + time_t& timeFromOut_; + time_t& timeToOut_; +}; + + +SelectTimespanDlg::SelectTimespanDlg(wxWindow* parent, time_t& timeFrom, time_t& timeTo) : + SelectTimespanDlgGenerated(parent), + timeFromOut_(timeFrom), + timeToOut_(timeTo) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + assert(m_calendarFrom->GetWindowStyle() == m_calendarTo->GetWindowStyle()); + assert(m_calendarFrom->HasFlag(wxCAL_SHOW_HOLIDAYS)); //caveat: for some stupid reason this is not honored when set by SetWindowStyle() + assert(m_calendarFrom->HasFlag(wxCAL_SHOW_SURROUNDING_WEEKS)); + assert(!m_calendarFrom->HasFlag(wxCAL_MONDAY_FIRST) && + !m_calendarFrom->HasFlag(wxCAL_SUNDAY_FIRST)); //...because we set it in the following: + long style = m_calendarFrom->GetWindowStyle(); + + style |= getFirstDayOfWeek() == WeekDay::sunday ? wxCAL_SUNDAY_FIRST : wxCAL_MONDAY_FIRST; //seems to be ignored on CentOS + + m_calendarFrom->SetWindowStyle(style); + m_calendarTo ->SetWindowStyle(style); + + //set default values + time_t timeFromTmp = timeFrom; + time_t timeToTmp = timeTo; + + if (timeToTmp == 0) + timeToTmp = std::time(nullptr); // + if (timeFromTmp == 0) + timeFromTmp = timeToTmp - 7 * 24 * 3600; //default time span: one week from "now" + + //wxDateTime models local(!) time (in contrast to what documentation says), but it has a constructor taking time_t UTC + m_calendarFrom->SetDate(timeFromTmp); + m_calendarTo ->SetDate(timeToTmp ); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //enable dialog-specific key events + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonOK->SetFocus(); +} + + +void SelectTimespanDlg::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + } + event.Skip(); +} + + +void SelectTimespanDlg::onOkay(wxCommandEvent& event) +{ + wxDateTime from = m_calendarFrom->GetDate(); + wxDateTime to = m_calendarTo ->GetDate(); + + //align to full days + from.ResetTime(); + to .ResetTime(); //reset local(!) time + to += wxTimeSpan::Day(); + to -= wxTimeSpan::Second(); //go back to end of previous day + + timeFromOut_ = from.GetTicks(); + timeToOut_ = to .GetTicks(); + + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showSelectTimespanDlg(wxWindow* parent, time_t& timeFrom, time_t& timeTo) +{ + SelectTimespanDlg dlg(parent, timeFrom, timeTo); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class PasswordPromptDlg : public PasswordPromptDlgGenerated +{ +public: + PasswordPromptDlg(wxWindow* parent, const std::wstring& msg, const std::wstring& lastErrorMsg /*optional*/, Zstring& password); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onToggleShowPassword(wxCommandEvent& event) override; + + void updateGui(); + + //work around defunct keyboard focus on macOS (or is it wxMac?) => not needed for this dialog! + //void onLocalKeyEvent(wxKeyEvent& event); + + //output-only parameters: + Zstring& passwordOut_; +}; + + +PasswordPromptDlg::PasswordPromptDlg(wxWindow* parent, const std::wstring& msg, const std::wstring& lastErrorMsg /*optional*/, Zstring& password) : + PasswordPromptDlgGenerated(parent), + passwordOut_(password) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + wxString titleTmp; + if (!parent || !parent->IsShownOnScreen()) + titleTmp = wxTheApp->GetAppDisplayName(); + SetTitle(titleTmp); + + const int maxWidthDip = 600; + + m_staticTextMain->SetLabelText(msg); + m_staticTextMain->Wrap(dipToWxsize(maxWidthDip)); + + m_checkBoxShowPassword->SetValue(false); + + m_textCtrlPasswordHidden->ChangeValue(utfTo(password)); + + bSizerError->Show(!lastErrorMsg.empty()); + if (!lastErrorMsg.empty()) + { + setImage(*m_bitmapError, loadImage("msg_error", dipToWxsize(32))); + + m_staticTextError->SetLabelText(lastErrorMsg); + m_staticTextError->Wrap(dipToWxsize(maxWidthDip) - m_bitmapError->GetSize().x - 10 /*border in non-DIP pixel*/); + } + + //set up default view for dialog size calculation + m_textCtrlPasswordVisible->Hide(); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + updateGui(); //*after* SetSizeHints when standard dialog height has been calculated + + //m_textCtrlPasswordHidden->SelectAll(); -> apparantly implicitly caused by SetFocus!? + m_textCtrlPasswordHidden->SetFocus(); +} + + +void PasswordPromptDlg::onToggleShowPassword(wxCommandEvent& event) +{ + if (m_checkBoxShowPassword->GetValue()) + m_textCtrlPasswordVisible->ChangeValue(m_textCtrlPasswordHidden->GetValue()); + else + m_textCtrlPasswordHidden->ChangeValue(m_textCtrlPasswordVisible->GetValue()); + + updateGui(); + + wxTextCtrl& textCtrl = *(m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden); + textCtrl.SetFocus(); //macOS: selects text as unwanted side effect => *before* SetInsertionPointEnd() + textCtrl.SetInsertionPointEnd(); +} + + +void PasswordPromptDlg::updateGui() +{ + m_textCtrlPasswordVisible->Show( m_checkBoxShowPassword->GetValue()); + m_textCtrlPasswordHidden ->Show(!m_checkBoxShowPassword->GetValue()); + + Layout(); + Refresh(); +} + + +void PasswordPromptDlg::onOkay(wxCommandEvent& event) +{ + passwordOut_ = utfTo((m_checkBoxShowPassword->GetValue() ? m_textCtrlPasswordVisible : m_textCtrlPasswordHidden)->GetValue()); + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showPasswordPrompt(wxWindow* parent, const std::wstring& msg, const std::wstring& lastErrorMsg /*optional*/, Zstring& password) +{ + PasswordPromptDlg dlg(parent, msg, lastErrorMsg, password); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class CfgHighlightDlg : public CfgHighlightDlgGenerated +{ +public: + CfgHighlightDlg(wxWindow* parent, int& cfgHistSyncOverdueDays); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + //work around defunct keyboard focus on macOS (or is it wxMac?) => not needed for this dialog! + //void onLocalKeyEvent(wxKeyEvent& event); + + //output-only parameters: + int& cfgHistSyncOverdueDaysOut_; +}; + + +CfgHighlightDlg::CfgHighlightDlg(wxWindow* parent, int& cfgHistSyncOverdueDays) : + CfgHighlightDlgGenerated(parent), + cfgHistSyncOverdueDaysOut_(cfgHistSyncOverdueDays) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + m_staticTextHighlight->Wrap(dipToWxsize(300)); + + setDefaultWidth(*m_spinCtrlOverdueDays); + + m_spinCtrlOverdueDays->SetValue(cfgHistSyncOverdueDays); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_spinCtrlOverdueDays->SetFocus(); +} + + +void CfgHighlightDlg::onOkay(wxCommandEvent& event) +{ + cfgHistSyncOverdueDaysOut_ = m_spinCtrlOverdueDays->GetValue(); + EndModal(static_cast(ConfirmationButton::accept)); +} +} + +ConfirmationButton fff::showCfgHighlightDlg(wxWindow* parent, int& cfgHistSyncOverdueDays) +{ + CfgHighlightDlg dlg(parent, cfgHistSyncOverdueDays); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +namespace +{ +class ActivationDlg : public ActivationDlgGenerated +{ +public: + ActivationDlg(wxWindow* parent, const std::wstring& lastErrorMsg, const std::wstring& manualActivationUrl, std::wstring& manualActivationKey); + +private: + void onActivateOnline (wxCommandEvent& event) override; + void onActivateOffline(wxCommandEvent& event) override; + void onOfflineActivationEnter(wxCommandEvent& event) override { onActivateOffline(event); } + void onCopyUrl (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ActivationDlgButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ActivationDlgButton::cancel)); } + + std::wstring& manualActivationKeyOut_; //in/out parameter +}; + + +ActivationDlg::ActivationDlg(wxWindow* parent, + const std::wstring& lastErrorMsg, + const std::wstring& manualActivationUrl, + std::wstring& manualActivationKey) : + ActivationDlgGenerated(parent), + manualActivationKeyOut_(manualActivationKey) +{ + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setCancel(m_buttonCancel)); + + std::wstring title = L"FreeFileSync " + utfTo(ffsVersion); + SetTitle(title); + + //setMainInstructionFont(*m_staticTextMain); + + m_richTextLastError ->SetMinSize({-1, m_richTextLastError ->GetCharHeight() * 8}); + m_richTextManualActivationUrl ->SetMinSize({-1, m_richTextManualActivationUrl->GetCharHeight() * 4}); + m_textCtrlOfflineActivationKey->SetMinSize({dipToWxsize(260), -1}); + + setImage(*m_bitmapActivation, loadImage("internet")); + m_textCtrlOfflineActivationKey->ForceUpper(); + + setTextWithUrls(*m_richTextLastError, lastErrorMsg); + setTextWithUrls(*m_richTextManualActivationUrl, manualActivationUrl); + + m_textCtrlOfflineActivationKey->ChangeValue(manualActivationKey); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + m_buttonActivateOnline->SetFocus(); +} + + +void ActivationDlg::onCopyUrl(wxCommandEvent& event) +{ + setClipboardText(m_richTextManualActivationUrl->GetValue()); + + m_richTextManualActivationUrl->SetFocus(); //[!] otherwise selection is lost + m_richTextManualActivationUrl->SelectAll(); //some visual feedback +} + + +void ActivationDlg::onActivateOnline(wxCommandEvent& event) +{ + manualActivationKeyOut_ = utfTo(m_textCtrlOfflineActivationKey->GetValue()); + EndModal(static_cast(ActivationDlgButton::activateOnline)); +} + + +void ActivationDlg::onActivateOffline(wxCommandEvent& event) +{ + manualActivationKeyOut_ = utfTo(m_textCtrlOfflineActivationKey->GetValue()); + if (trimCpy(manualActivationKeyOut_).empty()) //alternative: disable button? => user thinks option is not available! + { + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a key for offline activation."))); + m_textCtrlOfflineActivationKey->SetFocus(); + return; + } + + EndModal(static_cast(ActivationDlgButton::activateOffline)); +} +} + +ActivationDlgButton fff::showActivationDialog(wxWindow* parent, const std::wstring& lastErrorMsg, const std::wstring& manualActivationUrl, std::wstring& manualActivationKey) +{ + ActivationDlg dlg(parent, lastErrorMsg, manualActivationUrl, manualActivationKey); + return static_cast(dlg.ShowModal()); +} + +//######################################################################################## + +class DownloadProgressWindow::Impl : public DownloadProgressDlgGenerated +{ +public: + Impl(wxWindow* parent, int64_t fileSizeTotal); + + void notifyNewFile (const Zstring& filePath) { filePath_ = filePath; } + void notifyProgress(int64_t delta) { bytesCurrent_ += delta; } + + void requestUiUpdate() //throw CancelPressed + { + if (cancelled_) + throw CancelPressed(); + + if (uiUpdateDue()) + { + updateGui(); + //wxTheApp->Yield(); + ::wxSafeYield(this); //disables user input except for "this" (using wxWindowDisabler instead would move the FFS main dialog into the background: why?) + } + } + +private: + void onCancel(wxCommandEvent& event) override { cancelled_ = true; } + + void updateGui() + { + const double fraction = bytesTotal_ == 0 ? 0 : 1.0 * bytesCurrent_ / bytesTotal_; + m_staticTextHeader->SetLabelText(_("Downloading update...") + L' ' + formatProgressPercent(fraction) + L" (" + formatFilesizeShort(bytesCurrent_) + L')'); + m_gaugeProgress->SetValue(std::floor(fraction * GAUGE_FULL_RANGE)); + + m_staticTextDetails->SetLabelText(utfTo(filePath_)); + } + + bool cancelled_ = false; + int64_t bytesCurrent_ = 0; + const int64_t bytesTotal_; + Zstring filePath_; + const int GAUGE_FULL_RANGE = 1000'000; +}; + + +DownloadProgressWindow::Impl::Impl(wxWindow* parent, int64_t fileSizeTotal) : + DownloadProgressDlgGenerated(parent), + bytesTotal_(fileSizeTotal) +{ + + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setCancel(m_buttonCancel)); + + setMainInstructionFont(*m_staticTextHeader); + m_staticTextHeader->Wrap(dipToWxsize(460)); //*after* font change! + + m_staticTextDetails->SetMinSize({dipToWxsize(550), -1}); + + setImage(*m_bitmapDownloading, loadImage("internet")); + + m_gaugeProgress->SetRange(GAUGE_FULL_RANGE); + + updateGui(); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + Show(); + + //clear gui flicker: window must be visible to make this work! + ::wxSafeYield(); //at least on OS X a real Yield() is required to flush pending GUI updates; Update() is not enough + + m_buttonCancel->SetFocus(); +} + + +DownloadProgressWindow::DownloadProgressWindow(wxWindow* parent, int64_t fileSizeTotal) : + pimpl_(new DownloadProgressWindow::Impl(parent, fileSizeTotal)) {} + +DownloadProgressWindow::~DownloadProgressWindow() { pimpl_->Destroy(); } + +void DownloadProgressWindow::notifyNewFile(const Zstring& filePath) { pimpl_->notifyNewFile(filePath); } +void DownloadProgressWindow::notifyProgress(int64_t delta) { pimpl_->notifyProgress(delta); } +void DownloadProgressWindow::requestUiUpdate() { pimpl_->requestUiUpdate(); } //throw CancelPressed + +//######################################################################################## + diff --git a/FreeFileSync/Source/ui/small_dlgs.h b/FreeFileSync/Source/ui/small_dlgs.h new file mode 100644 index 0000000..f29b3a9 --- /dev/null +++ b/FreeFileSync/Source/ui/small_dlgs.h @@ -0,0 +1,78 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SMALL_DLGS_H_8321790875018750245 +#define SMALL_DLGS_H_8321790875018750245 + +#include +#include "../base/synchronization.h" +#include "../config.h" + + +namespace fff +{ +//parent window, optional: support correct dialog placement above parent on multiple monitor systems + +void showAboutDialog(wxWindow* parent); + +zen::ConfirmationButton showCopyToDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + Zstring& targetFolderPath, Zstring& targetFolderLastSelected, + std::vector& folderPathHistory, size_t folderPathHistoryMax, + Zstring& sftpKeyFileLastSelected, + bool& keepRelPaths, + bool& overwriteIfExists); + +zen::ConfirmationButton showDeleteDialog(wxWindow* parent, + const std::wstring& itemList, int itemCount, + bool& useRecycleBin); + +zen::ConfirmationButton showSyncConfirmationDlg(wxWindow* parent, + bool syncSelection, + std::optional syncVar, + const SyncStatistics& statistics, + bool& dontShowAgain); + +zen::ConfirmationButton showOptionsDlg(wxWindow* parent, GlobalConfig& globalCfg); + +zen::ConfirmationButton showSelectTimespanDlg(wxWindow* parent, time_t& timeFrom, time_t& timeTo); + +zen::ConfirmationButton showPasswordPrompt(wxWindow* parent, const std::wstring& msg, const std::wstring& lastErrorMsg /*optional*/, Zstring& password); + +zen::ConfirmationButton showCfgHighlightDlg(wxWindow* parent, int& cfgHistSyncOverdueDays); + +zen::ConfirmationButton showCloudSetupDialog(wxWindow* parent, Zstring& folderPathPhrase, Zstring& sftpKeyFileLastSelected, + size_t& parallelOps, bool canChangeParallelOp); + +enum class ActivationDlgButton +{ + cancel, + activateOnline, + activateOffline, +}; +ActivationDlgButton showActivationDialog(wxWindow* parent, const std::wstring& lastErrorMsg, const std::wstring& manualActivationUrl, std::wstring& manualActivationKey); + + +class DownloadProgressWindow //temporary progress info => life-time: stack +{ +public: + DownloadProgressWindow(wxWindow* parent, int64_t fileSizeTotal); + ~DownloadProgressWindow(); + + struct CancelPressed {}; + void notifyNewFile(const Zstring& filePath); + void notifyProgress(int64_t delta); + void requestUiUpdate(); //throw CancelPressed + +private: + class Impl; + Impl* const pimpl_; +}; + + +} + +#endif //SMALL_DLGS_H_8321790875018750245 diff --git a/FreeFileSync/Source/ui/sync_cfg.cpp b/FreeFileSync/Source/ui/sync_cfg.cpp new file mode 100644 index 0000000..110a847 --- /dev/null +++ b/FreeFileSync/Source/ui/sync_cfg.cpp @@ -0,0 +1,1859 @@ +// ***************************************************************************** +// * 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 "sync_cfg.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "gui_generated.h" +#include "folder_selector.h" +#include "../base/norm_filter.h" +#include "../base/file_hierarchy.h" +#include "../base/icon_loader.h" +#include "../afs/concrete.h" +#include "../base_tools.h" + + + +using namespace zen; +using namespace fff; + + +namespace +{ +const int CFG_DESCRIPTION_WIDTH_DIP = 250; +const wchar_t arrowRight[] = L"\u2192"; //"RIGHTWARDS ARROW" + + +void initBitmapRadioButtons(const std::vector>& buttons, bool alignLeft) +{ + const bool physicalLeft = alignLeft == (wxTheApp->GetLayoutDirection() != wxLayout_RightToLeft); + + auto generateSelectImage = [physicalLeft](wxButton& btn, const std::string& imgName, bool selected) + { + wxImage imgTxt = createImageFromText(btn.GetLabelText(), btn.GetFont(), + selected ? *wxBLACK : //accessibility: always set both foreground AND background colors! see renderSelectedButton() + btn.GetForegroundColour()); + + wxImage imgIco = mirrorIfRtl(loadImage(imgName, -1 /*maxWidth*/, dipToScreen(getMenuIconDipSize()))); + + if (imgName == "delete_recycler") //use system icon if available (can fail on Linux??) + try { imgIco = extractWxImage(fff::getTrashIcon(dipToScreen(getMenuIconDipSize()))); /*throw SysError*/ } + catch (SysError&) { assert(false); } + + if (!selected) + imgIco = greyScale(imgIco); + + wxImage imgStack = physicalLeft ? + stackImages(imgIco, imgTxt, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)) : + stackImages(imgTxt, imgIco, ImageStackLayout::horizontal, ImageStackAlignment::center, dipToScreen(5)); + + return resizeCanvas(imgStack, imgStack.GetSize() + wxSize(dipToScreen(14), dipToScreen(12)), wxALIGN_CENTER); + }; + + wxSize maxExtent; + std::unordered_map labelsNotSel; + for (auto& [btn, imgName] : buttons) + { + wxImage img = generateSelectImage(*btn, imgName, false /*selected*/); + maxExtent.x = std::max(maxExtent.x, img.GetWidth()); + maxExtent.y = std::max(maxExtent.y, img.GetHeight()); + + labelsNotSel[btn] = std::move(img); + } + + for (auto& [btn, imgName] : buttons) + { + btn->init(layOver(rectangleImage(maxExtent, getColorToggleButtonFill(), getColorToggleButtonBorder(), dipToScreen(1)), + generateSelectImage(*btn, imgName, true /*selected*/), wxALIGN_CENTER_VERTICAL | (physicalLeft ? wxALIGN_LEFT : wxALIGN_RIGHT)), + resizeCanvas(labelsNotSel[btn], maxExtent, wxALIGN_CENTER_VERTICAL | (physicalLeft ? wxALIGN_LEFT : wxALIGN_RIGHT))); + + btn->SetMinSize({screenToWxsize(maxExtent.x), + screenToWxsize(maxExtent.y)}); //get rid of selection border on Windows + macOS :) + //SetMinSize() instead of SetSize() is needed here for wxWindows layout determination to work correctly + } +} + + +bool sanitizeFilter(FilterConfig& filterCfg, const std::vector& baseFolderPaths, wxWindow* parent) +{ + //include filter must not be empty! + if (trimCpy(filterCfg.includeFilter).empty()) + filterCfg.includeFilter = FilterConfig().includeFilter; //no need to show error message, just correct user input + + + //replace full paths by relative ones: frequent user error => help out: https://freefilesync.org/forum/viewtopic.php?t=9225 + auto normalizeForSearch = [](Zstring str) + { + //1. ignore Unicode normalization form 2. ignore case 3. normalize path separator + str = getUpperCase(str); //getUnicodeNormalForm() is implied by getUpperCase() + + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) std::replace(str.begin(), str.end(), Zstr('/'), FILE_NAME_SEPARATOR); + if constexpr (FILE_NAME_SEPARATOR != Zstr('\\')) std::replace(str.begin(), str.end(), Zstr('\\'), FILE_NAME_SEPARATOR); + + return str; + }; + + std::vector folderPathsPf; //normalized + postfix path separator + { + const Zstring includeFilterNorm = normalizeForSearch(filterCfg.includeFilter); + const Zstring excludeFilterNorm = normalizeForSearch(filterCfg.excludeFilter); + + for (const AbstractPath& folderPath : baseFolderPaths) + if (!AFS::isNullPath(folderPath)) + if (const std::wstring& displayPath = AFS::getDisplayPath(folderPath); + !displayPath.empty()) + if (displayPath != L"/") //Linux/macOS: https://freefilesync.org/forum/viewtopic.php?t=9713 + if (const Zstring pathNormPf = appendSeparator(normalizeForSearch(utfTo(displayPath))); + contains(includeFilterNorm, pathNormPf) || //perf!? + contains(excludeFilterNorm, pathNormPf)) // + folderPathsPf.push_back(pathNormPf); + + removeDuplicates(folderPathsPf); + } + + + std::vector> replacements; + + auto replaceFullPaths = [&](Zstring& filterPhrase) + { + Zstring filterPhraseNew; + const Zchar* itFilterOrig = filterPhrase.begin(); + + split2(filterPhrase, [](Zchar c) { return c == FILTER_ITEM_SEPARATOR || c == Zstr('\n'); }, //delimiters + [&](const ZstringView phrase) + { + const ZstringView phraseTrm = trimCpy(phrase); + if (!phraseTrm.empty()) + { + const Zstring phraseNorm = normalizeForSearch(Zstring{phraseTrm}); + + for (const Zstring& pathNormPf : folderPathsPf) + if (startsWith(phraseNorm, pathNormPf)) + { + //emulate a "normalized afterFirst()": + ptrdiff_t sepCount = std::count(pathNormPf.begin(), pathNormPf.end(), FILE_NAME_SEPARATOR); + assert(sepCount > 0); + + for (auto it = phraseTrm.begin(); it != phraseTrm.end(); ++it) + if (*it == Zstr('/') || + *it == Zstr('\\')) + if (--sepCount == 0) + { + const Zstring relPath(it, phraseTrm.end()); //include first path separator + + filterPhraseNew.append(itFilterOrig, phraseTrm.data()); + filterPhraseNew += relPath; + itFilterOrig = phraseTrm.data() + phraseTrm.size(); + + replacements.emplace_back(phraseTrm, relPath); + return; //... to next block + } + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + } + }); + + if (itFilterOrig != filterPhrase.begin()) //perf!? + { + filterPhraseNew.append(itFilterOrig, filterPhrase.cend()); + filterPhrase = std::move(filterPhraseNew); + } + }; + replaceFullPaths(filterCfg.includeFilter); + replaceFullPaths(filterCfg.excludeFilter); + + if (!replacements.empty()) + { + std::wstring detailsMsg; + for (const auto& [from, to] : replacements) + if (to.empty()) + detailsMsg += _("Remove:") + L' ' + utfTo(from) + L'\n'; + else + detailsMsg += utfTo(from) + L' ' + arrowRight + L' ' + utfTo(to) + L'\n'; + detailsMsg.pop_back(); + + switch (showConfirmationDialog(parent, DialogInfoType::info, PopupDialogCfg(). + setMainInstructions(_("Each filter item must be a path relative to the selected folder pairs. The following changes are suggested:")). + setDetailInstructions(detailsMsg), _("&Change"))) + { + case ConfirmationButton::accept: //change + break; + + case ConfirmationButton::cancel: + return false; + } + } + return true; +} + +//========================================================================== + +class ConfigDialog : public ConfigDlgGenerated +{ +public: + ConfigDialog(wxWindow* parent, + SyncConfigPanel panelToShow, + int localPairIndexToShow, bool showMultipleCfgs, + GlobalPairConfig& globalPairCfg, + std::vector& localPairCfg, + FilterConfig& defaultFilter, + std::vector& versioningFolderHistory, Zstring& versioningFolderLastSelected, + std::vector& logFolderHistory, Zstring& logFolderLastSelected, const Zstring& globalLogFolderPhrase, + size_t folderHistoryMax, Zstring& sftpKeyFileLastSelected, + std::vector& emailHistory, size_t emailHistoryMax, + std::vector& commandHistory, size_t commandHistoryMax); + + ~ConfigDialog(); + +private: + void onOkay (wxCommandEvent& event) override; + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton::cancel)); } + + void onAddNotes(wxCommandEvent& event) override; + + void onLocalKeyEvent(wxKeyEvent& event); + void onListBoxKeyEvent(wxKeyEvent& event) override; + void onSelectFolderPair(wxCommandEvent& event) override; + + enum class ConfigTypeImage + { + compare = 0, //used as zero-based wxImageList index! + compareGrey, + filter, + filterGrey, + sync, + syncGrey, + }; + + //------------- comparison panel ---------------------- + void onToggleLocalCompSettings(wxCommandEvent& event) override { updateCompGui(); updateSyncGui(); /*affects sync settings, too!*/ } + void onToggleIgnoreErrors (wxCommandEvent& event) override { updateMiscGui(); } + void onToggleAutoRetry (wxCommandEvent& event) override { updateMiscGui(); } + + void onCompByTimeSize (wxCommandEvent& event) override { localCmpVar_ = CompareVariant::timeSize; updateCompGui(); updateSyncGui(); } // + void onCompByContent (wxCommandEvent& event) override { localCmpVar_ = CompareVariant::content; updateCompGui(); updateSyncGui(); } //affects sync settings, too! + void onCompBySize (wxCommandEvent& event) override { localCmpVar_ = CompareVariant::size; updateCompGui(); updateSyncGui(); } // + void onCompByTimeSizeDouble(wxMouseEvent& event) override; + void onCompByContentDouble (wxMouseEvent& event) override; + void onCompBySizeDouble (wxMouseEvent& event) override; + void onChangeCompOption (wxCommandEvent& event) override { updateCompGui(); } + + std::optional getCompConfig() const; + void setCompConfig(const CompConfig* compCfg); + + void updateCompGui(); + + CompareVariant localCmpVar_ = CompareVariant::timeSize; + + std::set devicesForEdit_; //helper data for deviceParallelOps + std::map deviceParallelOps_; // + + //------------- filter panel -------------------------- + void onChangeFilterOption(wxCommandEvent& event) override { updateFilterGui(); } + void onFilterClear (wxCommandEvent& event) override { setFilterConfig(FilterConfig()); } + void onFilterDefault (wxCommandEvent& event) override { setFilterConfig(defaultFilterOut_); } + + void onFilterDefaultContext (wxCommandEvent& event) override { onFilterDefaultContext(static_cast(event)); } + void onFilterDefaultContextMouse(wxMouseEvent& event) override { onFilterDefaultContext(static_cast(event)); } + void onFilterDefaultContext(wxEvent& event); + + FilterConfig getFilterConfig() const; + void setFilterConfig(const FilterConfig& filter); + + void updateFilterGui(); + + EnumDescrList enumTimeDescr_ + { + *m_choiceUnitTimespan, + { + {UnitTime::none, L'(' + _("None") + L')', {}}, //meta options should be enclosed in parentheses + {UnitTime::today, _("Today"), {}}, + //{UnitTime::THIS_WEEK, _("This week"), {}}, + {UnitTime::thisMonth, _("This month"), {}}, + {UnitTime::thisYear, _("This year"), {}}, + {UnitTime::lastDays, _("Last x days:"), {}}, + } + }; + EnumDescrList enumMinSizeDescr_ + { + *m_choiceUnitMinSize, + { + {UnitSize::none, L'(' + _("None") + L')', {}}, //meta options should be enclosed in parentheses + {UnitSize::byte, _("Byte"), {}}, + {UnitSize::kb, _("KB"), {}}, + {UnitSize::mb, _("MB"), {}}, + } + }; + + EnumDescrList enumMaxSizeDescr_{*m_choiceUnitMaxSize, enumMinSizeDescr_.getConfig()}; + + //------------- synchronization panel ----------------- + void onSyncTwoWay(wxCommandEvent& event) override { directionsCfg_ = getDefaultSyncCfg(SyncVariant::twoWay); updateSyncGui(); } + void onSyncMirror(wxCommandEvent& event) override { directionsCfg_ = getDefaultSyncCfg(SyncVariant::mirror); updateSyncGui(); } + void onSyncUpdate(wxCommandEvent& event) override { directionsCfg_ = getDefaultSyncCfg(SyncVariant::update); updateSyncGui(); } + void onSyncCustom(wxCommandEvent& event) override { directionsCfg_ = getDefaultSyncCfg(SyncVariant::custom); updateSyncGui(); } + + void onToggleLocalSyncSettings(wxCommandEvent& event) override { updateSyncGui(); } + void onToggleUseDatabase (wxCommandEvent& event) override; + void onChangeVersioningStyle (wxCommandEvent& event) override { updateSyncGui(); } + void onToggleVersioningLimit (wxCommandEvent& event) override { updateSyncGui(); } + + void onSyncTwoWayDouble(wxMouseEvent& event) override; + void onSyncMirrorDouble(wxMouseEvent& event) override; + void onSyncUpdateDouble(wxMouseEvent& event) override; + void onSyncCustomDouble(wxMouseEvent& event) override; + + void onLeftOnly (wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByDiff::leftOnly); } + void onRightOnly (wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByDiff::rightOnly); } + void onLeftNewer (wxCommandEvent& event) override; + void onRightNewer(wxCommandEvent& event) override; + void onDifferent (wxCommandEvent& event) override; + void toggleSyncDirButton(SyncDirection DirectionByDiff::* dir); + + void onLeftCreate (wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::left, &DirectionByChange::Changes::create); } + void onLeftUpdate (wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::left, &DirectionByChange::Changes::update); } + void onLeftDelete (wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::left, &DirectionByChange::Changes::delete_); } + void onRightCreate(wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::right, &DirectionByChange::Changes::create); } + void onRightUpdate(wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::right, &DirectionByChange::Changes::update); } + void onRightDelete(wxCommandEvent& event) override { toggleSyncDirButton(&DirectionByChange::right, &DirectionByChange::Changes::delete_); } + void toggleSyncDirButton(DirectionByChange::Changes DirectionByChange::* side, SyncDirection DirectionByChange::Changes::* dir); + + void onDeletionPermanent (wxCommandEvent& event) override { deletionVariant_ = DeletionVariant::permanent; updateSyncGui(); } + void onDeletionRecycler (wxCommandEvent& event) override { deletionVariant_ = DeletionVariant::recycler; updateSyncGui(); } + void onDeletionVersioning(wxCommandEvent& event) override { deletionVariant_ = DeletionVariant::versioning; updateSyncGui(); } + + void onToggleMiscOption(wxCommandEvent& event) override { updateMiscGui(); } + void onToggleMiscEmail (wxCommandEvent& event) override + { + onToggleMiscOption(event); + if (event.IsChecked()) //optimize UX + m_comboBoxEmail->SetFocus(); // + } + void onEmailAlways (wxCommandEvent& event) override { emailNotifyCondition_ = ResultsNotification::always; updateMiscGui(); } + void onEmailErrorWarning(wxCommandEvent& event) override { emailNotifyCondition_ = ResultsNotification::errorWarning; updateMiscGui(); } + void onEmailErrorOnly (wxCommandEvent& event) override { emailNotifyCondition_ = ResultsNotification::errorOnly; updateMiscGui(); } + + void onShowLogFolder(wxCommandEvent& event) override; + + std::optional getSyncConfig() const; + void setSyncConfig(const SyncConfig* syncCfg); + + bool leftRightNewerCombined() const; + + void updateSyncGui(); + //----------------------------------------------------- + + //parameters with ownership NOT within GUI controls! + SyncDirectionConfig directionsCfg_; + DeletionVariant deletionVariant_ = DeletionVariant::recycler; //use Recycler, delete permanently or move to user-defined location + + const std::function getDeviceParallelOps_; + const std::function setDeviceParallelOps_; + + FolderSelector versioningFolder_; + EnumDescrList enumVersioningStyle_ + { + *m_choiceVersioningStyle, + { + {VersioningStyle::replace, _("Replace"), _("Move files and replace if existing")}, + {VersioningStyle::timestampFolder, _("Time stamp") + L" [" + _("Folder") + L']', _("Move files into a time-stamped subfolder")}, + {VersioningStyle::timestampFile, _("Time stamp") + L" [" + _("File") + L']', _("Append a time stamp to each file name")}, + } + }; + + ResultsNotification emailNotifyCondition_ = ResultsNotification::always; + + EnumDescrList enumPostSyncCondition_ + { + *m_choicePostSyncCondition, + { + {PostSyncCondition::completion, _("On completion:"), {}}, + {PostSyncCondition::errors, _("On errors:"), {}}, + {PostSyncCondition::success, _("On success:"), {}}, + } + }; + + FolderSelector logFolderSelector_; + //----------------------------------------------------- + + MiscSyncConfig getMiscSyncOptions() const; + void setMiscSyncOptions(const MiscSyncConfig& miscCfg); + + void updateMiscGui(); + + //----------------------------------------------------- + + void selectFolderPairConfig(int newPairIndexToShow); + bool unselectFolderPairConfig(bool validateParams); //returns false on error: shows message box! + + //output parameters (sync config) + GlobalPairConfig& globalPairCfgOut_; + std::vector& localPairCfgOut_; + //output parameters (global) -> ignores OK/Cancel + FilterConfig& defaultFilterOut_; + std::vector& versioningFolderHistoryOut_; + std::vector& logFolderHistoryOut_; + std::vector& emailHistoryOut_; + std::vector& commandHistoryOut_; + + //working copy of ALL config parameters: only one folder pair is selected at a time! + GlobalPairConfig globalPairCfg_; + std::vector localPairCfg_; + + int selectedPairIndexToShow_ = EMPTY_PAIR_INDEX_SELECTED; + static constexpr int EMPTY_PAIR_INDEX_SELECTED = -2; + + bool showNotesPanel_ = false; + + const bool enableExtraFeatures_; + const bool showMultipleCfgs_; + + const Zstring globalLogFolderPhrase_; +}; + +//################################################################################################################# + +std::wstring getCompVariantDescription(CompareVariant var) +{ + switch (var) + { + case CompareVariant::timeSize: + return _("Identify equal files by comparing modification time and size."); + case CompareVariant::content: + return _("Identify equal files by comparing the file content."); + case CompareVariant::size: + return _("Identify equal files by comparing their file size."); + } + assert(false); + return _("Error"); +} + + +std::wstring getSyncVariantDescription(SyncVariant var) +{ + switch (var) + { + case SyncVariant::twoWay: + return _("Identify and propagate changes on both sides. Deletions, moves and conflicts are detected automatically using a database."); + case SyncVariant::mirror: + return _("Create a mirror backup of the left folder by adapting the right folder to match."); + case SyncVariant::update: + return _("Copy new and updated files to the right folder."); + case SyncVariant::custom: + return _("Configure your own synchronization rules."); + } + assert(false); + return _("Error"); +} + +//========================================================================== + +ConfigDialog::ConfigDialog(wxWindow* parent, + SyncConfigPanel panelToShow, + int localPairIndexToShow, bool showMultipleCfgs, + GlobalPairConfig& globalPairCfg, + std::vector& localPairCfg, + FilterConfig& defaultFilter, + std::vector& versioningFolderHistory, Zstring& versioningFolderLastSelected, + std::vector& logFolderHistory, Zstring& logFolderLastSelected, + const Zstring& globalLogFolderPhrase, + size_t folderHistoryMax, Zstring& sftpKeyFileLastSelected, + std::vector& emailHistory, size_t emailHistoryMax, + std::vector& commandHistory, size_t commandHistoryMax) : + ConfigDlgGenerated(parent), + + getDeviceParallelOps_([this](const Zstring& folderPathPhrase) +{ + assert(selectedPairIndexToShow_ == -1 || makeUnsigned(selectedPairIndexToShow_) < localPairCfg_.size()); + const auto& deviceParallelOps = selectedPairIndexToShow_ < 0 ? getMiscSyncOptions().deviceParallelOps : globalPairCfg_.miscCfg.deviceParallelOps; //ternary-WTF! + + return getDeviceParallelOps(deviceParallelOps, folderPathPhrase); +}), + +setDeviceParallelOps_([this](const Zstring& folderPathPhrase, size_t parallelOps) //setDeviceParallelOps() +{ + assert(selectedPairIndexToShow_ == -1 || makeUnsigned(selectedPairIndexToShow_) < localPairCfg_.size()); + if (selectedPairIndexToShow_ < 0) + { + MiscSyncConfig miscCfg = getMiscSyncOptions(); + setDeviceParallelOps(miscCfg.deviceParallelOps, folderPathPhrase, parallelOps); + setMiscSyncOptions(miscCfg); + } + else + setDeviceParallelOps(globalPairCfg_.miscCfg.deviceParallelOps, folderPathPhrase, parallelOps); +}), + +versioningFolder_(this, *m_panelVersioning, *m_buttonSelectVersioningFolder, *m_bpButtonSelectVersioningAltFolder, *m_versioningFolderPath, versioningFolderLastSelected, sftpKeyFileLastSelected, + nullptr /*staticText*/, nullptr /*dropWindow2*/, nullptr /*droppedPathsFilter*/, getDeviceParallelOps_, setDeviceParallelOps_), + +logFolderSelector_(this, *m_panelLogfile, *m_buttonSelectLogFolder, *m_bpButtonSelectAltLogFolder, *m_logFolderPath, logFolderLastSelected, sftpKeyFileLastSelected, + nullptr /*staticText*/, nullptr /*dropWindow2*/, nullptr /*droppedPathsFilter*/, getDeviceParallelOps_, setDeviceParallelOps_), + +globalPairCfgOut_(globalPairCfg), +localPairCfgOut_(localPairCfg), +defaultFilterOut_(defaultFilter), +versioningFolderHistoryOut_(versioningFolderHistory), +logFolderHistoryOut_(logFolderHistory), +emailHistoryOut_(emailHistory), +commandHistoryOut_(commandHistory), +globalPairCfg_(globalPairCfg), +localPairCfg_(localPairCfg), +showNotesPanel_(!globalPairCfg.miscCfg.notes.empty()), + enableExtraFeatures_(false), +showMultipleCfgs_(showMultipleCfgs), +globalLogFolderPhrase_(globalLogFolderPhrase) +{ + assert(!AFS::isNullPath(createAbstractPath(globalLogFolderPhrase_))); + + setStandardButtonLayout(*bSizerStdButtons, StdButtons().setAffirmative(m_buttonOK).setCancel(m_buttonCancel)); + + + setBitmapTextLabel(*m_buttonAddNotes, loadImage("notes", dipToScreen(16)), m_buttonAddNotes->GetLabelText()); + + setImage(*m_bitmapNotes, loadImage("notes", dipToScreen(20))); + + //set reasonable default height for notes: simplistic algorithm neglecting line-wrap! + int notesRows = 1; + for (wchar_t c : trimCpy(globalPairCfg.miscCfg.notes)) + if (c == L'\n') + ++notesRows; + + double visibleRows = 5; + if (showNotesPanel_) + visibleRows = notesRows <= 10 ? notesRows : 10.5; //add half a row as visual hint + m_textCtrNotes->SetMinSize({-1, getTextCtrlHeight(*m_textCtrNotes, visibleRows)}); + + + m_notebook->SetPadding(wxSize(dipToWxsize(2), 0)); //height cannot be changed + + //fill image list to cope with wxNotebook image setting design desaster... + const int imgListSize = dipToWxsize(16); //also required by GTK => don't use getMenuIconDipSize() + auto imgList = std::make_unique(imgListSize, imgListSize); + + auto addToImageList = [&](const wxImage& img) + { + imgList->Add(toScaledBitmap(img)); + imgList->Add(toScaledBitmap(greyScale(img))); + }; + //add images in same sequence like ConfigTypeImage enum!!! + addToImageList(loadImage("options_compare", wxsizeToScreen(imgListSize))); + addToImageList(loadImage("options_filter", wxsizeToScreen(imgListSize))); + addToImageList(loadImage("options_sync", wxsizeToScreen(imgListSize))); + assert(imgList->GetImageCount() == static_cast(ConfigTypeImage::syncGrey) + 1); + + m_notebook->AssignImageList(imgList.release()); //pass ownership + + + m_notebook->SetPageText(static_cast(SyncConfigPanel::compare), _("Comparison") + L" (F6)"); + m_notebook->SetPageText(static_cast(SyncConfigPanel::filter ), _("Filter") + L" (F7)"); + m_notebook->SetPageText(static_cast(SyncConfigPanel::sync ), _("Synchronization") + L" (F8)"); + + m_notebook->ChangeSelection(static_cast(panelToShow)); + + //------------- comparison panel ---------------------- + setRelativeFontSize(*m_buttonByTimeSize, 1.25); + setRelativeFontSize(*m_buttonByContent, 1.25); + setRelativeFontSize(*m_buttonBySize, 1.25); + + initBitmapRadioButtons( + { + {m_buttonByTimeSize, "cmp_time" }, + {m_buttonByContent, "cmp_content"}, + {m_buttonBySize, "cmp_size" }, + }, true /*alignLeft*/); + + m_buttonByTimeSize->SetToolTip(getCompVariantDescription(CompareVariant::timeSize)); + m_buttonByContent ->SetToolTip(getCompVariantDescription(CompareVariant::content)); + m_buttonBySize ->SetToolTip(getCompVariantDescription(CompareVariant::size)); + + m_staticTextCompVarDescription->SetMinSize({dipToWxsize(CFG_DESCRIPTION_WIDTH_DIP), -1}); + + m_scrolledWindowPerf->SetMinSize({dipToWxsize(220), -1}); + setImage(*m_bitmapPerf, greyScaleIfDisabled(loadImage("speed"), enableExtraFeatures_)); + + const int scrollDelta = GetCharHeight(); + m_scrolledWindowPerf->SetScrollRate(scrollDelta, scrollDelta); + + setDefaultWidth(*m_spinCtrlAutoRetryCount); + setDefaultWidth(*m_spinCtrlAutoRetryDelay); + + //ignore invalid input for time shift control: + wxTextValidator inputValidator(wxFILTER_DIGITS | wxFILTER_INCLUDE_CHAR_LIST); + inputValidator.SetCharIncludes(L"+-;,: "); + m_textCtrlTimeShift->SetValidator(inputValidator); + + //------------- filter panel -------------------------- + m_textCtrlInclude->SetMinSize({dipToWxsize(280), -1}); + + assert(!contains(m_buttonClear->GetLabel(), L"&C") && !contains(m_buttonClear->GetLabel(), L"&c")); //gazillionth wxWidgets bug on OS X: Command + C mistakenly hits "&C" access key! + + setDefaultWidth(*m_spinCtrlMinSize); + setDefaultWidth(*m_spinCtrlMaxSize); + setDefaultWidth(*m_spinCtrlTimespan); + + setImage(*m_bpButtonDefaultContext, mirrorIfRtl(loadImage("button_arrow_right"))); + + //------------- synchronization panel ----------------- + m_buttonTwoWay->SetToolTip(getSyncVariantDescription(SyncVariant::twoWay)); + m_buttonMirror->SetToolTip(getSyncVariantDescription(SyncVariant::mirror)); + m_buttonUpdate->SetToolTip(getSyncVariantDescription(SyncVariant::update)); + m_buttonCustom->SetToolTip(getSyncVariantDescription(SyncVariant::custom)); + + const int catSizeMax = loadImage("cat_left_only").GetWidth() * 8 / 10; + setImage(*m_bitmapLeftOnly, mirrorIfRtl(greyScale(loadImage("cat_left_only", catSizeMax)))); + setImage(*m_bitmapRightOnly, mirrorIfRtl(greyScale(loadImage("cat_right_only", catSizeMax)))); + setImage(*m_bitmapLeftNewer, mirrorIfRtl(greyScale(loadImage("cat_left_newer", catSizeMax)))); + setImage(*m_bitmapRightNewer, mirrorIfRtl(greyScale(loadImage("cat_right_newer", catSizeMax)))); + setImage(*m_bitmapDifferent, mirrorIfRtl(greyScale(loadImage("cat_different", catSizeMax)))); + + setRelativeFontSize(*m_buttonTwoWay, 1.25); + setRelativeFontSize(*m_buttonMirror, 1.25); + setRelativeFontSize(*m_buttonUpdate, 1.25); + setRelativeFontSize(*m_buttonCustom, 1.25); + + initBitmapRadioButtons( + { + {m_buttonTwoWay, "sync_twoway"}, + {m_buttonMirror, "sync_mirror"}, + {m_buttonUpdate, "sync_update"}, + {m_buttonCustom, "sync_custom"}, + }, false /*alignLeft*/); + + m_staticTextSyncVarDescription->SetMinSize({dipToWxsize(CFG_DESCRIPTION_WIDTH_DIP), -1}); + + m_buttonRecycler ->SetToolTip(_("Retain deleted and overwritten files in the recycle bin")); + m_buttonPermanent ->SetToolTip(_("Delete and overwrite files permanently")); + m_buttonVersioning->SetToolTip(_("Move files to a user-defined folder")); + + initBitmapRadioButtons( + { + {m_buttonRecycler, "delete_recycler" }, + {m_buttonPermanent, "delete_permanently"}, + {m_buttonVersioning, "delete_versioning" }, + }, true /*alignLeft*/); + + setDefaultWidth(*m_spinCtrlVersionMaxDays ); + setDefaultWidth(*m_spinCtrlVersionCountMin); + setDefaultWidth(*m_spinCtrlVersionCountMax); + + m_versioningFolderPath->setHistory(std::make_shared(versioningFolderHistory, folderHistoryMax)); + + + const wxImage imgFileManagerSmall_([] + { + try { return extractWxImage(fff::getFileManagerIcon(dipToScreen(20))); /*throw SysError*/ } + catch (SysError&) { assert(false); return loadImage("file_manager", dipToScreen(20)); } + }()); + setImage(*m_bpButtonShowLogFolder, imgFileManagerSmall_); + m_bpButtonShowLogFolder->SetToolTip(translate(extCommandFileManager.description));//translate default external apps on the fly: "Show in Explorer" + + m_logFolderPath->SetHint(utfTo(globalLogFolderPhrase_)); + //1. no text shown when control is disabled! 2. apparently there's a refresh problem on GTK + + m_logFolderPath->setHistory(std::make_shared(logFolderHistory, folderHistoryMax)); + + m_comboBoxEmail->SetHint(/*_("Example:") + */ L"john.doe@example.com"); + m_comboBoxEmail->setHistory(emailHistory, emailHistoryMax); + + m_comboBoxEmail ->Enable(enableExtraFeatures_); + m_bpButtonEmailAlways ->Enable(enableExtraFeatures_); + m_bpButtonEmailErrorWarning ->Enable(enableExtraFeatures_); + m_bpButtonEmailErrorOnly ->Enable(enableExtraFeatures_); + + //m_staticTextPostSync->SetMinSize({dipToWxsize(180), -1}); + + m_comboBoxPostSyncCommand->SetHint(_("Example:") + L" systemctl poweroff"); + + m_comboBoxPostSyncCommand->setHistory(commandHistory, commandHistoryMax); + + //----------------------------------------------------- + // + //enable dialog-specific key events + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); + + assert(!m_listBoxFolderPair->IsSorted()); + + m_listBoxFolderPair->Append(_("All folder pairs")); + for (const LocalPairConfig& lpc : localPairCfg) + { + std::wstring fpName = getShortDisplayNameForFolderPair(createAbstractPath(lpc.folderPathPhraseLeft), + createAbstractPath(lpc.folderPathPhraseRight)); + if (trimCpy(fpName).empty()) + fpName = L"<" + _("empty") + L">"; + + m_listBoxFolderPair->Append(TAB_SPACE + fpName); + } + + if (!showMultipleCfgs) + { + m_listBoxFolderPair->Hide(); + m_staticTextFolderPairLabel->Hide(); + } + + //temporarily set main config as reference for window min size calculations: + globalPairCfg_ = GlobalPairConfig(); + globalPairCfg_.syncCfg.directionCfg = getDefaultSyncCfg(SyncVariant::twoWay); + globalPairCfg_.syncCfg.deletionVariant = DeletionVariant::versioning; + globalPairCfg_.syncCfg.versioningFolderPhrase = Zstr("dummy"); + globalPairCfg_.syncCfg.versioningStyle = VersioningStyle::timestampFile; + globalPairCfg_.syncCfg.versionMaxAgeDays = 30; + globalPairCfg_.miscCfg.autoRetryCount = 1; + globalPairCfg_.miscCfg.altLogFolderPathPhrase = Zstr("dummy"); + globalPairCfg_.miscCfg.emailNotifyAddress = "dummy"; + + selectFolderPairConfig(-1); + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + //keep stable sizer height: change-based directions are taller than difference-based ones => init with SyncVariant::twoWay + bSizerSyncDirHolder ->SetMinSize(-1, bSizerSyncDirsChanges ->GetSize().y); + bSizerVersioningHolder->SetMinSize(-1, bSizerVersioningHolder->GetSize().y); + + unselectFolderPairConfig(false /*validateParams*/); + globalPairCfg_ = globalPairCfg; //restore proper value + + //set actual sync config + selectFolderPairConfig(localPairIndexToShow); + + //more useful and Enter is redirected to m_buttonOK anyway: + (m_listBoxFolderPair->IsShown() ? static_cast(m_listBoxFolderPair) : m_notebook)->SetFocus(); +} + + +void ConfigDialog::onLocalKeyEvent(wxKeyEvent& event) //process key events without explicit menu entry :) +{ + auto changeSelection = [&](SyncConfigPanel panel) + { + m_notebook->ChangeSelection(static_cast(panel)); + (m_listBoxFolderPair->IsShown() ? static_cast(m_listBoxFolderPair) : m_notebook)->SetFocus(); //GTK ignores F-keys if focus is on hidden item! + }; + + switch (event.GetKeyCode()) + { + case WXK_RETURN: + case WXK_NUMPAD_ENTER: + if (event.ControlDown()) //Ctrl+Enter or on macOS: Command+Enter + { + wxCommandEvent dummy(wxEVT_COMMAND_BUTTON_CLICKED); + m_buttonOK->Command(dummy); //simulate click + return; + } + break; + case WXK_F6: + changeSelection(SyncConfigPanel::compare); + return; //handled! + case WXK_F7: + changeSelection(SyncConfigPanel::filter); + return; + case WXK_F8: + changeSelection(SyncConfigPanel::sync); + return; + } + event.Skip(); +} + + +void ConfigDialog::onListBoxKeyEvent(wxKeyEvent& event) +{ + int keyCode = event.GetKeyCode(); + if (m_listBoxFolderPair->GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + + switch (keyCode) + { + case WXK_LEFT: + case WXK_NUMPAD_LEFT: + switch (static_cast(m_notebook->GetSelection())) + { + case SyncConfigPanel::compare: + break; + case SyncConfigPanel::filter: + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::compare)); + break; + case SyncConfigPanel::sync: + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::filter)); + break; + } + m_listBoxFolderPair->SetFocus(); //needed! wxNotebook::ChangeSelection() leads to focus change! + return; //handled! + + case WXK_RIGHT: + case WXK_NUMPAD_RIGHT: + switch (static_cast(m_notebook->GetSelection())) + { + case SyncConfigPanel::compare: + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::filter)); + break; + case SyncConfigPanel::filter: + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::sync)); + break; + case SyncConfigPanel::sync: + break; + } + m_listBoxFolderPair->SetFocus(); + return; //handled! + } + + event.Skip(); +} + + +void ConfigDialog::onSelectFolderPair(wxCommandEvent& event) +{ + assert(!m_listBoxFolderPair->HasMultipleSelection()); //single-choice! + const int selPos = event.GetSelection(); + assert(0 <= selPos && selPos < makeSigned(m_listBoxFolderPair->GetCount())); + + //m_listBoxFolderPair has no parameter ownership! => selectedPairIndexToShow has! + + if (!unselectFolderPairConfig(true /*validateParams*/)) + { + //restore old selection: + m_listBoxFolderPair->SetSelection(selectedPairIndexToShow_ + 1); + return; + } + selectFolderPairConfig(selPos - 1); +} + + +void ConfigDialog::onCompByTimeSizeDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onCompByTimeSize(dummy); + onOkay(dummy); +} + + +void ConfigDialog::onCompBySizeDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onCompBySize(dummy); + onOkay(dummy); +} + + +void ConfigDialog::onCompByContentDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onCompByContent(dummy); + onOkay(dummy); +} + + +std::optional ConfigDialog::getCompConfig() const +{ + if (!m_checkBoxUseLocalCmpOptions->GetValue()) + return {}; + + CompConfig compCfg; + compCfg.compareVar = localCmpVar_; + compCfg.handleSymlinks = !m_checkBoxSymlinksInclude->GetValue() ? SymLinkHandling::exclude : m_radioBtnSymlinksDirect->GetValue() ? SymLinkHandling::asLink : SymLinkHandling::follow; + compCfg.ignoreTimeShiftMinutes = fromTimeShiftPhrase(copyStringTo(m_textCtrlTimeShift->GetValue())); + + return compCfg; +} + + +void ConfigDialog::setCompConfig(const CompConfig* compCfg) +{ + m_checkBoxUseLocalCmpOptions->SetValue(compCfg); + + //when local settings are inactive, display (current) global settings instead: + const CompConfig tmpCfg = compCfg ? *compCfg : globalPairCfg_.cmpCfg; + + localCmpVar_ = tmpCfg.compareVar; + + switch (tmpCfg.handleSymlinks) + { + case SymLinkHandling::exclude: + m_checkBoxSymlinksInclude->SetValue(false); + m_radioBtnSymlinksFollow ->SetValue(true); + break; + case SymLinkHandling::follow: + m_checkBoxSymlinksInclude->SetValue(true); + m_radioBtnSymlinksFollow->SetValue(true); + break; + case SymLinkHandling::asLink: + m_checkBoxSymlinksInclude->SetValue(true); + m_radioBtnSymlinksDirect->SetValue(true); + break; + } + + m_textCtrlTimeShift->ChangeValue(toTimeShiftPhrase(tmpCfg.ignoreTimeShiftMinutes)); + + updateCompGui(); +} + + +void ConfigDialog::updateCompGui() +{ + const bool compOptionsEnabled = m_checkBoxUseLocalCmpOptions->GetValue(); + + m_panelComparisonSettings->Enable(compOptionsEnabled); + + m_notebook->SetPageImage(static_cast(SyncConfigPanel::compare), + static_cast(compOptionsEnabled ? ConfigTypeImage::compare : ConfigTypeImage::compareGrey)); + + //update toggle buttons -> they have no parameter-ownership at all! + m_buttonByTimeSize->setActive(CompareVariant::timeSize == localCmpVar_ && compOptionsEnabled); + m_buttonByContent ->setActive(CompareVariant::content == localCmpVar_ && compOptionsEnabled); + m_buttonBySize ->setActive(CompareVariant::size == localCmpVar_ && compOptionsEnabled); + //compOptionsEnabled: nudge wxWidgets to render inactive config state (needed on Windows, NOT on Linux!) + + switch (localCmpVar_) //unconditionally update image, including "local options off" + { + case CompareVariant::timeSize: + //help wxWidgets a little to render inactive config state (needed on Windows, NOT on Linux!) + setImage(*m_bitmapCompVariant, greyScaleIfDisabled(loadImage("cmp_time"), compOptionsEnabled)); + break; + case CompareVariant::content: + setImage(*m_bitmapCompVariant, greyScaleIfDisabled(loadImage("cmp_content"), compOptionsEnabled)); + break; + case CompareVariant::size: + setImage(*m_bitmapCompVariant, greyScaleIfDisabled(loadImage("cmp_size"), compOptionsEnabled)); + break; + } + + //active variant description: + setText(*m_staticTextCompVarDescription, getCompVariantDescription(localCmpVar_)); + m_staticTextCompVarDescription->Wrap(dipToWxsize(CFG_DESCRIPTION_WIDTH_DIP)); //needs to be reapplied after SetLabel() + + m_radioBtnSymlinksDirect->Enable(m_checkBoxSymlinksInclude->GetValue() && compOptionsEnabled); //help wxWidgets a little to render inactive config state (needed on Windows, NOT on Linux!) + m_radioBtnSymlinksFollow->Enable(m_checkBoxSymlinksInclude->GetValue() && compOptionsEnabled); // +} + + +void ConfigDialog::onFilterDefaultContext(wxEvent& event) +{ + const FilterConfig activeCfg = getFilterConfig(); + const FilterConfig defaultFilter = GlobalConfig().defaultFilter; + + ContextMenu menu; + menu.addItem(_("&Save"), [&] { defaultFilterOut_ = activeCfg; updateFilterGui(); }, + loadImage("cfg_save", dipToScreen(getMenuIconDipSize())), defaultFilterOut_ != activeCfg); + + menu.addItem(_("&Load factory default"), [&] { setFilterConfig(defaultFilter); }, wxNullImage, activeCfg != defaultFilter); + + menu.popup(*m_bpButtonDefaultContext, {m_bpButtonDefaultContext->GetSize().x, 0}); +} + + +FilterConfig ConfigDialog::getFilterConfig() const +{ + auto sanitizeFilter = [](wxString str) + { + //macOS: Ctrl+Enter inserts Unicode LINE_SEPARATOR which is indistinguishable from new line! + replace(str, LINE_SEPARATOR, L'\n'); + replace(str, PARAGRAPH_SEPARATOR, L'\n'); + + return utfTo(str); + }; + + return + { + sanitizeFilter(m_textCtrlInclude->GetValue()), sanitizeFilter(m_textCtrlExclude->GetValue()), + makeUnsigned(m_spinCtrlTimespan->GetValue()), + enumTimeDescr_.get(), + makeUnsigned(m_spinCtrlMinSize->GetValue()), enumMinSizeDescr_.get(), + makeUnsigned(m_spinCtrlMaxSize->GetValue()), enumMaxSizeDescr_.get()}; +} + + +void ConfigDialog::setFilterConfig(const FilterConfig& filter) +{ + m_textCtrlInclude->ChangeValue(utfTo(filter.includeFilter)); + m_textCtrlExclude->ChangeValue(utfTo(filter.excludeFilter)); + + enumTimeDescr_ .set(filter.unitTimeSpan); + enumMinSizeDescr_.set(filter.unitSizeMin); + enumMaxSizeDescr_.set(filter.unitSizeMax); + + m_spinCtrlTimespan->SetValue(static_cast(filter.timeSpan)); + m_spinCtrlMinSize ->SetValue(static_cast(filter.sizeMin)); + m_spinCtrlMaxSize ->SetValue(static_cast(filter.sizeMax)); + + updateFilterGui(); +} + + +void ConfigDialog::updateFilterGui() +{ + const FilterConfig activeCfg = getFilterConfig(); + + m_notebook->SetPageImage(static_cast(SyncConfigPanel::filter), + static_cast(!isNullFilter(activeCfg) ? ConfigTypeImage::filter: ConfigTypeImage::filterGrey)); + + setImage(*m_bitmapInclude, greyScaleIfDisabled(loadImage("filter_include"), !NameFilter::isNull(activeCfg.includeFilter, FilterConfig().excludeFilter))); + setImage(*m_bitmapExclude, greyScaleIfDisabled(loadImage("filter_exclude"), !NameFilter::isNull(FilterConfig().includeFilter, activeCfg.excludeFilter))); + setImage(*m_bitmapFilterDate, greyScaleIfDisabled(loadImage("cmp_time"), activeCfg.unitTimeSpan != UnitTime::none)); + setImage(*m_bitmapFilterSize, greyScaleIfDisabled(loadImage("cmp_size"), activeCfg.unitSizeMin != UnitSize::none || activeCfg.unitSizeMax != UnitSize::none)); + + m_spinCtrlTimespan->Enable(activeCfg.unitTimeSpan == UnitTime::lastDays); + m_spinCtrlMinSize ->Enable(activeCfg.unitSizeMin != UnitSize::none); + m_spinCtrlMaxSize ->Enable(activeCfg.unitSizeMax != UnitSize::none); + + m_buttonDefault->Enable(activeCfg != defaultFilterOut_); + m_buttonClear ->Enable(activeCfg != FilterConfig()); +} + + +void ConfigDialog::onToggleUseDatabase(wxCommandEvent& event) +{ + if (const DirectionByDiff* diffDirs = std::get_if(&directionsCfg_.dirs)) + directionsCfg_.dirs = getChangesDirDefault(*diffDirs); + else + { + const DirectionByChange& changeDirs = std::get(directionsCfg_.dirs); + directionsCfg_.dirs = getDiffDirDefault(changeDirs); + } + updateSyncGui(); +} + + +void ConfigDialog::onSyncTwoWayDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onSyncTwoWay(dummy); + onOkay(dummy); +} + + +void ConfigDialog::onSyncMirrorDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onSyncMirror(dummy); + onOkay(dummy); +} + + +void ConfigDialog::onSyncUpdateDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onSyncUpdate(dummy); + onOkay(dummy); +} + + +void ConfigDialog::onSyncCustomDouble(wxMouseEvent& event) +{ + wxCommandEvent dummy; + onSyncCustom(dummy); + onOkay(dummy); +} + + +void toggleSyncDirection(SyncDirection& current) +{ + switch (current) + { + case SyncDirection::right: + current = SyncDirection::left; + break; + case SyncDirection::left: + current = SyncDirection::none; + break; + case SyncDirection::none: + current = SyncDirection::right; + break; + } +} + + +void ConfigDialog::toggleSyncDirButton(SyncDirection DirectionByDiff::* dir) +{ + if (DirectionByDiff* diffDirs = std::get_if(&directionsCfg_.dirs)) + { + toggleSyncDirection(diffDirs->*dir); + updateSyncGui(); + } + else assert(false); +} + + +void ConfigDialog::onLeftNewer(wxCommandEvent& event) +{ + toggleSyncDirButton(&DirectionByDiff::leftNewer); + assert(!leftRightNewerCombined()); +} + + +void ConfigDialog::onRightNewer(wxCommandEvent& event) +{ + toggleSyncDirButton(&DirectionByDiff::rightNewer); + assert(!leftRightNewerCombined()); +} + + +void ConfigDialog::onDifferent(wxCommandEvent& event) +{ + toggleSyncDirButton(&DirectionByDiff::leftNewer); + + if (DirectionByDiff* diffDirs = std::get_if(&directionsCfg_.dirs)) + //simulate category "different" as leftNewer/rightNewer combined: + diffDirs->rightNewer = diffDirs->leftNewer; + else assert(false); + assert(leftRightNewerCombined()); +} + + +void ConfigDialog::toggleSyncDirButton(DirectionByChange::Changes DirectionByChange::* side, SyncDirection DirectionByChange::Changes::* dir) +{ + if (DirectionByChange* changeDirs = std::get_if(&directionsCfg_.dirs)) + { + toggleSyncDirection(changeDirs->*side.*dir); + updateSyncGui(); + } + else assert(false); +} + + +namespace +{ +auto updateDirButton(wxBitmapButton& button, SyncDirection dir, + const char* imgNameLeft, const char* imgNameNone, const char* imgNameRight, + SyncOperation opLeft, SyncOperation opNone, SyncOperation opRight) +{ + const char* imgName = nullptr; + switch (dir) + { + case SyncDirection::left: + imgName = imgNameLeft; + button.SetToolTip(getSyncOpDescription(opLeft)); + break; + case SyncDirection::none: + imgName = imgNameNone; + button.SetToolTip(getSyncOpDescription(opNone)); + break; + case SyncDirection::right: + imgName = imgNameRight; + button.SetToolTip(getSyncOpDescription(opRight)); + break; + } + wxImage img = mirrorIfRtl(loadImage(imgName)); + button.SetBitmapLabel (toScaledBitmap( img)); + button.SetBitmapDisabled(toScaledBitmap(greyScale(img))); //fix wxWidgets' all-too-clever multi-state! + //=> the disabled bitmap is generated during first SetBitmapLabel() call but never updated again by wxWidgets! +} + + +void updateDiffDirButtons(const DirectionByDiff& diffDirs, + wxBitmapButton& buttonLeftOnly, + wxBitmapButton& buttonRightOnly, + wxBitmapButton& buttonLeftNewer, + wxBitmapButton& buttonRightNewer, + wxBitmapButton& buttonDifferent) +{ + updateDirButton(buttonLeftOnly, diffDirs.leftOnly, "so_delete_left", "so_none", "so_create_right", SO_DELETE_LEFT, SO_DO_NOTHING, SO_CREATE_RIGHT); + updateDirButton(buttonRightOnly, diffDirs.rightOnly, "so_create_left", "so_none", "so_delete_right", SO_CREATE_LEFT, SO_DO_NOTHING, SO_DELETE_RIGHT); + updateDirButton(buttonLeftNewer, diffDirs.leftNewer, "so_update_left", "so_none", "so_update_right", SO_OVERWRITE_LEFT, SO_DO_NOTHING, SO_OVERWRITE_RIGHT); + updateDirButton(buttonRightNewer, diffDirs.rightNewer, "so_update_left", "so_none", "so_update_right", SO_OVERWRITE_LEFT, SO_DO_NOTHING, SO_OVERWRITE_RIGHT); + //simulate category "different" as leftNewer/rightNewer combined: + updateDirButton(buttonDifferent, diffDirs.leftNewer, "so_update_left", "so_none", "so_update_right", SO_OVERWRITE_LEFT, SO_DO_NOTHING, SO_OVERWRITE_RIGHT); +} + + +void updateChangeDirButtons(const DirectionByChange& changeDirs, + wxBitmapButton& buttonLeftCreate, + wxBitmapButton& buttonLeftUpdate, + wxBitmapButton& buttonLeftDelete, + wxBitmapButton& buttonRightCreate, + wxBitmapButton& buttonRightUpdate, + wxBitmapButton& buttonRightDelete) +{ + updateDirButton(buttonLeftCreate, changeDirs.left.create, "so_delete_left", "so_none", "so_create_right", SO_DELETE_LEFT, SO_DO_NOTHING, SO_CREATE_RIGHT); + updateDirButton(buttonLeftUpdate, changeDirs.left.update, "so_update_left", "so_none", "so_update_right", SO_OVERWRITE_LEFT, SO_DO_NOTHING, SO_OVERWRITE_RIGHT); + updateDirButton(buttonLeftDelete, changeDirs.left.delete_, "so_create_left", "so_none", "so_delete_right", SO_CREATE_LEFT, SO_DO_NOTHING, SO_DELETE_RIGHT); + + updateDirButton(buttonRightCreate, changeDirs.right.create, "so_create_left", "so_none", "so_delete_right", SO_CREATE_LEFT, SO_DO_NOTHING, SO_DELETE_RIGHT); + updateDirButton(buttonRightUpdate, changeDirs.right.update, "so_update_left", "so_none", "so_update_right", SO_OVERWRITE_LEFT, SO_DO_NOTHING, SO_OVERWRITE_RIGHT); + updateDirButton(buttonRightDelete, changeDirs.right.delete_, "so_delete_left", "so_none", "so_create_right", SO_DELETE_LEFT, SO_DO_NOTHING, SO_CREATE_RIGHT); +} +} + +void ConfigDialog::onShowLogFolder(wxCommandEvent& event) +{ + assert(selectedPairIndexToShow_ < 0); + if (selectedPairIndexToShow_ < 0) + try + { + AbstractPath logFolderPath = createAbstractPath(getMiscSyncOptions().altLogFolderPathPhrase); //optional + if (AFS::isNullPath(logFolderPath)) + logFolderPath = createAbstractPath(globalLogFolderPhrase_); + + openFolderInFileBrowser(logFolderPath); //throw FileError + } + catch (const FileError& e) { showNotificationDialog(this, DialogInfoType::error, PopupDialogCfg().setDetailInstructions(e.toString())); } +} + + +bool ConfigDialog::leftRightNewerCombined() const +{ + assert(std::get_if(&directionsCfg_.dirs)); + const CompareVariant activeCmpVar = m_checkBoxUseLocalCmpOptions->GetValue() ? localCmpVar_ : globalPairCfg_.cmpCfg.compareVar; + return activeCmpVar == CompareVariant::content || activeCmpVar == CompareVariant::size; +} + + +std::optional ConfigDialog::getSyncConfig() const +{ + if (!m_checkBoxUseLocalSyncOptions->GetValue()) + return {}; + + SyncConfig syncCfg; + syncCfg.directionCfg = directionsCfg_; + syncCfg.deletionVariant = deletionVariant_; + syncCfg.versioningFolderPhrase = versioningFolder_.getPath(); + syncCfg.versioningStyle = enumVersioningStyle_.get(); + if (syncCfg.versioningStyle != VersioningStyle::replace) + { + syncCfg.versionMaxAgeDays = m_checkBoxVersionMaxDays ->GetValue() ? m_spinCtrlVersionMaxDays->GetValue() : 0; + syncCfg.versionCountMin = m_checkBoxVersionCountMin->GetValue() && m_checkBoxVersionMaxDays->GetValue() ? m_spinCtrlVersionCountMin->GetValue() : 0; + syncCfg.versionCountMax = m_checkBoxVersionCountMax->GetValue() ? m_spinCtrlVersionCountMax->GetValue() : 0; + } + + //simulate category "different" as leftNewer/rightNewer combined: + if (DirectionByDiff* diffDirs = std::get_if(&syncCfg.directionCfg.dirs)) + if (leftRightNewerCombined()) + diffDirs->rightNewer = diffDirs->leftNewer; + + return syncCfg; +} + + +void ConfigDialog::setSyncConfig(const SyncConfig* syncCfg) +{ + m_checkBoxUseLocalSyncOptions->SetValue(syncCfg); + + //when local settings are inactive, display (current) global settings instead: + const SyncConfig tmpCfg = syncCfg ? *syncCfg : globalPairCfg_.syncCfg; + + directionsCfg_ = tmpCfg.directionCfg; //make working copy; ownership *not* on GUI + deletionVariant_ = tmpCfg.deletionVariant; + versioningFolder_.setPath(tmpCfg.versioningFolderPhrase); + enumVersioningStyle_.set(tmpCfg.versioningStyle); + + const bool useVersionLimits = tmpCfg.versioningStyle != VersioningStyle::replace; + + m_checkBoxVersionMaxDays ->SetValue(useVersionLimits && tmpCfg.versionMaxAgeDays > 0); + m_checkBoxVersionCountMin->SetValue(useVersionLimits && tmpCfg.versionCountMin > 0 && tmpCfg.versionMaxAgeDays > 0); + m_checkBoxVersionCountMax->SetValue(useVersionLimits && tmpCfg.versionCountMax > 0); + + m_spinCtrlVersionMaxDays ->SetValue(m_checkBoxVersionMaxDays ->GetValue() ? tmpCfg.versionMaxAgeDays : 30); + m_spinCtrlVersionCountMin->SetValue(m_checkBoxVersionCountMin->GetValue() ? tmpCfg.versionCountMin : 1); + m_spinCtrlVersionCountMax->SetValue(m_checkBoxVersionCountMax->GetValue() ? tmpCfg.versionCountMax : 1); + + updateSyncGui(); +} + + +void ConfigDialog::updateSyncGui() +{ + const bool syncOptionsEnabled = m_checkBoxUseLocalSyncOptions->GetValue(); + + m_panelSyncSettings->Enable(syncOptionsEnabled); + + m_notebook->SetPageImage(static_cast(SyncConfigPanel::sync), + static_cast(syncOptionsEnabled ? ConfigTypeImage::sync: ConfigTypeImage::syncGrey)); + + const bool setDirsByDifferences = std::get_if(&directionsCfg_.dirs); + + m_checkBoxUseDatabase->SetValue(!setDirsByDifferences); + + //display only relevant sync options + bSizerSyncDirsDiff ->Show( setDirsByDifferences); + bSizerSyncDirsChanges->Show(!setDirsByDifferences); + + if (const DirectionByDiff* diffDirs = std::get_if(&directionsCfg_.dirs)) //sync directions by differences + { + updateDiffDirButtons(*diffDirs, + *m_bpButtonLeftOnly, + *m_bpButtonRightOnly, + *m_bpButtonLeftNewer, + *m_bpButtonRightNewer, + *m_bpButtonDifferent); + + //simulate category "different" as leftNewer/rightNewer combined: + const bool haveLeftRightNewerCombined = leftRightNewerCombined(); + m_bitmapLeftNewer ->Show(!haveLeftRightNewerCombined); + m_bpButtonLeftNewer ->Show(!haveLeftRightNewerCombined); + m_bitmapRightNewer ->Show(!haveLeftRightNewerCombined); + m_bpButtonRightNewer->Show(!haveLeftRightNewerCombined); + + m_bitmapDifferent ->Show(haveLeftRightNewerCombined); + m_bpButtonDifferent->Show(haveLeftRightNewerCombined); + } + else //sync directions by changes + { + const DirectionByChange& changeDirs = std::get(directionsCfg_.dirs); + + updateChangeDirButtons(changeDirs, + *m_bpButtonLeftCreate, + *m_bpButtonLeftUpdate, + *m_bpButtonLeftDelete, + *m_bpButtonRightCreate, + *m_bpButtonRightUpdate, + *m_bpButtonRightDelete); + } + + const bool useDatabaseFile = std::get_if(&directionsCfg_.dirs); + + setImage(*m_bitmapDatabase, greyScaleIfDisabled(loadImage("database", dipToScreen(22)), useDatabaseFile && syncOptionsEnabled)); + + //"detect move files" is always active iff database is used: + setImage(*m_bitmapMoveLeft, greyScaleIfDisabled(loadImage("so_move_left", dipToScreen(20)), useDatabaseFile && syncOptionsEnabled)); + setImage(*m_bitmapMoveRight, greyScaleIfDisabled(loadImage("so_move_right", dipToScreen(20)), useDatabaseFile && syncOptionsEnabled)); + m_staticTextDetectMove->Enable(useDatabaseFile); + + const SyncVariant syncVar = getSyncVariant(directionsCfg_); + + //active variant description: + setText(*m_staticTextSyncVarDescription, getSyncVariantDescription(syncVar)); + m_staticTextSyncVarDescription->Wrap(dipToWxsize(CFG_DESCRIPTION_WIDTH_DIP)); //needs to be reapplied after SetLabel() + + //update toggle buttons -> they have no parameter-ownership at all! + m_buttonTwoWay->setActive(SyncVariant::twoWay == syncVar && syncOptionsEnabled); + m_buttonMirror->setActive(SyncVariant::mirror == syncVar && syncOptionsEnabled); + m_buttonUpdate->setActive(SyncVariant::update == syncVar && syncOptionsEnabled); + m_buttonCustom->setActive(SyncVariant::custom == syncVar && syncOptionsEnabled); + //syncOptionsEnabled: nudge wxWidgets to render inactive config state (needed on Windows, NOT on Linux!) + + m_buttonRecycler ->setActive(DeletionVariant::recycler == deletionVariant_ && syncOptionsEnabled); + m_buttonPermanent ->setActive(DeletionVariant::permanent == deletionVariant_ && syncOptionsEnabled); + m_buttonVersioning->setActive(DeletionVariant::versioning == deletionVariant_ && syncOptionsEnabled); + + switch (deletionVariant_) //unconditionally update image, including "local options off" + { + case DeletionVariant::recycler: + { + wxImage imgTrash = loadImage("delete_recycler"); + //use system icon if available (can fail on Linux??) + try { imgTrash = extractWxImage(fff::getTrashIcon(imgTrash.GetHeight())); /*throw SysError*/ } + catch (SysError&) { assert(false); } + + setImage(*m_bitmapDeletionType, greyScaleIfDisabled(imgTrash, syncOptionsEnabled)); + setText(*m_staticTextDeletionTypeDescription, _("Retain deleted and overwritten files in the recycle bin")); + } + break; + case DeletionVariant::permanent: + setImage(*m_bitmapDeletionType, greyScaleIfDisabled(loadImage("delete_permanently"), syncOptionsEnabled)); + setText(*m_staticTextDeletionTypeDescription, _("Delete and overwrite files permanently")); + break; + case DeletionVariant::versioning: + setImage(*m_bitmapVersioning, greyScaleIfDisabled(loadImage("delete_versioning"), syncOptionsEnabled)); + break; + } + //m_staticTextDeletionTypeDescription->Wrap(dipToWxsize(200)); //needs to be reapplied after SetLabel() + + const bool versioningSelected = deletionVariant_ == DeletionVariant::versioning; + + m_bitmapDeletionType ->Show(!versioningSelected); + m_staticTextDeletionTypeDescription->Show(!versioningSelected); + m_panelVersioning ->Show( versioningSelected); + + if (versioningSelected) + { + enumVersioningStyle_.updateTooltip(); + + const VersioningStyle versioningStyle = enumVersioningStyle_.get(); + const std::wstring pathSep = utfTo(FILE_NAME_SEPARATOR); + + switch (versioningStyle) + { + case VersioningStyle::replace: + setText(*m_staticTextNamingCvtPart1, pathSep + _("Folder") + pathSep + _("File") + L".doc"); + setText(*m_staticTextNamingCvtPart2Bold, L""); + setText(*m_staticTextNamingCvtPart3, L""); + break; + + case VersioningStyle::timestampFolder: + setText(*m_staticTextNamingCvtPart1, pathSep); + setText(*m_staticTextNamingCvtPart2Bold, _("YYYY-MM-DD hhmmss")); + setText(*m_staticTextNamingCvtPart3, pathSep + _("Folder") + pathSep + _("File") + L".doc "); + break; + + case VersioningStyle::timestampFile: + setText(*m_staticTextNamingCvtPart1, pathSep + _("Folder") + pathSep + _("File") + L".doc "); + setText(*m_staticTextNamingCvtPart2Bold, _("YYYY-MM-DD hhmmss")); + setText(*m_staticTextNamingCvtPart3, L".doc"); + break; + } + + const bool enableLimitCtrls = syncOptionsEnabled && versioningStyle != VersioningStyle::replace; + const bool showLimitCtrls = m_checkBoxVersionMaxDays->GetValue() || m_checkBoxVersionCountMax->GetValue(); + //m_checkBoxVersionCountMin->GetValue() => irrelevant if !m_checkBoxVersionMaxDays->GetValue()! + + if (!m_checkBoxVersionMaxDays->GetValue() && m_checkBoxVersionCountMin->GetValue()) + m_checkBoxVersionCountMin->SetValue(false); //make this dependency cristal-clear (don't just disable) + + m_staticTextLimitVersions->Show(!showLimitCtrls); + + m_spinCtrlVersionMaxDays ->Show(showLimitCtrls); + m_spinCtrlVersionCountMin->Show(showLimitCtrls); + m_spinCtrlVersionCountMax->Show(showLimitCtrls); + + m_staticTextLimitVersions->Enable(enableLimitCtrls); + m_checkBoxVersionMaxDays ->Enable(enableLimitCtrls); + m_checkBoxVersionCountMin->Enable(enableLimitCtrls && m_checkBoxVersionMaxDays->GetValue()); + m_checkBoxVersionCountMax->Enable(enableLimitCtrls); + + m_spinCtrlVersionMaxDays ->Enable(enableLimitCtrls && m_checkBoxVersionMaxDays ->GetValue()); + m_spinCtrlVersionCountMin->Enable(enableLimitCtrls && m_checkBoxVersionMaxDays->GetValue() && m_checkBoxVersionCountMin->GetValue()); + m_spinCtrlVersionCountMax->Enable(enableLimitCtrls && m_checkBoxVersionCountMax->GetValue()); + } + + m_panelSyncSettings->Layout(); + + //Refresh(); //removes a few artifacts when toggling display of versioning folder +} + + +MiscSyncConfig ConfigDialog::getMiscSyncOptions() const +{ + MiscSyncConfig miscCfg; + + // Avoid "fake" changed configs! => + // - don't touch items corresponding to paths not currently used + // - don't store parallel ops == 1 + miscCfg.deviceParallelOps = deviceParallelOps_; + assert(fgSizerPerf->GetItemCount() == 2 * devicesForEdit_.size()); + int i = 0; + for (const AfsDevice& afsDevice : devicesForEdit_) + { + wxSpinCtrl* spinCtrlParallelOps = dynamic_cast(fgSizerPerf->GetItem(i * 2)->GetWindow()); + setDeviceParallelOps(miscCfg.deviceParallelOps, afsDevice, spinCtrlParallelOps->GetValue()); + ++i; + } + //---------------------------------------------------------------------------- + miscCfg.ignoreErrors = m_checkBoxIgnoreErrors->GetValue(); + miscCfg.autoRetryCount = m_checkBoxAutoRetry ->GetValue() ? m_spinCtrlAutoRetryCount->GetValue() : 0; + miscCfg.autoRetryDelay = std::chrono::seconds(m_spinCtrlAutoRetryDelay->GetValue()); + //---------------------------------------------------------------------------- + miscCfg.postSyncCommand = m_comboBoxPostSyncCommand->getValue(); + miscCfg.postSyncCondition = enumPostSyncCondition_.get(); + //---------------------------------------------------------------------------- + Zstring altLogFolderPhrase = logFolderSelector_.getPath(); + if (altLogFolderPhrase.empty()) //"empty" already means "unchecked" + altLogFolderPhrase = Zstr(' '); //=> trigger error message on dialog close + miscCfg.altLogFolderPathPhrase = m_checkBoxOverrideLogPath->GetValue() ? altLogFolderPhrase : Zstring(); + //---------------------------------------------------------------------------- + std::string emailAddress = utfTo(m_comboBoxEmail->getValue()); + if (emailAddress.empty()) + emailAddress = ' '; //trigger error message on dialog close + miscCfg.emailNotifyAddress = m_checkBoxSendEmail->GetValue() ? emailAddress : std::string(); + miscCfg.emailNotifyCondition = emailNotifyCondition_; + //---------------------------------------------------------------------------- + miscCfg.notes = trimCpy(utfTo(m_textCtrNotes->GetValue())); + + return miscCfg; +} + + +void ConfigDialog::setMiscSyncOptions(const MiscSyncConfig& miscCfg) +{ + // Avoid "fake" changed configs! => + //- when editting, consider only the deviceParallelOps items corresponding to the currently-used folder paths + //- keep parallel ops == 1 only temporarily during edit + deviceParallelOps_ = miscCfg.deviceParallelOps; + + assert(fgSizerPerf->GetItemCount() % 2 == 0); + const int rowsToCreate = static_cast(devicesForEdit_.size()) - static_cast(fgSizerPerf->GetItemCount() / 2); + if (rowsToCreate >= 0) + for (int i = 0; i < rowsToCreate; ++i) + { + wxSpinCtrl* spinCtrlParallelOps = new wxSpinCtrl(m_scrolledWindowPerf, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxSP_ARROW_KEYS, 1, 2000'000'000, 1); + setDefaultWidth(*spinCtrlParallelOps); + spinCtrlParallelOps->Enable(enableExtraFeatures_); + fgSizerPerf->Add(spinCtrlParallelOps, 0, wxALIGN_CENTER_VERTICAL); + + wxStaticText* staticTextDevice = new wxStaticText(m_scrolledWindowPerf, wxID_ANY, wxEmptyString); + staticTextDevice->Enable(enableExtraFeatures_); + fgSizerPerf->Add(staticTextDevice, 0, wxALIGN_CENTER_VERTICAL); + } + else + for (int i = 0; i < -rowsToCreate * 2; ++i) + fgSizerPerf->GetItem(size_t(0))->GetWindow()->Destroy(); + assert(fgSizerPerf->GetItemCount() == 2 * devicesForEdit_.size()); + + int i = 0; + for (const AfsDevice& afsDevice : devicesForEdit_) + { + wxSpinCtrl* spinCtrlParallelOps = dynamic_cast (fgSizerPerf->GetItem(i * 2 )->GetWindow()); + wxStaticText* staticTextDevice = dynamic_cast(fgSizerPerf->GetItem(i * 2 + 1)->GetWindow()); + + spinCtrlParallelOps->SetValue(static_cast(getDeviceParallelOps(deviceParallelOps_, afsDevice))); + staticTextDevice->SetLabelText(AFS::getDisplayPath(AbstractPath(afsDevice, AfsPath()))); + ++i; + } + m_staticTextPerfParallelOps->Enable(enableExtraFeatures_ && !devicesForEdit_.empty()); + + m_panelComparisonSettings->Layout(); //*after* setting text labels + + //---------------------------------------------------------------------------- + m_checkBoxIgnoreErrors ->SetValue(miscCfg.ignoreErrors); + m_checkBoxAutoRetry ->SetValue(miscCfg.autoRetryCount > 0); + m_spinCtrlAutoRetryCount->SetValue(std::max(miscCfg.autoRetryCount, 0)); + m_spinCtrlAutoRetryDelay->SetValue(miscCfg.autoRetryDelay.count()); + //---------------------------------------------------------------------------- + m_comboBoxPostSyncCommand->setValue(miscCfg.postSyncCommand); + enumPostSyncCondition_.set(miscCfg.postSyncCondition); + //---------------------------------------------------------------------------- + m_checkBoxOverrideLogPath->SetValue(!miscCfg.altLogFolderPathPhrase.empty()); //only "empty path" means unchecked! everything else (e.g. " "): "checked" + logFolderSelector_.setPath(m_checkBoxOverrideLogPath->GetValue() ? miscCfg.altLogFolderPathPhrase : globalLogFolderPhrase_); + //---------------------------------------------------------------------------- + Zstring defaultEmail; + if (const std::vector& history = m_comboBoxEmail->getHistory(); + !history.empty()) + defaultEmail = history[0]; + + m_checkBoxSendEmail->SetValue(!trimCpy(miscCfg.emailNotifyAddress).empty()); + m_comboBoxEmail->setValue(m_checkBoxSendEmail->GetValue() ? utfTo(miscCfg.emailNotifyAddress) : defaultEmail); + emailNotifyCondition_ = miscCfg.emailNotifyCondition; + //---------------------------------------------------------------------------- + m_textCtrNotes->ChangeValue(utfTo(miscCfg.notes)); + + updateMiscGui(); +} + + +void ConfigDialog::updateMiscGui() +{ + if (selectedPairIndexToShow_ == -1) + { + const MiscSyncConfig miscCfg = getMiscSyncOptions(); + + setImage(*m_bitmapIgnoreErrors, greyScaleIfDisabled(loadImage("error_ignore_active"), miscCfg.ignoreErrors)); + setImage(*m_bitmapRetryErrors, greyScaleIfDisabled(loadImage("error_retry"), miscCfg.autoRetryCount > 0 )); + + fgSizerAutoRetry->Show(miscCfg.autoRetryCount > 0); + + m_panelComparisonSettings->Layout(); //showing "retry count" can affect bSizerPerformance! + //---------------------------------------------------------------------------- + const bool sendEmailEnabled = m_checkBoxSendEmail->GetValue(); + setImage(*m_bitmapEmail, greyScaleIfDisabled(loadImage("email"), sendEmailEnabled)); + m_comboBoxEmail->Show(sendEmailEnabled); + + auto updateButton = [successIcon = loadImage("msg_success", dipToScreen(getMenuIconDipSize())), + warningIcon = loadImage("msg_warning", dipToScreen(getMenuIconDipSize())), + errorIcon = loadImage("msg_error", dipToScreen(getMenuIconDipSize())), + sendEmailEnabled, this](wxBitmapButton& button, ResultsNotification notifyCondition) + { + button.Show(sendEmailEnabled); + if (sendEmailEnabled) + { + wxString tooltip = _("Error"); + wxImage label = errorIcon; + + if (notifyCondition == ResultsNotification::always || + notifyCondition == ResultsNotification::errorWarning) + { + tooltip += (L" | ") + _("Warning"); + label = stackImages(label, warningIcon, ImageStackLayout::horizontal, ImageStackAlignment::center); + } + else + label = resizeCanvas(label, {label.GetWidth() + warningIcon.GetWidth(), label.GetHeight()}, wxALIGN_LEFT); + + if (notifyCondition == ResultsNotification::always) + { + tooltip += (L" | ") + _("Success"); + label = stackImages(label, successIcon, ImageStackLayout::horizontal, ImageStackAlignment::center); + } + else + label = resizeCanvas(label, {label.GetWidth() + successIcon.GetWidth(), label.GetHeight()}, wxALIGN_LEFT); + + button.SetToolTip(tooltip); + button.SetBitmapLabel (toScaledBitmap(notifyCondition == emailNotifyCondition_ && sendEmailEnabled ? label : greyScale(label))); + button.SetBitmapDisabled(toScaledBitmap(greyScale(label))); //fix wxWidgets' all-too-clever multi-state! + //=> the disabled bitmap is generated during first SetBitmapLabel() call but never updated again by wxWidgets! + } + }; + updateButton(*m_bpButtonEmailAlways, ResultsNotification::always); + updateButton(*m_bpButtonEmailErrorWarning, ResultsNotification::errorWarning); + updateButton(*m_bpButtonEmailErrorOnly, ResultsNotification::errorOnly); + + m_hyperlinkPerfDeRequired2->Show(!enableExtraFeatures_); //required after each bSizerSyncMisc->Show() + + //---------------------------------------------------------------------------- + setImage(*m_bitmapLogFile, greyScaleIfDisabled(loadImage("log_file", dipToScreen(20)), m_checkBoxOverrideLogPath->GetValue())); + m_logFolderPath ->Enable(m_checkBoxOverrideLogPath->GetValue()); // + m_buttonSelectLogFolder ->Show(m_checkBoxOverrideLogPath->GetValue()); //enabled status can't be derived from resolved config! + m_bpButtonSelectAltLogFolder->Show(m_checkBoxOverrideLogPath->GetValue()); // + + m_panelSyncSettings->Layout(); //after showing/hiding m_buttonSelectLogFolder + + m_panelSyncSettings->Refresh(); //removes a few artifacts when toggling email notifications + m_panelLogfile ->Refresh();// + } + //---------------------------------------------------------------------------- + m_buttonAddNotes->Show(!showNotesPanel_); + m_panelNotes ->Show(showNotesPanel_); +} + + +void ConfigDialog::selectFolderPairConfig(int newPairIndexToShow) +{ + assert(selectedPairIndexToShow_ == EMPTY_PAIR_INDEX_SELECTED); + assert(newPairIndexToShow == -1 || makeUnsigned(newPairIndexToShow) < localPairCfg_.size()); + newPairIndexToShow = std::clamp(newPairIndexToShow, -1, static_cast(localPairCfg_.size()) - 1); + + selectedPairIndexToShow_ = newPairIndexToShow; + m_listBoxFolderPair->SetSelection(newPairIndexToShow + 1); + + //show/hide controls that are only relevant for main/local config + const bool mainConfigSelected = newPairIndexToShow < 0; + //comparison panel: + m_staticTextMainCompSettings->Show( mainConfigSelected && showMultipleCfgs_); + m_checkBoxUseLocalCmpOptions->Show(!mainConfigSelected && showMultipleCfgs_); + m_staticlineCompHeader->Show(showMultipleCfgs_); + //filter panel + m_staticTextMainFilterSettings ->Show( mainConfigSelected && showMultipleCfgs_); + m_staticTextLocalFilterSettings->Show(!mainConfigSelected && showMultipleCfgs_); + m_staticlineFilterHeader->Show(showMultipleCfgs_); + //sync panel: + m_staticTextMainSyncSettings ->Show( mainConfigSelected && showMultipleCfgs_); + m_checkBoxUseLocalSyncOptions->Show(!mainConfigSelected && showMultipleCfgs_); + m_staticlineSyncHeader->Show(showMultipleCfgs_); + //misc + bSizerPerformance->Show(mainConfigSelected); //caveat: recursively shows hidden child items! + bSizerCompMisc ->Show(mainConfigSelected); + bSizerSyncMisc ->Show(mainConfigSelected); + + if (mainConfigSelected) + { + m_hyperlinkPerfDeRequired->Show(!enableExtraFeatures_); //keep after bSizerPerformance->Show() + + //update the devices list for "parallel file operations" before calling setMiscSyncOptions(): + // => should be enough to do this when selecting the main config + // => to be "perfect" we'd have to update already when the user drags & drops a different versioning folder + devicesForEdit_.clear(); + auto addDevicePath = [&](const Zstring& folderPathPhrase) + { + const AfsDevice& afsDevice = createAbstractPath(folderPathPhrase).afsDevice; + if (!AFS::isNullDevice(afsDevice)) + devicesForEdit_.insert(afsDevice); + }; + for (const LocalPairConfig& fpCfg : localPairCfg_) + { + addDevicePath(fpCfg.folderPathPhraseLeft); + addDevicePath(fpCfg.folderPathPhraseRight); + + if (fpCfg.localSyncCfg && fpCfg.localSyncCfg->deletionVariant == DeletionVariant::versioning) + addDevicePath(fpCfg.localSyncCfg->versioningFolderPhrase); + } + if (globalPairCfg_.syncCfg.deletionVariant == DeletionVariant::versioning) //let's always add, even if *all* folder pairs use a local sync config (=> strange!) + addDevicePath(globalPairCfg_.syncCfg.versioningFolderPhrase); + //--------------------------------------------------------------------------------------------------------------- + + setCompConfig (&globalPairCfg_.cmpCfg); + setSyncConfig (&globalPairCfg_.syncCfg); + setFilterConfig(globalPairCfg_.filter); + } + else + { + setCompConfig(get(localPairCfg_[selectedPairIndexToShow_].localCmpCfg)); + setSyncConfig(get(localPairCfg_[selectedPairIndexToShow_].localSyncCfg)); + setFilterConfig (localPairCfg_[selectedPairIndexToShow_].localFilter); + } + setMiscSyncOptions(globalPairCfg_.miscCfg); + + m_panelCompSettingsTab ->Layout(); //fix comp panel glitch on Win 7 125% font size + perf panel + m_panelFilterSettingsTab->Layout(); + m_panelSyncSettingsTab ->Layout(); +} + + +bool ConfigDialog::unselectFolderPairConfig(bool validateParams) +{ + assert(selectedPairIndexToShow_ == -1 || makeUnsigned(selectedPairIndexToShow_) < localPairCfg_.size()); + + std::optional compCfg = getCompConfig(); + std::optional syncCfg = getSyncConfig(); + FilterConfig filterCfg = getFilterConfig(); + + MiscSyncConfig miscCfg = getMiscSyncOptions(); //some "misc" options are always visible, e.g. "notes" + + //------- parameter validation (BEFORE writing output!) ------- + if (validateParams) + { + //parameter validation and correction: + + std::vector baseFolderPaths; //display paths to fix filter if user pastes full folder paths + if (selectedPairIndexToShow_ < 0) + for (const LocalPairConfig& lpc : localPairCfg_) + { + baseFolderPaths.push_back(createAbstractPath(lpc.folderPathPhraseLeft)); + baseFolderPaths.push_back(createAbstractPath(lpc.folderPathPhraseRight)); + } + else + { + baseFolderPaths.push_back(createAbstractPath(localPairCfg_[selectedPairIndexToShow_].folderPathPhraseLeft)); + baseFolderPaths.push_back(createAbstractPath(localPairCfg_[selectedPairIndexToShow_].folderPathPhraseRight)); + } + if (!sanitizeFilter(filterCfg, baseFolderPaths, this)) + { + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::filter)); + m_textCtrlExclude->SetFocus(); + return false; + } + + if (syncCfg && syncCfg->deletionVariant == DeletionVariant::versioning) + { + if (AFS::isNullPath(createAbstractPath(syncCfg->versioningFolderPhrase))) + { + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::sync)); + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a target folder."))); + //don't show error icon to follow "Windows' encouraging tone" + m_versioningFolderPath->SetFocus(); + return false; + } + m_versioningFolderPath->getHistory()->addItem(syncCfg->versioningFolderPhrase); + + if (syncCfg->versioningStyle != VersioningStyle::replace && + syncCfg->versionMaxAgeDays > 0 && + syncCfg->versionCountMin > 0 && + syncCfg->versionCountMax > 0 && + syncCfg->versionCountMin >= syncCfg->versionCountMax) + { + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::sync)); + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Minimum version count must be smaller than maximum count."))); + m_spinCtrlVersionCountMin->SetFocus(); + return false; + } + } + + if (selectedPairIndexToShow_ < 0) + { + if (AFS::isNullPath(createAbstractPath(miscCfg.altLogFolderPathPhrase)) && + !miscCfg.altLogFolderPathPhrase.empty()) + { + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::sync)); + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a folder path."))); + m_logFolderPath->SetFocus(); + return false; + } + m_logFolderPath->getHistory()->addItem(miscCfg.altLogFolderPathPhrase); + + if (!miscCfg.emailNotifyAddress.empty() && + !isValidEmail(trimCpy(miscCfg.emailNotifyAddress))) + { + m_notebook->ChangeSelection(static_cast(SyncConfigPanel::sync)); + showNotificationDialog(this, DialogInfoType::info, PopupDialogCfg().setMainInstructions(_("Please enter a valid email address."))); + m_comboBoxEmail->SetFocus(); + return false; + } + m_comboBoxEmail ->addItemHistory(); + m_comboBoxPostSyncCommand->addItemHistory(); + } + } + //------------------------------------------------------------- + + if (selectedPairIndexToShow_ < 0) + { + globalPairCfg_.cmpCfg = *compCfg; + globalPairCfg_.syncCfg = *syncCfg; + globalPairCfg_.filter = filterCfg; + } + else + { + localPairCfg_[selectedPairIndexToShow_].localCmpCfg = compCfg; + localPairCfg_[selectedPairIndexToShow_].localSyncCfg = syncCfg; + localPairCfg_[selectedPairIndexToShow_].localFilter = filterCfg; + } + globalPairCfg_.miscCfg = miscCfg; + + selectedPairIndexToShow_ = EMPTY_PAIR_INDEX_SELECTED; + //m_listBoxFolderPair->SetSelection(wxNOT_FOUND); not needed, selectedPairIndexToShow has parameter ownership + return true; +} + + +void ConfigDialog::onAddNotes(wxCommandEvent& event) +{ + showNotesPanel_ = true; + updateMiscGui(); + + //=> enlarge dialog height! + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() + + m_textCtrNotes->SetFocus(); +} + + +void ConfigDialog::onOkay(wxCommandEvent& event) +{ + if (!unselectFolderPairConfig(true /*validateParams*/)) + return; + + globalPairCfgOut_ = globalPairCfg_; + localPairCfgOut_ = localPairCfg_; + + EndModal(static_cast(ConfirmationButton::accept)); +} + + +//save global settings: should NOT be impacted by OK/Cancel +ConfigDialog::~ConfigDialog() +{ + versioningFolderHistoryOut_ = m_versioningFolderPath->getHistory()->getList(); + logFolderHistoryOut_ = m_logFolderPath ->getHistory()->getList(); + + commandHistoryOut_ = m_comboBoxPostSyncCommand->getHistory(); + emailHistoryOut_ = m_comboBoxEmail ->getHistory(); +} +} + +//######################################################################################## + +ConfirmationButton fff::showSyncConfigDlg(wxWindow* parent, + SyncConfigPanel panelToShow, + int localPairIndexToShow, bool showMultipleCfgs, + + GlobalPairConfig& globalPairCfg, + std::vector& localPairCfg, + + FilterConfig& defaultFilter, + std::vector& versioningFolderHistory, Zstring& versioningFolderLastSelected, + std::vector& logFolderHistory, Zstring& logFolderLastSelected, const Zstring& globalLogFolderPhrase, + size_t folderHistoryMax, Zstring& sftpKeyFileLastSelected, + std::vector& emailHistory, size_t emailHistoryMax, + std::vector& commandHistory, size_t commandHistoryMax) +{ + + ConfigDialog syncDlg(parent, + panelToShow, + localPairIndexToShow, showMultipleCfgs, + globalPairCfg, + localPairCfg, + defaultFilter, + versioningFolderHistory, versioningFolderLastSelected, + logFolderHistory, logFolderLastSelected, globalLogFolderPhrase, + folderHistoryMax, sftpKeyFileLastSelected, + emailHistory, + emailHistoryMax, + commandHistory, + commandHistoryMax); + return static_cast(syncDlg.ShowModal()); +} diff --git a/FreeFileSync/Source/ui/sync_cfg.h b/FreeFileSync/Source/ui/sync_cfg.h new file mode 100644 index 0000000..6d32009 --- /dev/null +++ b/FreeFileSync/Source/ui/sync_cfg.h @@ -0,0 +1,66 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYNC_CFG_H_31289470134253425 +#define SYNC_CFG_H_31289470134253425 + +#include +#include "../base/structures.h" + + +namespace fff +{ +enum class SyncConfigPanel +{ + compare = 0, //used as zero-based notebook page index! + filter, + sync, +}; + +struct MiscSyncConfig +{ + std::map deviceParallelOps; + bool ignoreErrors = false; + size_t autoRetryCount = 0; + std::chrono::seconds autoRetryDelay{0}; + + Zstring postSyncCommand; + PostSyncCondition postSyncCondition = PostSyncCondition::completion; + + Zstring altLogFolderPathPhrase; + + std::string emailNotifyAddress; + ResultsNotification emailNotifyCondition = ResultsNotification::always; + + std::wstring notes; +}; + +struct GlobalPairConfig +{ + CompConfig cmpCfg; + SyncConfig syncCfg; + FilterConfig filter; + MiscSyncConfig miscCfg; +}; + + +zen::ConfirmationButton showSyncConfigDlg(wxWindow* parent, + SyncConfigPanel panelToShow, + int localPairIndexToShow, //< 0 to show global config + bool showMultipleCfgs, + + GlobalPairConfig& globalPairCfg, + std::vector& localPairCfg, + + FilterConfig& defaultFilter, + std::vector& versioningFolderHistory, Zstring& versioningFolderLastSelected, + std::vector& logFolderHistory, Zstring& logFolderLastSelected, const Zstring& globalLogFolderPhrase, + size_t folderHistoryMax, Zstring& sftpKeyFileLastSelected, + std::vector& emailHistory, size_t emailHistoryMax, + std::vector& commandHistory, size_t commandHistoryMax); +} + +#endif //SYNC_CFG_H_31289470134253425 diff --git a/FreeFileSync/Source/ui/tray_icon.cpp b/FreeFileSync/Source/ui/tray_icon.cpp new file mode 100644 index 0000000..53db435 --- /dev/null +++ b/FreeFileSync/Source/ui/tray_icon.cpp @@ -0,0 +1,198 @@ +// ***************************************************************************** +// * 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 "tray_icon.h" +#include +#include +#include +#include //req. by Linux +#include +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +void fillRange(wxImage& img, int pixelFirst, int pixelLast, const wxColor& col) //tolerant input range +{ + const int width = img.GetWidth (); + const int height = img.GetHeight(); + + if (width > 0 && height > 0) + { + pixelFirst = std::max(pixelFirst, 0); + pixelLast = std::min(pixelLast, width * height); + + if (pixelFirst < pixelLast) + { + const unsigned char r = col.Red (); // + const unsigned char g = col.Green(); //getting RGB involves virtual function calls! + const unsigned char b = col.Blue (); // + + unsigned char* rgb = img.GetData() + pixelFirst * 3; + for (int x = pixelFirst; x < pixelLast; ++x) + { + *rgb++ = r; + *rgb++ = g; + *rgb++ = b; + } + + if (img.HasAlpha()) //make progress indicator fully opaque: + std::fill(img.GetAlpha() + pixelFirst, img.GetAlpha() + pixelLast, wxIMAGE_ALPHA_OPAQUE); + } + } +} +} +//------------------------------------------------------------------------------------------------ + + +//generate icon with progress indicator +class FfsTrayIcon::ProgressIconGenerator +{ +public: + explicit ProgressIconGenerator(const wxImage& logo) : logo_(logo) {} + + wxBitmap get(double fraction); + +private: + const wxImage logo_; + wxBitmap iconBuf_; + int startPixBuf_ = -1; +}; + + +wxBitmap FfsTrayIcon::ProgressIconGenerator::get(double fraction) +{ + if (!logo_.IsOk() || logo_.GetWidth() <= 0 || logo_.GetHeight() <= 0) + return wxIcon(); + + const int pixelCount = logo_.GetWidth() * logo_.GetHeight(); + const int startFillPixel = std::clamp(std::floor(fraction * pixelCount), 0, pixelCount); + + if (startPixBuf_ != startFillPixel) + { + wxImage genImage(logo_.Copy()); //workaround wxWidgets' screwed-up design from hell: their copy-construction implements reference-counting WITHOUT copy-on-write! + + //gradually make FFS icon brighter while nearing completion + brighten(genImage, -200 * (1 - fraction)); + + //fill black border row + if (startFillPixel <= pixelCount - genImage.GetWidth()) + { + /* -------- + ---bbbbb + bbbbSyyy S : start yellow remainder + yyyyyyyy */ + + int bStart = startFillPixel - genImage.GetWidth(); + if (bStart % genImage.GetWidth() != 0) //add one more black pixel, see ascii-art + --bStart; + fillRange(genImage, bStart, startFillPixel, *wxBLACK); + } + else if (startFillPixel < pixelCount) + { + /* special handling for last row: + -------- + -------- + ---bbbbb + ---bSyyy S : start yellow remainder */ + + int bStart = startFillPixel - genImage.GetWidth() - 1; + int bEnd = (bStart / genImage.GetWidth() + 1) * genImage.GetWidth(); + + fillRange(genImage, bStart, bEnd, *wxBLACK); + fillRange(genImage, startFillPixel - 1, startFillPixel, *wxBLACK); + } + + //fill yellow remainder + fillRange(genImage, startFillPixel, pixelCount, wxColor(240, 200, 0)); + + iconBuf_ = toScaledBitmap(genImage); + startPixBuf_ = startFillPixel; + } + + return iconBuf_; +} + + +class FfsTrayIcon::TrayIconImpl : public wxTaskBarIcon +{ +public: + TrayIconImpl(const std::function& requestResume) : requestResume_(requestResume) + { + Bind(wxEVT_TASKBAR_LEFT_UP, [this](wxTaskBarIconEvent& event) { if (requestResume_) requestResume_(); }); + } + + void disconnectCallbacks() { requestResume_ = nullptr; } + +private: + wxMenu* CreatePopupMenu() override + { + if (!requestResume_) + return nullptr; + + wxMenu* contextMenu = new wxMenu; + + wxMenuItem* defaultItem = new wxMenuItem(contextMenu, wxID_ANY, _("&Restore")); + //wxWidgets font mess-up: + //1. font must be set *before* wxMenu::Append()! + //2. don't use defaultItem->GetFont(); making it bold creates a huge font size for some reason + contextMenu->Append(defaultItem); + + contextMenu->Bind(wxEVT_COMMAND_MENU_SELECTED, [this](wxCommandEvent& event) { if (requestResume_) requestResume_(); }, defaultItem->GetId()); + + return contextMenu; //ownership transferred to caller + } + + //void onLeftDownClick(wxEvent& event) + //{ + // //copied from wxTaskBarIconBase::OnRightButtonDown() + // if (wxMenu* menu = CreatePopupMenu()) + // { + // PopupMenu(menu); + // delete menu; + // } + //} + + std::function requestResume_; +}; + + +FfsTrayIcon::FfsTrayIcon(const std::function& requestResume) : + trayIcon_(new TrayIconImpl(requestResume)), + progressIcon_(std::make_unique(loadImage("start_sync", dipToScreen(24)))) +{ + [[maybe_unused]] const bool rv = trayIcon_->SetIcon(progressIcon_->get(activeFraction_), activeToolTip_); + assert(rv); //caveat wxTaskBarIcon::SetIcon() can return true, even if not wxTaskBarIcon::IsAvailable()!!! +} + + +FfsTrayIcon::~FfsTrayIcon() +{ + trayIcon_->disconnectCallbacks(); //TrayIconImpl has longer lifetime than FfsTrayIcon: avoid callback! + + trayIcon_->RemoveIcon(); + + //*schedule* for destruction: delete during next idle event (handle late window messages, e.g. when double-clicking) + trayIcon_->Destroy(); //uses wxPendingDelete +} + + +void FfsTrayIcon::setToolTip(const wxString& toolTip) +{ + activeToolTip_ = toolTip; + trayIcon_->SetIcon(progressIcon_->get(activeFraction_), activeToolTip_); //another wxWidgets design bug: non-orthogonal method! +} + + +void FfsTrayIcon::setProgress(double fraction) +{ + activeFraction_ = fraction; + trayIcon_->SetIcon(progressIcon_->get(activeFraction_), activeToolTip_); +} diff --git a/FreeFileSync/Source/ui/tray_icon.h b/FreeFileSync/Source/ui/tray_icon.h new file mode 100644 index 0000000..2106b18 --- /dev/null +++ b/FreeFileSync/Source/ui/tray_icon.h @@ -0,0 +1,50 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TRAY_ICON_H_84217830427534285 +#define TRAY_ICON_H_84217830427534285 + +#include +#include +#include + + +/* show tray icon with progress during lifetime of this instance + + ATTENTION: wxWidgets never assumes that an object indirectly destroys itself while processing an event! + this includes wxEvtHandler-derived objects!!! + it seems wxTaskBarIcon::ProcessEvent() works (on Windows), but AddPendingEvent() will crash since it uses "this" after the event processing! + + => don't derive from wxEvtHandler or any other wxWidgets object here!!!!!! + => use simple std::function as callback instead => FfsTrayIcon instance may now be safely deleted in callback + while ~wxTaskBarIcon is delayed via wxPendingDelete */ +namespace fff +{ +class FfsTrayIcon +{ +public: + explicit FfsTrayIcon(const std::function& requestResume); //callback only held during lifetime of this instance + ~FfsTrayIcon(); + + void setToolTip(const wxString& toolTip); + void setProgress(double fraction); //number between [0, 1], for small progress indicator + +private: + FfsTrayIcon (const FfsTrayIcon&) = delete; + FfsTrayIcon& operator=(const FfsTrayIcon&) = delete; + + class TrayIconImpl; + TrayIconImpl* trayIcon_; + + class ProgressIconGenerator; + std::unique_ptr progressIcon_; + + wxString activeToolTip_ = L"FreeFileSync"; + double activeFraction_ = 1; +}; +} + +#endif //TRAY_ICON_H_84217830427534285 diff --git a/FreeFileSync/Source/ui/tree_grid.cpp b/FreeFileSync/Source/ui/tree_grid.cpp new file mode 100644 index 0000000..7bf9401 --- /dev/null +++ b/FreeFileSync/Source/ui/tree_grid.cpp @@ -0,0 +1,1218 @@ +// ***************************************************************************** +// * 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 "tree_grid.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../icon_buffer.h" + +using namespace zen; +using namespace fff; + + +namespace +{ +//let's NOT create wxWidgets objects statically: +const int PERCENTAGE_BAR_WIDTH_DIP = 60; +const int TREE_GRID_GAP_SIZE_DIP = 4; + +inline wxColor getColorPercentBorder () { return {198, 198, 198}; } +inline wxColor getColorPercentBackground() { return {0xf8, 0xf8, 0xf8}; } + + +Zstring getFolderPairName(const FolderPair& folder) +{ + if (folder.hasEquivalentItemNames()) + return folder.getItemName(); + else + return folder.getItemName() + Zstr(" | ") + + folder.getItemName(); +} +} + + +TreeView::TreeView(FolderComparison& folderCmp, const SortInfo& si) : currentSort_(si) +{ + for (SharedRef& baseObj : folderCmp) + //remove truly empty folder pairs as early as this: we want to distinguish single/multiple folder pair cases by looking at "folderCmp" + if (!AFS::isNullPath(baseObj.ref().getAbstractPath()) || + !AFS::isNullPath(baseObj.ref().getAbstractPath())) + folderCmp_.push_back(baseObj.ptr()); +} + + +inline +void TreeView::compressNode(Container& cont) //remove single-element sub-trees -> gain clarity + usability (call *after* inclusion check!!!) +{ + if (cont.subDirs.empty()) //single files node + cont.showFilesNode = false; + +#if 0 //let's not go overboard: empty folders should not be condensed => used for file exclusion filter; user expects to see them + if (!cont.showFilesNode && //single dir node... + cont.subDirs.size() == 1 && // + !cont.subDirs[0].showFilesNode && //...that is empty + cont.subDirs[0].subDirs.empty()) // + cont.subDirs.clear(); +#endif +} + + +template //(const FileSystemObject&) -> bool +void TreeView::extractVisibleSubtree(ContainerObject& conObj, //in + TreeView::Container& cont, //out + Function pred) +{ + auto getBytes = [](const FilePair& file) //MSVC screws up miserably if we put this lambda into std::for_each + { +#if 0 //give accumulated bytes the semantics of a sync preview? + switch (getEffectiveSyncDir(file.getSyncOperation())) + { + case SyncDirection::none: break; + case SyncDirection::left: return file.getFileSize(); + case SyncDirection::right: return file.getFileSize(); + } +#endif + //prefer file-browser semantics over sync preview (=> always show useful numbers, even for SyncDirection::none) + //discussion: https://freefilesync.org/forum/viewtopic.php?t=1595 + return std::max(file.isEmpty() ? 0 : file.getFileSize(), + file.isEmpty() ? 0 : file.getFileSize()); + }; + + for (FilePair& file : conObj.files()) + if (pred(file)) + { + cont.bytesNet += getBytes(file); + ++cont.itemCountNet; + } + + for (SymlinkPair& symlink : conObj.symlinks()) + if (pred(symlink)) + ++cont.itemCountNet; + + cont.showFilesNode = cont.itemCountNet > 0; + + cont.bytesGross += cont.bytesNet; + cont.itemCountGross += cont.itemCountNet; + + cont.subDirs.reserve(conObj.subfolders().size()); //avoid expensive reallocations! + + for (FolderPair& folder : conObj.subfolders()) + { + const bool included = pred(folder); + + cont.subDirs.emplace_back(); // + auto& subDirCont = cont.subDirs.back(); + TreeView::extractVisibleSubtree(folder, subDirCont, pred); + if (included) + ++subDirCont.itemCountGross; + + cont.bytesGross += subDirCont.bytesGross; + cont.itemCountGross += subDirCont.itemCountGross; + + if (!included && subDirCont.subDirs.empty() && !subDirCont.showFilesNode) + cont.subDirs.pop_back(); + else + { + subDirCont.containerRef = std::static_pointer_cast(folder.shared_from_this()); + compressNode(subDirCont); + } + } +} + + +namespace +{ +//generate "nice" percentage numbers which precisely add up to 100 +void calcPercentage(std::vector>& workList) +{ + uint64_t bytesTotal = 0; + for (const auto& [bytes, percentOut] : workList) + bytesTotal += bytes; + + if (bytesTotal == 0U) //this case doesn't work with the error minimizing algorithm below + { + for (auto& [bytes, percentOut] : workList) + *percentOut = 0; + return; + } + + int remainingPercent = 100; + for (auto& [bytes, percentOut] : workList) + { + *percentOut = static_cast(bytes * 100U / bytesTotal); //round down + remainingPercent -= *percentOut; + } + assert(remainingPercent >= 0); + assert(remainingPercent < std::ssize(workList)); + + //distribute remaining percent so that overall error is minimized as much as possible: + remainingPercent = std::min(remainingPercent, static_cast(workList.size())); + if (remainingPercent > 0) + { + std::nth_element(workList.begin(), workList.begin() + remainingPercent - 1, workList.end(), + [bytesTotal](const std::pair& lhs, const std::pair& rhs) + { + return lhs.first * 100U % bytesTotal > rhs.first * 100U % bytesTotal; + }); + + std::for_each(workList.begin(), workList.begin() + remainingPercent, [&](std::pair& pair) { ++*pair.second; }); + } +} +} + + +template +struct TreeView::LessShortName +{ + bool operator()(const TreeLine& lhs, const TreeLine& rhs) const + { + //files last (irrespective of sort direction) + if (lhs.type == NodeType::files) + return false; + else if (rhs.type == NodeType::files) + return true; + + if (lhs.type != rhs.type) // + return lhs.type < rhs.type; //shouldn't happen! root nodes not mixed with files or directories + + switch (lhs.type) + { + case NodeType::root: + return makeSortDirection(LessNaturalSort() /*even on Linux*/, + std::bool_constant())(utfTo(static_cast(lhs.node)->displayName), + utfTo(static_cast(rhs.node)->displayName)); + case NodeType::folder: + { + const auto* folderL = static_cast(lhs.node->containerRef.lock().get()); + const auto* folderR = static_cast(rhs.node->containerRef.lock().get()); + + if (!folderL) + return false; + else if (!folderR) + return true; + + return makeSortDirection(LessNaturalSort(), std::bool_constant())(getFolderPairName(*folderL), getFolderPairName(*folderR)); + } + + case NodeType::files: + break; + } + assert(false); + return false; //:= all equal + } +}; + + +template +void TreeView::sortSingleLevel(std::vector& items, ColumnTypeOverview columnType) +{ + auto getBytes = [](const TreeLine& line) -> uint64_t + { + switch (line.type) + { + case NodeType::root: + case NodeType::folder: + return line.node->bytesGross; + case NodeType::files: + return line.node->bytesNet; + } + assert(false); + return 0U; + }; + + auto getCount = [](const TreeLine& line) -> int + { + switch (line.type) + { + case NodeType::root: + case NodeType::folder: + return line.node->itemCountGross; + + case NodeType::files: + return line.node->itemCountNet; + } + assert(false); + return 0; + }; + + const auto lessBytes = [&](const TreeLine& lhs, const TreeLine& rhs) { return getBytes(lhs) < getBytes(rhs); }; + const auto lessCount = [&](const TreeLine& lhs, const TreeLine& rhs) { return getCount(lhs) < getCount(rhs); }; + + switch (columnType) + { + case ColumnTypeOverview::folder: + std::sort(items.begin(), items.end(), LessShortName()); + break; + case ColumnTypeOverview::itemCount: + std::sort(items.begin(), items.end(), makeSortDirection(lessCount, std::bool_constant())); + break; + case ColumnTypeOverview::bytes: + std::sort(items.begin(), items.end(), makeSortDirection(lessBytes, std::bool_constant())); + break; + } +} + + +void TreeView::getChildren(const Container& cont, unsigned int level, std::vector& output) +{ + output.clear(); + output.reserve(cont.subDirs.size() + 1); //keep pointers in "workList" valid + std::vector> workList; + + for (const Container& subDir : cont.subDirs) + { + output.push_back({level, 0, &subDir, NodeType::folder}); + workList.emplace_back(subDir.bytesGross, &output.back().percent); + } + + if (cont.showFilesNode) + { + output.push_back({level, 0, &cont, NodeType::files}); + workList.emplace_back(cont.bytesNet, &output.back().percent); + } + calcPercentage(workList); + + if (currentSort_.ascending) + sortSingleLevel(output, currentSort_.sortCol); + else + sortSingleLevel(output, currentSort_.sortCol); +} + + +void TreeView::applySubView(std::vector&& newView) +{ + //preserve current node expansion status + auto getContainer = [](const TreeView::TreeLine& tl) -> const ContainerObject* + { + switch (tl.type) + { + case NodeType::root: + case NodeType::folder: + return tl.node->containerRef.lock().get(); + + case NodeType::files: + break; //none!!! + } + return nullptr; + }; + + std::unordered_set expandedNodes; + if (!flatTree_.empty()) + { + auto it = flatTree_.begin(); + for (auto itNext = flatTree_.begin() + 1; itNext != flatTree_.end(); ++itNext, ++it) + if (it->level < itNext->level) + if (auto conObj = getContainer(*it)) + expandedNodes.insert(conObj); + } + + //update view on full data + folderCmpView_.swap(newView); //newView may be an alias for folderCmpView! see sorting! + + //set default flat tree + flatTree_.clear(); + + if (folderCmp_.size() == 1) //single folder pair case (empty pairs were already removed!) do NOT use folderCmpView for this check! + { + if (!folderCmpView_.empty()) //possibly empty! + getChildren(folderCmpView_[0], 0, flatTree_); //do not show root + } + else + { + //following is almost identical with TreeView::getChildren(): + // however we *cannot* reuse code here since "std::vector" is not a "Container"! + + flatTree_.reserve(folderCmpView_.size()); //keep pointers in "workList" valid + std::vector> workList; + + for (const RootNodeImpl& root : folderCmpView_) + { + flatTree_.push_back({0, 0, &root, NodeType::root}); + workList.emplace_back(root.bytesGross, &flatTree_.back().percent); + } + + calcPercentage(workList); + + if (currentSort_.ascending) + sortSingleLevel(flatTree_, currentSort_.sortCol); + else + sortSingleLevel(flatTree_, currentSort_.sortCol); + } + + //restore node expansion status + for (size_t row = 0; row < flatTree_.size(); ++row) //flatTree size changes during loop! + { + const TreeLine& line = flatTree_[row]; + + if (auto conObj = getContainer(line)) + if (expandedNodes.contains(conObj)) + { + std::vector newLines; + getChildren(*line.node, line.level + 1, newLines); + + flatTree_.insert(flatTree_.begin() + row + 1, newLines.begin(), newLines.end()); + } + } +} + + +template +void TreeView::updateView(Predicate pred) +{ + //update view on full data + std::vector newView; + newView.reserve(folderCmp_.size()); //avoid expensive reallocations! + + for (const std::weak_ptr& baseObjRef : folderCmp_) + if (BaseFolderPair* baseObj = baseObjRef.lock().get()) + { + newView.emplace_back(); + RootNodeImpl& root = newView.back(); + this->extractVisibleSubtree(*baseObj, root, pred); //"this->" is bogus for a static method, but GCC screws this one up + + //warning: the following lines are almost 1:1 copy from extractVisibleSubtree, adapted for BaseFolderPair: + if (root.subDirs.empty() && !root.showFilesNode) + newView.pop_back(); + else + { + root.containerRef = baseObjRef; + root.displayName = getShortDisplayNameForFolderPair(baseObj->getAbstractPath(), + baseObj->getAbstractPath()); + + this->compressNode(root); //"this->" required by two-pass lookup as enforced by GCC 4.7 + } + } + + lastViewFilterPred_ = pred; + applySubView(std::move(newView)); +} + + +void TreeView::setSortDirection(ColumnTypeOverview colType, bool ascending) //apply permanently! +{ + currentSort_ = SortInfo{colType, ascending}; + + //reapply current view + applySubView(std::move(folderCmpView_)); +} + + +TreeView::NodeStatus TreeView::getStatus(size_t row) const +{ + if (row < flatTree_.size()) + { + if (row + 1 < flatTree_.size() && flatTree_[row + 1].level > flatTree_[row].level) + return NodeStatus::expanded; + + //it's either reduced or empty + switch (flatTree_[row].type) + { + case NodeType::root: + case NodeType::folder: + return flatTree_[row].node->showFilesNode || !flatTree_[row].node->subDirs.empty() ? NodeStatus::reduced : NodeStatus::empty; + + case NodeType::files: + return NodeStatus::empty; + } + } + return NodeStatus::empty; +} + + +void TreeView::expandNode(size_t row) +{ + if (getStatus(row) != NodeStatus::reduced) + { + assert(false); + return; + } + + if (row < flatTree_.size()) + { + std::vector newLines; + + switch (flatTree_[row].type) + { + case NodeType::root: + case NodeType::folder: + getChildren(*flatTree_[row].node, flatTree_[row].level + 1, newLines); + break; + case NodeType::files: + break; + } + flatTree_.insert(flatTree_.begin() + row + 1, newLines.begin(), newLines.end()); + } +} + + +void TreeView::reduceNode(size_t row) +{ + if (row < flatTree_.size()) + { + const unsigned int parentLevel = flatTree_[row].level; + + bool done = false; + flatTree_.erase(std::remove_if(flatTree_.begin() + row + 1, flatTree_.end(), + [&](const TreeLine& line) -> bool + { + if (done) + return false; + if (line.level > parentLevel) + return true; + else + { + done = true; + return false; + } + }), flatTree_.end()); + } +} + + +ptrdiff_t TreeView::getParent(size_t row) const +{ + if (row < flatTree_.size()) + { + const auto level = flatTree_[row].level; + + while (row-- > 0) + if (flatTree_[row].level < level) + return row; + } + return -1; +} + + +void TreeView::applyDifferenceFilter(bool showExcluded, + bool leftOnlyFilesActive, + bool rightOnlyFilesActive, + bool leftNewerFilesActive, + bool rightNewerFilesActive, + bool differentFilesActive, + bool equalFilesActive, + bool conflictFilesActive) +{ + updateView([showExcluded, //make sure the predicate can be stored safely! + leftOnlyFilesActive, + rightOnlyFilesActive, + leftNewerFilesActive, + rightNewerFilesActive, + differentFilesActive, + equalFilesActive, + conflictFilesActive](const FileSystemObject& fsObj) -> bool + { + if (!fsObj.isActive() && !showExcluded) + return false; + + switch (fsObj.getCategory()) + { + case FILE_LEFT_ONLY: + return leftOnlyFilesActive; + case FILE_RIGHT_ONLY: + return rightOnlyFilesActive; + case FILE_LEFT_NEWER: + return leftNewerFilesActive; + case FILE_RIGHT_NEWER: + return rightNewerFilesActive; + case FILE_DIFFERENT_CONTENT: + return differentFilesActive; + case FILE_EQUAL: + return equalFilesActive; + case FILE_RENAMED: + case FILE_CONFLICT: + case FILE_TIME_INVALID: + return conflictFilesActive; + } + assert(false); + return true; + }); +} + + +void TreeView::applyActionFilter(bool showExcluded, + bool syncCreateLeftActive, + bool syncCreateRightActive, + bool syncDeleteLeftActive, + bool syncDeleteRightActive, + bool syncDirOverwLeftActive, + bool syncDirOverwRightActive, + bool syncDirNoneActive, + bool syncEqualActive, + bool conflictFilesActive) +{ + updateView([showExcluded, //make sure the predicate can be stored safely! + syncCreateLeftActive, + syncCreateRightActive, + syncDeleteLeftActive, + syncDeleteRightActive, + syncDirOverwLeftActive, + syncDirOverwRightActive, + syncDirNoneActive, + syncEqualActive, + conflictFilesActive](const FileSystemObject& fsObj) -> bool + { + if (!fsObj.isActive() && !showExcluded) + return false; + + switch (fsObj.getSyncOperation()) + { + case SO_CREATE_LEFT: + return syncCreateLeftActive; + case SO_CREATE_RIGHT: + return syncCreateRightActive; + case SO_DELETE_LEFT: + return syncDeleteLeftActive; + case SO_DELETE_RIGHT: + return syncDeleteRightActive; + case SO_OVERWRITE_RIGHT: + case SO_RENAME_RIGHT: + case SO_MOVE_RIGHT_FROM: + case SO_MOVE_RIGHT_TO: + return syncDirOverwRightActive; + case SO_OVERWRITE_LEFT: + case SO_RENAME_LEFT: + case SO_MOVE_LEFT_FROM: + case SO_MOVE_LEFT_TO: + return syncDirOverwLeftActive; + case SO_DO_NOTHING: + return syncDirNoneActive; + case SO_EQUAL: + return syncEqualActive; + case SO_UNRESOLVED_CONFLICT: + return conflictFilesActive; + } + assert(false); + return true; + }); +} + + +std::unique_ptr TreeView::getLine(size_t row) const +{ + if (row < flatTree_.size()) + { + const auto level = flatTree_[row].level; + const int percent = flatTree_[row].percent; + + switch (flatTree_[row].type) + { + case NodeType::root: + { + const auto& root = *static_cast(flatTree_[row].node); + + if (BaseFolderPair* baseFolder = static_cast(root.containerRef.lock().get())) + return std::make_unique(percent, root.bytesGross, root.itemCountGross, getStatus(row), *baseFolder, root.displayName); + } + break; + + case NodeType::folder: + { + const Container& contObj = *flatTree_[row].node; + + if (FolderPair* folder = static_cast(contObj.containerRef.lock().get())) + return std::make_unique(percent, contObj.bytesGross, contObj.itemCountGross, level, getStatus(row), *folder); + } + break; + + case NodeType::files: + { + const auto& parentFolder = *flatTree_[row].node; + + if (ContainerObject* conObj = parentFolder.containerRef.lock().get()) + { + std::vector filesAndLinks; + + //lazy evaluation: recheck "lastViewFilterPred" again rather than buffer and bloat "lastViewFilterPred" + for (FileSystemObject& fsObj : conObj->files()) + if (lastViewFilterPred_(fsObj)) + filesAndLinks.push_back(&fsObj); + + for (FileSystemObject& fsObj : conObj->symlinks()) + if (lastViewFilterPred_(fsObj)) + filesAndLinks.push_back(&fsObj); + + return std::make_unique(percent, parentFolder.bytesNet, parentFolder.itemCountNet, level, std::move(filesAndLinks)); + } + } + break; + } + } + return nullptr; +} + +//########################################################################################################## + +namespace +{ +wxColor getColorForLevel(size_t level) +{ + switch (level % 12) + { + case 0: return {0xcc, 0xcc, 0xff}; + case 1: return {0xcc, 0xff, 0xcc}; + case 2: return {0xff, 0xff, 0x99}; + case 3: return {0xdd, 0xdd, 0xdd}; + case 4: return {0xff, 0xcc, 0xff}; + case 5: return {0x99, 0xff, 0xcc}; + case 6: return {0xcc, 0xcc, 0x99}; + case 7: return {0xff, 0xcc, 0xcc}; + case 8: return {0xcc, 0xff, 0x99}; + case 9: return {0xff, 0xff, 0xcc}; + case 10: return {0xcc, 0xff, 0xff}; + case 11: return {0xff, 0xcc, 0x99}; + } + assert(false); + return *wxBLACK; +} + + +class GridDataTree : private wxEvtHandler, public GridData +{ +public: + GridDataTree(Grid& grid) : + widthNodeIcon_(screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small))), + widthLevelStep_(widthNodeIcon_), + widthNodeStatus_(screenToWxsize(loadImage("node_expanded").GetWidth())), + rootIcon_(loadImage("root_folder", wxsizeToScreen(widthNodeIcon_))), + grid_(grid) + { + grid.Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event); }); + grid.Bind(EVENT_GRID_MOUSE_LEFT_DOWN, [this](GridClickEvent& event) { onMouseLeft (event); }); + grid.Bind(EVENT_GRID_MOUSE_LEFT_DOUBLE, [this](GridClickEvent& event) { onMouseLeftDouble(event); }); + grid.Bind(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, [this](GridLabelClickEvent& event) { onGridLabelContext (event); }); + grid.Bind(EVENT_GRID_COL_LABEL_MOUSE_LEFT, [this](GridLabelClickEvent& event) { onGridLabelLeftClick(event); }); + } + + void setData(FolderComparison& folderCmp) + { + const TreeView::SortInfo sortCfg = treeDataView_.ref().getSortConfig(); //preserve! + + treeDataView_ = makeSharedRef(); //clear old data view first! avoid memory peaks! + treeDataView_ = makeSharedRef(folderCmp, sortCfg); + } + + const TreeView& getDataView() const { return treeDataView_.ref(); } + /**/ TreeView& getDataView() { return treeDataView_.ref(); } + + void setShowPercentage(bool value) { showPercentBar_ = value; grid_.Refresh(); } + bool getShowPercentage() const { return showPercentBar_; } + +private: + size_t getRowCount() const override { return getDataView().rowsTotal(); } + + std::wstring getToolTip(size_t row, ColumnType colType, HoverArea rowHover) override + { + switch (static_cast(colType)) + { + case ColumnTypeOverview::folder: + if (std::unique_ptr node = getDataView().getLine(row)) + if (const TreeView::RootNode* root = dynamic_cast(node.get())) + { + const std::wstring& dirLeft = AFS::getDisplayPath(root->baseFolder.getAbstractPath()); + const std::wstring& dirRight = AFS::getDisplayPath(root->baseFolder.getAbstractPath()); + if (dirLeft.empty()) + return dirRight; + else if (dirRight.empty()) + return dirLeft; + return dirLeft + /*L' ' + EM_DASH + */ L'\n' + dirRight; + } + break; + + case ColumnTypeOverview::itemCount: + case ColumnTypeOverview::bytes: + break; + } + return std::wstring(); + } + + std::wstring getValue(size_t row, ColumnType colType) const override + { + if (std::unique_ptr node = getDataView().getLine(row)) + switch (static_cast(colType)) + { + case ColumnTypeOverview::folder: + if (const TreeView::RootNode* root = dynamic_cast(node.get())) + return root->displayName; + else if (const TreeView::DirNode* dir = dynamic_cast(node.get())) + return utfTo(getFolderPairName(dir->folder)); + else if (dynamic_cast(node.get())) + return _("Files"); + break; + + case ColumnTypeOverview::itemCount: + return formatNumber(node->itemCount_); + + case ColumnTypeOverview::bytes: + return formatFilesizeShort(node->bytes_); + } + + return std::wstring(); + } + + void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) override + { + const auto colTypeTree = static_cast(colType); + + const wxRect rectInner = drawColumnLabelBackground(dc, rect, highlighted); + wxRect rectRemain = rectInner; + + rectRemain.x += getColumnGapLeft(); + rectRemain.width -= getColumnGapLeft(); + drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); + + if (const auto [sortCol, ascending] = getDataView().getSortConfig(); + colTypeTree == sortCol) + { + const wxImage sortMarker = loadImage(ascending ? "sort_ascending" : "sort_descending"); + drawBitmapRtlNoMirror(dc, enabled ? sortMarker : sortMarker.ConvertToDisabled(), rectInner, wxALIGN_CENTER_HORIZONTAL); + } + } + + + void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) override + { + if (enabled && selected) + GridData::renderRowBackgound(dc, rect, row, true /*enabled*/, true /*selected*/, rowHover); + else + ; //clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default + } + + + enum class HoverAreaTree + { + node, + item, + }; + + void renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) override + { + wxDCTextColourChanger textColor(dc); + if (enabled && selected) //accessibility: always set *both* foreground AND background colors! + textColor.Set(*wxBLACK); + + //wxRect rectTmp= drawCellBorder(dc, rect); + wxRect rectTmp = rect; + + // Partitioning: + // ________________________________________________________________________________ + // | space | gap | percentage bar | 2 x gap | node status | gap |icon | gap | rest | + // -------------------------------------------------------------------------------- + // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() + + if (static_cast(colType) == ColumnTypeOverview::folder) + { + if (std::unique_ptr node = getDataView().getLine(row)) + { + auto drawIcon = [&](wxImage icon, const wxRect& rectIcon, bool drawActive) + { + if (!drawActive) + icon = icon.ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3); //treat all channels equally! + + if (!enabled) + icon = icon.ConvertToDisabled(); + + drawBitmapRtlNoMirror(dc, icon, rectIcon, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + }; + + //consume space + rectTmp.x += static_cast(node->level_) * widthLevelStep_; + rectTmp.width -= static_cast(node->level_) * widthLevelStep_; + + rectTmp.x += gapSize_; + rectTmp.width -= gapSize_; + + if (rectTmp.width > 0) + { + //percentage bar + if (showPercentBar_) + { + wxRect areaPerc(rectTmp.x, rectTmp.y + dipToWxsize(2), percentageBarWidth_, rectTmp.height - dipToWxsize(4)); + //clear background + drawFilledRectangle(dc, areaPerc, getColorPercentBackground(), getColorPercentBorder(), dipToWxsize(1)); + areaPerc.Deflate(dipToWxsize(1)); + + //inner area + wxRect areaPercTmp = areaPerc; + areaPercTmp.width = numeric::intDivRound(areaPercTmp.width * node->percent_, 100); + clearArea(dc, areaPercTmp, getColorForLevel(node->level_)); + + wxDCTextColourChanger textColorPercent(dc, *wxBLACK); //accessibility: always set both foreground AND background colors! + drawCellText(dc, areaPerc, numberTo(node->percent_) + L"%", wxALIGN_CENTER); + + rectTmp.x += percentageBarWidth_ + 2 * gapSize_; + rectTmp.width -= percentageBarWidth_ + 2 * gapSize_; + } + if (rectTmp.width > 0) + { + //node status + const bool drawMouseHover = static_cast(rowHover) == HoverAreaTree::node; + switch (node->status_) + { + case TreeView::NodeStatus::expanded: + drawIcon(loadImage(drawMouseHover ? "node_expanded_hover" : "node_expanded"), rectTmp, true /*drawActive*/); + break; + case TreeView::NodeStatus::reduced: + drawIcon(loadImage(drawMouseHover ? "node_reduced_hover" : "node_reduced"), rectTmp, true /*drawActive*/); + break; + case TreeView::NodeStatus::empty: + break; + } + + rectTmp.x += widthNodeStatus_ + gapSize_; + rectTmp.width -= widthNodeStatus_ + gapSize_; + if (rectTmp.width > 0) + { + wxImage nodeIcon; + bool isActive = true; + //icon + if (dynamic_cast(node.get())) + nodeIcon = rootIcon_; + else if (auto dir = dynamic_cast(node.get())) + { + nodeIcon = dirIcon_; + isActive = dir->folder.isActive(); + } + else if (dynamic_cast(node.get())) + nodeIcon = fileIcon_; + + drawIcon(nodeIcon, rectTmp, isActive); + + if (static_cast(rowHover) == HoverAreaTree::item) + drawRectangleBorder(dc, rectTmp, mouseHighlightColor_, dipToWxsize(1)); + + rectTmp.x += widthNodeIcon_ + gapSize_; + rectTmp.width -= widthNodeIcon_ + gapSize_; + + if (rectTmp.width > 0) + { + if (!isActive && + (!enabled || !selected)) + textColor.Set(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + + drawCellText(dc, rectTmp, getValue(row, colType), wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + } + } + } + } + } + } + else + { + int alignment = wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL; + + //have file size and item count right-justified (but don't change for RTL languages) + if ((static_cast(colType) == ColumnTypeOverview::bytes || + static_cast(colType) == ColumnTypeOverview::itemCount) && grid_.GetLayoutDirection() != wxLayout_RightToLeft) + { + rectTmp.width -= 2 * gapSize_; + alignment = wxALIGN_RIGHT | wxALIGN_CENTER_VERTICAL; + } + else //left-justified + { + rectTmp.x += 2 * gapSize_; + rectTmp.width -= 2 * gapSize_; + } + + drawCellText(dc, rectTmp, getValue(row, colType), alignment); + } + } + + int getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) override + { + // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() + + if (static_cast(colType) == ColumnTypeOverview::folder) + { + if (std::unique_ptr node = getDataView().getLine(row)) + return node->level_ * widthLevelStep_ + gapSize_ + (showPercentBar_ ? percentageBarWidth_ + 2 * gapSize_ : 0) + widthNodeStatus_ + gapSize_ + + widthNodeIcon_ + gapSize_ + dc.GetTextExtent(getValue(row, colType)).GetWidth() + + gapSize_; //additional gap from right + else + return 0; + } + else + return 2 * gapSize_ + dc.GetTextExtent(getValue(row, colType)).GetWidth() + + 2 * gapSize_; //include gap from right! + } + + HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) override + { + if (static_cast(colType) == ColumnTypeOverview::folder) + if (std::unique_ptr node = getDataView().getLine(row)) + { + const int nodeStatusXFirst = static_cast(node->level_) * widthLevelStep_ + gapSize_ + (showPercentBar_ ? percentageBarWidth_ + 2 * gapSize_ : 0); + const int nodeStatusXLast = nodeStatusXFirst + widthNodeStatus_; + // -> synchronize renderCell() <-> getBestSize() <-> getMouseHover() + + const int tolerance = dipToWxsize(5); + if (nodeStatusXFirst - tolerance <= cellRelativePosX && cellRelativePosX < nodeStatusXLast + tolerance) + return static_cast(HoverAreaTree::node); + } + return static_cast(HoverAreaTree::item); + } + + std::wstring getColumnLabel(ColumnType colType) const override + { + switch (static_cast(colType)) + { + case ColumnTypeOverview::folder: + return _("Folder"); + case ColumnTypeOverview::itemCount: + return _("Items"); + case ColumnTypeOverview::bytes: + return _("Size"); + } + return std::wstring(); + } + + void onMouseLeft(GridClickEvent& event) + { + switch (static_cast(event.hoverArea_)) + { + case HoverAreaTree::node: + switch (getDataView().getStatus(event.row_)) + { + case TreeView::NodeStatus::expanded: + return reduceNode(event.row_); + case TreeView::NodeStatus::reduced: + return expandNode(event.row_); + case TreeView::NodeStatus::empty: + break; + } + break; + case HoverAreaTree::item: + break; + } + event.Skip(); + } + + void onMouseLeftDouble(GridClickEvent& event) + { + switch (getDataView().getStatus(event.row_)) + { + case TreeView::NodeStatus::expanded: + return reduceNode(event.row_); + case TreeView::NodeStatus::reduced: + return expandNode(event.row_); + case TreeView::NodeStatus::empty: + break; + } + event.Skip(); + } + + void onKeyDown(wxKeyEvent& event) + { + int keyCode = event.GetKeyCode(); + if (grid_.GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + + const size_t rowCount = grid_.getRowCount(); + if (rowCount == 0) return; + + const size_t row = grid_.getGridCursor(); + if (event.ShiftDown()) + ; + else if (event.ControlDown()) + ; + else + switch (keyCode) + { + case WXK_LEFT: + case WXK_NUMPAD_LEFT: + case WXK_NUMPAD_SUBTRACT: //https://docs.microsoft.com/en-us/previous-versions/windows/desktop/dnacc/guidelines-for-keyboard-user-interface-design#windows-shortcut-keys + switch (getDataView().getStatus(row)) + { + case TreeView::NodeStatus::expanded: + return reduceNode(row); + case TreeView::NodeStatus::reduced: + case TreeView::NodeStatus::empty: + + const int parentRow = getDataView().getParent(row); + if (parentRow >= 0) + grid_.setGridCursor(parentRow, GridEventPolicy::allow); + break; + } + return; //swallow event + + case WXK_RIGHT: + case WXK_NUMPAD_RIGHT: + case WXK_NUMPAD_ADD: + switch (getDataView().getStatus(row)) + { + case TreeView::NodeStatus::expanded: + grid_.setGridCursor(std::min(rowCount - 1, row + 1), GridEventPolicy::allow); + break; + case TreeView::NodeStatus::reduced: + return expandNode(row); + case TreeView::NodeStatus::empty: + break; + } + return; //swallow event + } + + event.Skip(); + } + + void onGridLabelContext(GridLabelClickEvent& event) + { + ContextMenu menu; + //-------------------------------------------------------------------------------------------------------- + menu.addCheckBox(_("Percentage"), [this] { setShowPercentage(!getShowPercentage()); }, getShowPercentage()); + //-------------------------------------------------------------------------------------------------------- + auto toggleColumn = [&](ColumnType ct) + { + auto colAttr = grid_.getColumnConfig(); + + Grid::ColAttributes* caFolderName = nullptr; + Grid::ColAttributes* caToggle = nullptr; + + for (Grid::ColAttributes& ca : colAttr) + if (ca.type == static_cast(ColumnTypeOverview::folder)) + caFolderName = &ca; + else if (ca.type == ct) + caToggle = &ca; + + assert(caFolderName && caFolderName->stretch > 0 && caFolderName->visible); + assert(caToggle && caToggle->stretch == 0); + + if (caFolderName && caToggle) + { + caToggle->visible = !caToggle->visible; + + //take width of newly visible column from stretched folder name column + caFolderName->offset -= caToggle->visible ? caToggle->offset : -caToggle->offset; + + grid_.setColumnConfig(colAttr); + } + }; + + for (const Grid::ColAttributes& ca : grid_.getColumnConfig()) + { + menu.addCheckBox(getColumnLabel(ca.type), [ct = ca.type, toggleColumn] { toggleColumn(ct); }, + ca.visible, ca.type != static_cast(ColumnTypeOverview::folder)); //do not allow user to hide file name column! + } + //-------------------------------------------------------------------------------------------------------- + menu.addSeparator(); + + auto setDefaultColumns = [&] + { + setShowPercentage(overviewPanelShowPercentageDefault); + grid_.setColumnConfig(convertColAttributes(getOverviewDefaultColAttribs(), getOverviewDefaultColAttribs())); + }; + menu.addItem(_("&Default"), setDefaultColumns, loadImage("reset_sicon")); //'&' -> reuse text from "default" buttons elsewhere + //-------------------------------------------------------------------------------------------------------- + + menu.popup(grid_, {event.mousePos_.x, grid_.getColumnLabelHeight()}); + //event.Skip(); + } + + void onGridLabelLeftClick(GridLabelClickEvent& event) + { + const auto colTypeTree = static_cast(event.colType_); + + bool sortAscending = getDefaultSortDirection(colTypeTree); + const auto [sortCol, ascending] = getDataView().getSortConfig(); + if (sortCol == colTypeTree) + sortAscending = !ascending; + + getDataView().setSortDirection(colTypeTree, sortAscending); + grid_.Refresh(); //just in case, but setSortDirection() should not change grid size + grid_.clearSelection(GridEventPolicy::allow); + } + + void expandNode(size_t row) + { + getDataView().expandNode(row); + grid_.Refresh(); //implicitly clears selection (changed row count after expand) + grid_.setGridCursor(row, GridEventPolicy::allow); + //grid_.autoSizeColumns(); -> doesn't look as good as expected + } + + void reduceNode(size_t row) + { + getDataView().reduceNode(row); + grid_.Refresh(); + grid_.setGridCursor(row, GridEventPolicy::allow); + } + + SharedRef treeDataView_ = makeSharedRef(); + + const int gapSize_ = dipToWxsize(TREE_GRID_GAP_SIZE_DIP); + const int percentageBarWidth_ = dipToWxsize(PERCENTAGE_BAR_WIDTH_DIP); + + const wxImage fileIcon_ = IconBuffer::genericFileIcon(IconBuffer::IconSize::small); + const wxImage dirIcon_ = IconBuffer::genericDirIcon (IconBuffer::IconSize::small); + + const int widthNodeIcon_; + const int widthLevelStep_; + const int widthNodeStatus_; + + const wxImage rootIcon_; + const wxColor mouseHighlightColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode! + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 + + Grid& grid_; + bool showPercentBar_ = true; +}; +} + + +void treegrid::init(Grid& grid) +{ + grid.setDataProvider(std::make_shared(grid)); + grid.showRowLabel(false); + + const int rowHeight = std::max(screenToWxsize(IconBuffer::getPixSize(IconBuffer::IconSize::small)) + dipToWxsize(2), //1 extra pixel on top/bottom; dearly needed on OS X! + grid.getMainWin().GetCharHeight()); //seems to already include 3 margin pixels on top/bottom (consider percentage area) + grid.setRowHeight(rowHeight); +} + + +void treegrid::setData(zen::Grid& grid, FolderComparison& folderCmp) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->setData(folderCmp); + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] treegrid was not initialized."); +} + + +TreeView& treegrid::getDataView(Grid& grid) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->getDataView(); + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] treegrid was not initialized."); +} + + +void treegrid::setShowPercentage(Grid& grid, bool value) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + prov->setShowPercentage(value); + else + assert(false); +} + + +bool treegrid::getShowPercentage(const Grid& grid) +{ + if (auto* prov = dynamic_cast(grid.getDataProvider())) + return prov->getShowPercentage(); + assert(false); + return true; +} diff --git a/FreeFileSync/Source/ui/tree_grid.h b/FreeFileSync/Source/ui/tree_grid.h new file mode 100644 index 0000000..9fb3310 --- /dev/null +++ b/FreeFileSync/Source/ui/tree_grid.h @@ -0,0 +1,179 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TREE_VIEW_H_841703190201835280256673425 +#define TREE_VIEW_H_841703190201835280256673425 + +#include +#include +#include "tree_grid_attr.h" +#include "../base/file_hierarchy.h" + + +namespace fff +{ +//tree view of FolderComparison +class TreeView +{ +public: + struct SortInfo + { + ColumnTypeOverview sortCol = overviewPanelLastSortColumnDefault; + bool ascending = getDefaultSortDirection(overviewPanelLastSortColumnDefault); + }; + + TreeView() {} + TreeView(FolderComparison& folderCmp, const SortInfo& si); + + //apply view filter: comparison results + void applyDifferenceFilter(bool showExcluded, + bool leftOnlyFilesActive, + bool rightOnlyFilesActive, + bool leftNewerFilesActive, + bool rightNewerFilesActive, + bool differentFilesActive, + bool equalFilesActive, + bool conflictFilesActive); + + //apply view filter: synchronization preview + void applyActionFilter(bool showExcluded, + bool syncCreateLeftActive, + bool syncCreateRightActive, + bool syncDeleteLeftActive, + bool syncDeleteRightActive, + bool syncDirOverwLeftActive, + bool syncDirOverwRightActive, + bool syncDirNoneActive, + bool syncEqualActive, + bool conflictFilesActive); + + enum class NodeStatus + { + expanded, + reduced, + empty + }; + + //--------------------------------------------------------------------- + struct Node + { + Node(int percent, uint64_t bytes, int itemCount, unsigned int level, NodeStatus status) : + percent_(percent), bytes_(bytes), itemCount_(itemCount), level_(level), status_(status) {} + virtual ~Node() {} + + const int percent_; //[0, 100] + const uint64_t bytes_; + const int itemCount_; + const unsigned int level_; + const NodeStatus status_; + }; + + struct FilesNode : public Node + { + FilesNode(int percent, uint64_t bytes, int itemCount, unsigned int level, std::vector&& fsos) : + Node(percent, bytes, itemCount, level, NodeStatus::empty), filesAndLinks(std::move(fsos)) {} + + std::vector filesAndLinks; //files and symlinks matching view filter; pointers are bound! + }; + + struct DirNode : public Node + { + DirNode(int percent, uint64_t bytes, int itemCount, unsigned int level, NodeStatus status, FolderPair& fp) : Node(percent, bytes, itemCount, level, status), folder(fp) {} + FolderPair& folder; + }; + + struct RootNode : public Node + { + RootNode(int percent, uint64_t bytes, int itemCount, NodeStatus status, BaseFolderPair& bFolder, const std::wstring& dispName) : + Node(percent, bytes, itemCount, 0, status), baseFolder(bFolder), displayName(dispName) {} + + BaseFolderPair& baseFolder; + const std::wstring displayName; + }; + + std::unique_ptr getLine(size_t row) const; //return nullptr on error + size_t rowsTotal() const { return flatTree_.size(); } + + void expandNode(size_t row); + void reduceNode(size_t row); + NodeStatus getStatus(size_t row) const; + ptrdiff_t getParent(size_t row) const; //return < 0 if none + + void setSortDirection(ColumnTypeOverview colType, bool ascending); //apply permanently! + SortInfo getSortConfig() { return currentSort_; } + +private: + TreeView (const TreeView&) = delete; + TreeView& operator=(const TreeView&) = delete; + + struct Container + { + uint64_t bytesGross = 0; + uint64_t bytesNet = 0; //bytes for files on view in this directory only + int itemCountGross = 0; + int itemCountNet = 0; //number of files on view in this directory only + + std::vector subDirs; + bool showFilesNode = false; //"compress" algorithm may hide file nodes for directories with a single included file, i.e. itemCountGross == itemCountNet == 1 + std::weak_ptr containerRef; //-> BaseFolderPair if NodeType::root, + //FolderPair if NodeType::folder, and parent ContainerObject if NodeType::files + }; + + struct RootNodeImpl : public Container + { + std::wstring displayName; + }; + + enum class NodeType + { + root, //-> RootNodeImpl + folder, //-> Container + files //-> Container + }; + + struct TreeLine + { + unsigned int level = 0; + int percent = 0; //[0, 100] + const Container* node = nullptr; // + NodeType type = NodeType::root; //increase size of "flatTree" using C-style types rather than have a polymorphic "folderCmpView" + }; + + static void compressNode(Container& cont); + template + static void extractVisibleSubtree(ContainerObject& conObj, Container& cont, Function includeObject); + void getChildren(const Container& cont, unsigned int level, std::vector& output); + template void updateView(Predicate pred); + void applySubView(std::vector&& newView); + + template static void sortSingleLevel(std::vector& items, ColumnTypeOverview columnType); + template struct LessShortName; + + std::vector flatTree_; //collapsable/expandable sub-tree of folderCmpView -> always sorted! + /* /|\ + | (update...) */ + std::vector folderCmpView_; //partial view on folderCmp -> unsorted (cannot be, because files are not a separate entity) + std::function lastViewFilterPred_; //buffer view filter predicate for lazy evaluation of files/symlinks corresponding to a TYPE_FILES node + /* /|\ + | (update...) */ + std::vector> folderCmp_; //full raw data + + SortInfo currentSort_; +}; + + +namespace treegrid +{ +void init(zen::Grid& grid); +TreeView& getDataView(zen::Grid& grid); +void setData(zen::Grid& grid, FolderComparison& folderCmp); + +void setShowPercentage(zen::Grid& grid, bool value); +bool getShowPercentage(const zen::Grid& grid); +} +} + +#endif //TREE_VIEW_H_841703190201835280256673425 diff --git a/FreeFileSync/Source/ui/tree_grid_attr.h b/FreeFileSync/Source/ui/tree_grid_attr.h new file mode 100644 index 0000000..901f77b --- /dev/null +++ b/FreeFileSync/Source/ui/tree_grid_attr.h @@ -0,0 +1,65 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TREE_GRID_ATTR_H_83470918473021745 +#define TREE_GRID_ATTR_H_83470918473021745 + +#include +#include +#include + + +namespace fff +{ +enum class ColumnTypeOverview +{ + folder, + itemCount, + bytes, +}; + +struct ColumnAttribOverview +{ + ColumnTypeOverview type = ColumnTypeOverview::folder; + int offset = 0; + int stretch = 0; + bool visible = false; +}; + + +inline +std::vector getOverviewDefaultColAttribs() +{ + using namespace zen; + return //harmonize with tree_view.cpp::onGridLabelContext() => expects stretched folder and non-stretched other columns! + { + {ColumnTypeOverview::folder, - 2 * dipToWxsize(70), 1, true}, + {ColumnTypeOverview::itemCount, dipToWxsize(70), 0, true}, + {ColumnTypeOverview::bytes, dipToWxsize(70), 0, true}, + }; +} + +const bool overviewPanelShowPercentageDefault = true; +const ColumnTypeOverview overviewPanelLastSortColumnDefault = ColumnTypeOverview::bytes; + +inline +bool getDefaultSortDirection(ColumnTypeOverview colType) +{ + switch (colType) + { + case ColumnTypeOverview::folder: + return true; + case ColumnTypeOverview::itemCount: + return false; + case ColumnTypeOverview::bytes: + return false; + } + assert(false); + return true; +} +} + +#endif //TREE_GRID_ATTR_H_83470918473021745 diff --git a/FreeFileSync/Source/ui/triple_splitter.cpp b/FreeFileSync/Source/ui/triple_splitter.cpp new file mode 100644 index 0000000..09a627f --- /dev/null +++ b/FreeFileSync/Source/ui/triple_splitter.cpp @@ -0,0 +1,237 @@ +// ***************************************************************************** +// * 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 "triple_splitter.h" +#include +#include + +using namespace zen; +using namespace fff; + + +namespace +{ +//------------ Grid Constants ------------------------------- +const int SASH_HIT_TOLERANCE_DIP = 5; //currently only a placebo! +const int SASH_SIZE_DIP = 10; + +const double SASH_GRAVITY = 0.5; //value within [0, 1]; 1 := resize left only, 0 := resize right only +const int CHILD_WINDOW_MIN_SIZE_DIP = 50; //min. size of managed windows +} + + +class TripleSplitter::SashMove +{ +public: + SashMove(wxWindow& wnd, int mousePosX, int centerOffset) : wnd_(wnd), mousePosX_(mousePosX), centerOffset_(centerOffset) + { + wnd_.SetCursor(wxCURSOR_SIZEWE); + wnd_.CaptureMouse(); + } + ~SashMove() + { + wnd_.SetCursor(*wxSTANDARD_CURSOR); + if (wnd_.HasCapture()) + wnd_.ReleaseMouse(); + } + int getMousePosXStart () const { return mousePosX_; } + int getCenterOffsetStart() const { return centerOffset_; } + +private: + wxWindow& wnd_; + const int mousePosX_; + const int centerOffset_; +}; + + +TripleSplitter::TripleSplitter(wxWindow* parent, + wxWindowID id, + const wxPoint& pos, + const wxSize& size, + long style) : wxWindow(parent, id, pos, size, style | wxTAB_TRAVERSAL), //tab between windows + sashSize_ (dipToWxsize(SASH_SIZE_DIP)), + childWindowMinSize_(dipToWxsize(CHILD_WINDOW_MIN_SIZE_DIP)) +{ + //https://wiki.wxwidgets.org/Flicker-Free_Drawing + SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND + Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintEvent(event); }); + Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { updateWindowSizes(); event.Skip(); }); + + Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& event) { onMouseLeftDown (event); }); + Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& event) { onMouseLeftUp (event); }); + Bind(wxEVT_MOTION, [this](wxMouseEvent& event) { onMouseMovement (event); }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& event) { onLeaveWindow (event); }); + Bind(wxEVT_LEFT_DCLICK, [this](wxMouseEvent& event) { onMouseLeftDouble(event); }); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent& event) { onMouseCaptureLost(event); }); +} + + +TripleSplitter::~TripleSplitter() {} //make sure correct destructor gets created for std::unique_ptr + + +void TripleSplitter::updateWindowSizes() +{ + if (windowL_ && windowC_ && windowR_) + { + const int centerPosX = getCenterPosX(); + const int centerWidth = getCenterWidth(); + + const wxRect clientRect = GetClientRect(); + + const int widthL = centerPosX; + const int windowRposX = widthL + centerWidth; + const int widthR = clientRect.width - windowRposX; + + windowL_->SetSize(0, 0, widthL, clientRect.height); + windowC_->SetSize(widthL + sashSize_, 0, windowC_->GetSize().GetWidth(), clientRect.height); + windowR_->SetSize(windowRposX, 0, widthR, clientRect.height); + Refresh(); //repaint sash + } +} + + +inline +int TripleSplitter::getCenterWidth() const +{ + return 2 * sashSize_ + (windowC_ ? windowC_->GetSize().GetWidth() : 0); +} + + +int TripleSplitter::getCenterPosXOptimal() const +{ + const wxRect clientRect = GetClientRect(); + const int centerWidth = getCenterWidth(); + return (clientRect.width - centerWidth) * SASH_GRAVITY; //allowed to be negative for extreme client widths! +} + + +int TripleSplitter::getCenterPosX() const +{ + const wxRect clientRect = GetClientRect(); + const int centerWidth = getCenterWidth(); + const int centerPosXOptimal = getCenterPosXOptimal(); + + //normalize "centerPosXOptimal + centerOffset" + if (clientRect.width < 2 * childWindowMinSize_ + centerWidth) + //use fixed "centeroffset" when "clientRect.width == 2 * childWindowMinSize_ + centerWidth" + return centerPosXOptimal + childWindowMinSize_ - static_cast(2 * childWindowMinSize_ * SASH_GRAVITY); //avoid rounding error + //make sure transition between conditional branches is continuous! + return std::max(childWindowMinSize_, //make sure centerPosXOptimal + offset is within bounds + std::min(centerPosXOptimal + centerOffset_, clientRect.width - childWindowMinSize_ - centerWidth)); +} + + +void TripleSplitter::onPaintEvent(wxPaintEvent& event) +{ + DynBufPaintDC dc(*this, doubleBuffer_); + //GetUpdateRegion()? nah, just redraw everything => we mostly land here due to wxEVT_SIZE anyway (see Refresh() in updateWindowSizes()) + + assert(GetSize() == GetClientSize()); + + const int centerPosX = getCenterPosX(); + const int centerWidth = getCenterWidth(); + + auto draw = [&](wxRect rect) + { + //clear everything in border color: + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + //clear inner area except for left/right borders + clearArea(dc, wxRect(rect.x + dipToWxsize(1), rect.y, rect.width - 2 * dipToWxsize(1), rect.height), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE)); + }; + + const wxRect rectSashL(centerPosX, 0, sashSize_, GetClientRect().height); + const wxRect rectSashR(centerPosX + centerWidth - sashSize_, 0, sashSize_, GetClientRect().height); + + draw(rectSashL); + draw(rectSashR); +} + + +bool TripleSplitter::hitOnSashLine(int posX) const +{ + const int centerPosX = getCenterPosX(); + const int centerWidth = getCenterWidth(); + + //we don't get events outside of sash, so SASH_HIT_TOLERANCE_DIP is currently *useless* + auto hitSash = [&](int sashX) { return sashX - dipToWxsize(SASH_HIT_TOLERANCE_DIP) <= posX && posX < sashX + sashSize_ + dipToWxsize(SASH_HIT_TOLERANCE_DIP); }; + + return hitSash(centerPosX) || hitSash(centerPosX + centerWidth - sashSize_); //hit one of the two sash lines +} + + +void TripleSplitter::onMouseLeftDown(wxMouseEvent& event) +{ + activeMove_.reset(); + + const int posX = event.GetPosition().x; + if (hitOnSashLine(posX)) + activeMove_ = std::make_unique(*this, posX, centerOffset_); + event.Skip(); +} + + +void TripleSplitter::onMouseLeftUp(wxMouseEvent& event) +{ + activeMove_.reset(); //nothing else to do, actual work done by onMouseMovement() + event.Skip(); +} + + +void TripleSplitter::onMouseMovement(wxMouseEvent& event) +{ + if (activeMove_) + { + centerOffset_ = activeMove_->getCenterOffsetStart() + event.GetPosition().x - activeMove_->getMousePosXStart(); + + //CAVEAT: function getCenterPosX() normalizes centerPosX *not* centerOffset! + //This can lead to the strange effect of window not immediately resizing when centerOffset is extremely off limits + //=> normalize centerOffset right here + centerOffset_ = getCenterPosX() - getCenterPosXOptimal(); + + updateWindowSizes(); + Update(); //no time to wait until idle event! + } + else + { + //we receive those only while above the sash, not the managed windows (except when the managed windows are disabled!) + const int posX = event.GetPosition().x; + if (hitOnSashLine(posX)) + SetCursor(wxCURSOR_SIZEWE); //set window-local only! + else + SetCursor(*wxSTANDARD_CURSOR); + } + event.Skip(); +} + + +void TripleSplitter::onLeaveWindow(wxMouseEvent& event) +{ + //even called when moving from sash over to managed windows! + if (!activeMove_) + SetCursor(*wxSTANDARD_CURSOR); + event.Skip(); +} + + +void TripleSplitter::onMouseCaptureLost(wxMouseCaptureLostEvent& event) +{ + activeMove_.reset(); + updateWindowSizes(); + //event.Skip(); -> we DID handle it! +} + + +void TripleSplitter::onMouseLeftDouble(wxMouseEvent& event) +{ + const int posX = event.GetPosition().x; + if (hitOnSashLine(posX)) + { + centerOffset_ = 0; //reset sash according to gravity + updateWindowSizes(); + } + event.Skip(); +} diff --git a/FreeFileSync/Source/ui/triple_splitter.h b/FreeFileSync/Source/ui/triple_splitter.h new file mode 100644 index 0000000..b075c63 --- /dev/null +++ b/FreeFileSync/Source/ui/triple_splitter.h @@ -0,0 +1,82 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TRIPLE_SPLITTER_H_8257804292846842573942534254 +#define TRIPLE_SPLITTER_H_8257804292846842573942534254 + +#include +#include +#include +#include +#include + + +/* manage three contained windows: + 1. left and right window are stretched + 2. middle window is fixed size + 3. middle window position can be changed via mouse with two sash lines + ----------------- + | | | | + | | | | + | | | | + ----------------- */ +namespace fff +{ +class TripleSplitter : public wxWindow +{ +public: + TripleSplitter(wxWindow* parent, + wxWindowID id = wxID_ANY, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = 0); + + ~TripleSplitter(); + + void setupWindows(wxWindow* winL, wxWindow* winC, wxWindow* winR) + { + assert(winL->GetParent() == this && winC->GetParent() == this && winR->GetParent() == this && !GetSizer()); + windowL_ = winL; + windowC_ = winC; + windowR_ = winR; + updateWindowSizes(); + } + + int getSashOffset() const { return centerOffset_; } + void setSashOffset(int off) { centerOffset_ = off; updateWindowSizes(); } + +private: + void updateWindowSizes(); + int getCenterWidth() const; + int getCenterPosX() const; //return normalized posX + int getCenterPosXOptimal() const; + + void onPaintEvent(wxPaintEvent& event); + bool hitOnSashLine(int posX) const; + + void onMouseLeftDown(wxMouseEvent& event); + void onMouseLeftUp(wxMouseEvent& event); + void onMouseMovement(wxMouseEvent& event); + void onLeaveWindow(wxMouseEvent& event); + void onMouseCaptureLost(wxMouseCaptureLostEvent& event); + void onMouseLeftDouble(wxMouseEvent& event); + + class SashMove; + std::unique_ptr activeMove_; + + int centerOffset_ = 0; //offset to add after "gravity" stretching + const int sashSize_; + const int childWindowMinSize_; + + wxWindow* windowL_ = nullptr; + wxWindow* windowC_ = nullptr; + wxWindow* windowR_ = nullptr; + + std::optional doubleBuffer_; +}; +} + +#endif //TRIPLE_SPLITTER_H_8257804292846842573942534254 diff --git a/FreeFileSync/Source/ui/version_check.cpp b/FreeFileSync/Source/ui/version_check.cpp new file mode 100644 index 0000000..250ca82 --- /dev/null +++ b/FreeFileSync/Source/ui/version_check.cpp @@ -0,0 +1,358 @@ +// ***************************************************************************** +// * 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 "version_check.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "../ffs_paths.h" +#include "../version/version.h" +#include "small_dlgs.h" + + #include + #include + #include + #include + + +using namespace zen; +using namespace fff; + + +namespace +{ +const Zchar ffsUpdateCheckUserAgent[] = Zstr("FFS-Update-Check"); + + +time_t getVersionCheckCurrentTime() +{ + time_t now = std::time(nullptr); + return now; +} + + +void openBrowserForDownload(wxWindow* parent) +{ + wxLaunchDefaultBrowser(L"https://freefilesync.org/get_latest.php"); +} +} + + +bool fff::automaticUpdateCheckDue(time_t lastUpdateCheck) +{ + const time_t now = std::time(nullptr); + return numeric::dist(now, lastUpdateCheck) >= 7 * 24 * 3600; //check weekly +} + + +namespace +{ +std::wstring getIso639Language() +{ + assert(runningOnMainThread()); //this function is not thread-safe: consider wxWidgets usage + + std::wstring localeName(copyStringTo(wxUILocale::GetLanguageCanonicalName(wxUILocale::GetSystemLanguage()))); + localeName = beforeFirst(localeName, L'@', IfNotFoundReturn::all); //the locale may contain an @, e.g. "sr_RS@latin"; see wxUILocale::InitLanguagesDB() + + if (!localeName.empty()) + { + const std::wstring langCode = beforeFirst(localeName, L'_', IfNotFoundReturn::all); + assert(langCode.size() == 2 || langCode.size() == 3); //ISO 639: 3-letter possible! + return langCode; + } + assert(false); + return L"zz"; +} + + +std::wstring getIso3166Country() +{ + assert(runningOnMainThread()); //this function is not thread-safe, consider wxWidgets usage + + std::wstring localeName(copyStringTo(wxUILocale::GetLanguageCanonicalName(wxUILocale::GetSystemLanguage()))); + localeName = beforeFirst(localeName, L'@', IfNotFoundReturn::all); //the locale may contain an @, e.g. "sr_RS@latin"; see wxUILocale::InitLanguagesDB() + + if (contains(localeName, L'_')) + { + const std::wstring cc = afterFirst(localeName, L'_', IfNotFoundReturn::none); + assert(cc.size() == 2 || cc.size() == 3); //ISO 3166: 3-letter possible! + return cc; + } + assert(false); + return L"ZZ"; +} + + +//coordinate with get_latest_version_number.php +std::vector> geHttpPostParameters() //throw SysError +{ + assert(runningOnMainThread()); //this function is not thread-safe, e.g. consider wxWidgets usage in getIso639Language() + std::vector> params; + + params.emplace_back("ffs_version", ffsVersion); + + + params.emplace_back("os_name", "Linux"); + + const OsVersion osv = getOsVersion(); + params.emplace_back("os_version", numberTo(osv.major) + "." + numberTo(osv.minor)); + + const char* osArch = cpuArchName; + params.emplace_back("os_arch", osArch); + +#if GTK_MAJOR_VERSION == 2 + //GetContentScaleFactor() requires GTK3 or later +#elif GTK_MAJOR_VERSION == 3 + params.emplace_back("dip_scale", numberTo(wxScreenDC().GetContentScaleFactor())); +#else +#error unknown GTK version! +#endif + + const std::string ffsLang = [] + { + const wxLanguage lang = getLanguage(); + + for (const TranslationInfo& ti : getAvailableTranslations()) + if (ti.languageID == lang) + return ti.locale; + return std::string("zz"); + }(); + params.emplace_back("ffs_lang", ffsLang); + + params.emplace_back("language", utfTo(getIso639Language())); + params.emplace_back("country", utfTo(getIso3166Country())); + + return params; +} + + + + +void showUpdateAvailableDialog(wxWindow* parent, const std::string& onlineVersion) +{ + std::wstring updateDetailsMsg; + try + { + updateDetailsMsg = utfTo(sendHttpGet(utfTo("https://api.freefilesync.org/latest_changes?" + xWwwFormUrlEncode({{"since", ffsVersion}})), + ffsUpdateCheckUserAgent, Zstring() /*caCertFilePath*/).readAll(nullptr /*notifyUnbufferedIO*/)); //throw SysError + } + catch (const SysError& e) { updateDetailsMsg = _("Failed to retrieve update information.") + + L"\n\n" + e.toString(); } + + + switch (showConfirmationDialog(parent, DialogInfoType::info, PopupDialogCfg(). + setIcon(loadImage("FreeFileSync", dipToScreen(48))). + setTitle(_("Check for Software Updates")). + setMainInstructions(replaceCpy(_("FreeFileSync %x is available!"), L"%x", utfTo(onlineVersion)) + L"\n\n" + _("Download now?")). + setDetailInstructions(updateDetailsMsg), _("&Download"))) + { + case ConfirmationButton::accept: //download + openBrowserForDownload(parent); + break; + case ConfirmationButton::cancel: + break; + } +} + + +std::string getOnlineVersion(const std::vector>& postParams) //throw SysError +{ + const std::string response = sendHttpPost(Zstr("https://api.freefilesync.org/latest_version"), postParams, nullptr /*notifyUnbufferedIO*/, + ffsUpdateCheckUserAgent, Zstring() /*caCertFilePath*/).readAll(nullptr /*notifyUnbufferedIO*/); //throw SysError + + if (response.empty() || + !std::all_of(response.begin(), response.end(), [](const char c) { return isDigit(c) || c == FFS_VERSION_SEPARATOR; }) || + startsWith(response, FFS_VERSION_SEPARATOR) || + endsWith(response, FFS_VERSION_SEPARATOR) || + contains(response, std::string() + FFS_VERSION_SEPARATOR + FFS_VERSION_SEPARATOR)) + throw SysError(L"Unexpected server response: \"" + utfTo(response) + L'"'); + //response may be "This website has been moved...", or a Javascript challenge: https://freefilesync.org/forum/viewtopic.php?t=8400 + + return response; +} + + +std::string getUnknownVersionTag() +{ + return '<' + utfTo(_("unknown version")) + '>'; +} +} + + +bool fff::haveNewerVersionOnline(const std::string& onlineVersion) +{ + auto parseVersion = [](const std::string_view& version) + { + std::vector output; + split(version, FFS_VERSION_SEPARATOR, + [&](const std::string_view digit) { output.push_back(stringTo(digit)); }); + assert(!output.empty()); + return output; + }; + const std::vector current = parseVersion(ffsVersion); + const std::vector online = parseVersion(onlineVersion); + + if (online.empty() || online[0] == 0) //online version string may be "Unknown", see automaticUpdateCheckEval() below! + return true; + + return online > current; //std::vector compares lexicographically +} + + +void fff::checkForUpdateNow(wxWindow& parent, std::string& lastOnlineVersion) +{ + try + { + const std::string onlineVersion = getOnlineVersion(geHttpPostParameters()); //throw SysError + lastOnlineVersion = onlineVersion; + + if (haveNewerVersionOnline(onlineVersion)) + showUpdateAvailableDialog(&parent, onlineVersion); + else + { + std::wstring ffsVersionName = L"FreeFileSync " + utfTo(ffsVersion); + showNotificationDialog(&parent, DialogInfoType::info, PopupDialogCfg(). + setIcon(loadImage("update_check")). + setTitle(_("Check for Software Updates")). + setMainInstructions(replaceCpy(_("FreeFileSync is up-to-date."), L"FreeFileSync", ffsVersionName))); + } + } + catch (const SysError& e) + { + if (internetIsAlive()) + { + lastOnlineVersion = getUnknownVersionTag(); + + switch (showConfirmationDialog(&parent, DialogInfoType::error, PopupDialogCfg(). + setTitle(_("Check for Software Updates")). + setMainInstructions(_("Cannot find current FreeFileSync version number online. A newer version is likely available. Check manually now?")). + setDetailInstructions(e.toString()), _("&Check"), _("&Retry"))) + { + case ConfirmationButton2::accept: + openBrowserForDownload(&parent); + break; + case ConfirmationButton2::accept2: //retry + checkForUpdateNow(parent, lastOnlineVersion); //note: retry via recursion!!! + break; + case ConfirmationButton2::cancel: + break; + } + } + else + switch (showConfirmationDialog(&parent, DialogInfoType::error, PopupDialogCfg(). + setTitle(_("Check for Software Updates")). + setMainInstructions(replaceCpy(_("Unable to connect to %x."), L"%x", L"freefilesync.org")). + setDetailInstructions(e.toString()), _("&Retry"))) + { + case ConfirmationButton::accept: //retry + checkForUpdateNow(parent, lastOnlineVersion); //note: retry via recursion!!! + break; + case ConfirmationButton::cancel: + break; + } + } +} + + +struct fff::UpdateCheckResultPrep +{ + std::vector> postParameters; + std::optional error; +}; +SharedRef fff::automaticUpdateCheckPrepare(wxWindow& parent) +{ + assert(runningOnMainThread()); + auto prep = makeSharedRef(); + try + { + prep.ref().postParameters = geHttpPostParameters(); //throw SysError + } + catch (const SysError& e) + { + prep.ref().error = e; + } + return prep; +} + + +struct fff::UpdateCheckResult +{ + std::string onlineVersion; + std::optional error; + bool internetIsAlive = false; +}; +SharedRef fff::automaticUpdateCheckRunAsync(const UpdateCheckResultPrep& resultPrep) +{ + //assert(!runningOnMainThread()); -> allow synchronous call, too + auto result = makeSharedRef(); + try + { + if (resultPrep.error) + throw* resultPrep.error; //throw SysError + + result.ref().onlineVersion = getOnlineVersion(resultPrep.postParameters); //throw SysError + result.ref().internetIsAlive = true; + } + catch (const SysError& e) + { + result.ref().error = e; + result.ref().internetIsAlive = internetIsAlive(); + } + return result; +} + + +void fff::automaticUpdateCheckEval(wxWindow& parent, time_t& lastUpdateCheck, std::string& lastOnlineVersion, const UpdateCheckResult& result) +{ + assert(runningOnMainThread()); + + if (!result.error) + { + lastUpdateCheck = getVersionCheckCurrentTime(); + + if (lastOnlineVersion != result.onlineVersion) //show new version popup only *once* per new release + { + lastOnlineVersion = result.onlineVersion; + + if (haveNewerVersionOnline(result.onlineVersion)) //beta or development version is newer than online + showUpdateAvailableDialog(&parent, result.onlineVersion); + } + } + else + { + if (result.internetIsAlive) + { + if (lastOnlineVersion != getUnknownVersionTag()) + switch (showConfirmationDialog(&parent, DialogInfoType::error, PopupDialogCfg(). + setTitle(_("Check for Software Updates")). + setMainInstructions(_("Cannot find current FreeFileSync version number online. A newer version is likely available. Check manually now?")). + setDetailInstructions(result.error->toString()), + _("&Check"), _("&Retry"))) + { + case ConfirmationButton2::accept: + lastOnlineVersion = getUnknownVersionTag(); + openBrowserForDownload(&parent); + break; + case ConfirmationButton2::accept2: //retry + automaticUpdateCheckEval(parent, lastUpdateCheck, lastOnlineVersion, + automaticUpdateCheckRunAsync(automaticUpdateCheckPrepare(parent).ref()).ref()); //retry via recursion!!! + break; + case ConfirmationButton2::cancel: + lastOnlineVersion = getUnknownVersionTag(); + break; + } + } + else //no internet connection + { + if (lastOnlineVersion.empty()) + lastOnlineVersion = ffsVersion; + } + } +} diff --git a/FreeFileSync/Source/ui/version_check.h b/FreeFileSync/Source/ui/version_check.h new file mode 100644 index 0000000..e7b75f1 --- /dev/null +++ b/FreeFileSync/Source/ui/version_check.h @@ -0,0 +1,35 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef VERSION_CHECK_H_324872374893274983275 +#define VERSION_CHECK_H_324872374893274983275 + +#include +#include + + +namespace fff +{ +bool haveNewerVersionOnline(const std::string& onlineVersion); +//---------------------------------------------------------------------------- +bool automaticUpdateCheckDue(time_t lastUpdateCheck); + +struct UpdateCheckResultPrep; +struct UpdateCheckResult; + +//run on main thread: +zen::SharedRef automaticUpdateCheckPrepare(wxWindow& parent); +//run on worker thread: (long-running part of the check) +zen::SharedRef automaticUpdateCheckRunAsync(const UpdateCheckResultPrep& resultPrep); +//run on main thread: +void automaticUpdateCheckEval(wxWindow& parent, time_t& lastUpdateCheck, std::string& lastOnlineVersion, const UpdateCheckResult& result); +//---------------------------------------------------------------------------- +//call from main thread: +void checkForUpdateNow(wxWindow& parent, std::string& lastOnlineVersion); +//---------------------------------------------------------------------------- +} + +#endif //VERSION_CHECK_H_324872374893274983275 diff --git a/FreeFileSync/Source/version/version.h b/FreeFileSync/Source/version/version.h new file mode 100644 index 0000000..4c18247 --- /dev/null +++ b/FreeFileSync/Source/version/version.h @@ -0,0 +1,10 @@ +#ifndef VERSION_HEADER_434343489702544325 +#define VERSION_HEADER_434343489702544325 + +namespace fff +{ +const char ffsVersion[] = "14.6"; //internal linkage! +const char FFS_VERSION_SEPARATOR = '.'; +} + +#endif diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..2e3e12e --- /dev/null +++ b/License.txt @@ -0,0 +1,980 @@ + FreeFileSync: Terms of use + +The FreeFileSync standard and Donation Edition +are for **private use** only: +https://freefilesync.org/faq.php#donation-edition + +**Commercial use**, government use, and other non-private uses require +the purchase of the FreeFileSync Business Edition: +https://freefilesync.org/faq.php#business + +================================================================== + +A. GNU General Public License +B. wxWidgets License +C. OpenSSL License +D. curl License +E. libssh2 License +F. PuTTY License + +================================================================== +A. + GNU General Public License + Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +================================================================== +B. + wxWidgets License + +wxWindows Library Licence, Version 3.1 + +Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al + +Everyone is permitted to copy and distribute verbatim copies +of this licence document, but changing it is not allowed. + + WXWINDOWS LIBRARY LICENCE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +This library is free software; you can redistribute it and/or modify it +under the terms of the GNU Library General Public Licence as published by +the Free Software Foundation; either version 2 of the Licence, or (at your +option) any later version. + +This library is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public +Licence for more details. + +You should have received a copy of the GNU Library General Public Licence +along with this software, usually in a file named COPYING.LIB. If not, +write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth +Floor, Boston, MA 02110-1301 USA. + +EXCEPTION NOTICE + +1. As a special exception, the copyright holders of this library give + permission for additional uses of the text contained in this release of the + library as licenced under the wxWindows Library Licence, applying either + version 3.1 of the Licence, or (at your option) any later version of the + Licence as published by the copyright holders of version 3.1 of the Licence + document. + +2. The exception is that you may use, copy, link, modify and distribute + under your own terms, binary object code versions of works based on the + Library. + +3. If you copy code from files distributed under the terms of the GNU + General Public Licence or the GNU Library General Public Licence into a + copy of this library, as this licence permits, the exception does not apply + to the code that you add in this way. To avoid misleading anyone as to the + status of such modified files, you must delete this exception notice from + such code and/or adjust the licensing conditions notice accordingly. + +4. If you write modifications of your own for this library, it is your + choice whether to permit this exception to apply to your modifications. If + you do not wish that, you must delete the exception notice from such code + and/or adjust the licensing conditions notice accordingly. + +================================================================== +C. + OpenSSL License + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +================================================================== +D. + curl License + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2021, Daniel Stenberg, daniel@haxx.se, and many +contributors, see the THANKS file. + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. + +================================================================== +E. + libssh2 License + +Copyright (c) 2004-2007 Sara Golemon +Copyright (c) 2005,2006 Mikhail Gusarov +Copyright (c) 2006-2007 The Written Word, Inc. +Copyright (c) 2007 Eli Fant +Copyright (c) 2009-2021 Daniel Stenberg +Copyright (C) 2008, 2009 Simon Josefsson +Copyright (c) 2000 Markus Friedl +Copyright (c) 2015 Microsoft Corp. +All rights reserved. + +Redistribution and use in source and binary forms, +with or without modification, are permitted provided +that the following conditions are met: + + Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials + provided with the distribution. + + Neither the name of the copyright holder nor the names + of any other contributors may be used to endorse or + promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. + +================================================================== +F. + PuTTY License + +PuTTY is copyright 1997-2022 Simon Tatham. + +Portions copyright Robert de Bath, Joris van Rantwijk, Delian +Delchev, Andreas Schultz, Jeroen Massar, Wez Furlong, Nicolas Barry, +Justin Bradford, Ben Harris, Malcolm Smith, Ahmad Khalifa, Markus +Kuhn, Colin Watson, Christopher Staite, Lorenz Diener, Christian +Brabandt, Jeff Smith, Pavel Kryukov, Maxim Kuznetsov, Svyatoslav +Kuzmich, Nico Williams, Viktor Dukhovni, Josh Dersch, Lars Brinkhoff, +and CORE SDI S.A. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libcurl/curl_wrap.cpp b/libcurl/curl_wrap.cpp new file mode 100644 index 0000000..b2a7560 --- /dev/null +++ b/libcurl/curl_wrap.cpp @@ -0,0 +1,411 @@ +// ***************************************************************************** +// * 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 "curl_wrap.h" +#include +#include +#include + #include + +using namespace zen; + + +namespace +{ +int curlInitLevel = 0; //support interleaving initialization calls! +//zero-initialized POD => not subject to static initialization order fiasco +} + +void zen::libcurlInit() +{ + assert(runningOnMainThread()); //all OpenSSL/libssh2/libcurl require init on main thread! + assert(curlInitLevel >= 0); + if (++curlInitLevel != 1) //non-atomic => require call from main thread + return; + + openSslInit(); + + try + { + ASSERT_SYSERROR(::curl_global_init(CURL_GLOBAL_NOTHING /*CURL_GLOBAL_DEFAULT = CURL_GLOBAL_SSL|CURL_GLOBAL_WIN32*/) == CURLE_OK); + } + catch (const SysError& e) { logExtraError(_("Error during process initialization.") + L"\n\n" + e.toString()); } +} + + +void zen::libcurlTearDown() +{ + assert(runningOnMainThread()); //+ avoid race condition on "curlInitLevel" + assert(curlInitLevel >= 1); + if (--curlInitLevel != 0) + return; + + ::curl_global_cleanup(); + openSslTearDown(); +} + + +HttpSession::HttpSession(const Zstring& server, bool useTls, const Zstring& caCertFilePath) : //throw SysError + serverPrefix_((useTls ? "https://" : "http://") + utfTo(server)), + caCertFilePath_(utfTo(caCertFilePath)) {} + + +HttpSession::~HttpSession() +{ + if (easyHandle_) + ::curl_easy_cleanup(easyHandle_); +} + + +HttpSession::Result HttpSession::perform(const std::string& serverRelPath, + const std::vector& extraHeaders, const std::vector& extraOptions, + const std::function buf)>& writeResponse /*throw X*/, //optional + const std::function buf)>& readRequest /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream! + const std::function& receiveHeader /*throw X*/, + int timeoutSec) //throw SysError, X +{ + if (!easyHandle_) + { + easyHandle_ = ::curl_easy_init(); + if (!easyHandle_) + throw SysError(formatSystemError("curl_easy_init", formatCurlStatusCode(CURLE_OUT_OF_MEMORY), L"")); + } + else + ::curl_easy_reset(easyHandle_); + + auto setCurlOption = [easyHandle = easyHandle_](const CurlOption& curlOpt) //throw SysError + { + if (const CURLcode rc = ::curl_easy_setopt(easyHandle, curlOpt.option, curlOpt.value); + rc != CURLE_OK) + throw SysError(formatSystemError("curl_easy_setopt(" + numberTo(static_cast(curlOpt.option)) + ")", + formatCurlStatusCode(rc), utfTo(::curl_easy_strerror(rc)))); + }; + + char curlErrorBuf[CURL_ERROR_SIZE] = {}; + setCurlOption({CURLOPT_ERRORBUFFER, curlErrorBuf}); //throw SysError + + setCurlOption({CURLOPT_USERAGENT, "FreeFileSync"}); //throw SysError + //default value; may be overwritten by caller + + setCurlOption({CURLOPT_URL, (serverPrefix_ + serverRelPath).c_str()}); //throw SysError + + setCurlOption({CURLOPT_ACCEPT_ENCODING, ""}); //throw SysError + //libcurl: generate Accept-Encoding header containing all built-in supported encodings + //=> usually generates "Accept-Encoding: deflate, gzip" - note: "gzip" used by Google Drive + + setCurlOption({CURLOPT_NOSIGNAL, 1}); //throw SysError + //thread-safety: https://curl.haxx.se/libcurl/c/threadsafe.html + + setCurlOption({CURLOPT_CONNECTTIMEOUT, timeoutSec}); //throw SysError + + //CURLOPT_TIMEOUT: "Since this puts a hard limit for how long time a request is allowed to take, it has limited use in dynamic use cases with varying transfer times." + setCurlOption({CURLOPT_LOW_SPEED_TIME, timeoutSec}); //throw SysError + setCurlOption({CURLOPT_LOW_SPEED_LIMIT, 1 /*[bytes]*/}); //throw SysError + //can't use "0" which means "inactive", so use some low number + + //CURLOPT_SERVER_RESPONSE_TIMEOUT: does not apply to HTTP + + + std::exception_ptr userCallbackException; + + //libcurl does *not* set FD_CLOEXEC for us! https://github.com/curl/curl/issues/2252 + auto onSocketCreate = [&](curl_socket_t curlfd, curlsocktype purpose) + { + assert(::fcntl(curlfd, F_GETFD) == 0); + if (::fcntl(curlfd, F_SETFD, FD_CLOEXEC) == -1) //=> RACE-condition if other thread calls fork/execv before this thread sets FD_CLOEXEC! + { + userCallbackException = std::make_exception_ptr(SysError(formatSystemError("fcntl(FD_CLOEXEC)", errno))); + return CURL_SOCKOPT_ERROR; + } + return CURL_SOCKOPT_OK; + }; + + using SocketCbType = decltype(onSocketCreate); + using SocketCbWrapperType = int (*)(SocketCbType* clientp, curl_socket_t curlfd, curlsocktype purpose); //needed for cdecl function pointer cast + SocketCbWrapperType onSocketCreateWrapper = [](SocketCbType* clientp, curl_socket_t curlfd, curlsocktype purpose) + { + return (*clientp)(curlfd, purpose); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + + setCurlOption({CURLOPT_SOCKOPTFUNCTION, onSocketCreateWrapper}); //throw SysError + setCurlOption({CURLOPT_SOCKOPTDATA, &onSocketCreate}); //throw SysError + + //libcurl forwards this char-string to OpenSSL as is, which - thank god - accepts UTF8 + if (caCertFilePath_.empty()) + { + setCurlOption({CURLOPT_CAINFO, 0}); //throw SysError + setCurlOption({CURLOPT_SSL_VERIFYPEER, 0}); //throw SysError + setCurlOption({CURLOPT_SSL_VERIFYHOST, 0}); //throw SysError + //see remarks in ftp.cpp + } + else + setCurlOption({CURLOPT_CAINFO, caCertFilePath_.c_str()}); //throw SysError + //hopefully latest version from https://curl.haxx.se/docs/caextract.html + //CURLOPT_SSL_VERIFYPEER => already active by default + //CURLOPT_SSL_VERIFYHOST => + + //--------------------------------------------------- + auto onHeaderReceived = [&](const char* buffer, size_t len) + { + try + { + receiveHeader({buffer, len}); //throw X + return len; + } + catch (...) + { + userCallbackException = std::current_exception(); + return len + 1; //signal error condition => CURLE_WRITE_ERROR + } + }; + curl_write_callback onHeaderReceivedWrapper = [](/*const*/ char* buffer, size_t size, size_t nitems, void* callbackData) + { + return (*static_cast(callbackData))(buffer, size * nitems); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + //--------------------------------------------------- + auto onBytesReceived = [&](const char* buffer, size_t bytesToWrite) + { + try + { + writeResponse({buffer, bytesToWrite}); //throw X + //[!] let's NOT use "incomplete write Posix semantics" for libcurl! + //who knows if libcurl buffers properly, or if it sends incomplete packages!? + return bytesToWrite; + } + catch (...) + { + userCallbackException = std::current_exception(); + return bytesToWrite + 1; //signal error condition => CURLE_WRITE_ERROR + } + }; + curl_write_callback onBytesReceivedWrapper = [](char* buffer, size_t size, size_t nitems, void* callbackData) + { + return (*static_cast(callbackData))(buffer, size * nitems); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + //--------------------------------------------------- + auto getBytesToSend = [&](char* buffer, size_t bytesToRead) -> size_t + { + try + { + /* libcurl calls back until 0 bytes are returned (Posix read() semantics), or, + if CURLOPT_INFILESIZE_LARGE was set, after exactly this amount of bytes + + [!] let's NOT use "incomplete read Posix semantics" for libcurl! + who knows if libcurl buffers properly, or if it requests incomplete packages!? */ + const size_t bytesRead = readRequest({buffer, bytesToRead}); //throw X; return "bytesToRead" bytes unless end of stream + assert(bytesRead == bytesToRead || bytesRead == 0 || readRequest({buffer, bytesToRead}) == 0); + return bytesRead; + } + catch (...) + { + userCallbackException = std::current_exception(); + return CURL_READFUNC_ABORT; //signal error condition => CURLE_ABORTED_BY_CALLBACK + } + }; + curl_read_callback getBytesToSendWrapper = [](char* buffer, size_t size, size_t nitems, void* callbackData) + { + return (*static_cast(callbackData))(buffer, size * nitems); //free this poor little C-API from its shackles and redirect to a proper lambda + }; + //--------------------------------------------------- + if (receiveHeader) + { + setCurlOption({CURLOPT_HEADERDATA, &onHeaderReceived}); //throw SysError + setCurlOption({CURLOPT_HEADERFUNCTION, onHeaderReceivedWrapper}); //throw SysError + } + if (writeResponse) + { + setCurlOption({CURLOPT_WRITEDATA, &onBytesReceived}); //throw SysError + setCurlOption({CURLOPT_WRITEFUNCTION, onBytesReceivedWrapper}); //throw SysError + //{CURLOPT_BUFFERSIZE, 256 * 1024} -> defaults is 16 kB which seems to correspond to SSL packet size + //=> setting larget buffers size does nothing (recv still returns only 16 kB) + } + if (readRequest) + { + if (std::all_of(extraOptions.begin(), extraOptions.end(), [](const CurlOption& o) { return o.option != CURLOPT_POST; })) + /**/setCurlOption({CURLOPT_UPLOAD, 1}); //throw SysError + //issues HTTP PUT + + setCurlOption({CURLOPT_READDATA, &getBytesToSend}); //throw SysError + setCurlOption({CURLOPT_READFUNCTION, getBytesToSendWrapper}); //throw SysError + //{CURLOPT_UPLOAD_BUFFERSIZE, 256 * 1024} -> default is 64 kB. apparently no performance improvement for larger buffers like 256 kB + + //Contradicting options: CURLOPT_READFUNCTION, CURLOPT_POSTFIELDS: + if (std::any_of(extraOptions.begin(), extraOptions.end(), [](const CurlOption& o) { return o.option == CURLOPT_POSTFIELDS; })) + /**/ throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + } + + if (std::any_of(extraOptions.begin(), extraOptions.end(), [](const CurlOption& o) { return o.option == CURLOPT_WRITEFUNCTION || o.option == CURLOPT_READFUNCTION; })) + /**/ throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); //Option already used here! + + //--------------------------------------------------- + curl_slist* headers = nullptr; //"libcurl will not copy the entire list so you must keep it!" + ZEN_ON_SCOPE_EXIT(::curl_slist_free_all(headers)); + + for (const std::string& headerLine : extraHeaders) + headers = ::curl_slist_append(headers, headerLine.c_str()); + + //WTF!!! 1-sec delay when server doesn't support "Expect: 100-continue"!! https://stackoverflow.com/questions/49670008/how-to-disable-expect-100-continue-in-libcurl + headers = ::curl_slist_append(headers, "Expect:"); //guess, what: www.googleapis.com doesn't support it! e.g. gdriveUploadFile() + //CURLOPT_EXPECT_100_TIMEOUT_MS: should not be needed + + //CURLOPT_TCP_NODELAY => already set by default https://brooker.co.za/blog/2024/05/09/nagle.html + + if (headers) + setCurlOption({CURLOPT_HTTPHEADER, headers}); //throw SysError + //--------------------------------------------------- + + for (const CurlOption& option : extraOptions) + setCurlOption(option); //throw SysError + + //======================================================================================================= + const CURLcode rcPerf = ::curl_easy_perform(easyHandle_); + //WTF: curl_easy_perform() considers FTP response codes 4XX, 5XX as failure, but for HTTP response codes 4XX are considered success!! CONSISTENCY, people!!! + //=> at least libcurl is aware: CURLOPT_FAILONERROR: "request failure on HTTP response >= 400"; default: "0, do not fail on error" + //https://curl.haxx.se/docs/faq.html#curl_doesn_t_return_error_for_HT + //=> BUT Google also screws up in their REST API design and returns HTTP 4XX status for domain-level errors! https://blog.slimjim.xyz/posts/stop-using-http-codes/ + //=> let caller handle HTTP status to work around this mess! + + if (userCallbackException) + std::rethrow_exception(userCallbackException); //throw X + //======================================================================================================= + + long httpStatus = 0; //optional + /*const CURLcode rc = */ ::curl_easy_getinfo(easyHandle_, CURLINFO_RESPONSE_CODE, &httpStatus); + + if (rcPerf != CURLE_OK) + { + std::wstring errorMsg = trimCpy(utfTo(curlErrorBuf)); //optional + + if (httpStatus != 0) //optional + errorMsg += (errorMsg.empty() ? L"" : L"\n") + formatHttpError(httpStatus); +#if 0 + //utfTo(::curl_easy_strerror(ec)) is uninteresting + //use CURLINFO_OS_ERRNO ?? https://curl.haxx.se/libcurl/c/CURLINFO_OS_ERRNO.html + long nativeErrorCode = 0; + if (::curl_easy_getinfo(easyHandle, CURLINFO_OS_ERRNO, &nativeErrorCode) == CURLE_OK) + if (nativeErrorCode != 0) + errorMsg += (errorMsg.empty() ? L"" : L"\n") + std::wstring(L"Native error code: ") + numberTo(nativeErrorCode); +#endif + throw SysError(formatSystemError("curl_easy_perform", formatCurlStatusCode(rcPerf), errorMsg)); + } + + lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); + return {static_cast(httpStatus) /*, contentType ? contentType : ""*/}; +} + + +std::wstring zen::formatCurlStatusCode(CURLcode sc) +{ + switch (sc) + { + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OK); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_UNSUPPORTED_PROTOCOL); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FAILED_INIT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_URL_MALFORMAT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_NOT_BUILT_IN); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_COULDNT_RESOLVE_PROXY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_COULDNT_RESOLVE_HOST); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_COULDNT_CONNECT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_WEIRD_SERVER_REPLY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_REMOTE_ACCESS_DENIED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_ACCEPT_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_WEIRD_PASS_REPLY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_ACCEPT_TIMEOUT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_WEIRD_PASV_REPLY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_WEIRD_227_FORMAT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_CANT_GET_HOST); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_HTTP2); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_COULDNT_SET_TYPE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_PARTIAL_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_COULDNT_RETR_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE20); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_QUOTE_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_HTTP_RETURNED_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_WRITE_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE24); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_UPLOAD_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_READ_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OUT_OF_MEMORY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OPERATION_TIMEDOUT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE29); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_PORT_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_COULDNT_USE_REST); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE32); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_RANGE_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE34); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CONNECT_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_BAD_DOWNLOAD_RESUME); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FILE_COULDNT_READ_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_LDAP_CANNOT_BIND); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_LDAP_SEARCH_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE40); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE41); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_ABORTED_BY_CALLBACK); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_BAD_FUNCTION_ARGUMENT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE44); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_INTERFACE_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE46); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TOO_MANY_REDIRECTS); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_UNKNOWN_OPTION); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SETOPT_OPTION_SYNTAX); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE50); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE51); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_GOT_NOTHING); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_ENGINE_NOTFOUND); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_ENGINE_SETFAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SEND_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_RECV_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE57); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CERTPROBLEM); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CIPHER); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_PEER_FAILED_VERIFICATION); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_BAD_CONTENT_ENCODING); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE62); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FILESIZE_EXCEEDED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_USE_SSL_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SEND_FAIL_REWIND); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_ENGINE_INITFAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_LOGIN_DENIED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TFTP_NOTFOUND); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TFTP_PERM); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_REMOTE_DISK_FULL); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TFTP_ILLEGAL); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TFTP_UNKNOWNID); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_REMOTE_FILE_EXISTS); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TFTP_NOSUCHUSER); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE75); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_OBSOLETE76); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CACERT_BADFILE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_REMOTE_FILE_NOT_FOUND); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSH); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_SHUTDOWN_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_AGAIN); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CRL_BADFILE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_ISSUER_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_PRET_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_RTSP_CSEQ_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_RTSP_SESSION_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_FTP_BAD_FILE_LIST); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_CHUNK_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_NO_CONNECTION_AVAILABLE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_PINNEDPUBKEYNOTMATCH); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_INVALIDCERTSTATUS); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_HTTP2_STREAM); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_RECURSIVE_API_CALL); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_AUTH_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_HTTP3); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_QUIC_CONNECT_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_PROXY); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_SSL_CLIENTCERT); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_UNRECOVERABLE_POLL); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_TOO_LARGE); + ZEN_CHECK_CASE_FOR_CONSTANT(CURLE_ECH_REQUIRED); + ZEN_CHECK_CASE_FOR_CONSTANT(CURL_LAST); + } + static_assert(CURL_LAST == CURLE_ECH_REQUIRED + 1); + + return replaceCpy(L"Curl status %x", L"%x", numberTo(static_cast(sc))); +} diff --git a/libcurl/curl_wrap.h b/libcurl/curl_wrap.h new file mode 100644 index 0000000..dc53442 --- /dev/null +++ b/libcurl/curl_wrap.h @@ -0,0 +1,79 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CURL_WRAP_H_2879058325032785032789645 +#define CURL_WRAP_H_2879058325032785032789645 + +#include +#include +#include +#include + + +//------------------------------------------------- +#include +//------------------------------------------------- + +#ifndef CURLINC_CURL_H + #error curl.h header guard changed +#endif + +namespace zen +{ +void libcurlInit(); +void libcurlTearDown(); + + +struct CurlOption +{ + template + CurlOption(CURLoption o, T val) : option(o), value(static_cast(val)) { static_assert(sizeof(val) <= sizeof(value)); } + + template + CurlOption(CURLoption o, T* val) : option(o), value(reinterpret_cast(val)) { static_assert(sizeof(val) <= sizeof(value)); } + + CURLoption option = CURLOPT_LASTENTRY; + uint64_t value = 0; +}; + + +class HttpSession +{ +public: + HttpSession(const Zstring& server, bool useTls, const Zstring& caCertFilePath /*optional*/); //throw SysError + ~HttpSession(); + + struct Result + { + int statusCode = 0; + //std::string contentType; + }; + Result perform(const std::string& serverRelPath, + const std::vector& extraHeaders, const std::vector& extraOptions, + const std::function buf)>& writeResponse /*throw X*/, //optional + const std::function buf)>& readRequest /*throw X*/, //optional; return "bytesToRead" bytes unless end of stream! + const std::function& receiveHeader /*throw X*/, + int timeoutSec); //throw SysError, X + + std::chrono::steady_clock::time_point getLastUseTime() const { return lastSuccessfulUseTime_; } + +private: + HttpSession (const HttpSession&) = delete; + HttpSession& operator=(const HttpSession&) = delete; + + const std::string serverPrefix_; + const std::string caCertFilePath_; //optional + CURL* easyHandle_ = nullptr; + std::chrono::steady_clock::time_point lastSuccessfulUseTime_ = std::chrono::steady_clock::now(); +}; + + +std::wstring formatCurlStatusCode(CURLcode sc); +} + +#else +#error Why is this header already defined? Do not include in other headers: encapsulate the gory details! +#endif //CURL_WRAP_H_2879058325032785032789645 diff --git a/libssh2/libssh2_wrap.h b/libssh2/libssh2_wrap.h new file mode 100644 index 0000000..ab1af0c --- /dev/null +++ b/libssh2/libssh2_wrap.h @@ -0,0 +1,247 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef LIBSSH2_WRAP_H_087280957180967346572465 +#define LIBSSH2_WRAP_H_087280957180967346572465 + +#include +#include + + + +//------------------------------------------------- +#include +//------------------------------------------------- + +#ifndef LIBSSH2_SFTP_H + #error libssh2_sftp.h header guard changed +#endif + +//fix libssh2 64-bit warning mess: https://github.com/libssh2/libssh2/pull/96 +#undef libssh2_userauth_password +inline int libssh2_userauth_password(LIBSSH2_SESSION* session, const std::string& username, const std::string& password) +{ + return libssh2_userauth_password_ex(session, + username.c_str(), static_cast(username.size()), + password.c_str(), static_cast(password.size()), nullptr); +} + +#undef libssh2_userauth_keyboard_interactive +inline int libssh2_userauth_keyboard_interactive(LIBSSH2_SESSION* session, const std::string& username, LIBSSH2_USERAUTH_KBDINT_RESPONSE_FUNC((*response_callback))) +{ + return libssh2_userauth_keyboard_interactive_ex(session, username.c_str(), static_cast(username.size()), response_callback); +} + +inline char* libssh2_userauth_list(LIBSSH2_SESSION* session, const std::string& username) +{ + return libssh2_userauth_list(session, username.c_str(), static_cast(username.size())); +} + + +inline int libssh2_userauth_publickey_frommemory(LIBSSH2_SESSION* session, const std::string& username, const std::string& privateKeyStream, const std::string& passphrase) +{ + return libssh2_userauth_publickey_frommemory(session, username.c_str(), username.size(), nullptr, 0, + privateKeyStream.c_str(), privateKeyStream.size(), passphrase.c_str()); +} + +#undef libssh2_sftp_opendir +inline LIBSSH2_SFTP_HANDLE* libssh2_sftp_opendir(LIBSSH2_SFTP* sftp, const std::string& path) +{ + return libssh2_sftp_open_ex(sftp, path.c_str(), static_cast(path.size()), 0, 0, LIBSSH2_SFTP_OPENDIR); +} + +#undef libssh2_sftp_stat +inline int libssh2_sftp_stat(LIBSSH2_SFTP* sftp, const std::string& path, LIBSSH2_SFTP_ATTRIBUTES* attrs) +{ + return libssh2_sftp_stat_ex(sftp, path.c_str(), static_cast(path.size()), LIBSSH2_SFTP_STAT, attrs); +} + +#undef libssh2_sftp_open +inline LIBSSH2_SFTP_HANDLE* libssh2_sftp_open(LIBSSH2_SFTP* sftp, const std::string& path, unsigned long flags, long mode) +{ + return libssh2_sftp_open_ex(sftp, path.c_str(), static_cast(path.size()), flags, mode, LIBSSH2_SFTP_OPENFILE); +} + +#undef libssh2_sftp_setstat +inline int libssh2_sftp_setstat(LIBSSH2_SFTP* sftp, const std::string& path, LIBSSH2_SFTP_ATTRIBUTES* attrs) +{ + return libssh2_sftp_stat_ex(sftp, path.c_str(), static_cast(path.size()), LIBSSH2_SFTP_SETSTAT, attrs); +} + +#undef libssh2_sftp_lstat +inline int libssh2_sftp_lstat(LIBSSH2_SFTP* sftp, const std::string& path, LIBSSH2_SFTP_ATTRIBUTES* attrs) +{ + return libssh2_sftp_stat_ex(sftp, path.c_str(), static_cast(path.size()), LIBSSH2_SFTP_LSTAT, attrs); +} + +#undef libssh2_sftp_mkdir +inline int libssh2_sftp_mkdir(LIBSSH2_SFTP* sftp, const std::string& path, long mode) +{ + return libssh2_sftp_mkdir_ex(sftp, path.c_str(), static_cast(path.size()), mode); +} + +#undef libssh2_sftp_unlink +inline int libssh2_sftp_unlink(LIBSSH2_SFTP* sftp, const std::string& path) +{ + return libssh2_sftp_unlink_ex(sftp, path.c_str(), static_cast(path.size())); +} + +#undef libssh2_sftp_rmdir +inline int libssh2_sftp_rmdir(LIBSSH2_SFTP* sftp, const std::string& path) +{ + return libssh2_sftp_rmdir_ex(sftp, path.c_str(), static_cast(path.size())); +} + +#undef libssh2_sftp_realpath +inline int libssh2_sftp_realpath(LIBSSH2_SFTP* sftp, const std::string& path, char* buf, size_t bufSize) +{ + return libssh2_sftp_symlink_ex(sftp, path.c_str(), static_cast(path.size()), buf, static_cast(bufSize), LIBSSH2_SFTP_REALPATH); +} + +#undef libssh2_sftp_readlink +inline int libssh2_sftp_readlink(LIBSSH2_SFTP* sftp, const std::string& path, char* buf, size_t bufSize) +{ + return libssh2_sftp_symlink_ex(sftp, path.c_str(), static_cast(path.size()), buf, static_cast(bufSize), LIBSSH2_SFTP_READLINK); +} + +#undef libssh2_sftp_symlink +inline int libssh2_sftp_symlink(LIBSSH2_SFTP* sftp, const std::string& path, const std::string_view buf) +{ + return libssh2_sftp_symlink_ex(sftp, + /* CAVEAT: https://www.sftp.net/spec/openssh-sftp-extensions.txt + "When OpenSSH's sftp-server was implemented, the order of the arguments + to the SSH_FXP_SYMLINK method was inadvertently reversed." + + => of course libssh2 didn't get the memo: fix this shit: */ + /**/ buf .data (), static_cast(buf .size()), + const_cast(path.c_str()), static_cast(path.size()), + LIBSSH2_SFTP_SYMLINK); +} + +#undef libssh2_sftp_rename +inline int libssh2_sftp_rename(LIBSSH2_SFTP* sftp, const std::string& pathFrom, const std::string& pathTo, long flags) +{ + return libssh2_sftp_rename_ex(sftp, + pathFrom.c_str(), static_cast(pathFrom.size()), + pathTo .c_str(), static_cast(pathTo.size()), flags); +} + + +namespace zen +{ +namespace +{ +std::wstring formatSshStatusCode(int sc) +{ + switch (sc) + { + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_NONE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SOCKET_NONE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_BANNER_RECV); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_BANNER_SEND); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_INVALID_MAC); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_KEX_FAILURE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_ALLOC); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SOCKET_SEND); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_KEY_EXCHANGE_FAILURE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_TIMEOUT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_HOSTKEY_INIT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_HOSTKEY_SIGN); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_DECRYPT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SOCKET_DISCONNECT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_PROTO); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_PASSWORD_EXPIRED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_METHOD_NONE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_AUTHENTICATION_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_PUBLICKEY_UNVERIFIED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_OUTOFORDER); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_FAILURE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_UNKNOWN); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_CLOSED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_EOF_SENT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SCP_PROTOCOL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_ZLIB); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SOCKET_TIMEOUT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SFTP_PROTOCOL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_REQUEST_DENIED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_METHOD_NOT_SUPPORTED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_INVAL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_INVALID_POLL_TYPE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_PUBLICKEY_PROTOCOL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_EAGAIN); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_BUFFER_TOO_SMALL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_BAD_USE); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_COMPRESS); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_OUT_OF_BOUNDARY); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_AGENT_PROTOCOL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_SOCKET_RECV); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_ENCRYPT); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_BAD_SOCKET); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_KNOWN_HOSTS); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_CHANNEL_WINDOW_FULL); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_KEYFILE_AUTH_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_RANDGEN); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_MISSING_USERAUTH_BANNER); + ZEN_CHECK_CASE_FOR_CONSTANT(LIBSSH2_ERROR_ALGO_UNSUPPORTED); + + default: + return replaceCpy(L"SSH status %x", L"%x", numberTo(sc)); + } +} + + +std::wstring formatSftpStatusCode(unsigned long sc) +{ + //libssh2 only defines LIBSSH2_FX_OK(0) to LIBSSH2_FX_LINK_LOOP(21) + //=> all SFTP codes: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.1 + switch (sc) + { + case 0: return L"SSH_FX_OK"; + case 1: return L"SSH_FX_EOF"; + case 2: return L"SSH_FX_NO_SUCH_FILE"; + case 3: return L"SSH_FX_PERMISSION_DENIED"; + case 4: return L"SSH_FX_FAILURE"; + case 5: return L"SSH_FX_BAD_MESSAGE"; + case 6: return L"SSH_FX_NO_CONNECTION"; + case 7: return L"SSH_FX_CONNECTION_LOST"; + case 8: return L"SSH_FX_OP_UNSUPPORTED"; + case 9: return L"SSH_FX_INVALID_HANDLE"; + case 10: return L"SSH_FX_NO_SUCH_PATH"; + case 11: return L"SSH_FX_FILE_ALREADY_EXISTS"; + case 12: return L"SSH_FX_WRITE_PROTECT"; + case 13: return L"SSH_FX_NO_MEDIA"; + case 14: return L"SSH_FX_NO_SPACE_ON_FILESYSTEM"; + case 15: return L"SSH_FX_QUOTA_EXCEEDED"; + case 16: return L"SSH_FX_UNKNOWN_PRINCIPAL"; + case 17: return L"SSH_FX_LOCK_CONFLICT"; + case 18: return L"SSH_FX_DIR_NOT_EMPTY"; + case 19: return L"SSH_FX_NOT_A_DIRECTORY"; + case 20: return L"SSH_FX_INVALID_FILENAME"; + case 21: return L"SSH_FX_LINK_LOOP"; + case 22: return L"SSH_FX_CANNOT_DELETE"; + case 23: return L"SSH_FX_INVALID_PARAMETER"; + case 24: return L"SSH_FX_FILE_IS_A_DIRECTORY"; + case 25: return L"SSH_FX_BYTE_RANGE_LOCK_CONFLICT"; + case 26: return L"SSH_FX_BYTE_RANGE_LOCK_REFUSED"; + case 27: return L"SSH_FX_DELETE_PENDING"; + case 28: return L"SSH_FX_FILE_CORRUPT"; + case 29: return L"SSH_FX_OWNER_INVALID"; + case 30: return L"SSH_FX_GROUP_INVALID"; + case 31: return L"SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK"; + + default: return replaceCpy(L"SFTP status %x", L"%x", numberTo(sc)); + } +} +} +} + +#else +#error Why is this header already defined? Do not include in other headers: encapsulate the gory details! +#endif //LIBSSH2_WRAP_H_087280957180967346572465 diff --git a/wx+/app_main.h b/wx+/app_main.h new file mode 100644 index 0000000..38438d0 --- /dev/null +++ b/wx+/app_main.h @@ -0,0 +1,17 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef APP_MAIN_H_08215601837818347575856 +#define APP_MAIN_H_08215601837818347575856 + +#include + + +namespace zen +{ +} + +#endif //APP_MAIN_H_08215601837818347575856 diff --git a/wx+/async_task.h b/wx+/async_task.h new file mode 100644 index 0000000..87a179f --- /dev/null +++ b/wx+/async_task.h @@ -0,0 +1,162 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ASYNC_TASK_H_839147839170432143214321 +#define ASYNC_TASK_H_839147839170432143214321 + +#include +#include +#include +#include + + +namespace zen +{ +/* Run a task in an async thread, but process result in GUI event loop + ------------------------------------------------------------------- + 1. put AsyncGuiQueue instance inside a dialog: + AsyncGuiQueue guiQueue; + + 2. schedule async task and synchronous continuation: + guiQueue.processAsync(evalAsync, evalOnGui); + + Alternative: wxWidgets' inter-thread communication (wxEvtHandler::QueueEvent) https://wiki.wxwidgets.org/Inter-Thread_and_Inter-Process_communication + => don't bother, probably too many MT race conditions lurking around */ + +namespace impl +{ +struct Task +{ + virtual ~Task() {} + virtual bool resultReady () const = 0; + virtual void evaluateResult() = 0; +}; + + +template +class ConcreteTask : public Task +{ +public: + template + ConcreteTask(std::future&& asyncResult, Fun2&& evalOnGui) : + asyncResult_(std::move(asyncResult)), evalOnGui_(std::forward(evalOnGui)) {} + + bool resultReady() const override { return isReady(asyncResult_); } + + void evaluateResult() override + { + if constexpr (std::is_same_v) + { + asyncResult_.get(); + evalOnGui_(); + } + else + evalOnGui_(asyncResult_.get()); + } + +private: + std::future asyncResult_; + Fun evalOnGui_; //keep "evalOnGui" strictly separated from async thread: in particular do not copy in thread! +}; + + +class AsyncTasks +{ +public: + AsyncTasks() {} + + template + void add(Fun&& evalAsync, Fun2&& evalOnGui) + { + using ResultType = decltype(evalAsync()); + + std::promise prom; + tasks_.push_back(std::make_unique>>(prom.get_future(), std::forward(evalOnGui))); + + //don't use zen::runAsync() and std::packaged_task => let exceptions crash the app directly at throw location! + std::thread([prom = std::move(prom), + fun = std::forward(evalAsync)]() mutable + { + if constexpr (std::is_same_v) + { + fun(); //throw? => let it crash! + prom.set_value(); + } + else + prom.set_value(fun()); //throw? => let it crash! + }).detach(); + } + //equivalent to "evalOnGui(evalAsync())" + // -> evalAsync: the usual thread-safety requirements apply! + // -> evalOnGui: no thread-safety concerns, but must only reference variables with greater-equal lifetime than the AsyncTask instance! + + void evalResults() //call from GUI thread repreatedly + { + if (!inRecursion_) //prevent implicit recursion, e.g. if we're called from an idle event and spawn another one within the callback below + { + inRecursion_ = true; + ZEN_ON_SCOPE_EXIT(inRecursion_ = false); + + std::vector> readyTasks; //Reentrancy; access to AsyncTasks::add is not protected! => evaluate outside of eraseIf() + + eraseIf(tasks_, [&](std::unique_ptr& task) + { + if (task->resultReady()) + { + readyTasks.push_back(std::move(task)); + return true; + } + return false; + }); + + for (std::unique_ptr& task : readyTasks) + task->evaluateResult(); + } + } + + bool empty() const { return tasks_.empty(); } + +private: + AsyncTasks (const AsyncTasks&) = delete; + AsyncTasks& operator=(const AsyncTasks&) = delete; + + bool inRecursion_ = false; + std::vector> tasks_; +}; +} + + +class AsyncGuiQueue : private wxEvtHandler +{ +public: + explicit AsyncGuiQueue(int pollingMs = 50) : + pollingMs_(pollingMs) { timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { onTimerEvent(event); }); } + + template + void processAsync(Fun&& evalAsync, Fun2&& evalOnGui) + { + asyncTasks_.add(std::forward(evalAsync), + std::forward(evalOnGui)); + if (!timer_.IsRunning()) + timer_.Start(pollingMs_ /*unit: [ms]*/); + } + +private: + void onTimerEvent(wxEvent& event) //schedule and run long-running tasks asynchronously + { + asyncTasks_.evalResults(); //process results on GUI queue + if (asyncTasks_.empty()) + timer_.Stop(); + } + + const int pollingMs_; + impl::AsyncTasks asyncTasks_; + wxTimer timer_; //don't use wxWidgets' idle handling => repeated idle requests/consumption hogs 100% cpu! +}; + +} + +#endif //ASYNC_TASK_H_839147839170432143214321 diff --git a/wx+/bitmap_button.h b/wx+/bitmap_button.h new file mode 100644 index 0000000..89ea891 --- /dev/null +++ b/wx+/bitmap_button.h @@ -0,0 +1,150 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BITMAP_BUTTON_H_83415718945878341563415 +#define BITMAP_BUTTON_H_83415718945878341563415 + +#include +#include +#include +#include "image_tools.h" +#include "std_button_layout.h" +#include "dc.h" + + +namespace zen +{ +//zen::BitmapTextButton is identical to wxBitmapButton, but preserves the label via SetLabel(), which wxFormbuilder would ditch! +class BitmapTextButton : public wxBitmapButton +{ +public: + BitmapTextButton(wxWindow* parent, + wxWindowID id, + const wxString& label, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = 0, + const wxValidator& validator = wxDefaultValidator, + const wxString& name = wxASCII_STR(wxButtonNameStr)) : + wxBitmapButton(parent, id, + //(FreeFileSync_x86_64:77379): Gtk-CRITICAL **: 11:04:31.752: IA__gtk_widget_modify_style: assertion 'GTK_IS_WIDGET (widget)' failed + rectangleImage({1, 1}, *wxRED), + pos, size, style, validator, name) + { + SetLabel(label); + } +}; + +//wxButton::SetBitmap() also supports "image + text", but screws up proper gap and border handling +void setBitmapTextLabel(wxBitmapButton& btn, const wxImage& img, const wxString& text, int gap = dipToWxsize(5), int border = dipToWxsize(5)); + +//set bitmap label flicker free: +void setImage(wxAnyButton& button, const wxImage& bmp); +void setImage(wxStaticBitmap& staticBmp, const wxImage& img); + +wxImage renderPressedButton(const wxSize& sz); + +inline wxColor getColorToggleButtonBorder() { return {0x79, 0xbc, 0xed}; } //medium blue +inline wxColor getColorToggleButtonFill () { return {0xcc, 0xe4, 0xf8}; } //light blue + + + + + + + + +//################################### implementation ################################### +inline +void setBitmapTextLabel(wxBitmapButton& btn, const wxImage& img, const wxString& text, int gap, int border) +{ + assert(gap >= 0 && border >= 0); + gap = std::max(0, gap); + border = std::max(0, border); + + wxImage imgTxt = createImageFromText(text, btn.GetFont(), btn.GetForegroundColour()); + if (img.IsOk()) + imgTxt = btn.GetLayoutDirection() != wxLayout_RightToLeft ? + stackImages(img, imgTxt, ImageStackLayout::horizontal, ImageStackAlignment::center, wxsizeToScreen(gap)) : + stackImages(imgTxt, img, ImageStackLayout::horizontal, ImageStackAlignment::center, wxsizeToScreen(gap)); + + //SetMinSize() instead of SetSize() is needed here for wxWidgets layout determination to work correctly + btn.SetMinSize({screenToWxsize(imgTxt.GetWidth()) + 2 * border, + std::max(screenToWxsize(imgTxt.GetHeight()) + 2 * border, getDefaultButtonHeight())}); + + setImage(btn, imgTxt); +} + + +inline +void setImage(wxAnyButton& button, const wxImage& img) +{ + if (!img.IsOk()) + { + button.SetBitmapLabel (wxNullBitmap); + button.SetBitmapDisabled(wxNullBitmap); + return; + } + + + button.SetBitmapLabel(toScaledBitmap(img)); + + //wxWidgets excels at screwing up consistently once again: + //the first call to SetBitmapLabel() *implicitly* sets the disabled bitmap, too, subsequent calls, DON'T! + button.SetBitmapDisabled(toScaledBitmap(img.ConvertToDisabled())); //inefficiency: wxBitmap::ConvertToDisabled() implicitly converts to wxImage! +} + + +inline +void setImage(wxStaticBitmap& staticBmp, const wxImage& img) +{ + staticBmp.SetBitmap(toScaledBitmap(img)); +} + + + +inline +wxImage generatePressedButtonBack(const wxSize& sz) +{ +#if 1 + return rectangleImage(sz, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), {0x11, 0x79, 0xfe} /*light blue*/, dipToScreen(2)); + +#else //rectangle border with gradient as background + wxBitmap bmp(wxsizeToScreen(sz.x), + wxsizeToScreen(sz.y)); //seems we don't need to pass 24-bit depth here even for high-contrast color schemes + bmp.SetScaleFactor(getScreenDpiScale()); + { + //draw rectangle border with gradient + const wxColor colFrom = wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE); + const wxColor colTo(0x11, 0x79, 0xfe); //light blue + + wxMemoryDC dc(bmp); + dc.SetPen(*wxTRANSPARENT_PEN); //wxTRANSPARENT_PEN is about 2x faster than redundantly drawing with col! + + wxRect rect(sz); + + const int borderSize = dipToWxsize(3); + for (int i = 1 ; i <= borderSize; ++i) + { + const wxColor colGradient((colFrom.Red () * (borderSize - i) + colTo.Red () * i) / borderSize, + (colFrom.Green() * (borderSize - i) + colTo.Green() * i) / borderSize, + (colFrom.Blue () * (borderSize - i) + colTo.Blue () * i) / borderSize); + dc.SetBrush(colGradient); + dc.DrawRectangle(rect); + rect.Deflate(dipToWxsize(1)); + } + + dc.SetBrush(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + dc.DrawRectangle(rect); + } + wxImage img = bmp.ConvertToImage(); + convertToVanillaImage(img); + return img; +#endif +} +} + +#endif //BITMAP_BUTTON_H_83415718945878341563415 diff --git a/wx+/choice_enum.h b/wx+/choice_enum.h new file mode 100644 index 0000000..6b28b4f --- /dev/null +++ b/wx+/choice_enum.h @@ -0,0 +1,117 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CHOICE_ENUM_H_132413545345687 +#define CHOICE_ENUM_H_132413545345687 + +//#include +#include + + +namespace zen +{ +//handle mapping of enum values to wxChoice controls +template +class EnumDescrList +{ +public: + using DescrItem = std::tuple; + + EnumDescrList(wxChoice& ctrl, std::vector list); + ~EnumDescrList(); + + void set(Enum value); + Enum get() const ; + void updateTooltip(); //after user changed selection + + const std::vector& getConfig() const { return descrList_; } + +private: + wxChoice& ctrl_; + const std::vector descrList_; + std::vector labels_; +}; + + + + + + + + + + + + + + +//--------------- impelementation ------------------------------------------- +template +EnumDescrList::EnumDescrList(wxChoice& ctrl, std::vector list) : ctrl_(ctrl), descrList_(std::move(list)) +{ + for (const auto& [val, label, tooltip] : descrList_) + labels_.push_back(label); + + ctrl_.Set(labels_); //expensive as fuck! => only call when needed! +} + + +template inline +EnumDescrList::~EnumDescrList() +{ +} + + +template +void EnumDescrList::set(Enum value) +{ + const auto it = std::find_if(descrList_.begin(), descrList_.end(), [&](const auto& mapItem) { return std::get(mapItem) == value; }); + if (it != descrList_.end()) + { + const auto& [val, label, tooltip] = *it; + if (!tooltip.empty()) + ctrl_.SetToolTip(tooltip); + else + ctrl_.UnsetToolTip(); + + const int selectedPos = it - descrList_.begin(); + ctrl_.SetSelection(selectedPos); + } + else assert(false); +} + + +template +Enum EnumDescrList::get() const +{ + const int selectedPos = ctrl_.GetSelection(); + + if (0 <= selectedPos && selectedPos < std::ssize(descrList_)) + return std::get(descrList_[selectedPos]); + + assert(false); + return Enum(0); +} + + +template +void EnumDescrList::updateTooltip() +{ + const int selectedPos = ctrl_.GetSelection(); + + if (0 <= selectedPos && selectedPos < std::ssize(descrList_)) + { + const auto& [val, label, tooltip] = descrList_[selectedPos]; + if (!tooltip.empty()) + ctrl_.SetToolTip(tooltip); + else + ctrl_.UnsetToolTip(); + } + else assert(false); +} +} + +#endif //CHOICE_ENUM_H_132413545345687 diff --git a/wx+/color_tools.h b/wx+/color_tools.h new file mode 100644 index 0000000..45f2220 --- /dev/null +++ b/wx+/color_tools.h @@ -0,0 +1,219 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef COLOR_TOOLS_H_18301239864123785613 +#define COLOR_TOOLS_H_18301239864123785613 + +#include +#include + + +namespace zen +{ +inline +double srgbDecode(unsigned char c) //https://en.wikipedia.org/wiki/SRGB +{ + const double c_ = c / 255.0; + return c_ <= 0.04045 ? c_ / 12.92 : std::pow((c_ + 0.055) / 1.055, 2.4); +} + + +inline +unsigned char srgbEncode(double c) +{ + const double c_ = c <= 0.0031308 ? c * 12.92 : std::pow(c, 1 / 2.4) * 1.055 - 0.055; + return std::clamp(std::round(c_ * 255), 0, 255); +} + + +inline //https://www.w3.org/WAI/GL/wiki/Relative_luminance +double relLuminance(double r, double g, double b) //input: gamma-decoded sRGB +{ + return 0.2126 * r + 0.7152 * g + 0.0722 * b; //= the Y part of CIEXYZ +} + + +inline +double relativeLuminance(const wxColor& col) //[0, 1] +{ + assert(col.Alpha() == wxALPHA_OPAQUE); + return relLuminance(srgbDecode(col.Red()), srgbDecode(col.Green()), srgbDecode(col.Blue())); +} + + +inline +double relativeContrast(const wxColor& c1, const wxColor& c2) +{ + //https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef + //https://snook.ca/technical/colour_contrast/colour.html + double lum1 = relativeLuminance(c1); + double lum2 = relativeLuminance(c2); + if (lum1 < lum2) + std::swap(lum1, lum2); + return (lum1 + 0.05) / (lum2 + 0.05); +} + + +namespace +{ +//get first color between [col1, white/black] (assuming direct line in decoded sRGB) where minimum contrast is satisfied against col2 +wxColor enhanceContrast(wxColor col1, const wxColor& col2, double contrastRatioMin) +{ + const wxColor colMax = relativeLuminance(col2) < 0.17912878474779204 /* = sqrt(0.05 * 1.05) - 0.05 */ ? 0xffffff : 0; + //equivalent to: relativeContrast(col2, *wxWHITE) > relativeContrast(col2, *wxBLACK) ? *wxWHITE : *wxBLACK + + assert(col2.Alpha() == wxALPHA_OPAQUE); + if (col2.Alpha() != wxALPHA_OPAQUE) + return *wxRED; //make some noise + + /* CAVEAT: macOS uses partially-transparent colors! e.g. in #RGBA: + wxSYS_COLOUR_GRAYTEXT #FFFFFF3F + wxSYS_COLOUR_WINDOWTEXT #FFFFFFD8 + wxSYS_COLOUR_WINDOW #171717FF */ + if (col1.Alpha() != wxALPHA_OPAQUE) + { + auto calcChannel = [a = col1.Alpha()](unsigned char f, unsigned char b) + { + return static_cast(numeric::intDivRound(f * a + b * (255 - a), 255)); + }; + + col1 = wxColor(calcChannel(col1.Red (), col2.Red ()), + calcChannel(col1.Green(), col2.Green()), + calcChannel(col1.Blue (), col2.Blue ())); + } + + //--------------------------------------------------------------- + assert(contrastRatioMin >= 3); //lower values (especially near 1) probably aren't sensible mathematically, also: W3C recommends >= 4.5 for base AA compliance + auto contrast = [](double lum1, double lum2) //input: relative luminance + { + if (lum1 < lum2) + std::swap(lum1, lum2); + return (lum1 + 0.05) / (lum2 + 0.05); + }; + const double r_1 = srgbDecode(col1.Red()); + const double g_1 = srgbDecode(col1.Green()); + const double b_1 = srgbDecode(col1.Blue()); + const double r_m = srgbDecode(colMax.Red()); + const double g_m = srgbDecode(colMax.Green()); + const double b_m = srgbDecode(colMax.Blue()); + + const double lum_1 = relLuminance(r_1, g_1, b_1); + const double lum_m = relLuminance(r_m, g_m, b_m); + const double lum_2 = relativeLuminance(col2); + + if (contrast(lum_1, lum_2) >= contrastRatioMin) + return col1; //nothing to do! + + if (contrast(lum_m, lum_2) <= contrastRatioMin) + { + assert(false); //problem! + return colMax; + } + + if (lum_m < lum_2) + contrastRatioMin = 1 / contrastRatioMin; + + const double lum_t = contrastRatioMin * (lum_2 + 0.05) - 0.05; //target luminance + const double t = (lum_t - lum_1) / (lum_m - lum_1); + + return wxColor(srgbEncode(t * (r_m - r_1) + r_1), + srgbEncode(t * (g_m - g_1) + g_1), + srgbEncode(t * (b_m - b_1) + b_1)); +} +} + +#if 0 +//toy sample code: gamma-encoded sRGB -> CIEXYZ -> CIELAB and back: input === output RGB color (verified) +wxColor colorConversion(const wxColor& col) +{ + assert(col.GetAlpha() == wxALPHA_OPAQUE); + const double r = srgbDecode(col.Red()); + const double g = srgbDecode(col.Green()); + const double b = srgbDecode(col.Blue()); + + //https://en.wikipedia.org/wiki/SRGB#Correspondence_to_CIE_XYZ_stimulus + const double x = 0.4124 * r + 0.3576 * g + 0.1805 * b; + const double y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const double z = 0.0193 * r + 0.1192 * g + 0.9505 * b; + //----------------------------------------------- + //https://en.wikipedia.org/wiki/CIELAB_color_space#Converting_between_CIELAB_and_CIEXYZ_coordinates + using numeric::power; + auto f = [](double t) + { + constexpr double delta = 6.0 / 29; + return t > power<3>(delta) ? + std::pow(t, 1.0 / 3) : + t / (3 * power<2>(delta)) + 4.0 / 29; + }; + const double L_ = 116 * f(y) - 16; //[ 0, 100] + const double a_ = 500 * (f(x / 0.950489) - f(y)); //[-128, 127] + const double b_ = 200 * (f(y) - f(z / 1.088840)); //[-128, 127] + //----------------------------------------------- + auto f_1 = [](double t) + { + constexpr double delta = 6.0 / 29; + return t > delta ? + power<3>(t) : + 3 * power<2>(delta) * (t - 4.0 / 29); + }; + const double x2 = 0.950489 * f_1((L_ + 16) / 116 + a_ / 500); + const double y2 = f_1((L_ + 16) / 116); + const double z2 = 1.088840 * f_1((L_ + 16) / 116 - b_ / 200); + //----------------------------------------------- + const double r2 = 3.2406255 * x2 + -1.5372080 * y2 + -0.4986286 * z2; + const double g2 = -0.9689307 * x2 + 1.8757561 * y2 + 0.0415175 * z2; + const double b2 = 0.0557101 * x2 + -0.2040211 * y2 + 1.0569959 * z2; + + return wxColor(srgbEncode(r2), srgbEncode(g2), srgbEncode(b2)); +} + + +//https://en.wikipedia.org/wiki/HSL_and_HSV +wxColor hsvColor(double h, double s, double v) //h within [0, 360), s, v within [0, 1] +{ + //make input values fit into bounds + if (h > 360) + h -= static_cast(h / 360) * 360; + else if (h < 0) + h -= static_cast(h / 360) * 360 - 360; + s = std::clamp(s, 0.0, 1.0); + v = std::clamp(v, 0.0, 1.0); + //------------------------------------ + const int h_i = h / 60; + const float f = h / 60 - h_i; + + auto to8Bit = [](double val) -> unsigned char + { + return std::clamp(std::round(val * 255), 0, 255); + }; + + const unsigned char p = to8Bit(v * (1 - s)); + const unsigned char q = to8Bit(v * (1 - s * f)); + const unsigned char t = to8Bit(v * (1 - s * (1 - f))); + const unsigned char vi = to8Bit(v); + + switch (h_i) + { + case 0: + return wxColor(vi, t, p); + case 1: + return wxColor(q, vi, p); + case 2: + return wxColor(p, vi, t); + case 3: + return wxColor(p, q, vi); + case 4: + return wxColor(t, p, vi); + case 5: + return wxColor(vi, p, q); + } + assert(false); + return *wxBLACK; +} +#endif +} + +#endif //COLOR_TOOLS_H_18301239864123785613 diff --git a/wx+/context_menu.h b/wx+/context_menu.h new file mode 100644 index 0000000..06e8470 --- /dev/null +++ b/wx+/context_menu.h @@ -0,0 +1,163 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CONTEXT_MENU_H_18047302153418174632141234 +#define CONTEXT_MENU_H_18047302153418174632141234 + +#include +#include +#include +#include +#include +#include "dc.h" + + +/* A context menu supporting lambda callbacks! + + Usage: + ContextMenu menu; + menu.addItem(L"Some Label", [&]{ ...do something... }); -> capture by reference is fine, as long as captured variables have at least scope of ContextMenu::popup()! + ... + menu.popup(wnd); */ + +namespace zen +{ +inline +void setImage(wxMenuItem& menuItem, const wxImage& img) +{ + menuItem.SetBitmap(toScaledBitmap(img)); +} + + +class ContextMenu : private wxEvtHandler +{ +public: + ContextMenu() {} + + void addItem(const wxString& label, const std::function& command, const wxImage& img = wxNullImage, bool enabled = true) + { + wxMenuItem* newItem = new wxMenuItem(menu_.get(), wxID_ANY, label); //menu owns item! + if (img.IsOk()) + setImage(*newItem, img); //do not set AFTER appending item! wxWidgets screws up for yet another crappy reason + menu_->Append(newItem); + if (!enabled) + newItem->Enable(false); //do not enable BEFORE appending item! wxWidgets screws up for yet another crappy reason + commandList_[newItem->GetId()] = command; //defer event connection, this may be a submenu only! + } + + void addCheckBox(const wxString& label, const std::function& command, bool checked, bool enabled = true) + { + wxMenuItem* newItem = menu_->AppendCheckItem(wxID_ANY, label); + newItem->Check(checked); + if (!enabled) + newItem->Enable(false); + commandList_[newItem->GetId()] = command; + } + + void addRadio(const wxString& label, const std::function& command, bool selected, bool enabled = true) + { + wxMenuItem* newItem = menu_->AppendRadioItem(wxID_ANY, label); + newItem->Check(selected); + if (!enabled) + newItem->Enable(false); + commandList_[newItem->GetId()] = command; + } + + void addSeparator() { menu_->AppendSeparator(); } + + void addSubmenu(const wxString& label, ContextMenu& submenu, const wxImage& img = wxNullImage, bool enabled = true) //invalidates submenu! + { + //transfer submenu commands: + commandList_.insert(submenu.commandList_.begin(), submenu.commandList_.end()); + submenu.commandList_.clear(); + + submenu.menu_->SetNextHandler(menu_.get()); //on wxGTK submenu events are not propagated to their parent menu by default! + + wxMenuItem* newItem = new wxMenuItem(menu_.get(), wxID_ANY, label, L"", wxITEM_NORMAL, submenu.menu_.release()); //menu owns item, item owns submenu! + if (img.IsOk()) + setImage(*newItem, img); //do not set AFTER appending item! wxWidgets screws up for yet another crappy reason + menu_->Append(newItem); + if (!enabled) + newItem->Enable(false); + } + + void popup(wxWindow& wnd, const wxPoint& pos = wxDefaultPosition) //show popup menu + process lambdas + { + //eventually all events from submenu items will be received by the parent menu + for (const auto& [itemId, command] : commandList_) + menu_->Bind(wxEVT_COMMAND_MENU_SELECTED, [command /*clang bug*/= command](wxCommandEvent& event) { command(); }, itemId); + + wnd.PopupMenu(menu_.get(), pos); + wxTheApp->ProcessPendingEvents(); //make sure lambdas are evaluated before going out of scope; + //although all events seem to be processed within wxWindows::PopupMenu, we shouldn't trust wxWidgets in this regard + } + +private: + ContextMenu (const ContextMenu&) = delete; + ContextMenu& operator=(const ContextMenu&) = delete; + + std::unique_ptr menu_ = std::make_unique(); + std::unordered_map /*command*/> commandList_; +}; + + +//GTK: image must be set *before* adding wxMenuItem to menu or it won't show => workaround: +inline //also needed on Windows + macOS since wxWidgets 3.1.6 (thanks?) +void fixMenuIcons(wxMenu& menu) +{ + std::vector> itemsWithBmp; + { + size_t pos = 0; + for (wxMenuItem* item : menu.GetMenuItems()) + { + if (item->GetBitmap().IsOk()) + itemsWithBmp.emplace_back(item, pos); + ++pos; + } + } + + for (const auto& [item, pos] : itemsWithBmp) + if (!menu.Insert(pos, menu.Remove(item))) //detach + reinsert + assert(false); +} + + +//better call wxClipboard::Get()->Flush() *once* during app exit instead of after each setClipboardText()? +// => OleFlushClipboard: "Carries out the clipboard shutdown sequence" +// => maybe this helps with clipboard randomly "forgetting" content after app exit? +inline +void setClipboardText(const wxString& txt) +{ + wxClipboard& clip = *wxClipboard::Get(); + if (clip.Open()) + { + ZEN_ON_SCOPE_EXIT(clip.Close()); + [[maybe_unused]] const bool rv = clip.SetData(new wxTextDataObject(txt)); //ownership passed + assert(rv); + } + else assert(false); +} + + +inline +std::optional getClipboardText() +{ + wxClipboard& clip = *wxClipboard::Get(); + if (clip.Open()) + { + ZEN_ON_SCOPE_EXIT(clip.Close()); + + //if (clip.IsSupported(wxDF_TEXT or wxDF_UNICODETEXT !???)) - superfluous? already handled by wxClipboard::GetData()!? + wxTextDataObject data; + if (clip.GetData(data)) + return data.GetText(); + } + else assert(false); + return std::nullopt; +} +} + +#endif //CONTEXT_MENU_H_18047302153418174632141234 diff --git a/wx+/darkmode.cpp b/wx+/darkmode.cpp new file mode 100644 index 0000000..626ba35 --- /dev/null +++ b/wx+/darkmode.cpp @@ -0,0 +1,102 @@ +// ***************************************************************************** +// * 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 "darkmode.h" +#include +#include +#include "color_tools.h" + #include + +using namespace zen; + + +bool zen::darkModeAvailable() +{ + +#if GTK_MAJOR_VERSION == 2 + return false; +#elif GTK_MAJOR_VERSION >= 3 + return true; +#else +#error unknown GTK version! +#endif + +} + + +namespace +{ +class SysColorsHook : public wxColorHook +{ +public: + + wxColor getColor(wxSystemColour index) const override + { + //fix contrast e.g. Ubuntu's Adwaita-Dark theme and macOS dark mode: + if (index == wxSYS_COLOUR_GRAYTEXT) + return colGreyTextEnhContrast_; +#if 0 + auto colToString = [](wxColor c) { return utfTo(c.GetAsString(wxC2S_HTML_SYNTAX)); /* #RRGGBB(AA) */ }; + std::cerr << "wxSYS_COLOUR_GRAYTEXT " << colToString(wxSystemSettingsNative::GetColour(wxSYS_COLOUR_GRAYTEXT)) << "\n"; +#endif + return wxSystemSettingsNative::GetColour(index); //fallback + } + +private: + const wxColor colGreyTextEnhContrast_ = + enhanceContrast(wxSystemSettingsNative::GetColour(wxSYS_COLOUR_GRAYTEXT), + wxSystemSettingsNative::GetColour(wxSYS_COLOUR_WINDOW), 4.5 /*contrastRatioMin*/); //W3C recommends >= 4.5 +}; + + +std::optional globalDefaultThemeIsDark; +} + + +void zen::colorThemeInit(wxApp& app, ColorTheme colTheme) //throw FileError +{ + assert(!refGlobalColorHook()); + + globalDefaultThemeIsDark = wxSystemSettings::GetAppearance().AreAppsDark(); + ZEN_ON_SCOPE_EXIT(if (!refGlobalColorHook()) refGlobalColorHook() = std::make_unique()); //*after* SetAppearance() and despite errors + + //caveat: on macOS there are more themes than light/dark: https://developer.apple.com/documentation/appkit/nsappearance/name-swift.struct + if (colTheme != ColorTheme::System && //"System" is already the default for macOS/Linux(GTK3) + darkModeAvailable()) + changeColorTheme(colTheme); //throw FileError +} + + +void zen::colorThemeCleanup() +{ + assert(refGlobalColorHook()); + refGlobalColorHook().reset(); +} + + +bool zen::equalAppearance(ColorTheme colTheme1, ColorTheme colTheme2) +{ + if (colTheme1 == ColorTheme::System) colTheme1 = *globalDefaultThemeIsDark ? ColorTheme::Dark : ColorTheme::Light; + if (colTheme2 == ColorTheme::System) colTheme2 = *globalDefaultThemeIsDark ? ColorTheme::Dark : ColorTheme::Light; + return colTheme1 == colTheme2; +} + + +void zen::changeColorTheme(ColorTheme colTheme) //throw FileError +{ + if (colTheme == ColorTheme::System) //SetAppearance(System) isn't working reliably! surprise!? + colTheme = *globalDefaultThemeIsDark ? ColorTheme::Dark : ColorTheme::Light; + + try + { + ZEN_ON_SCOPE_SUCCESS(refGlobalColorHook() = std::make_unique()); //*after* SetAppearance() + if (wxApp::AppearanceResult rv = wxTheApp->SetAppearance(colTheme); + rv != wxApp::AppearanceResult::Ok) + throw SysError(formatSystemError("wxApp::SetAppearance", + rv == wxApp::AppearanceResult::CannotChange ? L"CannotChange" : L"Failure", L"" /*errorMsg*/)); + } + catch (const SysError& e) { throw FileError(_("Failed to update the color theme."), e.toString()); } +} diff --git a/wx+/darkmode.h b/wx+/darkmode.h new file mode 100644 index 0000000..91d2a78 --- /dev/null +++ b/wx+/darkmode.h @@ -0,0 +1,28 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DARKMODE_H_754298057018 +#define DARKMODE_H_754298057018 + +#include +#include + + +namespace zen +{ +bool darkModeAvailable(); + +//support not only "dark mode" but dark themes in general +using ColorTheme = wxApp::Appearance; //why reinvent the wheel? + +void colorThemeInit(wxApp& app, ColorTheme colTheme); //throw FileError +void colorThemeCleanup(); + +bool equalAppearance(ColorTheme colTheme1, ColorTheme colTheme2); +void changeColorTheme(ColorTheme colTheme); //throw FileError +} + +#endif //DARKMODE_H_754298057018 diff --git a/wx+/dc.h b/wx+/dc.h new file mode 100644 index 0000000..862568c --- /dev/null +++ b/wx+/dc.h @@ -0,0 +1,316 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DC_H_4987123956832143243214 +#define DC_H_4987123956832143243214 + +#include +#include +#include +//#include //macOS: std::get +#include //for macro: wxALWAYS_NATIVE_DOUBLE_BUFFER +#include + + +namespace zen +{ +inline +void clearArea(wxDC& dc, const wxRect& rect, const wxColor& col) +{ + assert(col.IsSolid()); + if (rect.width > 0 && //clearArea() is surprisingly expensive + rect.height > 0) + { + //wxDC::DrawRectangle() just widens inner area if wxTRANSPARENT_PEN is used! + //bonus: wxTRANSPARENT_PEN is about 2x faster than redundantly drawing with col! + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(col); + dc.DrawRectangle(rect); + } +} + + +//properly draw rectangle respecting high DPI (and avoiding wxPen position fuzzyness) +inline +void drawFilledRectangle(wxDC& dc, wxRect rect, const wxColor& innerCol, const wxColor& borderCol, int borderSize) +{ + assert(innerCol.IsSolid() && borderCol.IsSolid()); + if (rect.width > 0 && + rect.height > 0) + { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(borderCol); + dc.DrawRectangle(rect); + + rect.Deflate(borderSize); //more wxWidgets design mistakes: behavior of wxRect::Deflate depends on object being const/non-const!!! + + if (rect.width > 0 && + rect.height > 0) + { + dc.SetBrush(innerCol); + dc.DrawRectangle(rect); + } + } +} + + +inline +void drawRectangleBorder(wxDC& dc, const wxRect& rect, const wxColor& col, int borderSize) +{ + assert(col.IsSolid()); + if (rect.width > 0 && + rect.height > 0) + { + if (2 * borderSize >= std::min(rect.width, rect.height)) + return clearArea(dc, rect, col); + + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(col); + dc.DrawRectangle(rect.x, rect.y, borderSize, rect.height); //left + dc.DrawRectangle(rect.x + rect.width - borderSize, rect.y, borderSize, rect.height); //right + dc.DrawRectangle(rect.x, rect.y, rect.width, borderSize); //top + dc.DrawRectangle(rect.x, rect.y + rect.height - borderSize, rect.width, borderSize); //bottom + } +} + + +/* figure out wxWidgets cross-platform high DPI mess: + + 1. "wxsize" := what wxWidgets is using: device-dependent on Windows, device-indepent on macOS (...mostly) + 2. screen unit := device-dependent size in pixels + 3. DIP := device-independent pixels + + corollary: + macOS: "wxsize = DIP" + Windows: "wxsize = screen unit" + cross-platform: images are in "screen unit" */ + +inline +double getScreenDpiScale() +{ + //GTK2 doesn't properly support high DPI: https://freefilesync.org/forum/viewtopic.php?t=6114 + //=> requires general fix at wxWidgets-level + + //https://github.com/wxWidgets/wxWidgets/blob/d9d05c2bb201078f5e762c42458ca2f74af5b322/include/wx/window.h#L2060 + const double scale = 1.0; //e.g. macOS, GTK3 + + return scale; +} + + +inline +double getWxsizeDpiScale() +{ +#ifndef wxHAS_DPI_INDEPENDENT_PIXELS +#error why is wxHAS_DPI_INDEPENDENT_PIXELS not defined? +#endif + return 1.0; //e.g. macOS, GTK3 +} + + +//similar to wxWindow::FromDIP (but tied to primary monitor and buffered) +inline int dipToWxsize (int d) { return std::round(d * getWxsizeDpiScale() - 0.1 /*round values like 1.5 down => 1 pixel on 150% scale*/); } +inline int dipToScreen (int d) { return std::round(d * getScreenDpiScale()); } +inline int wxsizeToScreen(int u) { return std::round(u / getWxsizeDpiScale() * getScreenDpiScale()); } +inline int screenToWxsize(int s) { return std::round(s / getScreenDpiScale() * getWxsizeDpiScale()); } + +int dipToWxsize (double d) = delete; +int dipToScreen (double d) = delete; +int wxsizeToScreen(double d) = delete; +int screenToWxsize(double d) = delete; + + +inline +int getDpiScalePercent() +{ + return std::round(100 * getScreenDpiScale()); +} + + +inline +wxBitmap toScaledBitmap(const wxImage& img /*expected to be DPI-scaled!*/) +{ + //wxBitmap(const wxImage& image, int depth = -1, double WXUNUSED(scale) = 1.0) => wxWidgets just ignores scale parameter! WTF! + wxBitmap bmpScaled(img); + bmpScaled.SetScaleFactor(getScreenDpiScale()); + return bmpScaled; //when testing use 175% scaling: wxWidgets' scaling logic doesn't kick in for 150% only +} + + +//all this shit just because wxDC::SetScaleFactor() is missing: +inline +void setScaleFactor(wxDC& dc, double scale) +{ + struct wxDcSurgeon : public wxDCImpl + { + void setScaleFactor(double scale) { m_contentScaleFactor = scale; } + }; + static_cast(dc.GetImpl())->setScaleFactor(scale); +} + + +//add some sanity to moronic const/non-const wxRect::Intersect() +inline +wxRect getIntersection(const wxRect& rect1, const wxRect& rect2) +{ + return rect1.Intersect(rect2); +} + + +//---------------------- implementation ------------------------ +class RecursiveDcClipper //wxDCClipper does *not* stack => fix for yet another poor wxWidgets implementation: +{ +public: + RecursiveDcClipper(wxDC& dc, const wxRect& r) : dc_(dc) + { + if (auto it = clippingAreas_.find(&dc); + it != clippingAreas_.end()) + { + oldRect_ = it->second; + + const wxRect tmp = getIntersection(r, *oldRect_); //better safe than sorry + assert(!tmp.IsEmpty()); //"setting an empty clipping region is equivalent to DestroyClippingRegion()" + + if (tmp != *oldRect_) + { + dc.SetClippingRegion(tmp); //new clipping region is intersection of given and previously set regions + it->second = tmp; + clippingDone = true; + } + } + else + { + const wxRect dcArea(dc.GetSize()); + + //since wxWidgets 3.3.0 the DC may be pre-clipped to wxDC::GetSize() or smaller (related to double-buffering) + //=> consider "no clipping" and "clipped to wxDC::GetSize()" equivalent! + wxRect rectClip; + if (dc.GetClippingBox(rectClip)) + { + rectClip = getIntersection(rectClip, dcArea); + if (rectClip != dcArea) + oldRect_ = rectClip; + } + + //caveat: actual clipping region is smaller when rect is partially outside the DC + //=> ensure consistency for validateClippingBuffer() + const wxRect tmp = getIntersection(r, oldRect_? *oldRect_ : dcArea); + assert(!tmp.IsEmpty()); + + if (tmp != (oldRect_? *oldRect_ : dcArea)) + { + dc.SetClippingRegion(tmp); + clippingAreas_.emplace(&dc, tmp); + clippingDone = true; + recursionBegin_ = true; + } + } + } + + ~RecursiveDcClipper() + { + if (clippingDone) + { + dc_.DestroyClippingRegion(); + if (oldRect_) + dc_.SetClippingRegion(*oldRect_); + + if (recursionBegin_) + clippingAreas_.erase(&dc_); + else + clippingAreas_[&dc_] = *oldRect_; + } + } + +private: + RecursiveDcClipper (const RecursiveDcClipper&) = delete; + RecursiveDcClipper& operator=(const RecursiveDcClipper&) = delete; + + + //associate "active" clipping area with each DC + inline static std::unordered_map clippingAreas_; + + bool recursionBegin_ = false; + bool clippingDone = false; + std::optional oldRect_; + wxDC& dc_; +}; + + +//fix wxBufferedPaintDC: happily fucks up for RTL layout by not drawing the first column (x = 0)! +class BufferedPaintDC : public wxMemoryDC +{ +public: + BufferedPaintDC(wxWindow& wnd, std::optional& buffer) : buffer_(buffer), paintDc_(&wnd) + { + assert(!wnd.IsDoubleBuffered()); + + const wxSize clientSize = wnd.GetClientSize(); + if (clientSize.GetWidth() > 0 && clientSize.GetHeight() > 0) //wxBitmap asserts this!! width can be 0; test case "Grid::CornerWin": compare both sides, then change config + { + if (!buffer_ || buffer->GetSize() != clientSize) + buffer.emplace(clientSize); + + if (buffer->GetScaleFactor() != wnd.GetDPIScaleFactor()) + buffer->SetScaleFactor(wnd.GetDPIScaleFactor()); + + SelectObject(*buffer); //copies scale factor from wxBitmap + + //note: wxPaintDC on wxGTK/wxMAC does not implement SetLayoutDirection()!!! => GetLayoutDirection() == wxLayout_Default + if (paintDc_.IsOk() && paintDc_.GetLayoutDirection() == wxLayout_RightToLeft) + SetLayoutDirection(wxLayout_RightToLeft); + } + else + buffer.reset(); + } + + ~BufferedPaintDC() + { + if (buffer_) + { + if (GetLayoutDirection() == wxLayout_RightToLeft) + { + paintDc_.SetLayoutDirection(wxLayout_LeftToRight); //work around bug in wxDC::Blit() + SetLayoutDirection(wxLayout_LeftToRight); // + } + + const wxPoint origin = GetDeviceOrigin(); + paintDc_.Blit(0, 0, buffer_->GetWidth(), buffer_->GetHeight(), this, -origin.x, -origin.y); + } + } + +private: + BufferedPaintDC (const BufferedPaintDC&) = delete; + BufferedPaintDC& operator=(const BufferedPaintDC&) = delete; + + std::optional& buffer_; + wxPaintDC paintDc_; +}; + + +//BufferedPaintDC if wxWindow::IsDoubleBuffered, wxPaintDC otherwise (= the proper C++ implementation wxAutoBufferedPaintDCFactory wished it had) +class DynBufPaintDC +{ +public: + DynBufPaintDC(wxWindow& wnd, std::optional& buffer) + { + assert(wnd.IsDoubleBuffered()); + dc_.emplace(&wnd); + } + + operator wxDC& () + { + if (wxPaintDC* dc = std::get_if(&dc_)) + return *dc; + return std::get(dc_); + } + +private: + std::variant dc_; +}; +} + +#endif //DC_H_4987123956832143243214 diff --git a/wx+/file_drop.cpp b/wx+/file_drop.cpp new file mode 100644 index 0000000..cd233c5 --- /dev/null +++ b/wx+/file_drop.cpp @@ -0,0 +1,75 @@ +// ***************************************************************************** +// * 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 "file_drop.h" +#include +#include +#include + + +using namespace zen; + + +namespace zen +{ +wxDEFINE_EVENT(EVENT_DROP_FILE, FileDropEvent); +} + + + + +namespace +{ +class WindowDropTarget : public wxFileDropTarget +{ +public: + explicit WindowDropTarget(const wxWindow& dropWindow) : dropWindow_(dropWindow) {} + +private: + wxDragResult OnDragOver(wxCoord x, wxCoord y, wxDragResult def) override + { + //why the FUCK I is drag & drop still working while showing another modal dialog!??? + //why the FUCK II is drag & drop working even when dropWindow is disabled!?? [Windows] => we can fix this + //why the FUCK III is dropWindow NOT disabled while showing another modal dialog!??? [macOS, Linux] => we CANNOT fix this: FUUUUUUUUUUUUUU... + if (!dropWindow_.IsEnabled()) + return wxDragNone; + + return wxFileDropTarget::OnDragOver(x, y, def); + } + + //"bool wxDropTarget::GetData() [...] This method may only be called from within OnData()." + //=> FUUUUUUUUUUUUUU........ a.k.a. no support for DragDropValidator during mouse hover! >:( + + bool OnDropFiles(wxCoord x, wxCoord y, const wxArrayString& fileArray) override + { + /*Linux, MTP: we get an empty file array + => switching to wxTextDropTarget won't help (much): we'd get the format + mtp://[usb:001,002]/Telefonspeicher/Folder/file.txt + instead of + /run/user/1000/gvfs/mtp:host=%5Busb%3A001%2C002%5D/Telefonspeicher/Folder/file.txt */ + + if (!dropWindow_.IsEnabled()) + return false; + + //wxPoint clientDropPos(x, y) + std::vector filePaths; + for (const wxString& file : fileArray) + filePaths.push_back(utfTo(file)); + + //create a custom event on drop window: execute event after file dropping is completed! (after mouse is released) + dropWindow_.GetEventHandler()->AddPendingEvent(FileDropEvent(filePaths)); + return true; + } + + const wxWindow& dropWindow_; +}; +} + + +void zen::setupFileDrop(wxWindow& dropWindow) +{ + dropWindow.SetDropTarget(new WindowDropTarget(dropWindow)); /*takes ownership*/ +} diff --git a/wx+/file_drop.h b/wx+/file_drop.h new file mode 100644 index 0000000..2474ae4 --- /dev/null +++ b/wx+/file_drop.h @@ -0,0 +1,45 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_DROP_H_09457802957842560325626 +#define FILE_DROP_H_09457802957842560325626 + +#include +#include +#include +#include + + +namespace zen +{ +/* register simple file drop event (without issue of freezing dialogs and without wxFileDropTarget overdesign) + CAVEAT: a drop target window must not be directly or indirectly contained within a wxStaticBoxSizer until the following wxGTK bug + is fixed. According to wxWidgets release cycles this is expected to be: never https://github.com/wxWidgets/wxWidgets/issues/2763 + + 1. setup a window to emit EVENT_DROP_FILE: + - simple file system paths: setupFileDrop + - any shell paths with validation: setupShellItemDrop + + 2. register events: + wnd.Bind(EVENT_DROP_FILE, [this](FileDropEvent& event) { onFilesDropped(event); }); */ +struct FileDropEvent; +wxDECLARE_EVENT(EVENT_DROP_FILE, FileDropEvent); + + +struct FileDropEvent : public wxEvent +{ + explicit FileDropEvent(const std::vector& droppedPaths) : wxEvent(0 /*winid*/, EVENT_DROP_FILE), itemPaths_(droppedPaths) {} + FileDropEvent* Clone() const override { return new FileDropEvent(*this); } + + const std::vector itemPaths_; +}; + + + +void setupFileDrop(wxWindow& dropWindow); +} + +#endif //FILE_DROP_H_09457802957842560325626 diff --git a/wx+/graph.cpp b/wx+/graph.cpp new file mode 100644 index 0000000..2f10541 --- /dev/null +++ b/wx+/graph.cpp @@ -0,0 +1,800 @@ +// ***************************************************************************** +// * 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 "graph.h" +#include +#include +#include +#include + +using namespace zen; + + +//TODO: support zoom via mouse wheel? + +namespace zen +{ +wxDEFINE_EVENT(EVENT_GRAPH_SELECTION, GraphSelectEvent); +} + + +double zen::nextNiceNumber(double blockSize) //round to next number which is a convenient to read block size +{ + if (blockSize <= 0) + return 0; + + const double k = std::floor(std::log10(blockSize)); + const double e = std::pow(10, k); + if (numeric::isNull(e)) + return 0; + const double a = blockSize / e; //blockSize = a * 10^k with a in [1, 10) + assert(1 <= a && a < 10); + + //have a look at leading two digits: "nice" numbers start with 1, 2, 2.5 and 5 + const double steps[] = {1, 2, 2.5, 5, 10}; + return e * numeric::roundToGrid(a, std::begin(steps), std::end(steps)); +} + + +namespace +{ +wxColor getDefaultColor(size_t pos) +{ + switch (pos % 10) + { + case 0: return { 0, 69, 134}; //blue + case 1: return {255, 66, 14}; //red + case 2: return {255, 211, 32}; //yellow + case 3: return { 87, 157, 28}; //green + case 4: return {126, 0, 33}; //royal + case 5: return {131, 202, 255}; //light blue + case 6: return { 49, 64, 4}; //dark green + case 7: return {174, 207, 0}; //light green + case 8: return { 75, 31, 111}; //purple + case 9: return {255, 149, 14}; //orange + } + assert(false); + return *wxBLACK; +} + + +class ConvertCoord //convert between screen and input data coordinates +{ +public: + ConvertCoord(double valMin, double valMax, size_t screenSize) : + min_(valMin), + scaleToReal_(screenSize == 0 ? 0 : (valMax - valMin) / screenSize), + scaleToScr_(numeric::isNull((valMax - valMin)) ? 0 : screenSize / (valMax - valMin)), + outOfBoundsLow_ (-1 * scaleToReal_ + valMin), + outOfBoundsHigh_((screenSize + 1) * scaleToReal_ + valMin) { if (outOfBoundsLow_ > outOfBoundsHigh_) std::swap(outOfBoundsLow_, outOfBoundsHigh_); } + + double screenToReal(double screenPos) const //map [0, screenSize] -> [valMin, valMax] + { + return screenPos * scaleToReal_ + min_; + } + double realToScreen(double realPos) const //return screen position in pixel (but with double precision!) + { + return (realPos - min_) * scaleToScr_; + } + int realToScreenRound(double realPos) const //returns -1 and screenSize + 1 if out of bounds! + { + //catch large double values: if double is larger than what int can represent => undefined behavior! + realPos = std::clamp(realPos, outOfBoundsLow_, outOfBoundsHigh_); + return std::round(realToScreen(realPos)); + } + +private: + const double min_; + const double scaleToReal_; + const double scaleToScr_; + + double outOfBoundsLow_; + double outOfBoundsHigh_; +}; + + +//enlarge value range to display to a multiple of a "useful" block size +//returns block cound +int widenRange(double& valMin, double& valMax, //in/out + int graphAreaSize, //in pixel + int optimalBlockSizePx, // + const LabelFormatter& labelFmt) +{ + if (graphAreaSize <= 0) return 0; + + const double minValRangePerBlock = (valMax - valMin) / graphAreaSize; + const double proposedValRangePerBlock = (valMax - valMin) * optimalBlockSizePx / graphAreaSize; + double valRangePerBlock = labelFmt.getOptimalBlockSize(proposedValRangePerBlock); + assert(numeric::isNull(proposedValRangePerBlock) || valRangePerBlock > minValRangePerBlock); + + if (numeric::isNull(valRangePerBlock)) //valMin == valMax or strange "optimal block size" + return 1; + + //don't allow sub-pixel blocks! => avoid erroneously high GDI render work load! + if (valRangePerBlock < minValRangePerBlock) + valRangePerBlock = std::ceil(minValRangePerBlock / valRangePerBlock) * valRangePerBlock; + + double blockMin = std::floor(valMin / valRangePerBlock); //store as double, not int: truncation possible, e.g. if valRangePerBlock == 1 + double blockMax = std::ceil (valMax / valRangePerBlock); // + int blockCount = std::round(blockMax - blockMin); + assert(blockCount >= 0); + + //handle valMin == valMax == integer + if (blockCount <= 0) + { + ++blockMax; + blockCount = 1; + } + + valMin = blockMin * valRangePerBlock; + valMax = blockMax * valRangePerBlock; + return blockCount; +} + + +void drawXLabel(wxDC& dc, double xMin, double xMax, int blockCount, const ConvertCoord& cvrtX, const wxRect& graphArea, + const wxRect& labelArea, const LabelFormatter& labelFmt, const wxColor& colGridLine) +{ + assert(graphArea.width == labelArea.width && graphArea.x == labelArea.x); + if (blockCount <= 0) + return; + + const double valRangePerBlock = (xMax - xMin) / blockCount; + + for (int i = 1; i < blockCount; ++i) + { + const double valX = xMin + i * valRangePerBlock; //step over raw data, not graph area pixels, to not lose precision + const int x = graphArea.x + cvrtX.realToScreenRound(valX); + + //draw grey vertical lines + clearArea(dc, {x - dipToWxsize(1) / 2, graphArea.y, dipToWxsize(1), graphArea.height}, colGridLine); + + //draw x axis labels + const wxString label = labelFmt.formatText(valX, valRangePerBlock); + const wxSize labelExtent = dc.GetMultiLineTextExtent(label); + dc.DrawText(label, wxPoint(x - labelExtent.GetWidth() / 2, labelArea.y + (labelArea.height - labelExtent.GetHeight()) / 2)); //center + } +} + + +void drawYLabel(wxDC& dc, double yMin, double yMax, int blockCount, const ConvertCoord& cvrtY, const wxRect& graphArea, + const wxRect& labelArea, const LabelFormatter& labelFmt, const wxColor& colGridLine) +{ + assert(graphArea.height == labelArea.height && graphArea.y == labelArea.y); + if (blockCount <= 0) + return; + + const double valRangePerBlock = (yMax - yMin) / blockCount; + + for (int i = 1; i < blockCount; ++i) + { + //draw grey horizontal lines + const double valY = yMin + i * valRangePerBlock; //step over raw data, not graph area pixels, to not lose precision + const int y = graphArea.y + cvrtY.realToScreenRound(valY); + + clearArea(dc, {graphArea.x, y - dipToWxsize(1) / 2, graphArea.width, dipToWxsize(1)}, colGridLine); + + //draw y axis labels + const wxString label = labelFmt.formatText(valY, valRangePerBlock); + const wxSize labelExtent = dc.GetMultiLineTextExtent(label); + dc.DrawText(label, wxPoint(labelArea.x + (labelArea.width - labelExtent.GetWidth()) / 2, y - labelExtent.GetHeight() / 2)); //center + } +} + + +void drawCornerText(wxDC& dc, const wxRect& graphArea, const wxString& txt, GraphCorner pos, const wxColor& colorText, const wxColor& colorBack) +{ + if (txt.empty()) return; + + const wxSize border(dipToWxsize(5), dipToWxsize(2)); + //it looks like wxDC::GetMultiLineTextExtent() precisely returns width, but too large a height: maybe they consider "text row height"? + + const wxSize boxExtent = dc.GetMultiLineTextExtent(txt) + 2 * border; + + wxPoint drawPos = graphArea.GetTopLeft(); + switch (pos) + { + case GraphCorner::topL: + break; + case GraphCorner::topR: + drawPos.x += graphArea.width - boxExtent.GetWidth(); + break; + case GraphCorner::bottomL: + drawPos.y += graphArea.height - boxExtent.GetHeight(); + break; + case GraphCorner::bottomR: + drawPos.x += graphArea.width - boxExtent.GetWidth(); + drawPos.y += graphArea.height - boxExtent.GetHeight(); + break; + } + + //add text shadow to improve readability: + wxDCTextColourChanger textColor(dc, colorBack); + dc.DrawText(txt, drawPos + border + wxSize(1, 1) /*better without dipToWxsize()?*/); + + textColor.Set(colorText); + dc.DrawText(txt, drawPos + border); +} + + +//calculate intersection of polygon with half-plane +template +void cutPoints(std::vector& curvePoints, std::vector& oobMarker, Function isInside, Function2 getIntersection, bool doPolygonCut) +{ + assert(curvePoints.size() == oobMarker.size()); + + if (curvePoints.size() != oobMarker.size() || curvePoints.empty()) return; + + auto isMarkedOob = [&](size_t index) { return oobMarker[index] != 0; }; //test if point is start of an OOB line + + std::vector curvePointsTmp; + std::vector oobMarkerTmp; + curvePointsTmp.reserve(curvePoints.size()); //allocating memory for these containers is one + oobMarkerTmp .reserve(oobMarker .size()); //of the more expensive operations of Graph2D! + + auto savePoint = [&](const CurvePoint& pt, bool markedOob) { curvePointsTmp.push_back(pt); oobMarkerTmp.push_back(markedOob); }; + + bool pointInside = isInside(curvePoints[0]); + if (pointInside) + savePoint(curvePoints[0], isMarkedOob(0)); + + for (size_t index = 1; index < curvePoints.size(); ++index) + { + if (isInside(curvePoints[index]) != pointInside) + { + pointInside = !pointInside; + const CurvePoint is = getIntersection(curvePoints[index - 1], curvePoints[index]); //getIntersection returns "to" when delta is zero + savePoint(is, !pointInside || isMarkedOob(index - 1)); + } + if (pointInside) + savePoint(curvePoints[index], isMarkedOob(index)); + } + + //make sure the output polygon area is correctly shaped if either begin or end points are cut + if (doPolygonCut) //note: impacts min/max height-calculations! + if (curvePoints.size() >= 3) + if (isInside(curvePoints.front()) != pointInside) + { + assert(!oobMarkerTmp.empty()); + oobMarkerTmp.back() = true; + + const CurvePoint is = getIntersection(curvePoints.back(), curvePoints.front()); + savePoint(is, true); + } + + curvePointsTmp.swap(curvePoints); + oobMarkerTmp .swap(oobMarker); +} + + +struct GetIntersectionX +{ + explicit GetIntersectionX(double x) : x_(x) {} + + CurvePoint operator()(const CurvePoint& from, const CurvePoint& to) const + { + const double deltaX = to.x - from.x; + const double deltaY = to.y - from.y; + return numeric::isNull(deltaX) ? to : CurvePoint{x_, from.y + (x_ - from.x) / deltaX * deltaY}; + } + +private: + const double x_; +}; + +struct GetIntersectionY +{ + explicit GetIntersectionY(double y) : y_(y) {} + + CurvePoint operator()(const CurvePoint& from, const CurvePoint& to) const + { + const double deltaX = to.x - from.x; + const double deltaY = to.y - from.y; + return numeric::isNull(deltaY) ? to : CurvePoint{from.x + (y_ - from.y) / deltaY * deltaX, y_}; + } + +private: + const double y_; +}; + +void cutPointsOutsideX(std::vector& curvePoints, std::vector& oobMarker, double minX, double maxX, bool doPolygonCut) +{ + cutPoints(curvePoints, oobMarker, [&](const CurvePoint& pt) { return pt.x >= minX; }, GetIntersectionX(minX), doPolygonCut); + cutPoints(curvePoints, oobMarker, [&](const CurvePoint& pt) { return pt.x <= maxX; }, GetIntersectionX(maxX), doPolygonCut); +} + +void cutPointsOutsideY(std::vector& curvePoints, std::vector& oobMarker, double minY, double maxY, bool doPolygonCut) +{ + cutPoints(curvePoints, oobMarker, [&](const CurvePoint& pt) { return pt.y >= minY; }, GetIntersectionY(minY), doPolygonCut); + cutPoints(curvePoints, oobMarker, [&](const CurvePoint& pt) { return pt.y <= maxY; }, GetIntersectionY(maxY), doPolygonCut); +} +} + + +std::vector ContinuousCurveData::getPoints(double minX, double maxX, const wxSize& areaSizePx) const +{ + std::vector points; + + const int pixelWidth = areaSizePx.GetWidth(); + if (pixelWidth <= 1) return points; + const ConvertCoord cvrtX(minX, maxX, pixelWidth - 1); //map [minX, maxX] to [0, pixelWidth - 1] + + const std::pair rangeX = getRangeX(); + + const double screenLow = cvrtX.realToScreen(std::max(rangeX.first, minX)); //=> xLow >= 0 + const double screenHigh = cvrtX.realToScreen(std::min(rangeX.second, maxX)); //=> xHigh <= pixelWidth - 1 + //if double is larger than what int can represent => undefined behavior! + //=> convert to int *after* checking value range! + if (screenLow <= screenHigh) + { + const int posFrom = std::ceil (screenLow ); //do not step outside [minX, maxX] in loop below! + const int posTo = std::floor(screenHigh); // + //conversion from std::floor/std::ceil double return value to int is loss-free for full value range of 32-bit int! tested successfully on MSVC + + for (int i = posFrom; i <= posTo; ++i) + { + const double x = cvrtX.screenToReal(i); + points.emplace_back(CurvePoint{x, getValue(x)}); + } + } + return points; +} + + +std::vector SparseCurveData::getPoints(double minX, double maxX, const wxSize& areaSizePx) const +{ + std::vector points; + + const int pixelWidth = areaSizePx.GetWidth(); + if (pixelWidth <= 1) return points; + const ConvertCoord cvrtX(minX, maxX, pixelWidth - 1); //map [minX, maxX] to [0, pixelWidth - 1] + const std::pair rangeX = getRangeX(); + + auto addPoint = [&](const CurvePoint& pt) + { + if (!points.empty()) + { + if (pt.x <= points.back().x) //allow ascending x-positions only! algorithm below may cause double-insertion after empty x-ranges! + return; + + if (addSteps_) + if (pt.y != points.back().y) + points.emplace_back(CurvePoint{pt.x, points.back().y}); //[!] aliasing parameter not yet supported via emplace_back: VS bug! => make copy + } + points.push_back(pt); + }; + + const int posFrom = cvrtX.realToScreenRound(std::max(rangeX.first, minX)); + const int posTo = cvrtX.realToScreenRound(std::min(rangeX.second, maxX)); + + for (int i = posFrom; i <= posTo; ++i) + { + const double x = cvrtX.screenToReal(i); + std::optional ptLe = getLessEq(x); + std::optional ptGe = getGreaterEq(x); + //both non-existent and invalid return values are mapped to out of expected range: => check on posLe/posGe NOT ptLe/ptGe in the following! + const int posLe = ptLe ? cvrtX.realToScreenRound(ptLe->x) : i + 1; + const int posGe = ptGe ? cvrtX.realToScreenRound(ptGe->x) : i - 1; + assert(!ptLe || posLe <= i); //check for invalid return values + assert(!ptGe || posGe >= i); // + + /* Breakdown of all combinations of posLe, posGe and expected action (n >= 1) + Note: For every empty x-range of at least one pixel, both next and previous points must be saved to keep the interpolating line stable!!! + + posLe | posGe | action + +-------+-------+-------- + | none | none | break + | i | none | save ptLe; break + | i - n | none | break; + +-------+-------+-------- + | none | i | save ptGe; continue + | i | i | save one of ptLe, ptGe; continue + | i - n | i | save ptGe; continue + +-------+-------+-------- + | none | i + n | save ptGe; jump to position posGe + 1 + | i | i + n | save ptLe; if n == 1: continue; else: save ptGe; jump to position posGe + 1 + | i - n | i + n | save ptLe, ptGe; jump to position posGe + 1 + +-------+-------+-------- */ + if (posGe < i) + { + if (posLe == i) + addPoint(*ptLe); + break; + } + else if (posGe == i) //test if point would be mapped to pixel x-position i + { + if (posLe == i) // + addPoint(x - ptLe->x < ptGe->x - x ? *ptLe : *ptGe); + else + addPoint(*ptGe); + } + else + { + if (posLe <= i) + addPoint(*ptLe); + + if (posLe != i || posGe > i + 1) + { + addPoint(*ptGe); + i = posGe; //skip sparse area: +1 will be added by for-loop! + } + } + } + return points; +} + + +Graph2D::Graph2D(wxWindow* parent, + wxWindowID winid, + const wxPoint& pos, + const wxSize& size, + long style, + const wxString& name) : wxPanel(parent, winid, pos, size, style, name) +{ + //https://wiki.wxwidgets.org/Flicker-Free_Drawing + SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND + Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintEvent(event); }); + Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { Refresh(); event.Skip(); }); + + //perf: WS_EX_COMPOSITED vs BufferedPaintDC doesn't seem to matter. Even for 200 FPS graph, CPU consumption is barely noticeable! + //MSWDisableComposited(); -> see comment in grid.cpp + + Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& event) { onMouseLeftDown(event); }); + Bind(wxEVT_MOTION, [this](wxMouseEvent& event) { onMouseMovement(event); }); + Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& event) { onMouseLeftUp (event); }); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent& event) { onMouseCaptureLost(event); }); +} + + +void Graph2D::onPaintEvent(wxPaintEvent& event) +{ + DynBufPaintDC dc(*this, doubleBuffer_); + render(dc); +} + + +void Graph2D::onMouseLeftDown(wxMouseEvent& event) +{ + activeSel_ = std::make_unique(*this, event.GetPosition()); + + if (!event.ControlDown()) + oldSel_.clear(); + Refresh(); +} + + +void Graph2D::onMouseMovement(wxMouseEvent& event) +{ + if (activeSel_.get()) + { + activeSel_->refCurrentPos() = event.GetPosition(); //corresponding activeSel->refSelection() is updated in Graph2D::render() + Refresh(); + } +} + + +void Graph2D::onMouseLeftUp(wxMouseEvent& event) +{ + if (activeSel_.get()) + { + if (activeSel_->getStartPos() != activeSel_->refCurrentPos()) //if it's just a single mouse click: discard selection + { + GetEventHandler()->AddPendingEvent(GraphSelectEvent(activeSel_->refSelection())); + + oldSel_.push_back(activeSel_->refSelection()); //commit selection + } + + activeSel_.reset(); + Refresh(); + } +} + + +void Graph2D::onMouseCaptureLost(wxMouseCaptureLostEvent& event) +{ + activeSel_.reset(); + Refresh(); +} + + +void Graph2D::addCurve(const SharedRef& data, const CurveAttributes& ca) +{ + CurveAttributes newAttr = ca; + if (newAttr.autoColor) + newAttr.setColor(getDefaultColor(curves_.size())); + curves_.emplace_back(data, newAttr); + Refresh(); +} + + +void Graph2D::render(wxDC& dc) const +{ + //set label font right at the start so that it is considered by wxDC::GetTextExtent() below! + dc.SetFont(GetFont()); + dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + + const wxRect clientRect = GetClientRect(); //DON'T use wxDC::GetSize()! DC may be larger than visible area! + + clearArea(dc, clientRect, GetBackgroundColour() /*user-configurable!*/); + //wxPanel::GetClassDefaultAttributes().colBg : + //wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE); + + const int xLabelHeight = attr_.xLabelHeight ? *attr_.xLabelHeight : GetCharHeight() + dipToWxsize(2) /*margin*/; + const int yLabelWidth = attr_.yLabelWidth ? *attr_.yLabelWidth : dc.GetTextExtent(L"1.23457e+07").x; + + /* ----------------------- + | | x-label | + ----------------------- + |y-label | graph area | + |---------------------- */ + + wxRect graphArea = clientRect; + int xLabelPosY = clientRect.y; + int yLabelPosX = clientRect.x; + + switch (attr_.xLabelpos) + { + case XLabelPos::none: + break; + case XLabelPos::top: + graphArea.y += xLabelHeight; + graphArea.height -= xLabelHeight; + break; + case XLabelPos::bottom: + xLabelPosY += clientRect.height - xLabelHeight; + graphArea.height -= xLabelHeight; + break; + } + switch (attr_.yLabelpos) + { + case YLabelPos::none: + break; + case YLabelPos::left: + graphArea.x += yLabelWidth; + graphArea.width -= yLabelWidth; + break; + case YLabelPos::right: + yLabelPosX += clientRect.width - yLabelWidth; + graphArea.width -= yLabelWidth; + break; + } + + assert(attr_.xLabelpos == XLabelPos::none || attr_.labelFmtX); + assert(attr_.yLabelpos == YLabelPos::none || attr_.labelFmtY); + + //paint graph background (excluding label area) + drawFilledRectangle(dc, graphArea, attr_.colorBack, attr_.colorGridLine, dipToWxsize(1)); + graphArea.Deflate(dipToWxsize(1)); + + //set label areas respecting graph area border! + const wxRect xLabelArea(graphArea.x, xLabelPosY, graphArea.width, xLabelHeight); + const wxRect yLabelArea(yLabelPosX, graphArea.y, yLabelWidth, graphArea.height); + + //detect x value range + double minX = attr_.minX ? *attr_.minX : std::numeric_limits::infinity(); //automatic: ensure values are initialized by first curve + double maxX = attr_.maxX ? *attr_.maxX : -std::numeric_limits::infinity(); // + for (const auto& [curve, attrib] : curves_) + { + const std::pair rangeX = curve.ref().getRangeX(); + assert(rangeX.first <= rangeX.second + 1.0e-9); + //GCC fucks up badly when comparing two *binary identical* doubles and finds "begin > end" with diff of 1e-18 + + if (!attr_.minX) + minX = std::min(minX, rangeX.first); + if (!attr_.maxX) + maxX = std::max(maxX, rangeX.second); + } + + if (minX <= maxX && maxX - minX < std::numeric_limits::infinity()) //valid x-range + { + const wxSize minimalBlockSizePx = dc.GetTextExtent(L"00"); + + int blockCountX = 0; + //enlarge minX, maxX to a multiple of a "useful" block size + if (attr_.xLabelpos != XLabelPos::none && attr_.labelFmtX.get()) + blockCountX = widenRange(minX, maxX, //in/out + graphArea.width, + minimalBlockSizePx.GetWidth() * 7, + *attr_.labelFmtX); + + //get raw values + detect y value range + double minY = attr_.minY ? *attr_.minY : std::numeric_limits::infinity(); //automatic: ensure values are initialized by first curve + double maxY = attr_.maxY ? *attr_.maxY : -std::numeric_limits::infinity(); // + + std::vector> curvePoints(curves_.size()); + std::vector> oobMarker (curves_.size()); //effectively a std::vector marking points that start an out-of-bounds line + + for (size_t index = 0; index < curves_.size(); ++index) + { + const CurveData& curve = curves_ [index].first.ref(); + std::vector& points = curvePoints[index]; + auto& marker = oobMarker [index]; + + points = curve.getPoints(minX, maxX, graphArea.GetSize()); + marker.resize(points.size()); //default value: false + if (!points.empty()) + { + //cut points outside visible x-range now in order to calculate height of visible line fragments only! + const bool doPolygonCut = curves_[index].second.fillMode == CurveFillMode::polygon; //impacts auto minY/maxY!! + cutPointsOutsideX(points, marker, minX, maxX, doPolygonCut); + + if (!attr_.minY || !attr_.maxY) + { + const auto& [itMin, itMax] = std::minmax_element(points.begin(), points.end(), [](const CurvePoint& lhs, const CurvePoint& rhs) { return lhs.y < rhs.y; }); + if (!attr_.minY) + minY = std::min(minY, itMin->y); + if (!attr_.maxY) + maxY = std::max(maxY, itMax->y); + } + } + } + + if (minY <= maxY) //valid y-range + { + int blockCountY = 0; + //enlarge minY, maxY to a multiple of a "useful" block size + if (attr_.yLabelpos != YLabelPos::none && attr_.labelFmtY.get()) + blockCountY = widenRange(minY, maxY, //in/out + graphArea.height, + minimalBlockSizePx.GetHeight() * 3, + *attr_.labelFmtY); + + if (graphArea.width <= 1 || graphArea.height <= 1) + return; + + const ConvertCoord cvrtX(minX, maxX, graphArea.width - 1); //map [minX, maxX] to [0, pixelWidth - 1] + const ConvertCoord cvrtY(maxY, minY, graphArea.height - 1); //map [minY, maxY] to [pixelHeight - 1, 0] + + //calculate curve coordinates on graph area + std::vector> drawPoints(curves_.size()); + + for (size_t index = 0; index < curves_.size(); ++index) + { + auto& cp = curvePoints[index]; + + //add two artificial points to fill the curve area towards x-axis => do this before cutPointsOutsideY() to handle curve leaving upper bound + if (curves_[index].second.fillMode == CurveFillMode::curve) + if (!cp.empty()) + { + cp.emplace_back(CurvePoint{cp.back ().x, minY}); //add lower right and left corners + cp.emplace_back(CurvePoint{cp.front().x, minY}); //[!] aliasing parameter not yet supported via emplace_back: VS bug! => make copy + oobMarker[index].back() = true; + oobMarker[index].push_back(true); + oobMarker[index].push_back(true); + } + + //cut points outside visible y-range before calculating pixels: + //1. realToScreenRound() deforms out-of-range values! + //2. pixels that are grossly out of range can be a severe performance problem when drawing on the DC (Windows) + const bool doPolygonCut = curves_[index].second.fillMode != CurveFillMode::none; + cutPointsOutsideY(cp, oobMarker[index], minY, maxY, doPolygonCut); + + auto& dp = drawPoints[index]; + for (const CurvePoint& pt : cp) + dp.push_back(wxSize(cvrtX.realToScreenRound(pt.x), + cvrtY.realToScreenRound(pt.y)) + graphArea.GetTopLeft()); + } + + //update active mouse selection + if (activeSel_) + { + wxPoint screenFrom = activeSel_->getStartPos() - graphArea.GetTopLeft(); //make relative to graphArea + wxPoint screenTo = activeSel_->refCurrentPos() - graphArea.GetTopLeft(); + + //normalize positions: + screenFrom.x = std::clamp(screenFrom.x, 0, graphArea.width - 1); + screenFrom.y = std::clamp(screenFrom.y, 0, graphArea.height - 1); + screenTo .x = std::clamp(screenTo .x, 0, graphArea.width - 1); + screenTo .y = std::clamp(screenTo .y, 0, graphArea.height - 1); + + //save current selection as "double" coordinates + activeSel_->refSelection().from = CurvePoint{cvrtX.screenToReal(screenFrom.x), + cvrtY.screenToReal(screenFrom.y)}; + + activeSel_->refSelection().to = CurvePoint{cvrtX.screenToReal(screenTo.x), + cvrtY.screenToReal(screenTo.y)}; + } + + //#################### begin drawing #################### + //1. draw colored area under curves + for (auto it = curves_.begin(); it != curves_.end(); ++it) + if (it->second.fillMode != CurveFillMode::none) + if (const std::vector& points = drawPoints[it - curves_.begin()]; + points.size() >= 3) + { + //wxDC::DrawPolygon() draws *transparent* border if wxTRANSPARENT_PEN is used! + //unlike wxDC::DrawRectangle() which widens inner area instead! + dc.SetPen ({it->second.fillColor, 1 /*[!] width*/}); + dc.SetBrush(it->second.fillColor); + dc.DrawPolygon(static_cast(points.size()), points.data()); + } + + //2. draw all currently set mouse selections (including active selection) + std::vector allSelections = oldSel_; + if (activeSel_) + allSelections.push_back(activeSel_->refSelection()); + + if (!allSelections.empty()) + { + const wxColor innerCol(168, 202, 236); //light blue + const wxColor borderCol(51, 153, 255); //dark blue + + //alpha channel not supported on wxMSW, so draw selection before curves + for (const SelectionBlock& sel : allSelections) + { + //harmonize with active mouse selection above + int screenFromX = cvrtX.realToScreenRound(sel.from.x); + int screenFromY = cvrtY.realToScreenRound(sel.from.y); + int screenToX = cvrtX.realToScreenRound(sel.to.x); + int screenToY = cvrtY.realToScreenRound(sel.to.y); + + if (screenFromX > screenToX) std::swap(screenFromX, screenToX); + if (screenFromY > screenToY) std::swap(screenFromY, screenToY); + + const wxRect rectSel{graphArea.GetTopLeft() + wxSize(screenFromX, + screenFromY), + wxSize(screenToX - screenFromX + 1, //mouse selection is symmetric + screenToY - screenFromY + 1)}; //and *not* a half-open range! + switch (attr_.mouseSelMode) + { + case GraphSelMode::none: + break; + case GraphSelMode::rect: + drawFilledRectangle(dc, rectSel, innerCol, borderCol, dipToWxsize(1)); + break; + case GraphSelMode::x: + drawFilledRectangle(dc, {rectSel.x, graphArea.y, rectSel.width, graphArea.height}, innerCol, borderCol, dipToWxsize(1)); + break; + case GraphSelMode::y: + drawFilledRectangle(dc, {graphArea.x, rectSel.y, graphArea.width, rectSel.height}, innerCol, borderCol, dipToWxsize(1)); + break; + } + } + } + + //3. draw labels and background grid + if (attr_.labelFmtX) drawXLabel(dc, minX, maxX, blockCountX, cvrtX, graphArea, xLabelArea, *attr_.labelFmtX, attr_.colorGridLine); + if (attr_.labelFmtY) drawYLabel(dc, minY, maxY, blockCountY, cvrtY, graphArea, yLabelArea, *attr_.labelFmtY, attr_.colorGridLine); + + //4. finally draw curves + { + dc.SetClippingRegion(graphArea); //prevent thick curves from drawing slightly outside + ZEN_ON_SCOPE_EXIT(dc.DestroyClippingRegion()); + + for (auto it = curves_.begin(); it != curves_.end(); ++it) + { + dc.SetPen({it->second.color, it->second.lineWidth}); + + const size_t index = it - curves_.begin(); + const std::vector& points = drawPoints[index]; + const auto& marker = oobMarker [index]; + assert(points.size() == marker.size()); + + //draw all parts of the curve except for the out-of-bounds fragments + size_t drawIndexFirst = 0; + while (drawIndexFirst < points.size()) + { + size_t drawIndexLast = std::find(marker.begin() + drawIndexFirst, marker.end(), static_cast(true)) - marker.begin(); + if (drawIndexLast < points.size()) ++drawIndexLast; + + const int pointCount = static_cast(drawIndexLast - drawIndexFirst); + if (pointCount > 0) + { + if (pointCount >= 2) //on macOS wxWidgets has a nasty assert on this + dc.DrawLines(pointCount, &points[drawIndexFirst]); + dc.DrawPoint(points[drawIndexLast - 1]); //wxDC::DrawLines() doesn't draw last pixel + } + drawIndexFirst = std::find(marker.begin() + drawIndexLast, marker.end(), static_cast(false)) - marker.begin(); + } + } + } + + //5. draw corner texts + for (const auto& [cornerPos, text] : attr_.cornerTexts) + drawCornerText(dc, graphArea, text, cornerPos, attr_.colorText, attr_.colorBack); + } + } +} diff --git a/wx+/graph.h b/wx+/graph.h new file mode 100644 index 0000000..ba35a08 --- /dev/null +++ b/wx+/graph.h @@ -0,0 +1,346 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GRAPH_H_234425245936567345799 +#define GRAPH_H_234425245936567345799 + +#include +#include +#include +#include +#include +#include +#include +#include "color_tools.h" +#include "dc.h" + + +//elegant 2D graph as wxPanel specialization +namespace zen +{ +/* //init graph (optional) + m_panelGraph->setAttributes(Graph2D::MainAttributes(). + setLabelX(Graph2D::LABEL_X_BOTTOM, 20, std::make_shared()). + setLabelY(Graph2D::LABEL_Y_RIGHT, 60, std::make_shared())); + //set graph data + SharedRef curveDataBytes_ = ... + m_panelGraph->setCurve(curveDataBytes_, Graph2D::CurveAttributes().setLineWidth(2).setColor(wxColor(0, 192, 0))); */ + +struct CurvePoint +{ + double x = 0; + double y = 0; +}; + + +struct CurveData +{ + virtual ~CurveData() {} + + virtual std::pair getRangeX() const = 0; + virtual std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const = 0; //points outside the draw area are automatically trimmed! +}; + +//special curve types: +struct ContinuousCurveData : public CurveData +{ + virtual double getValue(double x) const = 0; + +private: + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override; +}; + +struct SparseCurveData : public CurveData +{ + explicit SparseCurveData(bool addSteps = false) : addSteps_(addSteps) {} //addSteps: add points to get a staircase effect or connect points via a direct line + + virtual std::optional getLessEq (double x) const = 0; + virtual std::optional getGreaterEq(double x) const = 0; + +private: + std::vector getPoints(double minX, double maxX, const wxSize& areaSizePx) const override; + const bool addSteps_; +}; + + +struct ArrayCurveData : public SparseCurveData +{ + virtual double getValue(size_t pos) const = 0; + virtual size_t getSize () const = 0; + +private: + std::pair getRangeX() const override { const size_t sz = getSize(); return { 0.0, sz == 0 ? 0.0 : sz - 1.0}; } + + std::optional getLessEq(double x) const override + { + const size_t sz = getSize(); + const size_t pos = std::min(std::floor(x), sz - 1); //[!] expect unsigned underflow if empty! + if (pos < sz) + return CurvePoint{1.0 * pos, getValue(pos)}; + return {}; + } + + std::optional getGreaterEq(double x) const override + { + const size_t pos = std::max(std::ceil(x), 0); //[!] use std::max with signed type! + if (pos < getSize()) + return CurvePoint{1.0 * pos, getValue(pos)}; + return {}; + } +}; + + +struct VectorCurveData : public ArrayCurveData +{ + std::vector& refData() { return data_; } +private: + double getValue(size_t pos) const override { return pos < data_.size() ? data_[pos] : 0; } + size_t getSize() const override { return data_.size(); } + + std::vector data_; +}; + +//------------------------------------------------------------------------------------------------------------ + +struct LabelFormatter +{ + virtual ~LabelFormatter() {} + + //determine convenient graph label block size in unit of data: usually some small deviation on "sizeProposed" + virtual double getOptimalBlockSize(double sizeProposed) const = 0; + + //create human-readable text for x or y-axis position + virtual wxString formatText(double value, double optimalBlockSize) const = 0; +}; + + +double nextNiceNumber(double blockSize); //round to next number which is convenient to read, e.g. 2.13 -> 2; 2.7 -> 2.5 + +struct DecimalNumberFormatter : public LabelFormatter +{ + double getOptimalBlockSize(double sizeProposed ) const override { return nextNiceNumber(sizeProposed); } + wxString formatText (double value, double optimalBlockSize) const override { return numberTo(value); } +}; + +//------------------------------------------------------------------------------------------------------------ +//example: wnd.Bind(EVENT_GRAPH_SELECTION, [this](GraphSelectEvent& event) { onGraphSelect(event); }); + +struct GraphSelectEvent; +wxDECLARE_EVENT(EVENT_GRAPH_SELECTION, GraphSelectEvent); + + +struct SelectionBlock +{ + CurvePoint from; + CurvePoint to; +}; + +struct GraphSelectEvent : public wxEvent +{ + explicit GraphSelectEvent(const SelectionBlock& selBlock) : wxEvent(0 /*winid*/, EVENT_GRAPH_SELECTION), selectBlock_(selBlock) {} + GraphSelectEvent* Clone() const override { return new GraphSelectEvent(*this); } + + SelectionBlock selectBlock_; +}; + +//------------------------------------------------------------------------------------------------------------ +enum class XLabelPos +{ + none, + top, + bottom, +}; + +enum class YLabelPos +{ + none, + left, + right, +}; + +enum class CurveFillMode +{ + none, + curve, + polygon +}; + +enum class GraphCorner +{ + topL, + topR, + bottomL, + bottomR, +}; + +enum class GraphSelMode +{ + none, + rect, + x, + y, +}; +//------------------------------------------------------------------------------------------------------------ + +class Graph2D : public wxPanel +{ +public: + Graph2D(wxWindow* parent, + wxWindowID winid = wxID_ANY, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = wxTAB_TRAVERSAL | wxNO_BORDER, + const wxString& name = wxASCII_STR(wxPanelNameStr)); + + class CurveAttributes + { + public: + CurveAttributes() {} //required by GCC + CurveAttributes& setColor (const wxColor& col) { color = col; autoColor = false; return *this; } + CurveAttributes& fillCurveArea (const wxColor& col) { fillColor = col; fillMode = CurveFillMode::curve; return *this; } + CurveAttributes& fillPolygonArea(const wxColor& col) { fillColor = col; fillMode = CurveFillMode::polygon; return *this; } + CurveAttributes& setLineWidth(size_t width) { lineWidth = static_cast(width); return *this; } + + private: + friend class Graph2D; + + bool autoColor = true; + wxColor color; + + CurveFillMode fillMode = CurveFillMode::none; + wxColor fillColor; + + int lineWidth = dipToWxsize(2); + }; + + void addCurve(const SharedRef& data, const CurveAttributes& ca = CurveAttributes()); + void clearCurves() { curves_.clear(); } + + class MainAttributes + { + public: + MainAttributes() + { + //accessibility: consider system text and background colors; + //small drawback: color of graphs is NOT related to the background! => responsibility of client to use correct colors + setBaseColors(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT), + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + } + MainAttributes& setMinX(double newMinX) { minX = newMinX; return *this; } + MainAttributes& setMaxX(double newMaxX) { maxX = newMaxX; return *this; } + + MainAttributes& setMinY(double newMinY) { minY = newMinY; return *this; } + MainAttributes& setMaxY(double newMaxY) { maxY = newMaxY; return *this; } + + MainAttributes& setAutoSize() { minX = maxX = minY = maxY = {}; return *this; } + + MainAttributes& setLabelX(XLabelPos posX, int height = -1, std::shared_ptr newLabelFmt = nullptr) + { + xLabelpos = posX; + if (height >= 0) xLabelHeight = height; + if (newLabelFmt) labelFmtX = newLabelFmt; + return *this; + } + MainAttributes& setLabelY(YLabelPos posY, int width = -1, std::shared_ptr newLabelFmt = nullptr) + { + yLabelpos = posY; + if (width >= 0) yLabelWidth = width; + if (newLabelFmt) labelFmtY = newLabelFmt; + return *this; + } + + MainAttributes& setCornerText(const wxString& txt, GraphCorner pos) { cornerTexts[pos] = txt; return *this; } + + MainAttributes& setBaseColors(const wxColor& text, const wxColor& back) //accessibility: always set both colors + { + colorText = text; + colorBack = back; + colorGridLine = enhanceContrast(colorBack, //start with back color and deviate only as little as required + colorBack, 4 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text + return *this; + } + + wxColor getGridLineColor() const { return colorGridLine; } + + MainAttributes& setSelectionMode(GraphSelMode mode) { mouseSelMode = mode; return *this; } + + private: + friend class Graph2D; + + std::optional minX; //x-range to visualize + std::optional maxX; // + + std::optional minY; //y-range to visualize + std::optional maxY; // + + XLabelPos xLabelpos = XLabelPos::bottom; + std::optional xLabelHeight; + std::shared_ptr labelFmtX = std::make_shared(); + + YLabelPos yLabelpos = YLabelPos::left; + std::optional yLabelWidth; + std::shared_ptr labelFmtY = std::make_shared(); + + std::map cornerTexts; + + wxColor colorText; + wxColor colorBack; + wxColor colorGridLine; + + GraphSelMode mouseSelMode = GraphSelMode::rect; + }; + + void setAttributes(const MainAttributes& newAttr) { attr_ = newAttr; Refresh(); } + MainAttributes getAttributes() const { return attr_; } + + std::vector getSelections() const { return oldSel_; } + void setSelections(const std::vector& sel) + { + oldSel_ = sel; + activeSel_.reset(); + Refresh(); + } + void clearSelection() { oldSel_.clear(); Refresh(); } + +private: + void onMouseLeftDown(wxMouseEvent& event); + void onMouseMovement(wxMouseEvent& event); + void onMouseLeftUp (wxMouseEvent& event); + void onMouseCaptureLost(wxMouseCaptureLostEvent& event); + + void onPaintEvent(wxPaintEvent& event); + + void render(wxDC& dc) const; + + class MouseSelection + { + public: + MouseSelection(wxWindow& wnd, const wxPoint& posDragStart) : wnd_(wnd), posDragStart_(posDragStart), posDragCurrent(posDragStart) { wnd_.CaptureMouse(); } + ~MouseSelection() { if (wnd_.HasCapture()) wnd_.ReleaseMouse(); } + + wxPoint getStartPos() const { return posDragStart_; } + wxPoint& refCurrentPos() { return posDragCurrent; } + + SelectionBlock& refSelection() { return selBlock; } //updated in Graph2d::render(): this is fine, since only what's shown is selected! + + private: + wxWindow& wnd_; + const wxPoint posDragStart_; + wxPoint posDragCurrent; + SelectionBlock selBlock; + }; + std::vector oldSel_; //applied selections + std::unique_ptr activeSel_; //set during mouse selection + + MainAttributes attr_; //global attributes + + std::vector, CurveAttributes>> curves_; + + std::optional doubleBuffer_; +}; +} + +#endif //GRAPH_H_234425245936567345799 diff --git a/wx+/grid.cpp b/wx+/grid.cpp new file mode 100644 index 0000000..489272d --- /dev/null +++ b/wx+/grid.cpp @@ -0,0 +1,2440 @@ +// ***************************************************************************** +// * 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 "grid.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "color_tools.h" +#include "dc.h" + + #include + +using namespace zen; + +/* wxWidgets 3.3 defaults to system-powered double-buffering (WS_EX_COMPOSITED) on Windows: + => ~60% higher CPU time (test case: scrolling large file list via keyboard) see comment in file_grid.cpp :(( + + "wxMSW now uses double buffering by default, meaning that updating the + windows using wxClientDC doesn't work any longer, which is consistent with + the behaviour of wxGTK with Wayland backend and of wxOSX, but not with the + traditional historic behaviour of wxMSW (or wxGTK/X11). + You may call MSWDisableComposited() to restore the previous behaviour [...]" + + WS_EX_COMPOSITED "Paints all *descendants* of a window in bottom-to-top painting order using double-buffering." + + => can only be set for *top* window below the wxFrame/wxDialog otherwise SetWindowLongPtr(WS_EX_COMPOSITED) fails!!! + + => use MSWDisableComposited() to remove WS_EX_COMPOSITED from top window under wxFrame/wxDialog if perf issue. + SetDoubleBuffered(false) OTOH is useless as it doesn't affect parent windows and silently fails! + IsDoubleBuffered() however correctly checks parents: CONSISTENCY, people, for fucks sake! + + CAVEAT: MSWDisableComposited() leads to severe flickering for other child windows (e.g. wxStaticBitmap, wxBitmapButton) + that lack custom double-buffering. It's even worse since wxWidgets in its wisdom sets WS_EX_COMPOSITED + together with CS_HREDRAW/CS_VREDRAW, https://github.com/vadz/wxWidgets/blob/8de0694a5e9c9d7c24e0af2ccf71454df5e6b9d0/src/msw/window.cpp#L507 + and MSWDisableComposited() only removes former attribute. */ + + +//let's NOT create wxWidgets objects statically: +wxColor GridData::getColorSelectionGradientFrom() { return {137, 172, 255}; } //blue: HSL: 158, 255, 196 HSV: 222, 0.46, 1 +wxColor GridData::getColorSelectionGradientTo () { return {225, 234, 255}; } // HSL: 158, 255, 240 HSV: 222, 0.12, 1 + +int GridData::getColumnGapLeft() { return dipToWxsize(4); } + + +namespace +{ +//------------------------------ Grid Parameters -------------------------------- +wxColor getColorLabelText(bool enabled) { return wxSystemSettings::GetColour(enabled ? wxSYS_COLOUR_BTNTEXT : wxSYS_COLOUR_GRAYTEXT); } +wxColor getColorGridLine() { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW); } + +wxColor getColorLabelGradientFrom() +{ + if (wxSystemSettings::GetAppearance().IsDark()) //upper gradient part must always be lighter than lower part! + { + const wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE); + + auto liftChannel = [](unsigned char c) { return static_cast(std::clamp(c + 25, 0, 255)); }; + + return wxColor(liftChannel(backCol.Red ()), + liftChannel(backCol.Green()), + liftChannel(backCol.Blue ())); + } + else + return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); +} +wxColor getColorLabelGradientTo() { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE); } + +wxColor getColorLabelGradientFocusFrom() { return wxSystemSettings::GetAppearance().IsDark() ? wxSystemSettings::GetColour(wxSYS_COLOUR_BTNHIGHLIGHT) : getColorLabelGradientFrom(); } +wxColor getColorLabelGradientFocusTo () { return wxSystemSettings::GetAppearance().IsDark() ? getColorLabelGradientTo() : GridData::getColorSelectionGradientFrom(); } + +const double MOUSE_DRAG_ACCELERATION_DIP = 1.5; //unit: [rows / (DIP * sec)] -> same value like Explorer! +const int DEFAULT_COL_LABEL_BORDER_DIP = 6; //top + bottom border in addition to label height +const int COLUMN_MOVE_DELAY_DIP = 5; //unit: [pixel] (from Explorer) +const int COLUMN_MIN_WIDTH_DIP = 40; //only honored when resizing manually! +const int ROW_LABEL_BORDER_DIP = 3; +const int COLUMN_RESIZE_TOLERANCE_DIP = 6; //unit [pixel] +const int COLUMN_FILL_GAP_TOLERANCE_DIP = 10; //enlarge column to fill full width when resizing +const int COLUMN_MOVE_MARKER_WIDTH_DIP = 3; + +const bool fillGapAfterColumns = true; //draw rows/column label to fill full window width; may become an instance variable some time? + +/* IsEnabled() vs IsThisEnabled() since wxWidgets 2.9.5: + + void wxWindowBase::NotifyWindowOnEnableChange(), called from bool wxWindowBase::Enable(), fails to refresh + child elements when disabling a IsTopLevel() dialog, e.g. when showing a modal dialog. + The unfortunate effect on XP for using IsEnabled() when rendering the grid is that the user can move the modal dialog + and *draw* with it on the background while the grid refreshes as disabled incrementally! + + => Don't use IsEnabled() since it considers the top level window, but a disabled top-level should NOT + lead to child elements being rendered disabled! + + => IsThisEnabled() OTOH is too shallow and does not consider parent windows which are not top level. + + The perfect solution would be a bool renderAsEnabled() { return "IsEnabled() but ignore effects of showing a modal dialog"; } + + However "IsThisEnabled()" is good enough (same as old IsEnabled() on wxWidgets 2.8.12) and it avoids this pathetic behavior on XP. + (Similar problem on Win 7: e.g. directly click sync button without comparing first) + + => 2018-07-30: roll our own: */ +bool renderAsEnabled(wxWindow& win) +{ + if (win.IsTopLevel()) + return true; + + if (wxWindow* parent = win.GetParent()) + return win.IsThisEnabled() && renderAsEnabled(*parent); + else + return win.IsThisEnabled(); +} +} + +//---------------------------------------------------------------------------------------------------------------- +namespace zen +{ +wxDEFINE_EVENT(EVENT_GRID_MOUSE_LEFT_DOUBLE, GridClickEvent); +wxDEFINE_EVENT(EVENT_GRID_MOUSE_LEFT_DOWN, GridClickEvent); +wxDEFINE_EVENT(EVENT_GRID_MOUSE_RIGHT_DOWN, GridClickEvent); +wxDEFINE_EVENT(EVENT_GRID_SELECT_RANGE, GridSelectEvent); +wxDEFINE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_LEFT, GridLabelClickEvent); +wxDEFINE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, GridLabelClickEvent); +wxDEFINE_EVENT(EVENT_GRID_COL_RESIZE, GridColumnResizeEvent); +wxDEFINE_EVENT(EVENT_GRID_CONTEXT_MENU, GridContextMenuEvent); +} +//---------------------------------------------------------------------------------------------------------------- + +void GridData::renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover) +{ + if (enabled) + { + if (selected) + dc.GradientFillLinear(rect, getColorSelectionGradientFrom(), getColorSelectionGradientTo(), wxEAST); + //else: clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default + } + else + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE)); +} + + +void GridData::renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover) +{ + wxDCTextColourChanger textColor(dc); + if (enabled && selected) //accessibility: always set *both* foreground AND background colors! + textColor.Set(*wxBLACK); + + wxRect rectTmp = drawCellBorder(dc, rect); + + rectTmp.x += getColumnGapLeft(); + rectTmp.width -= getColumnGapLeft(); + drawCellText(dc, rectTmp, getValue(row, colType)); +} + + +int GridData::getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType) +{ + return dc.GetTextExtent(getValue(row, colType)).GetWidth() + 2 * getColumnGapLeft() + dipToWxsize(1); //gap on left and right side + border +} + + +wxRect GridData::drawCellBorder(wxDC& dc, const wxRect& rect) //returns remaining rectangle +{ + clearArea(dc, {rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height}, getColorGridLine()); //right border + clearArea(dc, {rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)}, getColorGridLine()); //bottom border + + return {rect.x, rect.y, rect.width - dipToWxsize(1), rect.height - dipToWxsize(1)}; +} + + +void GridData::drawCellText(wxDC& dc, const wxRect& rect, const std::wstring_view text, int alignment, const wxSize* textExtentHint) +{ + /* Performance Notes (Windows): + - wxDC::GetTextExtent() is by far the most expensive call (20x more expensive than wxDC::DrawText()) + - wxDC::DrawLabel() is inefficiently implemented; internally calls: wxDC::GetMultiLineTextExtent(), wxDC::GetTextExtent(), wxDC::DrawText() + - wxDC::GetMultiLineTextExtent() calls wxDC::GetTextExtent() + - wxDC::DrawText also calls wxDC::GetTextExtent()!! + => wxDC::DrawLabel() boils down to 3(!) calls to wxDC::GetTextExtent()!!! + - wxDC::DrawLabel results in GetTextExtent() call even for empty strings!!! + => NEVER EVER call wxDC::DrawLabel() cruft and directly call wxDC::DrawText()! */ + assert(!contains(text, L'\n')); + if (rect.width <= 0 || rect.height <= 0 || text.empty()) + return; + + //truncate large texts and add ellipsis + wxString textTrunc(&text[0], text.size()); + wxSize extentTrunc = textExtentHint ? *textExtentHint : dc.GetTextExtent(textTrunc); + assert(!textExtentHint || *textExtentHint == dc.GetTextExtent(textTrunc)); //"trust, but verify" :> + + if (extentTrunc.GetWidth() > rect.width) + { + //unlike File Explorer, we truncate UTF-16 correctly: e.g. CJK-Ideograph encodes to TWO wchar_t: utfTo("\xf0\xa4\xbd\x9c"); + size_t low = 0; //number of Unicode chars! + size_t high = unicodeLength(text); // + if (high > 1) + for (;;) + { + if (high - low <= 1) + { + if (low == 0) + { + textTrunc = ELLIPSIS; + extentTrunc = dc.GetTextExtent(ELLIPSIS); + } + break; + } + const size_t middle = (low + high) / 2; //=> never 0 when "high - low > 1" + + /*const*/ wxString candidate = getUnicodeSubstring(text, 0, middle) + ELLIPSIS; + const wxSize extentCand = dc.GetTextExtent(candidate); //perf: most expensive call of this routine! + + if (extentCand.GetWidth() <= rect.width) + { + low = middle; + textTrunc = std::move(candidate); + extentTrunc = extentCand; + } + else + high = middle; + } + } + + wxPoint pt = rect.GetTopLeft(); + if (alignment & wxALIGN_RIGHT) //note: wxALIGN_LEFT == 0! + pt.x += rect.width - extentTrunc.GetWidth(); + else if (alignment & wxALIGN_CENTER_HORIZONTAL) + pt.x += numeric::intDivFloor(rect.width - extentTrunc.GetWidth(), 2); //round down negative values, too! + + if (alignment & wxALIGN_BOTTOM) //note: wxALIGN_TOP == 0! + pt.y += rect.height - extentTrunc.GetHeight(); + else if (alignment & wxALIGN_CENTER_VERTICAL) + pt.y += numeric::intDivFloor(rect.height - extentTrunc.GetHeight(), 2); //round down negative values, too! + + //std::optional clip; -> redundant!? RecursiveDcClipper already used during grid cell rendering + //if (extentTrunc.GetWidth() > rect.width) + // clip.emplace(dc, rect); + + dc.DrawText(textTrunc, pt); +} + + +void GridData::renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted) +{ + wxRect rectRemain = drawColumnLabelBackground(dc, rect, highlighted); + + rectRemain.x += getColumnGapLeft(); + rectRemain.width -= getColumnGapLeft(); + drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled); +} + + +wxRect GridData::drawColumnLabelBackground(wxDC& dc, const wxRect& rect, bool highlighted) +{ + if (highlighted) + dc.GradientFillLinear(rect, getColorLabelGradientFocusFrom(), getColorLabelGradientFocusTo(), wxSOUTH); + else //regular background gradient + dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxSOUTH); + + //left border + clearArea(dc, wxRect(rect.GetTopLeft(), wxSize(dipToWxsize(1), rect.height)), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + //right border + dc.GradientFillLinear(wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height), + getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH); + + //bottom border + clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + return rect.Deflate(dipToWxsize(1), dipToWxsize(1)); +} + + +void GridData::drawColumnLabelText(wxDC& dc, const wxRect& rect, const std::wstring& text, bool enabled) +{ + wxDCTextColourChanger textColor(dc, getColorLabelText(enabled)); //accessibility: always set both foreground AND background colors! + drawCellText(dc, rect, text, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); +} + +//---------------------------------------------------------------------------------------------------------------- +/* SubWindow + /|\ + __________________|__________________ + | | | | + CornerWin RowLabelWin ColLabelWin MainWin */ + +class Grid::SubWindow : public wxWindow +{ +public: + SubWindow(Grid& parent) : + wxWindow(&parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS | wxBORDER_NONE, wxASCII_STR(wxPanelNameStr)), + parent_(parent) + { + //https://wiki.wxwidgets.org/Flicker-Free_Drawing + SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND + Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintEvent(event); }); + Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { Refresh(); event.Skip(); }); + + Bind(wxEVT_CHILD_FOCUS, [](wxChildFocusEvent& event) {}); //wxGTK::wxScrolledWindow automatically scrolls to child window when child gets focus -> prevent! + + Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& event) { onMouseLeftDown (event); }); + Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& event) { onMouseLeftUp (event); }); + Bind(wxEVT_LEFT_DCLICK, [this](wxMouseEvent& event) { onMouseLeftDouble(event); }); + Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& event) { onMouseRightDown (event); }); + Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& event) { onMouseRightUp (event); }); + Bind(wxEVT_MOTION, [this](wxMouseEvent& event) { onMouseMovement (event); }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& event) { onLeaveWindow (event); }); + Bind(wxEVT_MOUSEWHEEL, [this](wxMouseEvent& event) { onMouseWheel (event); }); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent& event) { onMouseCaptureLost(event); }); + + Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) + { + if (!sendEventToParent(event)) //let parent collect all key events + event.Skip(); + }); + //Bind(wxEVT_KEY_UP, [this](wxKeyEvent& event) { onKeyUp (event); }); -> superfluous? + + assert(GetClientAreaOrigin() == wxPoint()); //generally assumed when dealing with coordinates below + } + Grid& refParent() { return parent_; } + const Grid& refParent() const { return parent_; } + + template + bool sendEventToParent(T&& event) //take both "rvalue + lvalues", return "true" if a suitable event handler function was found and executed, and the function did not call wxEvent::Skip. + { + return parent_.GetEventHandler()->ProcessEvent(event); + } + +protected: + void setToolTip(const std::wstring& text) //proper fix for wxWindow + { + if (text != GetToolTipText()) + { + if (text.empty()) + UnsetToolTip(); //wxGTK doesn't allow wxToolTip with empty text! + else + { + wxToolTip* tt = GetToolTip(); + if (!tt) + { + //wxWidgets bug: tooltip multiline property is defined by first tooltip text containing newlines or not (same is true for maximum width) + tt = new wxToolTip(L"a b\n\ + a b"); //ugly, but working (on Windows) + SetToolTip(tt); //pass ownership + } + tt->SetTip(text); + } + } + } + +private: + virtual void render(wxDC& dc, const wxRect& rect) = 0; + + virtual void onMouseLeftDown (wxMouseEvent& event) { event.Skip(); } + virtual void onMouseLeftUp (wxMouseEvent& event) { event.Skip(); } + virtual void onMouseLeftDouble(wxMouseEvent& event) { event.Skip(); } + virtual void onMouseRightDown (wxMouseEvent& event) { event.Skip(); } + virtual void onMouseRightUp (wxMouseEvent& event) { event.Skip(); } + virtual void onMouseMovement (wxMouseEvent& event) { event.Skip(); } + virtual void onLeaveWindow (wxMouseEvent& event) { event.Skip(); } + virtual void onMouseCaptureLost(wxMouseCaptureLostEvent& event) { event.Skip(); } + + void onMouseWheel(wxMouseEvent& event) + { + /* MSDN, WM_MOUSEWHEEL: "Sent to the focus window when the mouse wheel is rotated. + The DefWindowProc function propagates the message to the window's parent. + There should be no internal forwarding of the message, since DefWindowProc propagates + it up the parent chain until it finds a window that processes it." + + On macOS there is no such propagation! => we need a redirection (the same wxGrid implements) + + new wxWidgets 3.0 screw-up for GTK2: wxScrollHelperEvtHandler::ProcessEvent() ignores wxEVT_MOUSEWHEEL events + thereby breaking the scenario of redirection to parent we need here (but also breaking their very own wxGrid sample) + => call wxScrolledWindow mouse wheel handler directly */ + + //wxWidgets never ceases to amaze: multi-line scrolling is implemented maximally inefficient by repeating wxEVT_SCROLLWIN_LINEUP!! => WTF! + if (event.GetWheelAxis() == wxMOUSE_WHEEL_VERTICAL && //=> reimplement wxScrollHelperBase::HandleOnMouseWheel() in a non-retarded way + !event.IsPageScroll()) + { + mouseRotateRemainder_ += -event.GetWheelRotation(); + int rotations = mouseRotateRemainder_ / event.GetWheelDelta(); + mouseRotateRemainder_ -= rotations * event.GetWheelDelta(); + + if (rotations == 0) //macOS generates tiny GetWheelRotation()! => don't allow! Always scroll a single row at least! + { + rotations = -numeric::sign(event.GetWheelRotation()); + mouseRotateRemainder_ = 0; + } + + const int rowsDelta = rotations * event.GetLinesPerAction(); + parent_.scrollDelta(0, rowsDelta); + } + else + parent_.HandleOnMouseWheel(event); + + onMouseMovement(event); + event.Skip(false); + + //if (!sendEventToParent(event)) + // event.Skip(); + } + + void onPaintEvent(wxPaintEvent& event) + { + + DynBufPaintDC dc(*this, doubleBuffer_); + assert(GetSize() == GetClientSize()); + + const wxRegion& updateReg = GetUpdateRegion(); + for (wxRegionIterator it = updateReg; it; ++it) + render(dc, it.GetRect()); + } + + Grid& parent_; + std::optional doubleBuffer_; + int mouseRotateRemainder_ = 0; +}; + +//---------------------------------------------------------------------------------------------------------------- +//---------------------------------------------------------------------------------------------------------------- + +class Grid::CornerWin : public SubWindow +{ +public: + explicit CornerWin(Grid& parent) : SubWindow(parent) {} + +private: + bool AcceptsFocus() const override { return false; } + + void render(wxDC& dc, const wxRect& /*rect*/) override + { + const wxRect& rect = GetClientRect(); //would be overkill to support GetUpdateRegion()! + + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + //caveat: wxSYS_COLOUR_BTNSHADOW is partially transparent on macOS! + + dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxSOUTH); + + //left border + dc.GradientFillLinear(wxRect(rect.GetTopLeft(), wxSize(dipToWxsize(1), rect.height)), + getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH); + + //left border2 + clearArea(dc, wxRect(rect.x + dipToWxsize(1), rect.y, dipToWxsize(1), rect.height), + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + //right border + dc.GradientFillLinear(wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height), + getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH); + + //bottom border + clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)), + wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + } +}; + +//---------------------------------------------------------------------------------------------------------------- +//---------------------------------------------------------------------------------------------------------------- + +class Grid::RowLabelWin : public SubWindow +{ +public: + explicit RowLabelWin(Grid& parent) : + SubWindow(parent), + rowHeight_(parent.GetCharHeight() + dipToWxsize(2) + dipToWxsize(1)) {} //default height; don't call any functions on "parent" other than those from wxWindow during construction! + //2 for some more space, 1 for bottom border (gives 15 + 2 + 1 on Windows, 17 + 2 + 1 on Ubuntu) + + int getBestWidth(ptrdiff_t rowFrom, ptrdiff_t rowTo) + { + wxInfoDC dc(this); + dc.SetFont(GetFont()); //harmonize with RowLabelWin::render()! + + int bestWidth = 0; + for (ptrdiff_t i = rowFrom; i <= rowTo; ++i) + bestWidth = std::max(bestWidth, dc.GetTextExtent(formatRowNum(i)).GetWidth() + dipToWxsize(2 * ROW_LABEL_BORDER_DIP)); + return bestWidth; + } + + size_t getLogicalHeight() const { return refParent().getRowCount() * rowHeight_; } + + ptrdiff_t getRowAtPos(ptrdiff_t posY) const //returns < 0 on invalid input, else row number within: [0, rowCount]; rowCount if out of range + { + if (posY < 0) + return -1; + + const size_t row = posY / rowHeight_; + return std::min(row, refParent().getRowCount()); + } + + int getRowHeight() const { return rowHeight_; } //guarantees to return size >= 1 ! + void setRowHeight(int height) { assert(height > 0); rowHeight_ = std::max(1, height); } + + wxRect getRowLabelArea(size_t row) const //returns empty rect if row not found + { + assert(GetClientAreaOrigin() == wxPoint()); + if (row < refParent().getRowCount()) + return wxRect(wxPoint(0, rowHeight_ * row), + wxSize(GetClientSize().GetWidth(), rowHeight_)); + return wxRect(); + } + +private: + static std::wstring formatRowNum(size_t row) { return formatNumber(row + 1); } //convert number to std::wstring including thousands separator + + bool AcceptsFocus() const override { return false; } + + void render(wxDC& dc, const wxRect& rect) override + { + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + const bool enabled = renderAsEnabled(*this); + + dc.SetFont(GetFont()); //harmonize with RowLabelWin::getBestWidth()! + + const auto& [rowFirst, rowLast] = refParent().getVisibleRows(rect); + for (auto row = rowFirst; row < rowLast; ++row) + { + wxRect rectRowLabel = getRowLabelArea(row); //returns empty rect if row not found + if (rectRowLabel.height > 0) + { + rectRowLabel.y = refParent().CalcScrolledPosition(rectRowLabel.GetTopLeft()).y; + drawRowLabel(dc, rectRowLabel, row, enabled); + } + } + } + + void drawRowLabel(wxDC& dc, const wxRect& rect, size_t row, bool enabled) + { + //clearArea(dc, rect, getColorRowLabel()); + dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxEAST); //clear overlapping cells + + //top border + clearArea(dc, wxRect(rect.x, rect.y, rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + //left border + clearArea(dc, wxRect(rect.x, rect.y, dipToWxsize(1), rect.height), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + //right border + clearArea(dc, wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + //bottom border + clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + //label text + wxRect textRect = rect; + textRect.Deflate(dipToWxsize(1)); + + wxDCTextColourChanger textColor(dc, getColorLabelText(enabled)); //accessibility: always set both foreground AND background colors! + GridData::drawCellText(dc, textRect, formatRowNum(row), wxALIGN_CENTRE); + } + + void onMouseLeftDown(wxMouseEvent& event) override { redirectMouseEvent(event); } + void onMouseLeftUp (wxMouseEvent& event) override { redirectMouseEvent(event); } + void onMouseMovement(wxMouseEvent& event) override { redirectMouseEvent(event); } + void onLeaveWindow (wxMouseEvent& event) override { redirectMouseEvent(event); } + void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override { refParent().getMainWin().GetEventHandler()->ProcessEvent(event); } + + void redirectMouseEvent(wxMouseEvent& event) + { + event.m_x = 0; //simulate click on left side of mainWin_! + + wxWindow& mainWin = refParent().getMainWin(); + mainWin.GetEventHandler()->ProcessEvent(event); + + if (event.ButtonDown() && wxWindow::FindFocus() != &mainWin) + mainWin.SetFocus(); + } + + int rowHeight_; +}; + + +namespace +{ +class ColumnResizing +{ +public: + ColumnResizing(wxWindow& wnd, size_t col, int startWidth, int clientPosX) : + wnd_(wnd), col_(col), startWidth_(startWidth), clientPosX_(clientPosX) + { + wnd_.CaptureMouse(); + } + ~ColumnResizing() + { + if (wnd_.HasCapture()) + wnd_.ReleaseMouse(); + } + + size_t getColumn () const { return col_; } + int getStartWidth () const { return startWidth_; } + int getStartPosX () const { return clientPosX_; } + +private: + wxWindow& wnd_; + const size_t col_; + const int startWidth_; + const int clientPosX_; +}; + + +class ColumnMove +{ +public: + ColumnMove(wxWindow& wnd, size_t colFrom, int clientPosX) : + wnd_(wnd), + colFrom_(colFrom), + colTo_(colFrom), + clientPosX_(clientPosX) { wnd_.CaptureMouse(); } + ~ColumnMove() { if (wnd_.HasCapture()) wnd_.ReleaseMouse(); } + + size_t getColumnFrom() const { return colFrom_; } + size_t& refColumnTo() { return colTo_; } + int getStartPosX () const { return clientPosX_; } + + bool isRealMove() const { return !singleClick_; } + void setRealMove() { singleClick_ = false; } + +private: + wxWindow& wnd_; + const size_t colFrom_; + size_t colTo_; + const int clientPosX_; + bool singleClick_ = true; +}; +} + +//---------------------------------------------------------------------------------------------------------------- + +class Grid::ColLabelWin : public SubWindow +{ +public: + explicit ColLabelWin(Grid& parent) : SubWindow(parent), + labelFont_(GetFont().Bold()) + { + //coordinate with ColLabelWin::render(): + colLabelHeight_ = dipToWxsize(2 * DEFAULT_COL_LABEL_BORDER_DIP) + labelFont_.GetPixelSize().GetHeight(); + } + + int getColumnLabelHeight() const { return colLabelHeight_; } + void setColumnLabelHeight(int height) { colLabelHeight_ = std::max(0, height); } + +private: + bool AcceptsFocus() const override { return false; } + + void render(wxDC& dc, const wxRect& rect) override + { + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + //caveat: system colors can be partially transparent on macOS + + dc.SetFont(labelFont_); //coordinate with "colLabelHeight" in Grid constructor + dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + + const bool enabled = renderAsEnabled(*this); + + wxPoint labelAreaTL(refParent().CalcScrolledPosition(wxPoint(0, 0)).x, 0); //client coordinates + + const std::vector& absWidths = refParent().getColWidths(); //resolve stretched widths + for (size_t col = 0; col < absWidths.size(); ++col) + { + const int width = absWidths[col].width; //don't use unsigned for calculations! + + if (labelAreaTL.x > rect.GetRight()) + return; //done, rect is fully covered + if (labelAreaTL.x + width > rect.x) + drawColumnLabel(dc, wxRect(labelAreaTL, wxSize(width, colLabelHeight_)), col, absWidths[col].type, enabled); + labelAreaTL.x += width; + } + if (labelAreaTL.x > rect.GetRight()) + return; //done, rect is fully covered + + //fill gap after columns and cover full width + if (fillGapAfterColumns) + { + int totalWidth = 0; + for (const ColumnWidth& cw : absWidths) + totalWidth += cw.width; + const int clientWidth = GetClientSize().GetWidth(); //need reliable, stable width in contrast to rect.width + + if (totalWidth < clientWidth) + drawColumnLabel(dc, wxRect(labelAreaTL, wxSize(clientWidth - totalWidth, colLabelHeight_)), absWidths.size(), ColumnType::none, enabled); + } + } + + void drawColumnLabel(wxDC& dc, const wxRect& rect, size_t col, ColumnType colType, bool enabled) + { + if (auto prov = refParent().getDataProvider()) + { + const bool isHighlighted = activeResizing_ ? col == activeResizing_ ->getColumn () : //highlight_ column on mouse-over + activeClickOrMove_ ? col == activeClickOrMove_->getColumnFrom() : + highlightCol_ ? col == *highlightCol_ : + false; + + RecursiveDcClipper clip(dc, rect); + prov->renderColumnLabel(dc, rect, colType, enabled, isHighlighted); + + //draw move target location + if (refParent().allowColumnMove_) + if (activeClickOrMove_ && activeClickOrMove_->isRealMove()) + { + const int markerWidth = dipToWxsize(COLUMN_MOVE_MARKER_WIDTH_DIP); + + if (col + 1 == activeClickOrMove_->refColumnTo()) //handle pos 1, 2, .. up to "at end" position + dc.GradientFillLinear(wxRect(rect.x + rect.width - markerWidth, rect.y, markerWidth, rect.height), getColorLabelGradientFrom(), colDropMarkerColor_, wxSOUTH); + else if (col == activeClickOrMove_->refColumnTo() && col == 0) //pos 0 + dc.GradientFillLinear(wxRect(rect.GetTopLeft(), wxSize(markerWidth, rect.height)), getColorLabelGradientFrom(), colDropMarkerColor_, wxSOUTH); + } + } + } + + std::optional clientPosToColumnAction(const wxPoint& pos) const + { + if (0 <= pos.y && pos.y < colLabelHeight_) + if (const int absPosX = refParent().CalcUnscrolledPosition(pos).x; + absPosX >= 0) + { + const int resizeTolerance = refParent().allowColumnResize_ ? dipToWxsize(COLUMN_RESIZE_TOLERANCE_DIP) : 0; + const std::vector& absWidths = refParent().getColWidths(); //resolve stretched widths + + int accuWidth = 0; + for (size_t col = 0; col < absWidths.size(); ++col) + { + accuWidth += absWidths[col].width; + if (std::abs(absPosX - accuWidth) < resizeTolerance) + { + ColAction out; + out.wantResize = true; + out.col = col; + return out; + } + else if (absPosX < accuWidth) + { + ColAction out; + out.wantResize = false; + out.col = col; + return out; + } + } + } + return {}; + } + + size_t clientPosToMoveTargetColumn(const wxPoint& pos) const + { + const int absPosX = refParent().CalcUnscrolledPosition(pos).x; + const std::vector& absWidths = refParent().getColWidths(); //resolve negative/stretched widths + + int accWidth = 0; + for (size_t col = 0; col < absWidths.size(); ++col) + { + const int width = absWidths[col].width; //beware dreaded unsigned conversions! + accWidth += width; + + if (absPosX < accWidth - width / 2) + return col; + } + return absWidths.size(); + } + + void onMouseLeftDown(wxMouseEvent& event) override + { + //if (FindFocus() != &refParent().getMainWin()) -> clicking column label shouldn't change input focus, right!? e.g. resizing column, sorting...(other grid) + // refParent().getMainWin().SetFocus(); + + activeResizing_ .reset(); + activeClickOrMove_.reset(); + + if (std::optional action = clientPosToColumnAction(event.GetPosition())) + { + if (action->wantResize) + { + if (!event.LeftDClick()) //double-clicks never seem to arrive here; why is this checked at all??? + if (std::optional colWidth = refParent().getColWidth(action->col)) + activeResizing_.emplace(*this, action->col, *colWidth, event.GetPosition().x); + } + else //a move or single click + activeClickOrMove_.emplace(*this, action->col, event.GetPosition().x); + } + event.Skip(); + } + + void onMouseLeftUp(wxMouseEvent& event) override + { + activeResizing_.reset(); //nothing else to do, actual work done by onMouseMovement() + + if (activeClickOrMove_) + { + if (activeClickOrMove_->isRealMove()) + { + if (refParent().allowColumnMove_) + { + const size_t colFrom = activeClickOrMove_->getColumnFrom(); + size_t colTo = activeClickOrMove_->refColumnTo(); + + if (colTo > colFrom) //simulate "colFrom" deletion + --colTo; + + refParent().moveColumn(colFrom, colTo); + } + } + else //notify single label click + { + const wxPoint mousePos = GetPosition() + event.GetPosition(); + if (const std::optional colType = refParent().colToType(activeClickOrMove_->getColumnFrom())) + sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_LEFT, *colType, mousePos)); + } + activeClickOrMove_.reset(); + } + + refParent().updateWindowSizes(); //looks strange if done during onMouseMovement() + refParent().Refresh(); + event.Skip(); + } + + void onMouseLeftDouble(wxMouseEvent& event) override + { + if (std::optional action = clientPosToColumnAction(event.GetPosition())) + if (action->wantResize) + { + //auto-size visible range on double-click + const int bestWidth = refParent().getBestColumnSize(action->col); //return -1 on error + if (bestWidth >= 0) + { + refParent().setColumnWidth(bestWidth, action->col, GridEventPolicy::allow); + refParent().Refresh(); //refresh main grid as well! + } + } + event.Skip(); + } + + void onMouseRightDown(wxMouseEvent& event) override + { + evalMouseMovement(event.GetPosition()); //update highlight in obscure cases (e.g. right-click while other context menu is open) + + const wxPoint mousePos = GetPosition() + event.GetPosition(); + + if (const std::optional action = clientPosToColumnAction(event.GetPosition())) + { + if (const std::optional colType = refParent().colToType(action->col)) + sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, *colType, mousePos)); //notify right click + else assert(false); + } + else + //notify right click (on free space after last column) + if (fillGapAfterColumns) + sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, ColumnType::none, mousePos)); + + //update mouse highlight (e.g. mouse position changed after showing context menu) => needed on Linux/macOS + evalMouseMovement(ScreenToClient(wxGetMousePosition())); + + event.Skip(); + } + + void onMouseMovement(wxMouseEvent& event) override + { + evalMouseMovement(event.GetPosition()); + event.Skip(); + } + + void evalMouseMovement(wxPoint clientPos) + { + if (activeResizing_) + { + const auto col = activeResizing_->getColumn(); + const int newWidth = activeResizing_->getStartWidth() + clientPos.x - activeResizing_->getStartPosX(); + + //set width tentatively + refParent().setColumnWidth(newWidth, col, GridEventPolicy::allow); + + //check if there's a small gap after last column, if yes, fill it + const int gapWidth = GetClientSize().GetWidth() - refParent().getColWidthsSum(GetClientSize().GetWidth()); + if (std::abs(gapWidth) < dipToWxsize(COLUMN_FILL_GAP_TOLERANCE_DIP)) + refParent().setColumnWidth(newWidth + gapWidth, col, GridEventPolicy::allow); + + Refresh(); + refParent().Refresh(); //refresh columns on main grid as well! + } + else if (activeClickOrMove_) + { + const int clientPosX = clientPos.x; + if (std::abs(clientPosX - activeClickOrMove_->getStartPosX()) > dipToWxsize(COLUMN_MOVE_DELAY_DIP)) //real move (not a single click) + { + activeClickOrMove_->setRealMove(); + activeClickOrMove_->refColumnTo() = clientPosToMoveTargetColumn(clientPos); + Refresh(); + } + } + else + { + if (const std::optional action = clientPosToColumnAction(clientPos)) + { + setMouseHighlight(action->col); + + if (action->wantResize) + SetCursor(wxCURSOR_SIZEWE); //window-local only! :) + else + SetCursor(*wxSTANDARD_CURSOR); //NOOP when setting same cursor + } + else + { + setMouseHighlight(std::nullopt); + SetCursor(*wxSTANDARD_CURSOR); + } + } + + const std::wstring toolTip = [&] + { + if (const ColumnType colType = refParent().getColumnAtWinPos(clientPos.x).colType; //returns ColumnType::none if no column at x position! + colType != ColumnType::none) + if (auto prov = refParent().getDataProvider()) + return prov->getToolTip(colType); + return std::wstring(); + }(); + setToolTip(toolTip); + } + + void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override + { + if (activeResizing_ || activeClickOrMove_) + { + activeResizing_ .reset(); + activeClickOrMove_.reset(); + Refresh(); + } + setMouseHighlight(std::nullopt); + //event.Skip(); -> we DID handle it! + } + + void onLeaveWindow(wxMouseEvent& event) override + { + if (!activeResizing_ && !activeClickOrMove_) + //wxEVT_LEAVE_WINDOW does not respect mouse capture! -> however highlight is drawn unconditionally during move/resize! + setMouseHighlight(std::nullopt); + + event.Skip(); + } + + void setMouseHighlight(const std::optional& hl) + { + if (highlightCol_ != hl) + { + highlightCol_ = hl; + Refresh(); + } + } + + std::optional activeResizing_; + std::optional activeClickOrMove_; + std::optional highlightCol_; + + int colLabelHeight_ = 0; + const wxFont labelFont_; + + const wxColor colDropMarkerColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode! + getColorLabelGradientTo(), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text +}; + +//---------------------------------------------------------------------------------------------------------------- + +class Grid::MainWin : public SubWindow +{ +public: + MainWin(Grid& parent, + RowLabelWin& rowLabelWin, + ColLabelWin& colLabelWin) : SubWindow(parent), + rowLabelWin_(rowLabelWin), + colLabelWin_(colLabelWin) + { + Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) + { + if (event.GetKeyCode() == WXK_ESCAPE && activeSelection_) //allow Escape key to cancel active selection! + { + wxMouseCaptureLostEvent evt; + GetEventHandler()->ProcessEvent(evt); //better integrate into event handling rather than calling onMouseCaptureLost() directly!? + return; + } + + /* using keyboard: => clear distracting mouse highlights + + wxEVT_KEY_DOWN evaluation order: + 1. this callback + 2. Grid::SubWindow ... sendEventToParent() + 3. clients binding to Grid wxEVT_KEY_DOWN + 4. Grid::onKeyDown() */ + setMouseHighlight(std::nullopt); + + event.Skip(); + }); + } + + ~MainWin() { assert(!gridUpdatePending_); } + + size_t getCursor() const { return cursorRow_; } + size_t getAnchor() const { return selectionAnchor_; } + + void setCursor(size_t newCursorRow, size_t newAnchorRow) + { + cursorRow_ = newCursorRow; + selectionAnchor_ = newAnchorRow; + activeSelection_.reset(); //e.g. user might search with F3 while holding down left mouse button + } + +private: + void render(wxDC& dc, const wxRect& rect) override + { + clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); //CONTRACT! expected by GridData::renderRowBackgound()! + + const bool enabled = renderAsEnabled(*this); + + if (auto prov = refParent().getDataProvider()) + { + dc.SetFont(GetFont()); //harmonize with Grid::getBestColumnSize() + dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); + + const std::vector& absWidths = refParent().getColWidths(); //resolve stretched widths + + int totalRowWidth = 0; + for (const ColumnWidth& cw : absWidths) + totalRowWidth += cw.width; + + //fill gap after columns and cover full width + if (fillGapAfterColumns) + totalRowWidth = std::max(totalRowWidth, GetClientSize().GetWidth()); + + RecursiveDcClipper dummy(dc, rect); //do NOT draw background on cells outside of invalidated rect invalidating foreground text! + + const wxPoint gridAreaTL(refParent().CalcScrolledPosition(wxPoint(0, 0))); //client coordinates + const int rowHeight = rowLabelWin_.getRowHeight(); + const auto& [rowFirst, rowLast] = refParent().getVisibleRows(rect); + + for (auto row = rowFirst; row < rowLast; ++row) + { + //draw background lines + const wxRect rowRect(gridAreaTL + wxPoint(0, row * rowHeight), wxSize(totalRowWidth, rowHeight)); + const bool drawSelected = drawAsSelected(row); + const HoverArea rowHover = getRowHoverToDraw(row); + + RecursiveDcClipper dummy2(dc, rowRect); + prov->renderRowBackgound(dc, rowRect, row, enabled, drawSelected, rowHover); + + //draw cells column by column + wxRect cellRect = rowRect; + for (const ColumnWidth& cw : absWidths) + { + cellRect.width = cw.width; + + if (cellRect.x > rect.GetRight()) + break; //done + + if (cellRect.x + cw.width > rect.x) + { + RecursiveDcClipper dummy3(dc, cellRect); + prov->renderCell(dc, cellRect, row, cw.type, enabled, drawSelected, rowHover); + } + cellRect.x += cw.width; + } + } + } + } + + HoverArea getRowHoverToDraw(ptrdiff_t row) const + { + if (activeSelection_) + { + if (activeSelection_->getFirstClick().row_ == row) + return activeSelection_->getFirstClick().hoverArea_; + } + else if (highlight_) + { + if (makeSigned(highlight_->row) == row) + return highlight_->rowHover; + } + return HoverArea::none; + } + + bool drawAsSelected(size_t row) const + { + if (activeSelection_) //check if user is currently selecting with mouse + { + const size_t rowFrom = std::min(activeSelection_->getStartRow(), activeSelection_->getCurrentRow()); + const size_t rowTo = std::max(activeSelection_->getStartRow(), activeSelection_->getCurrentRow()); + + if (rowFrom <= row && row <= rowTo) + return activeSelection_->isPositiveSelect(); //overwrite default + } + return refParent().isSelected(row); + } + + void onMouseLeftDown (wxMouseEvent& event) override { onMouseDown(event); } + void onMouseLeftUp (wxMouseEvent& event) override { onMouseUp (event); } + void onMouseRightDown(wxMouseEvent& event) override { onMouseDown(event); } + void onMouseRightUp (wxMouseEvent& event) override { onMouseUp (event); } + + void onMouseLeftDouble(wxMouseEvent& event) override + { + if (auto prov = refParent().getDataProvider()) + { + const wxPoint mousePos = GetPosition() + event.GetPosition(); + const ptrdiff_t rowCount = refParent().getRowCount(); + const ptrdiff_t row = refParent().getRowAtWinPos (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range + const ColumnPosInfo cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position! + const HoverArea rowHover = [&] + { + if (0 <= row && row < rowCount && cpi.colType != ColumnType::none) + { + wxInfoDC dc(this); + dc.SetFont(GetFont()); + return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth); + } + return HoverArea::none; + }(); + + //client is interested in all double-clicks, even those outside of the grid! + sendEventToParent(GridClickEvent(EVENT_GRID_MOUSE_LEFT_DOUBLE, row, rowHover, mousePos)); + } + event.Skip(); + } + + void onMouseDown(wxMouseEvent& event) //handle left and right mouse button clicks (almost) the same + { + if (activeSelection_) //allow other mouse button to cancel active selection! + { + wxMouseCaptureLostEvent evt; + GetEventHandler()->ProcessEvent(evt); + return; + } + + if (auto prov = refParent().getDataProvider()) + { + evalMouseMovement(event.GetPosition()); //update highlight in obscure cases (e.g. right-click while other context menu is open) + + const wxPoint mousePos = GetPosition() + event.GetPosition(); + const ptrdiff_t rowCount = refParent().getRowCount(); + const ptrdiff_t row = refParent().getRowAtWinPos (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range + const ColumnPosInfo cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position! + const HoverArea rowHover = [&] + { + if (0 <= row && row < rowCount && cpi.colType != ColumnType::none) + { + wxInfoDC dc(this); + dc.SetFont(GetFont()); + return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth); + } + return HoverArea::none; + }(); + + assert(row >= 0); + //row < 0 was possible in older wxWidgets: https://github.com/wxWidgets/wxWidgets/commit/2c69d27c0d225d3a331c773da466686153185320#diff-9f11c8f2cb1f734f7c0c1071aba491a5 + //=> pressing "Menu Key" simulated mouse-right-button down + up at position 0xffff/0xffff! + + GridClickEvent mouseEvent(event.RightDown() ? EVENT_GRID_MOUSE_RIGHT_DOWN : EVENT_GRID_MOUSE_LEFT_DOWN, row, rowHover, mousePos); + + if (const bool processed = sendEventToParent(mouseEvent); //allow client to swallow event! + !processed) + { + if (wxWindow::FindFocus() != this) //doesn't seem to happen automatically for right mouse button + SetFocus(); + + if (event.RightDown() && (row < 0 || refParent().isSelected(row))) //=> open context menu *immediately* and do *not* start a new selection + sendEventToParent(GridContextMenuEvent(mousePos)); + else if (row >= 0) + { + if (event.ControlDown()) + activeSelection_.emplace(*this, row, !refParent().isSelected(row) /*positive*/, false /*gridWasCleared*/, mouseEvent); + else if (event.ShiftDown()) + { + refParent().clearSelection(GridEventPolicy::deny); + activeSelection_.emplace(*this, selectionAnchor_, true /*positive*/, true /*gridWasCleared*/, mouseEvent); + } + else + { + refParent().clearSelection(GridEventPolicy::deny); + activeSelection_.emplace(*this, row, true /*positive*/, true /*gridWasCleared*/, mouseEvent); + //DO NOT emit range event for clearing selection! would be inconsistent with keyboard handling (moving cursor neither emits range event) + //and is also harmful when range event is considered a final action + //e.g. cfg grid would prematurely show a modal dialog after changed config + } + } + } + + //update mouse highlight (e.g. mouse position changed after showing context menu) => needed on Linux/macOS + evalMouseMovement(ScreenToClient(wxGetMousePosition())); + } + event.Skip(); //allow changing focus + } + + void onMouseUp(wxMouseEvent& event) + { + if (activeSelection_) + { + const size_t rowCount = refParent().getRowCount(); + if (rowCount > 0) + { + if (activeSelection_->getCurrentRow() < rowCount) + { + cursorRow_ = activeSelection_->getCurrentRow(); + selectionAnchor_ = activeSelection_->getStartRow(); //allowed to be "out of range" + } + else if (activeSelection_->getStartRow() < rowCount) //don't change cursor if "to" and "from" are out of range + { + cursorRow_ = rowCount - 1; + selectionAnchor_ = activeSelection_->getStartRow(); //allowed to be "out of range" + } + else //total selection "out of range" + selectionAnchor_ = cursorRow_; + } + //slight deviation from Explorer: change cursor while dragging mouse! -> unify behavior with shift + direction keys + const wxPoint mousePos = GetPosition() + event.GetPosition(); + const size_t rowFrom = activeSelection_->getStartRow(); + const size_t rowTo = activeSelection_->getCurrentRow(); + const bool positive = activeSelection_->isPositiveSelect(); + const GridClickEvent mouseClick = activeSelection_->getFirstClick(); + assert((mouseClick.GetEventType() == EVENT_GRID_MOUSE_RIGHT_DOWN) == event.RightUp()); + + activeSelection_.reset(); //release mouse capture *before* sending the event (which might show a modal popup dialog requiring the mouse!!!) + + const size_t rowFirst = std::min(rowFrom, rowTo); //sort + convert to half-open range + const size_t rowLast = std::max(rowFrom, rowTo) + 1; // + refParent().selectRange2(rowFirst, rowLast, positive, &mouseClick, GridEventPolicy::allow); + + if (mouseClick.GetEventType() == EVENT_GRID_MOUSE_RIGHT_DOWN) + sendEventToParent(GridContextMenuEvent(mousePos)); //... *not* mouseClick.mousePos_ + } +#if 0 + if (!event.RightUp()) + if (auto prov = refParent().getDataProvider()) + { + //this one may point to row which is not in visible area! + const wxPoint mousePos = GetPosition() + event.GetPosition(); + const ptrdiff_t rowCount = refParent().getRowCount(); + const ptrdiff_t row = refParent().getRowAtWinPos (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range + const ColumnPosInfo cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position! + const HoverArea rowHover = [&] + { + if (0 <= row && row < rowCount && cpi.colType != ColumnType::none) + { + wxInfoDC dc(this); + dc.SetFont(GetFont()); + return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth); + } + return HoverArea::none; + }(); + //notify click event after the range selection! e.g. this makes sure the selection is applied before showing a context menu + sendEventToParent(GridClickEvent(EVENT_GRID_MOUSE_LEFT_UP, row, rowHover, mousePos)); + } +#endif + //update mouse highlight (e.g. mouse position changed after showing context menu) + //=> macOS no mouse movement event is generated after a mouse button click (unlike on Windows) + evalMouseMovement(ScreenToClient(wxGetMousePosition())); + + event.Skip(); //allow changing focus + } + + void onMouseMovement(wxMouseEvent& event) override + { + evalMouseMovement(event.GetPosition()); + event.Skip(); + } + + void evalMouseMovement(wxPoint clientPos) + { + if (auto prov = refParent().getDataProvider()) + { + const ptrdiff_t rowCount = refParent().getRowCount(); + const ptrdiff_t row = refParent().getRowAtWinPos (clientPos.y); //return -1 for invalid position; >= rowCount if out of range + const ColumnPosInfo cpi = refParent().getColumnAtWinPos(clientPos.x); //returns ColumnType::none if no column at x position! + const HoverArea rowHover = [&] + { + if (0 <= row && row < rowCount && cpi.colType != ColumnType::none) + { + wxInfoDC dc(this); + dc.SetFont(GetFont()); + return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth); + } + return HoverArea::none; + }(); + + const std::wstring toolTip = [&] + { + if (0 <= row && row < rowCount && cpi.colType != ColumnType::none) + return prov->getToolTip(row, cpi.colType, rowHover); + return std::wstring(); + }(); + setToolTip(toolTip); //change even during mouse selection! + + if (activeSelection_) + activeSelection_->evalMousePos(); //call on both mouse movement + timer event! + else + setMouseHighlight(rowHover != HoverArea::none ? std::make_optional({static_cast(row), rowHover}) : std::nullopt); + } + } + + void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override + { + if (activeSelection_) + { + if (activeSelection_->gridWasCleared()) + refParent().clearSelection(GridEventPolicy::allow); //see onMouseDown(); selection is "completed" => emit GridSelectEvent + + activeSelection_.reset(); + Refresh(); + } + setMouseHighlight(std::nullopt); + //event.Skip(); -> we DID handle it! + } + + void onLeaveWindow(wxMouseEvent& event) override + { + if (!activeSelection_) //wxEVT_LEAVE_WINDOW does not respect mouse capture! + setMouseHighlight(std::nullopt); + + //CAVEAT: we can get wxEVT_MOTION *after* wxEVT_LEAVE_WINDOW: see RowLabelWin::redirectMouseEvent() + // => therefore we also redirect wxEVT_LEAVE_WINDOW, but user will see a little flicker when moving between RowLabelWin and MainWin + event.Skip(); + } + + class MouseSelection : private wxEvtHandler + { + public: + MouseSelection(MainWin& wnd, size_t rowStart, bool positive, bool gridWasCleared, const GridClickEvent& firstClick) : + wnd_(wnd), rowStart_(rowStart), rowCurrent_(rowStart), positiveSelect_(positive), gridWasCleared_(gridWasCleared), firstClick_(firstClick) + { + wnd_.CaptureMouse(); + timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { evalMousePos(); }); + timer_.Start(100); //timer interval in ms + evalMousePos(); + wnd_.Refresh(); + } + ~MouseSelection() { if (wnd_.HasCapture()) wnd_.ReleaseMouse(); } + + size_t getStartRow () const { return rowStart_; } + size_t getCurrentRow () const { return rowCurrent_; } + bool isPositiveSelect() const { return positiveSelect_; } //are we selecting or unselecting? + bool gridWasCleared () const { return gridWasCleared_; } + + const GridClickEvent& getFirstClick() const { return firstClick_; } + + void evalMousePos() + { + const auto now = std::chrono::steady_clock::now(); + const double deltaSecs = std::chrono::duration(now - lastEvalTime_).count(); //unit: [sec] + lastEvalTime_ = now; + + const wxPoint clientPos = wnd_.ScreenToClient(wxGetMousePosition()); + const wxSize clientSize = wnd_.GetClientSize(); + assert(wnd_.GetClientAreaOrigin() == wxPoint()); + + //scroll while dragging mouse + const int overlapPixY = clientPos.y < 0 ? clientPos.y : + clientPos.y >= clientSize.GetHeight() ? clientPos.y - (clientSize.GetHeight() - 1) : 0; + const int overlapPixX = clientPos.x < 0 ? clientPos.x : + clientPos.x >= clientSize.GetWidth() ? clientPos.x - (clientSize.GetWidth() - 1) : 0; + + int pixelsPerUnitY = 0; + wnd_.refParent().GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY); + assert(pixelsPerUnitY > 0); + if (pixelsPerUnitY <= 0) + return; + + const double mouseDragSpeedIncScrollU = MOUSE_DRAG_ACCELERATION_DIP * wnd_.rowLabelWin_.getRowHeight() / pixelsPerUnitY; //unit: [scroll units / (DIP * sec)] + //design alternative: "Dynamic autoscroll based on escape velocity": https://devblogs.microsoft.com/oldnewthing/20210128-00/?p=104768 + + auto autoScroll = [&](int overlapPix, double& toScroll) + { + if (overlapPix != 0) + { + const double scrollSpeed = wnd_.ToDIP(overlapPix) * mouseDragSpeedIncScrollU; //unit: [scroll units / sec] + toScroll += scrollSpeed * deltaSecs; + } + else + toScroll = 0; + }; + + autoScroll(overlapPixX, toScrollX_); + autoScroll(overlapPixY, toScrollY_); + + if (static_cast(toScrollX_) != 0 || static_cast(toScrollY_) != 0) + { + wnd_.refParent().scrollDelta(static_cast(toScrollX_), static_cast(toScrollY_)); // + toScrollX_ -= static_cast(toScrollX_); //rounds down for positive numbers, up for negative, + toScrollY_ -= static_cast(toScrollY_); //exactly what we want + } + + //select current row *after* scrolling + wxPoint clientPosTrimmed = clientPos; + clientPosTrimmed.y = std::clamp(clientPosTrimmed.y, 0, clientSize.GetHeight() - 1); //do not select row outside client window! + + const ptrdiff_t newRow = wnd_.refParent().getRowAtWinPos(clientPosTrimmed.y); //return -1 for invalid position; >= rowCount if out of range + assert(newRow >= 0); + if (newRow >= 0) + if (rowCurrent_ != newRow) + { + rowCurrent_ = newRow; + wnd_.Refresh(); + } + } + + private: + MainWin& wnd_; + const size_t rowStart_; + ptrdiff_t rowCurrent_; + const bool positiveSelect_; + const bool gridWasCleared_; + const GridClickEvent firstClick_; + wxTimer timer_; + double toScrollX_ = 0; //count outstanding scroll unit fractions while dragging mouse + double toScrollY_ = 0; // + std::chrono::steady_clock::time_point lastEvalTime_ = std::chrono::steady_clock::now(); + }; + + void ScrollWindow(int dx, int dy, const wxRect* rect) override + { + wxWindow::ScrollWindow(dx, dy, rect); + rowLabelWin_.ScrollWindow(0, dy, rect); + colLabelWin_.ScrollWindow(dx, 0, rect); + + //attention, wxGTK call sequence: wxScrolledWindow::Scroll() -> wxScrolledHelperNative::Scroll() -> wxScrolledHelperNative::DoScroll() + //which *first* calls us, MainWin::ScrollWindow(), and *then* internally updates m_yScrollPosition + //=> we cannot use CalcUnscrolledPosition() here which gives the wrong/outdated value!!! + //=> we need to update asynchronously: + //=> don't send async event repeatedly => severe performance issues on wxGTK! + //=> can't use idle event neither: too few idle events on Windows, e.g. NO idle events while mouse drag-scrolling! + //=> solution: send single async event at most! + if (!gridUpdatePending_) //without guarding, the number of outstanding async events can become very high during scrolling!! test case: Ubuntu: 170; Windows: 20 + { + gridUpdatePending_ = true; + + GetEventHandler()->CallAfter([this] + { + refParent().updateWindowSizes(false); //row label width has changed -> do *not* update scrollbars: recursion on wxGTK! -> still a problem, now that this function is called async?? + rowLabelWin_.Update(); //update while dragging scroll thumb + + assert(gridUpdatePending_); + gridUpdatePending_ = false; + }); + } + } + + void refreshRow(size_t row) + { + const wxRect& rowArea = rowLabelWin_.getRowLabelArea(row); //returns empty rect if row not found + const wxPoint topLeft = refParent().CalcScrolledPosition(wxPoint(0, rowArea.y)); //logical -> window coordinates + wxRect cellArea(topLeft, wxSize(refParent().getColWidthsSum(GetClientSize().GetWidth()), rowArea.height)); + RefreshRect(cellArea); + } + + struct MouseHighlight + { + size_t row = 0; + HoverArea rowHover = HoverArea::none; + + bool operator==(const MouseHighlight&) const = default; + }; + + void setMouseHighlight(const std::optional& hl) + { + assert(!hl || (hl->row < refParent().getRowCount() && hl->rowHover != HoverArea::none)); + if (highlight_ != hl) + { + if (highlight_) + refreshRow(highlight_->row); + + highlight_ = hl; + + if (highlight_) + refreshRow(highlight_->row); + } + } + + + RowLabelWin& rowLabelWin_; + ColLabelWin& colLabelWin_; + + std::optional activeSelection_; //bound while user is selecting with mouse + std::optional highlight_; + + size_t cursorRow_ = 0; + size_t selectionAnchor_ = 0; + bool gridUpdatePending_ = false; +}; + +//---------------------------------------------------------------------------------------------------------------- +//---------------------------------------------------------------------------------------------------------------- + +Grid::Grid(wxWindow* parent, + wxWindowID id, + const wxPoint& pos, + const wxSize& size, + long style, + const wxString& name) : wxScrolledWindow(parent, id, pos, size, style | wxWANTS_CHARS, name) +{ + cornerWin_ = new CornerWin (*this); // + rowLabelWin_ = new RowLabelWin(*this); //owership handled by "this" + colLabelWin_ = new ColLabelWin(*this); // + mainWin_ = new MainWin (*this, *rowLabelWin_, *colLabelWin_); // + + SetTargetWindow(mainWin_); + + SetInitialSize(size); //"Most controls will use this to set their initial size" -> why not + + assert(GetClientSize() == GetSize() && GetWindowBorderSize() == wxSize()); //borders are NOT allowed for Grid + //reason: updateWindowSizes() wants to use "GetSize()" as a "GetClientSize()" including scrollbars + + //https://wiki.wxwidgets.org/Flicker-Free_Drawing + SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND + //wxEVT_PAINT: "If you have an EVT_PAINT() handler, you must create a wxPaintDC object within it even if you don't actually use it." + //=> and if not, wxScrollHelperEvtHandler::ProcessEvent() helps out and creates wxPaintDC (without rendering anything) + Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { updateWindowSizes(); event.Skip(); }); + Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event) { onKeyDown(event); }); +} + + +void Grid::updateWindowSizes(bool updateScrollbar) +{ + /* We have to deal with TWO nasty circular dependencies: + 1. + rowLabelWidth + /|\ + mainWin::client width + /|\ + SetScrollbars -> show/hide horizontal scrollbar depending on client width + /|\ + mainWin::client height -> possibly trimmed by horizontal scrollbars + /|\ + rowLabelWidth + + 2. + mainWin_->GetClientSize() + /|\ + SetScrollbars -> show/hide scrollbars depending on whether client size is big enough + /|\ + GetClientSize(); -> possibly trimmed by scrollbars + /|\ + mainWin_->GetClientSize() -> also trimmed, since it's a sub-window! + */ + + //break this vicious circle: + + //harmonize with Grid::GetSizeAvailableForScrollTarget()! + + //1. calculate row label width independent from scrollbars + const int mainWinHeightGross = std::max(0, GetSize().GetHeight() - getColumnLabelHeight()); //independent from client sizes and scrollbars! + const ptrdiff_t logicalHeight = rowLabelWin_->getLogicalHeight(); // + + const int rowLabelWidth = [&] + { + if (drawRowLabel_ && logicalHeight > 0) + { + ptrdiff_t yFrom = CalcUnscrolledPosition(wxPoint(0, 0)).y; + ptrdiff_t yTo = CalcUnscrolledPosition(wxPoint(0, mainWinHeightGross - 1)).y ; + yFrom = std::clamp(yFrom, 0, logicalHeight - 1); + yTo = std::clamp(yTo, 0, logicalHeight - 1); + + const ptrdiff_t rowFrom = rowLabelWin_->getRowAtPos(yFrom); + const ptrdiff_t rowTo = rowLabelWin_->getRowAtPos(yTo); + if (rowFrom >= 0 && rowTo >= 0) + return rowLabelWin_->getBestWidth(rowFrom, rowTo); + } + return 0; + }(); + + //2. update managed windows' sizes: just assume scrollbars are already set correctly, even if they may not be (yet)! + //this ensures mainWin_->SetVirtualSize() and AdjustScrollbars() are working with the correct main window size, unless sb change later, which triggers a recalculation anyway! + const wxSize mainWinSize(std::max(0, GetClientSize().GetWidth () - rowLabelWidth), + std::max(0, GetClientSize().GetHeight() - getColumnLabelHeight())); + + cornerWin_ ->SetSize(0, 0, rowLabelWidth, getColumnLabelHeight()); + rowLabelWin_->SetSize(0, getColumnLabelHeight(), rowLabelWidth, mainWinSize.GetHeight()); + colLabelWin_->SetSize(rowLabelWidth, 0, mainWinSize.GetWidth(), getColumnLabelHeight()); + mainWin_ ->SetSize(rowLabelWidth, getColumnLabelHeight(), mainWinSize.GetWidth(), mainWinSize.GetHeight()); + + //avoid flicker in wxWindowMSW::HandleSize() when calling ::EndDeferWindowPos() where the sub-windows are moved only although they need to be redrawn! + colLabelWin_->Refresh(); + mainWin_ ->Refresh(); + + //3. update scrollbars: "guide wxScrolledHelper to not screw up too much" + if (updateScrollbar) + { + auto setScrollbars2 = [&](int logWidth, int logHeight) //replace SetScrollbars, which loses precision of pixelsPerUnitX for some brain-dead reason + { + mainWin_->SetVirtualSize(logWidth, logHeight); //set before calling SetScrollRate(): + //else SetScrollRate() would fail to preserve scroll position when "new virtual pixel-pos > old virtual height" + + int ppsuX = 0; //pixel per scroll unit + int ppsuY = 0; + GetScrollPixelsPerUnit(&ppsuX, &ppsuY); + + const int ppsuNew = rowLabelWin_->getRowHeight(); + if (ppsuX != ppsuNew || ppsuY != ppsuNew) //support polling! + SetScrollRate(ppsuNew, ppsuNew); //internally calls AdjustScrollbars() and GetVirtualSize()! + + AdjustScrollbars(); //lousy wxWidgets design decision: internally calls mainWin_->GetClientSize() without considering impact of scrollbars! + //Attention: setting scrollbars triggers *synchronous* resize event if scrollbars are shown or hidden! => updateWindowSizes() recursion! (Windows) + }; + + const int mainWinWidthGross = std::max(0, GetSize().GetWidth() - rowLabelWidth); + + if (logicalHeight <= mainWinHeightGross && + getColWidthsSum(mainWinWidthGross) <= mainWinWidthGross && + //this special case needs to be considered *only* when both scrollbars are flexible: + showScrollbarH_ == SB_SHOW_AUTOMATIC && + showScrollbarV_ == SB_SHOW_AUTOMATIC) + setScrollbars2(0, 0); //no scrollbars required at all! -> wxScrolledWindow requires active help to detect this special case! + else + { + const int logicalWidthTmp = getColWidthsSum(mainWinSize.GetWidth()); //assuming vertical scrollbar stays as it is... + setScrollbars2(logicalWidthTmp, logicalHeight); //if scrollbars are shown or hidden a new resize event recurses into updateWindowSizes() + /* + is there a risk of endless recursion? No, 2-level recursion at most, consider the following 6 cases: + + <----------gw----------> + <----------nw------> + ------------------------ /|\ /|\ + | | | | | + | main window | | nh | + | | | | gh + ------------------------ \|/ | + | | | | + ------------------------ \|/ + gw := gross width + nw := net width := gross width - sb size + gh := gross height + nh := net height := gross height - sb size + + There are 6 cases that can occur: + --------------------------------- + lw := logical width + lh := logical height + + 1. lw <= gw && lh <= gh => no scrollbars needed + + 2. lw > gw && lh > gh => need both scrollbars + + lh > gh + 3. lw <= nw => need vertical scrollbar only + 4. nw < lw <= gw => need both scrollbars + + lw > gw + 5. lh <= nh => need horizontal scrollbar only + 6. nh < lh <= gh => need both scrollbars + */ + } + } +} + + +wxSize Grid::GetSizeAvailableForScrollTarget(const wxSize& size) +{ + //1. "size == GetSize() == (0, 0)" happens temporarily during initialization + //2. often it's even (0, 20) + //3. fuck knows why, but we *temporarily* get "size == GetSize() == (1, 1)" when wxAUI panel containing Grid is dropped + if (size.x <= 1 || size.y <= 1) + return {}; //probably best considering calling code in generic/scrlwing.cpp: wxScrollHelper::AdjustScrollbars() + + //1. calculate row label width independent from scrollbars + const int mainWinHeightGross = std::max(0, size.GetHeight() - getColumnLabelHeight()); //independent from client sizes and scrollbars! + const ptrdiff_t logicalHeight = rowLabelWin_->getLogicalHeight(); // + + const int rowLabelWidth = [&] + { + if (drawRowLabel_ && logicalHeight > 0) + { + ptrdiff_t yFrom = CalcUnscrolledPosition(wxPoint(0, 0)).y; + ptrdiff_t yTo = CalcUnscrolledPosition(wxPoint(0, mainWinHeightGross - 1)).y ; + yFrom = std::clamp(yFrom, 0, logicalHeight - 1); + yTo = std::clamp(yTo, 0, logicalHeight - 1); + + const ptrdiff_t rowFrom = rowLabelWin_->getRowAtPos(yFrom); + const ptrdiff_t rowTo = rowLabelWin_->getRowAtPos(yTo); + if (rowFrom >= 0 && rowTo >= 0) + return rowLabelWin_->getBestWidth(rowFrom, rowTo); + } + return 0; + }(); + + //2. try(!) to determine scrollbar sizes: +#if GTK_MAJOR_VERSION == 2 + /* Ubuntu 19.10: "scrollbar-spacing" has a default value of 3: https://developer.gnome.org/gtk2/stable/GtkScrolledWindow.html#GtkScrolledWindow--s-scrollbar-spacing + => the default Ubuntu theme (but also our Gtk2Styles.rc) set it to 0, but still the first call to gtk_widget_style_get() returns 3: why? + => maybe styles are applied asynchronously? GetClientSize() is affected by this, so can't use! + => always ignore spacing to get consistent scrollbar dimensions! */ + GtkScrolledWindow* scrollWin = GTK_SCROLLED_WINDOW(wxWindow::m_widget); + assert(scrollWin); + GtkWidget* rangeH = ::gtk_scrolled_window_get_hscrollbar(scrollWin); + GtkWidget* rangeV = ::gtk_scrolled_window_get_vscrollbar(scrollWin); + + GtkRequisition reqH = {}; + GtkRequisition reqV = {}; + if (rangeH) ::gtk_widget_size_request(rangeH, &reqH); + if (rangeV) ::gtk_widget_size_request(rangeV, &reqV); + assert(reqH.width > 0 && reqH.height > 0); + assert(reqV.width > 0 && reqV.height > 0); + + const wxSize scrollBarSizeTmp(reqV.width, reqH.height); + assert(scrollBarHeightH_ == 0 || scrollBarHeightH_ == scrollBarSizeTmp.y); + assert(scrollBarWidthV_ == 0 || scrollBarWidthV_ == scrollBarSizeTmp.x); + +#elif GTK_MAJOR_VERSION == 3 + //scrollbar size increases dynamically on mouse-hover! + //see "overlay scrolling": https://developer.gnome.org/gtk3/stable/GtkScrolledWindow.html#gtk-scrolled-window-set-overlay-scrolling + //luckily "scrollbar-spacing" is stable on GTK3 + const wxSize scrollBarSizeTmp = GetSize() - GetClientSize(); + + //lame hard-coded numbers (from Ubuntu 19.10) and openSuse + //=> let's have a *close* eye on scrollbar fluctuation! + assert(scrollBarSizeTmp.x == 0 || + scrollBarSizeTmp.x == 6 || scrollBarSizeTmp.x == 13 || //Ubuntu 19.10 + scrollBarSizeTmp.x == 16); //openSuse + assert(scrollBarSizeTmp.y == 0 || + scrollBarSizeTmp.y == 6 || scrollBarSizeTmp.y == 13 || //Ubuntu 19.10 + scrollBarSizeTmp.y == 16); //openSuse +#else +#error unknown GTK version! +#endif + scrollBarHeightH_ = std::max(scrollBarHeightH_, scrollBarSizeTmp.y); + scrollBarWidthV_ = std::max(scrollBarWidthV_, scrollBarSizeTmp.x); + //this function is called again by wxScrollHelper::AdjustScrollbars() if SB_SHOW_ALWAYS-scrollbars are not yet shown => scrollbar size > 0 eventually! + + //----------------------------------------------------------------------------- + //harmonize with Grid::updateWindowSizes()! + wxSize sizeAvail = size - wxSize(rowLabelWidth, getColumnLabelHeight()); + + //EXCEPTION: space consumed by SB_SHOW_ALWAYS-scrollbars is *never* available for "scroll target"; see wxScrollHelper::AdjustScrollbars() + if (showScrollbarH_ == SB_SHOW_ALWAYS) + sizeAvail.y -= (scrollBarHeightH_ > 0 ? scrollBarHeightH_ : /*fallback:*/ scrollBarWidthV_); + if (showScrollbarV_ == SB_SHOW_ALWAYS) + sizeAvail.x -= (scrollBarWidthV_ > 0 ? scrollBarWidthV_ : /*fallback:*/ scrollBarHeightH_); + + return wxSize(std::max(0, sizeAvail.x), + std::max(0, sizeAvail.y)); +} + + +void Grid::onKeyDown(wxKeyEvent& event) +{ + int keyCode = event.GetKeyCode(); + if (GetLayoutDirection() == wxLayout_RightToLeft) + { + if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT) + keyCode = WXK_RIGHT; + else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT) + keyCode = WXK_LEFT; + } + if (event.ShiftDown() && keyCode == WXK_F10) //== alias for menu key + keyCode = WXK_WINDOWS_MENU; + + const ptrdiff_t rowCount = getRowCount(); + const ptrdiff_t cursorRow = mainWin_->getCursor(); + + auto moveCursorTo = [&](ptrdiff_t row) + { + if (rowCount > 0) + setGridCursor(std::clamp(row, 0, rowCount - 1), GridEventPolicy::allow); + }; + + auto selectWithCursorTo = [&](ptrdiff_t row) + { + if (rowCount > 0) + { + row = std::clamp(row, 0, rowCount - 1); + const ptrdiff_t anchorRow = mainWin_->getAnchor(); + + mainWin_->setCursor(row, anchorRow); + makeRowVisible(row); + + selection_.clear(); //clear selection, do NOT fire event + + const ptrdiff_t rowFirst = std::min(anchorRow, row); //sort + convert to half-open range + const ptrdiff_t rowLast = std::max(anchorRow, row) + 1; // + selectRange(rowFirst, rowLast, true /*positive*/, GridEventPolicy::allow); //set new selection + fire event + } + }; + + switch (keyCode) + { + case WXK_MENU: //simulate right mouse click at cursor row position (on lower edge) + case WXK_WINDOWS_MENU: //(but truncate to window if cursor is out of view) + { + const size_t row = std::min(mainWin_->getCursor(), getRowCount()); + + const int clientPosMainWinY = std::clamp(CalcScrolledPosition(wxPoint(0, rowLabelWin_->getRowHeight() * (row + 1))).y - 1, //logical -> window coordinates + 0, mainWin_->GetClientSize().GetHeight() - 1); + + const wxPoint mousePos = mainWin_->GetPosition() + wxPoint(0, clientPosMainWinY); //mainWin_-relative to Grid-relative + + GridContextMenuEvent contextEvent(mousePos); + GetEventHandler()->ProcessEvent(contextEvent); + } + return; + + //case WXK_TAB: + // if (Navigate(event.ShiftDown() ? wxNavigationKeyEvent::IsBackward : wxNavigationKeyEvent::IsForward)) + // return; + // break; + + case WXK_UP: + case WXK_NUMPAD_UP: + if (event.ShiftDown()) + selectWithCursorTo(cursorRow - 1); + else if (event.ControlDown()) + scrollDelta(0, -1); + else + moveCursorTo(cursorRow - 1); + return; //swallow event: wxScrolledWindow, wxWidgets 2.9.3 on Kubuntu x64 processes arrow keys: prevent this! + + case WXK_DOWN: + case WXK_NUMPAD_DOWN: + if (event.ShiftDown()) + selectWithCursorTo(cursorRow + 1); + else if (event.ControlDown()) + scrollDelta(0, 1); + else + moveCursorTo(cursorRow + 1); + return; //swallow event + + case WXK_LEFT: + case WXK_NUMPAD_LEFT: + if (event.ControlDown()) + scrollDelta(-1, 0); + else if (event.ShiftDown()) + ; + else + moveCursorTo(cursorRow); + return; + + case WXK_RIGHT: + case WXK_NUMPAD_RIGHT: + if (event.ControlDown()) + scrollDelta(1, 0); + else if (event.ShiftDown()) + ; + else + moveCursorTo(cursorRow); + return; + + case WXK_HOME: + case WXK_NUMPAD_HOME: + if (event.ShiftDown()) + selectWithCursorTo(0); + //else if (event.ControlDown()) + // ; + else + moveCursorTo(0); + return; + + case WXK_END: + case WXK_NUMPAD_END: + if (event.ShiftDown()) + selectWithCursorTo(rowCount - 1); + //else if (event.ControlDown()) + // ; + else + moveCursorTo(rowCount - 1); + return; + + case WXK_PAGEUP: + case WXK_NUMPAD_PAGEUP: + if (event.ShiftDown()) + selectWithCursorTo(cursorRow - rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight()); + //else if (event.ControlDown()) + // ; + else + moveCursorTo(cursorRow - rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight()); + return; + + case WXK_PAGEDOWN: + case WXK_NUMPAD_PAGEDOWN: + if (event.ShiftDown()) + selectWithCursorTo(cursorRow + rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight()); + //else if (event.ControlDown()) + // ; + else + moveCursorTo(cursorRow + rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight()); + return; + + case 'A': //Ctrl + A - select all + if (event.ControlDown()) + selectRange(0, rowCount, true /*positive*/, GridEventPolicy::allow); + break; + + case WXK_NUMPAD_ADD: //CTRL + '+' - auto-size all + if (event.ControlDown()) + autoSizeColumns(GridEventPolicy::allow); + return; + } + + event.Skip(); +} + + +void Grid::setColumnLabelHeight(int height) +{ + colLabelWin_->setColumnLabelHeight(height); + updateWindowSizes(); +} + + +int Grid::getColumnLabelHeight() const { return colLabelWin_->getColumnLabelHeight(); } + + +void Grid::showRowLabel(bool show) +{ + drawRowLabel_ = show; + updateWindowSizes(); +} + + +void Grid::selectRange(size_t rowFirst, size_t rowLast, bool positive, GridEventPolicy rangeEventPolicy) +{ + selectRange2(rowFirst, rowLast, positive, nullptr /*mouseClick*/, rangeEventPolicy); +} + + +void Grid::selectRange2(size_t rowFirst, size_t rowLast, bool positive, const GridClickEvent* mouseClick, GridEventPolicy rangeEventPolicy) +{ + assert(rowFirst <= rowLast); + assert(getRowCount() == selection_.gridSize()); + rowFirst = std::clamp(rowFirst, 0, selection_.gridSize()); + rowLast = std::clamp(rowLast, 0, selection_.gridSize()); + + if (rowFirst < rowLast && !selection_.matchesRange(rowFirst, rowLast, positive)) + { + selection_.selectRange(rowFirst, rowLast, positive); + mainWin_->Refresh(); + } + + //issue event even for unchanged selection! e.g. MainWin::onMouseDown() temporarily clears range with GridEventPolicy::deny! + if (rangeEventPolicy == GridEventPolicy::allow) + { + GridSelectEvent selEvent(rowFirst, rowLast, positive, mouseClick); + [[maybe_unused]] const bool processed = GetEventHandler()->ProcessEvent(selEvent); + } +} + +void Grid::selectRow(size_t row, GridEventPolicy rangeEventPolicy) { selectRange(row, row + 1, true /*positive*/, rangeEventPolicy); } +void Grid::selectAllRows (GridEventPolicy rangeEventPolicy) { selectRange(0, selection_.gridSize(), true /*positive*/, rangeEventPolicy); } +void Grid::clearSelection (GridEventPolicy rangeEventPolicy) { selectRange(0, selection_.gridSize(), false /*positive*/, rangeEventPolicy); } + + +void Grid::scrollDelta(int deltaX, int deltaY) +{ + const wxPoint scrollPosOld = GetViewStart(); + + wxPoint scrollPosNew = scrollPosOld; + scrollPosNew.x += deltaX; + scrollPosNew.y += deltaY; + + scrollPosNew.x = std::max(0, scrollPosNew.x); //wxScrollHelper::Scroll() will exit prematurely if input happens to be "-1"! + scrollPosNew.y = std::max(0, scrollPosNew.y); // + + if (scrollPosNew != scrollPosOld) + { + Scroll(scrollPosNew); //internally calls wxWindows::Update()! + updateWindowSizes(); //may show horizontal scroll bar if row column gets wider + } +} + + +size_t Grid::getRowCount() const +{ + return dataView_ ? dataView_->getRowCount() : 0; +} + + +void Grid::Refresh(bool eraseBackground, const wxRect* rect) +{ + const size_t rowCountNew = getRowCount(); + if (rowCountOld_ != rowCountNew) + { + rowCountOld_ = rowCountNew; + updateWindowSizes(); + } + + if (selection_.gridSize() != rowCountNew) + { + const bool priorSelection = !selection_.matchesRange(0, selection_.gridSize(), false /*positive*/); + + selection_.resize(rowCountNew); + + if (priorSelection) //clear selection only when needed + { + //clearSelection(GridEventPolicy::allow); -> no, we need async event to make filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR) work + selection_.clear(); + GetEventHandler()->AddPendingEvent(GridSelectEvent(0, rowCountNew, false /*positive*/, nullptr /*mouseClick*/)); + } + } + + wxScrolledWindow::Refresh(eraseBackground, rect); +} + + +void Grid::setRowHeight(int height) +{ + rowLabelWin_->setRowHeight(height); + updateWindowSizes(); + Refresh(); +} + + +int Grid::getRowHeight() const { return rowLabelWin_->getRowHeight(); } + + +void Grid::setColumnConfig(const std::vector& attr) +{ + //hold ownership of non-visible columns + oldColAttributes_ = attr; + + std::vector visCols; + for (const ColAttributes& ca : attr) + { + assert(ca.stretch >= 0); + assert(ca.type != ColumnType::none); + + if (ca.visible) + visCols.push_back({ca.type, ca.offset, std::max(ca.stretch, 0)}); + } + + //"ownership" of visible columns is now within Grid + visibleCols_ = std::move(visCols); + + updateWindowSizes(); + Refresh(); +} + + +std::vector Grid::getColumnConfig() const +{ + //get non-visible columns (+ outdated visible ones) + std::vector output = oldColAttributes_; + + auto itVcols = visibleCols_.begin(); + auto itVcolsend = visibleCols_.end(); + + //update visible columns but keep order of non-visible ones! + for (ColAttributes& ca : output) + if (ca.visible) + { + if (itVcols != itVcolsend) + { + ca.type = itVcols->type; + ca.stretch = itVcols->stretch; + ca.offset = itVcols->offset; + ++itVcols; + } + else + assert(false); + } + assert(itVcols == itVcolsend); + + return output; +} + + +void Grid::showScrollBars(Grid::ScrollBarStatus horizontal, Grid::ScrollBarStatus vertical) +{ + if (showScrollbarH_ == horizontal && + showScrollbarV_ == vertical) return; //support polling! + + showScrollbarH_ = horizontal; + showScrollbarV_ = vertical; + + //the following wxGTK approach is pretty much identical to wxWidgets 2.9 ShowScrollbars() code! + + auto mapStatus = [](ScrollBarStatus sbStatus) -> GtkPolicyType + { + switch (sbStatus) + { + case SB_SHOW_AUTOMATIC: + return GTK_POLICY_AUTOMATIC; + case SB_SHOW_ALWAYS: + return GTK_POLICY_ALWAYS; + case SB_SHOW_NEVER: + return GTK_POLICY_NEVER; + } + assert(false); + return GTK_POLICY_AUTOMATIC; + }; + + GtkScrolledWindow* scrollWin = GTK_SCROLLED_WINDOW(wxWindow::m_widget); + assert(scrollWin); + ::gtk_scrolled_window_set_policy(scrollWin, + mapStatus(horizontal), + mapStatus(vertical)); + + updateWindowSizes(); +} + + + +wxWindow& Grid::getCornerWin () { return *cornerWin_; } +wxWindow& Grid::getRowLabelWin() { return *rowLabelWin_; } +wxWindow& Grid::getColLabelWin() { return *colLabelWin_; } +wxWindow& Grid::getMainWin () { return *mainWin_; } +const wxWindow& Grid::getMainWin() const { return *mainWin_; } + + +void Grid::moveColumn(size_t colFrom, size_t colTo) +{ + if (colFrom < visibleCols_.size() && + colTo < visibleCols_.size() && + colTo != colFrom) + { + const VisibleColumn colAtt = visibleCols_[colFrom]; + visibleCols_.erase (visibleCols_.begin() + colFrom); + visibleCols_.insert(visibleCols_.begin() + colTo, colAtt); + } +} + + +ColumnType Grid::colToType(size_t col) const +{ + if (col < visibleCols_.size()) + return visibleCols_[col].type; + return ColumnType::none; +} + + +Grid::ColumnPosInfo Grid::getColumnAtWinPos(int posX) const +{ + if (const int absX = CalcUnscrolledPosition(wxPoint(posX, 0)).x; + absX >= 0) + { + int accWidth = 0; + for (const ColumnWidth& cw : getColWidths()) + { + accWidth += cw.width; + if (absX < accWidth) + return {cw.type, absX + cw.width - accWidth, cw.width}; + } + } + return {ColumnType::none, 0, 0}; +} + + +ptrdiff_t Grid::getRowAtWinPos(int posY) const +{ + const int absY = CalcUnscrolledPosition(wxPoint(0, posY)).y; + return rowLabelWin_->getRowAtPos(absY); //return -1 for invalid position, rowCount if past the end +} + + +std::pair Grid::getVisibleRows(const wxRect& clientRect) const //returns range [begin, end) +{ + if (clientRect.height > 0) + { + const int rowFrom = getRowAtWinPos(clientRect.y); + const int rowTo = getRowAtWinPos(clientRect.GetBottom()); + + return {std::max(rowFrom, 0), + std::min((rowTo) + 1, getRowCount())}; + } + return {}; +} + + +wxRect Grid::getColumnLabelArea(ColumnType colType) const +{ + const std::vector& absWidths = getColWidths(); //resolve negative/stretched widths + + //colType is not unique in general, but *this* function expects it! + assert(std::count_if(absWidths.begin(), absWidths.end(), [&](const ColumnWidth& cw) { return cw.type == colType; }) <= 1); + + auto itCol = std::find_if(absWidths.begin(), absWidths.end(), [&](const ColumnWidth& cw) { return cw.type == colType; }); + if (itCol != absWidths.end()) + { + ptrdiff_t posX = 0; + std::for_each(absWidths.begin(), itCol, + [&](const ColumnWidth& cw) { posX += cw.width; }); + + return wxRect(wxPoint(posX, 0), wxSize(itCol->width, getColumnLabelHeight())); + } + return wxRect(); +} + + +void Grid::refreshCell(size_t row, ColumnType colType) +{ + const wxRect& colArea = getColumnLabelArea(colType); //returns empty rect if column not found + const wxRect& rowArea = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found + if (colArea.width > 0 && rowArea.height > 0) + { + const wxPoint topLeft = CalcScrolledPosition(wxPoint(colArea.x, rowArea.y)); //logical -> window coordinates + const wxRect cellArea(topLeft, wxSize(colArea.width, rowArea.height)); + + getMainWin().RefreshRect(cellArea); + } +} + + +void Grid::setGridCursor(size_t row, GridEventPolicy rangeEventPolicy) +{ + mainWin_->setCursor(row, row); + makeRowVisible(row); + + selection_.clear(); //clear selection, do NOT fire event + selectRow(row, rangeEventPolicy); //set new selection + fire event +} + + +void Grid::makeRowVisible(size_t row) +{ + const wxRect labelRect = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found + if (labelRect.height > 0) + { + int pixelsPerUnitY = 0; + GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY); + if (pixelsPerUnitY > 0) + { + const wxPoint scrollPosOld = GetViewStart(); + + const int clientPosY = CalcScrolledPosition(labelRect.GetTopLeft()).y; + if (clientPosY < 0) + { + const int scrollPosNewY = labelRect.y / pixelsPerUnitY; + Scroll(scrollPosOld.x, scrollPosNewY); //internally calls wxWindows::Update()! + updateWindowSizes(); //may show horizontal scroll bar if row column gets wider + Refresh(); + } + else if (clientPosY + labelRect.height > rowLabelWin_->GetClientSize().GetHeight()) + { + auto execScroll = [&](int clientHeight) + { + const int scrollPosNewY = numeric::intDivCeil(labelRect.y + labelRect.height - clientHeight, pixelsPerUnitY); + Scroll(scrollPosOld.x, scrollPosNewY); + updateWindowSizes(); //may show horizontal scroll bar if row column gets wider + Refresh(); + }; + + const int clientHeightBefore = rowLabelWin_->GetClientSize().GetHeight(); + execScroll(clientHeightBefore); + + //client height may decrease after scroll due to a new horizontal scrollbar, resulting in a partially visible last row + const int clientHeightAfter = rowLabelWin_->GetClientSize().GetHeight(); + if (clientHeightAfter < clientHeightBefore) + execScroll(clientHeightAfter); + } + } + } +} + + +void Grid::scrollTo(size_t row) +{ + const wxRect labelRect = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found + if (labelRect.height > 0) + { + int pixelsPerUnitY = 0; + GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY); + if (pixelsPerUnitY > 0) + { + const int scrollPosNewY = labelRect.y / pixelsPerUnitY; + const wxPoint scrollPosOld = GetViewStart(); + + if (scrollPosOld.y != scrollPosNewY) //support polling + { + Scroll(scrollPosOld.x, scrollPosNewY); //internally calls wxWindows::Update()! + updateWindowSizes(); //may show horizontal scroll bar if row column gets wider + Refresh(); + } + } + } +} + + +bool Grid::Enable(bool enable) +{ + Refresh(); + return wxScrolledWindow::Enable(enable); +} + + +size_t Grid::getGridCursor() const +{ + return mainWin_->getCursor(); +} + + +int Grid::getBestColumnSize(size_t col) const +{ + if (dataView_ && col < visibleCols_.size()) + { + const ColumnType type = visibleCols_[col].type; + + wxInfoDC dc(mainWin_); + dc.SetFont(mainWin_->GetFont()); //harmonize with MainWin::render() + + const auto& [rowFirst, rowLast] = getVisibleRows(mainWin_->GetClientRect()); + + int maxSize = 0; + for (auto row = rowFirst; row < rowLast; ++row) + maxSize = std::max(maxSize, dataView_->getBestSize(dc, row, type)); + + return maxSize; + } + return -1; +} + + +void Grid::setColumnWidth(int width, size_t col, GridEventPolicy columnResizeEventPolicy, bool notifyAsync) +{ + if (col < visibleCols_.size()) + { + VisibleColumn& vcRs = visibleCols_[col]; + + const std::vector stretchedWidths = getColStretchedWidths(mainWin_->GetClientSize().GetWidth()); + if (stretchedWidths.size() != visibleCols_.size()) + { + assert(false); + return; + } + //CAVEATS: + //I. fixed-size columns: normalize offset so that resulting width is at least COLUMN_MIN_WIDTH_DIP: this is NOT enforced by getColWidths()! + //II. stretched columns: do not allow user to set offsets so small that they result in negative (non-normalized) widths: this gives an + //unusual delay when enlarging the column again later + width = std::max(width, dipToWxsize(COLUMN_MIN_WIDTH_DIP)); + + vcRs.offset = width - stretchedWidths[col]; //width := stretchedWidth + offset + + //III. resizing any column should normalize *all* other stretched columns' offsets considering current mainWinWidth! + // test case: + //1. have columns, both fixed-size and stretched, fit whole window width + //2. shrink main window width so that horizontal scrollbars are shown despite the streched column + //3. shrink a fixed-size column so that the scrollbars vanish and columns cover full width again + //4. now verify that the stretched column is resizing immediately if main window is enlarged again + for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2) + if (visibleCols_[col2].stretch > 0) //normalize stretched columns only + visibleCols_[col2].offset = std::max(visibleCols_[col2].offset, dipToWxsize(COLUMN_MIN_WIDTH_DIP) - stretchedWidths[col2]); + + if (columnResizeEventPolicy == GridEventPolicy::allow) + { + GridColumnResizeEvent sizeEvent(vcRs.offset, vcRs.type); + if (notifyAsync) + GetEventHandler()->AddPendingEvent(sizeEvent); + else + GetEventHandler()->ProcessEvent(sizeEvent); + } + } + else + assert(false); +} + + +void Grid::autoSizeColumns(GridEventPolicy columnResizeEventPolicy) +{ + if (allowColumnResize_) + { + for (size_t col = 0; col < visibleCols_.size(); ++col) + { + const int bestWidth = getBestColumnSize(col); //return -1 on error + if (bestWidth >= 0) + setColumnWidth(bestWidth, col, columnResizeEventPolicy, true /*notifyAsync*/); + } + updateWindowSizes(); + Refresh(); + } +} + + +std::vector Grid::getColStretchedWidths(int clientWidth) const //final width = (normalized) (stretchedWidth + offset) +{ + assert(clientWidth >= 0); + clientWidth = std::max(clientWidth, 0); + int stretchTotal = 0; + for (const VisibleColumn& vc : visibleCols_) + { + assert(vc.stretch >= 0); + stretchTotal += vc.stretch; + } + + int remainingWidth = clientWidth; + + std::vector output; + + if (stretchTotal <= 0) + output.resize(visibleCols_.size()); //fill with zeros + else + { + for (const VisibleColumn& vc : visibleCols_) + { + const int width = clientWidth * vc.stretch / stretchTotal; //rounds down! + output.push_back(width); + remainingWidth -= width; + } + + //distribute *all* of clientWidth: should suffice to enlarge the first few stretched columns; no need to minimize total absolute error of distribution + if (remainingWidth > 0) + for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2) + if (visibleCols_[col2].stretch > 0) + { + ++output[col2]; + if (--remainingWidth == 0) + break; + } + assert(remainingWidth == 0); + } + return output; +} + + +std::vector Grid::getColWidths() const +{ + return getColWidths(mainWin_->GetClientSize().GetWidth()); +} + + +std::vector Grid::getColWidths(int mainWinWidth) const //evaluate stretched columns +{ + const std::vector stretchedWidths = getColStretchedWidths(mainWinWidth); + assert(stretchedWidths.size() == visibleCols_.size()); + + std::vector output; + for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2) + { + const auto& vc = visibleCols_[col2]; + int width = stretchedWidths[col2] + vc.offset; + + if (vc.stretch > 0) + width = std::max(width, dipToWxsize(COLUMN_MIN_WIDTH_DIP)); //normalization really needed here: e.g. smaller main window would result in negative width + else + width = std::max(width, 0); //support smaller width than COLUMN_MIN_WIDTH_DIP if set via configuration + + output.push_back({vc.type, width}); + } + return output; +} + + +int Grid::getColWidthsSum(int mainWinWidth) const +{ + int sum = 0; + for (const ColumnWidth& cw : getColWidths(mainWinWidth)) + sum += cw.width; + return sum; +} diff --git a/wx+/grid.h b/wx+/grid.h new file mode 100644 index 0000000..9ee811b --- /dev/null +++ b/wx+/grid.h @@ -0,0 +1,404 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GRID_H_834702134831734869987 +#define GRID_H_834702134831734869987 + +#include +#include +#include +#include +#include + + +//a user-friendly, extensible and high-performance grid control +namespace zen +{ +enum class ColumnType { none = -1 }; //user-defiend column type +enum class HoverArea { none = -1 }; //user-defined area for mouse selections for a given row (may span multiple columns or split a single column into multiple areas) + +//------------------------ events ------------------------------------------------ +//example: wnd.Bind(EVENT_GRID_COL_LABEL_LEFT_CLICK, [this](GridClickEvent& event) { onGridLeftClick(event); }); + +struct GridClickEvent; +struct GridSelectEvent; +struct GridLabelClickEvent; +struct GridColumnResizeEvent; +struct GridContextMenuEvent; + +wxDECLARE_EVENT(EVENT_GRID_MOUSE_LEFT_DOUBLE, GridClickEvent); +wxDECLARE_EVENT(EVENT_GRID_MOUSE_LEFT_DOWN, GridClickEvent); +wxDECLARE_EVENT(EVENT_GRID_MOUSE_RIGHT_DOWN, GridClickEvent); + +wxDECLARE_EVENT(EVENT_GRID_SELECT_RANGE, GridSelectEvent); +//NOTE: neither first nor second row need to match EVENT_GRID_MOUSE_LEFT_DOWN/EVENT_GRID_MOUSE_LEFT_UP: user holding SHIFT; moving out of window... + +wxDECLARE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_LEFT, GridLabelClickEvent); +wxDECLARE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, GridLabelClickEvent); +wxDECLARE_EVENT(EVENT_GRID_COL_RESIZE, GridColumnResizeEvent); + +//wxContextMenuEvent? => generated by wxWidgets when right mouse down/up is not handled; even OS-dependent in which case event is generated +//=> inappropriate! we know better when to show context! +wxDECLARE_EVENT(EVENT_GRID_CONTEXT_MENU, GridContextMenuEvent); + + +struct GridClickEvent : public wxEvent +{ + GridClickEvent(wxEventType et, ptrdiff_t row, HoverArea hoverArea, const wxPoint& mousePos) : + wxEvent(0 /*winid*/, et), row_(row), hoverArea_(hoverArea), mousePos_(mousePos) {} + GridClickEvent* Clone() const override { return new GridClickEvent(*this); } + + const ptrdiff_t row_; //-1 for invalid position, >= rowCount if out of range + const HoverArea hoverArea_; //may be HoverArea::none + const wxPoint mousePos_; //Grid-relative coordinates +}; + +struct GridSelectEvent : public wxEvent +{ + GridSelectEvent(size_t rowFirst, size_t rowLast, bool positive, const GridClickEvent* mouseClick) : + wxEvent(0 /*winid*/, EVENT_GRID_SELECT_RANGE), rowFirst_(rowFirst), rowLast_(rowLast), positive_(positive), + mouseClick_(mouseClick ? *mouseClick : std::optional()) { assert(rowFirst <= rowLast); } + GridSelectEvent* Clone() const override { return new GridSelectEvent(*this); } + + const size_t rowFirst_; //selected range: [rowFirst_, rowLast_) + const size_t rowLast_; // + const bool positive_; //"false" when clearing selection! + const std::optional mouseClick_; //filled unless selection was performed via keyboard shortcuts +}; + +struct GridLabelClickEvent : public wxEvent +{ + GridLabelClickEvent(wxEventType et, ColumnType colType, const wxPoint& mousePos) : wxEvent(0 /*winid*/, et), colType_(colType), mousePos_(mousePos) {} + GridLabelClickEvent* Clone() const override { return new GridLabelClickEvent(*this); } + + const ColumnType colType_; //may be ColumnType::none + const wxPoint mousePos_; //Grid-relative coordinates +}; + +struct GridColumnResizeEvent : public wxEvent +{ + GridColumnResizeEvent(int offset, ColumnType colType) : wxEvent(0 /*winid*/, EVENT_GRID_COL_RESIZE), colType_(colType), offset_(offset) {} + GridColumnResizeEvent* Clone() const override { return new GridColumnResizeEvent(*this); } + + const ColumnType colType_; + const int offset_; +}; + +struct GridContextMenuEvent : public wxEvent +{ + GridContextMenuEvent(const wxPoint& mousePos) : wxEvent(0 /*winid*/, EVENT_GRID_CONTEXT_MENU), mousePos_(mousePos) {} + GridContextMenuEvent* Clone() const override { return new GridContextMenuEvent(*this); } + + const wxPoint mousePos_; //Grid-relative coordinates +}; +//------------------------------------------------------------------------------------------------------------ + +class GridData +{ +public: + virtual ~GridData() {} + + virtual size_t getRowCount() const = 0; + + //cell area: + virtual std::wstring getValue(size_t row, ColumnType colType) const = 0; + virtual void renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover); //default implementation + virtual void renderCell (wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover); + virtual int getBestSize (const wxReadOnlyDC& dc, size_t row, ColumnType colType); //must correspond to renderCell()! + virtual HoverArea getMouseHover(const wxReadOnlyDC& dc, size_t row, ColumnType colType, int cellRelativePosX, int cellWidth) { return HoverArea::none; } + virtual std::wstring getToolTip (size_t row, ColumnType colType, HoverArea rowHover) { return std::wstring(); } + + //label area: + virtual std::wstring getColumnLabel(ColumnType colType) const = 0; + virtual void renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted); //default implementation + virtual std::wstring getToolTip(ColumnType colType) const { return std::wstring(); } + + //optional helper routines: + static int getColumnGapLeft(); //for left-aligned text + static wxColor getColorSelectionGradientFrom(); + static wxColor getColorSelectionGradientTo(); + + static void drawCellText(wxDC& dc, const wxRect& rect, const std::wstring_view text, + int alignment = wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL, const wxSize* textExtentHint = nullptr); + static wxRect drawCellBorder(wxDC& dc, const wxRect& rect); //returns inner rectangle + + static wxRect drawColumnLabelBackground(wxDC& dc, const wxRect& rect, bool highlighted); //returns inner rectangle + static void drawColumnLabelText (wxDC& dc, const wxRect& rect, const std::wstring& text, bool enabled); +}; + + +enum class GridEventPolicy +{ + allow, + deny +}; + + +class Grid : public wxScrolledWindow +{ +public: + Grid(wxWindow* parent, + wxWindowID id = wxID_ANY, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = wxTAB_TRAVERSAL | wxNO_BORDER, + const wxString& name = wxASCII_STR(wxPanelNameStr)); + + size_t getRowCount() const; + + void setRowHeight(int height); + int getRowHeight() const; + + struct ColAttributes + { + ColumnType type = ColumnType::none; + //first, client width is partitioned according to all available stretch factors, then "offset_" is added + //universal model: a non-stretched column has stretch factor 0 with the "offset" becoming identical to final width! + int offset = 0; + int stretch = 0; //>= 0 + bool visible = false; + }; + + void setColumnConfig(const std::vector& attr); //set column count + widths + std::vector getColumnConfig() const; + + void setDataProvider(const std::shared_ptr& dataView) { dataView_ = dataView; } + /**/ GridData* getDataProvider() { return dataView_.get(); } + const GridData* getDataProvider() const { return dataView_.get(); } + //----------------------------------------------------------------------------- + + void setColumnLabelHeight(int height); + int getColumnLabelHeight() const; + void showRowLabel(bool visible); + + enum ScrollBarStatus + { + SB_SHOW_AUTOMATIC, + SB_SHOW_ALWAYS, + SB_SHOW_NEVER, + }; + //alternative until wxScrollHelper::ShowScrollbars() becomes available in wxWidgets 2.9 + void showScrollBars(ScrollBarStatus horizontal, ScrollBarStatus vertical); + + std::vector getSelectedRows() const { return selection_.get(); } + + void selectRow(size_t row, GridEventPolicy rangeEventPolicy); + void selectAllRows (GridEventPolicy rangeEventPolicy); //turn off range selection event when calling this function in an event handler to avoid recursion! + void clearSelection(GridEventPolicy rangeEventPolicy); // + void selectRange(size_t rowFirst, size_t rowLast, bool positive, GridEventPolicy rangeEventPolicy); //select [rowFirst, rowLast) + + void scrollDelta(int deltaX, int deltaY); //in scroll units + + wxWindow& getCornerWin (); + wxWindow& getRowLabelWin(); + wxWindow& getColLabelWin(); + wxWindow& getMainWin (); + const wxWindow& getMainWin() const; + + struct ColumnPosInfo + { + ColumnType colType = ColumnType::none; //ColumnType::none => no column at posX! + int cellRelativePosX = 0; + int colWidth = 0; + }; + ColumnPosInfo getColumnAtWinPos(int posX) const; + ptrdiff_t getRowAtWinPos(int posY) const; //return -1 for invalid position, >= rowCount if out of range + + std::pair getVisibleRows(const wxRect& clientRect) const; //returns range [begin, end) + + void refreshCell(size_t row, ColumnType colType); + + void enableColumnMove (bool value) { allowColumnMove_ = value; } + void enableColumnResize(bool value) { allowColumnResize_ = value; } + + void setGridCursor(size_t row, GridEventPolicy rangeEventPolicy); //set + show + select cursor + size_t getGridCursor() const; //returns row + + void scrollTo(size_t row); + + void makeRowVisible(size_t row); + + void Refresh(bool eraseBackground = true, const wxRect* rect = nullptr) override; + bool Enable(bool enable = true) override; + + //############################################################################################################ + +private: + void onKeyDown(wxKeyEvent& event); + + void updateWindowSizes(bool updateScrollbar = true); + + void selectWithCursor(ptrdiff_t row); //emits GridSelectEvent + + wxSize GetSizeAvailableForScrollTarget(const wxSize& size) override; //required since wxWidgets 2.9 if SetTargetWindow() is used + + + int getBestColumnSize(size_t col) const; //return -1 on error + + void autoSizeColumns(GridEventPolicy columnResizeEventPolicy); + + friend class GridData; + class SubWindow; + class CornerWin; + class RowLabelWin; + class ColLabelWin; + class MainWin; + + class Selection + { + public: + void resize(size_t rowCount) { selected_.resize(rowCount, false); } + + size_t gridSize() const { return selected_.size(); } + + std::vector get() const + { + std::vector result; + for (size_t row = 0; row < selected_.size(); ++row) + if (selected_[row] != 0) + result.push_back(row); + return result; + } + + bool isSelected(size_t row) const { return row < selected_.size() ? selected_[row] != 0 : false; } + + bool matchesRange(size_t rowFirst, size_t rowLast, bool positive) + { + if (rowFirst <= rowLast && rowLast <= selected_.size()) + { + const auto rangeEnd = selected_.begin() + rowLast; + return std::find(selected_.begin() + rowFirst, rangeEnd, static_cast(!positive)) == rangeEnd; + } + else + { + assert(false); + return false; + } + } + + void clear() { selectRange(0, selected_.size(), false); } + + void selectRange(size_t rowFirst, size_t rowLast, bool positive = true) //select [rowFirst, rowLast), trims if required! + { + assert(rowFirst <= rowLast && rowLast <= selected_.size()); + if (rowFirst < rowLast) + std::fill(selected_.begin() + std::min(rowFirst, selected_.size()), + selected_.begin() + std::min(rowLast, selected_.size()), positive); + } + + private: + std::vector selected_; //effectively a vector of size "number of rows" + }; + + struct VisibleColumn + { + ColumnType type = ColumnType::none; + int offset = 0; + int stretch = 0; //>= 0 + }; + + struct ColumnWidth + { + ColumnType type = ColumnType::none; + int width = 0; + }; + std::vector getColWidths() const; // + std::vector getColWidths(int mainWinWidth) const; //evaluate stretched columns + int getColWidthsSum(int mainWinWidth) const; + std::vector getColStretchedWidths(int clientWidth) const; //final width = (normalized) (stretchedWidth + offset) + + std::optional getColWidth(size_t col) const + { + const auto& widths = getColWidths(); + if (col < widths.size()) + return widths[col].width; + return {}; + } + + void setColumnWidth(int width, size_t col, GridEventPolicy columnResizeEventPolicy, bool notifyAsync = false); + + wxRect getColumnLabelArea(ColumnType colType) const; //returns empty rect if column not found + + //select inclusive range [rowFrom, rowTo] + void selectRange2(size_t rowFirst, size_t rowLast, bool positive, const GridClickEvent* mouseClick, GridEventPolicy rangeEventPolicy); + + bool isSelected(size_t row) const { return selection_.isSelected(row); } + + struct ColAction + { + bool wantResize = false; //"!wantResize" means "move" or "single click" + size_t col = 0; + }; + void moveColumn(size_t colFrom, size_t colTo); + + ColumnType colToType(size_t col) const; //returns ColumnType::none on error + + /* Grid window layout: + _______________________________ + | CornerWin | ColLabelWin | + |_____________|_______________| + | RowLabelWin | MainWin | + | | | + |_____________|_______________| */ + CornerWin* cornerWin_; + RowLabelWin* rowLabelWin_; + ColLabelWin* colLabelWin_; + MainWin* mainWin_; + + ScrollBarStatus showScrollbarH_ = SB_SHOW_AUTOMATIC; + ScrollBarStatus showScrollbarV_ = SB_SHOW_AUTOMATIC; + + bool drawRowLabel_ = true; + + std::shared_ptr dataView_; + Selection selection_; + bool allowColumnMove_ = true; + bool allowColumnResize_ = true; + + std::vector visibleCols_; //individual widths, type and total column count + std::vector oldColAttributes_; //visible + nonvisible columns; use for conversion in setColumnConfig()/getColumnConfig() *only*! + + size_t rowCountOld_ = 0; //at the time of last Grid::Refresh() + + int scrollBarHeightH_ = 0; //optional: may not be known (yet) + int scrollBarWidthV_ = 0; // +}; + +//------------------------------------------------------------------------------------------------------------ + +template +std::vector makeConsistent(const std::vector& attribs, const std::vector& defaults) +{ + std::vector output = attribs; + append(output, defaults); //make sure each type is existing! + removeDuplicatesStable(output, [](const ColAttrReal& lhs, const ColAttrReal& rhs) { return lhs.type < rhs.type; }); + return output; +} + + +template +std::vector convertColAttributes(const std::vector& attribs, const std::vector& defaults) +{ + std::vector output; + for (const ColAttrReal& ca : makeConsistent(attribs, defaults)) + output.push_back({static_cast(ca.type), ca.offset, ca.stretch, ca.visible}); + return output; +} + + +template +std::vector convertColAttributes(const std::vector& attribs) +{ + using ColTypeReal = decltype(ColAttrReal().type); + + std::vector output; + for (const Grid::ColAttributes& ca : attribs) + output.push_back({static_cast(ca.type), ca.offset, ca.stretch, ca.visible}); + return output; +} +} + +#endif //GRID_H_834702134831734869987 diff --git a/wx+/image_holder.h b/wx+/image_holder.h new file mode 100644 index 0000000..8902104 --- /dev/null +++ b/wx+/image_holder.h @@ -0,0 +1,72 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef IMAGE_HOLDER_H_284578426342567457 +#define IMAGE_HOLDER_H_284578426342567457 + +#include + #include +//used by fs/abstract.h => check carefully before adding dependencies! +//DO NOT add any wx/wx+ includes! + +namespace zen +{ +struct ImageHolder //prepare conversion to wxImage as much as possible while staying thread-safe (in contrast to wxIcon/wxBitmap) +{ + ImageHolder() {} + + ImageHolder(int w, int h, bool withAlpha) : //init with memory allocated + width_(w), height_(h), + rgb_( static_cast(::malloc(w * h * 3))), + alpha_(withAlpha ? static_cast(::malloc(w * h)) : nullptr) {} + + ImageHolder (ImageHolder&&) noexcept = default; // + ImageHolder& operator=(ImageHolder&&) noexcept = default; //move semantics only! + ImageHolder (const ImageHolder&) = delete; // + ImageHolder& operator=(const ImageHolder&) = delete; // + + explicit operator bool() const { return rgb_.get() != nullptr; } + + int getWidth () const { return width_; } + int getHeight() const { return height_; } + + unsigned char* getRgb () { return rgb_ .get(); } + unsigned char* getAlpha() { return alpha_.get(); } + + unsigned char* releaseRgb () { return rgb_ .release(); } + unsigned char* releaseAlpha() { return alpha_.release(); } + +private: + struct CLibFree { void operator()(unsigned char* p) const { ::free(p); } }; //use malloc/free to allow direct move into wxImage! + + int width_ = 0; + int height_ = 0; + std::unique_ptr rgb_; //optional + std::unique_ptr alpha_; // +}; + + +struct FileIconHolder +{ + //- GTK is NOT thread-safe! The most we can do from worker threads is retrieve a GIcon and later *try*(!) to convert it on the MAIN THREAD! >:( what a waste + //- at least g_file_query_info() *always* returns G_IS_THEMED_ICON(gicon) for native file systems => main thread won't block https://gitlab.gnome.org/GNOME/glib/blob/master/gio/glocalfileinfo.c#L1733 + //- what about G_IS_FILE_ICON(gicon), G_IS_LOADABLE_ICON(gicon)? => may block! => do NOT convert on main thread! (no big deal: doesn't seem to occur in practice) + FileIconHolder() {}; + + FileIconHolder(GIcon* icon, int maxSz) : //takes ownership! + gicon(icon), + maxSize(maxSz) {} + + struct GiconFree { void operator()(GIcon* icon) const { ::g_object_unref(icon); } }; + + std::unique_ptr gicon; + int maxSize = 0; + + explicit operator bool() const { return static_cast(gicon); } +}; +} + +#endif //IMAGE_HOLDER_H_284578426342567457 diff --git a/wx+/image_resources.cpp b/wx+/image_resources.cpp new file mode 100644 index 0000000..5611df8 --- /dev/null +++ b/wx+/image_resources.cpp @@ -0,0 +1,340 @@ +// ***************************************************************************** +// * 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 "image_resources.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "image_tools.h" +#include "image_holder.h" +#include "dc.h" + +using namespace zen; + + +namespace +{ +ImageHolder xbrzScale(int width, int height, const unsigned char* imageRgb, const unsigned char* imageAlpha, int hqScale) +{ + assert(imageRgb && imageAlpha && width > 0 && height > 0); //see convertToVanillaImage() + if (width <= 0 || height <= 0) + return ImageHolder(0, 0, true /*withAlpha*/); + + const int hqWidth = width * hqScale; + const int hqHeight = height * hqScale; + + //get rid of allocation and buffer std::vector<> at thread-level? => no discernable perf improvement + std::vector buf(hqWidth * hqHeight + width * height); + uint32_t* const argbSrc = buf.data() + hqWidth * hqHeight; + uint32_t* const xbrTrg = buf.data(); + + //convert RGB (RGB byte order) to ARGB (BGRA byte order) + { + const unsigned char* rgb = imageRgb; + const unsigned char* rgbEnd = rgb + 3 * width * height; + const unsigned char* alpha = imageAlpha; + uint32_t* out = argbSrc; + + for (; rgb < rgbEnd; rgb += 3) + *out++ = xbrz::makePixel(*alpha++, rgb[0], rgb[1], rgb[2]); + } + //----------------------------------------------------- + xbrz::scale(hqScale, //size_t factor - valid range: 2 - SCALE_FACTOR_MAX + argbSrc, //const uint32_t* src + xbrTrg, //uint32_t* trg + width, height, //int srcWidth, int srcHeight + xbrz::ColorFormat::argbUnbuffered); //ColorFormat colFmt + //test: total xBRZ scaling time with ARGB: 300ms, ARGB unbuffered: 50ms + //----------------------------------------------------- + //convert BGRA to RGB + alpha + ImageHolder trgImg(hqWidth, hqHeight, true /*withAlpha*/); + + std::for_each(xbrTrg, xbrTrg + hqWidth * hqHeight, [rgb = trgImg.getRgb(), alpha = trgImg.getAlpha()](uint32_t col) mutable + { + *alpha++ = xbrz::getAlpha(col); + *rgb++ = xbrz::getRed (col); + *rgb++ = xbrz::getGreen(col); + *rgb++ = xbrz::getBlue (col); + }); + return trgImg; +} + + +auto createScalerTask(const std::string& imageName, const wxImage& img, int hqScale, Protected>>& protResult) +{ + assert(runningOnMainThread()); + return [imageName, + width = img.GetWidth(), // + height = img.GetHeight(), //don't call these wxWidgets functions from worker thread + rgb = img.GetData(), // + alpha = img.GetAlpha(), // + hqScale, &protResult] + { + ImageHolder ih = xbrzScale(width, height, rgb, alpha, hqScale); + protResult.access([&](std::vector>& result) { result.emplace_back(imageName, std::move(ih)); }); + }; +} + + +class HqParallelScaler +{ +public: + explicit HqParallelScaler(int hqScale) : hqScale_(hqScale) { assert(hqScale > 1); } + + ~HqParallelScaler() { threadGroup_ = {}; } //imgKeeper_ must out-live threadGroup!!! + + void add(const std::string& imageName, const wxImage& img) + { + assert(runningOnMainThread()); + imgKeeper_.push_back(img); //retain (ref-counted) wxImage so that the rgb/alpha pointers remain valid after passed to threads + threadGroup_->run(createScalerTask(imageName, img, hqScale_, protResult_)); + } + + std::unordered_map waitAndGetResult() + { + assert(runningOnMainThread()); + threadGroup_->wait(); + + std::unordered_map output; + + protResult_.access([&](std::vector>& result) + { + for (auto& [imageName, ih] : result) + { + wxImage img(ih.getWidth(), ih.getHeight(), ih.releaseRgb(), false /*static_data*/); //pass ownership + img.SetAlpha(ih.releaseAlpha(), false /*static_data*/); + + output.emplace(imageName, std::move(img)); + } + }); + return output; + } + +private: + const int hqScale_; + std::vector imgKeeper_; + Protected>> protResult_; + + using TaskType = FunctionReturnTypeT; + std::optional> threadGroup_{ThreadGroup(std::max(std::thread::hardware_concurrency(), 1), Zstr("xBRZ Scaler"))}; + //hardware_concurrency() == 0 if "not computable or well defined" +}; + +//================================================================================================ +//================================================================================================ + +class ImageBuffer +{ +public: + explicit ImageBuffer(const Zstring& filePath); //throw FileError + + const wxImage& getImage(const std::string& name, int maxWidth /*optional*/, int maxHeight /*optional*/); + +private: + ImageBuffer (const ImageBuffer&) = delete; + ImageBuffer& operator=(const ImageBuffer&) = delete; + + const wxImage& getRawImage (const std::string& name); + const wxImage& getHqScaledImage(const std::string& name); + + std::unordered_map imagesRaw_; + std::unordered_map imagesScaled_; + + std::optional hqScaler_; + + using OutImageKey = std::tuple; + + struct OutImageKeyHash + { + size_t operator()(const OutImageKey& imKey) const + { + const auto& [name, height] = imKey; + + FNV1aHash hash; + for (const char c : name) + hash.add(c); + + hash.add(height); + + return hash.get(); + } + }; + std::unordered_map imagesOut_; +}; + + +ImageBuffer::ImageBuffer(const Zstring& zipPath) //throw FileError +{ + std::vector> streams; + + try //to load from ZIP first: + { + //wxFFileInputStream/wxZipInputStream loads in junks of 512 bytes => WTF!!! => implement sane file loading: + const std::string rawStream = getFileContent(zipPath, nullptr /*notifyUnbufferedIO*/); //throw FileError + wxMemoryInputStream memStream(rawStream.c_str(), rawStream.size()); //does not take ownership + wxZipInputStream zipStream(memStream, wxConvUTF8); + //do NOT rely on wxConvLocal! On failure shows unhelpful popup "Cannot convert from the charset 'Unknown encoding (-1)'!" + + while (const auto& entry = std::unique_ptr(zipStream.GetNextEntry())) //take ownership! + if (std::string stream(entry->GetSize(), '\0'); + zipStream.ReadAll(stream.data(), stream.size())) + streams.emplace_back(utfTo(entry->GetName()), std::move(stream)); + else + assert(false); + } + catch (FileError&) //fall back to folder: dev build (only!?) + { + const Zstring fallbackFolder = beforeLast(zipPath, Zstr(".zip"), IfNotFoundReturn::none); + if (!itemExists(fallbackFolder)) //throw FileError + throw; + + traverseFolder(fallbackFolder, [&](const FileInfo& fi) + { + if (endsWith(fi.fullPath, Zstr(".png"))) + { + std::string stream = getFileContent(fi.fullPath, nullptr /*notifyUnbufferedIO*/); //throw FileError + streams.emplace_back(fi.itemName, std::move(stream)); + } + }, nullptr, nullptr); //throw FileError + } + //-------------------------------------------------------------------- + + wxImage::AddHandler(new wxPNGHandler/*ownership passed*/); //activate support for .png files + + //do we need xBRZ scaling for high quality DPI images? + const int hqScale = std::clamp(static_cast(std::ceil(getScreenDpiScale())), 1, xbrz::SCALE_FACTOR_MAX); + //even for 125% DPI scaling, "2xBRZ + bilinear downscale" gives a better result than mere "125% bilinear upscale"! + if (hqScale > 1) + hqScaler_.emplace(hqScale); + + for (const auto& [fileName, stream] : streams) + if (endsWith(fileName, Zstr(".png"))) + { + wxMemoryInputStream wxstream(stream.c_str(), stream.size()); //stream does not take ownership of data + + wxImage img(wxstream, wxBITMAP_TYPE_PNG); + assert(img.IsOk()); + + //end this alpha/no-alpha/mask/wxDC::DrawBitmap/RTL/high-contrast-scheme interoperability nightmare here and now!!!! + //=> there's only one type of wxImage: with alpha channel, no mask!!! + convertToVanillaImage(img); + + const std::string imageName = utfTo(beforeLast(fileName, Zstr("."), IfNotFoundReturn::none)); + + imagesRaw_.emplace(imageName, img); + if (hqScaler_) + hqScaler_->add(imageName, img); //scale in parallel! + else + imagesScaled_.emplace(imageName, img); + + //wxBitmap::NewFromPNGData(stream.c_str(), stream.size())? + // => Windows: just a (slow!) wrapper for wxBitmap(wxImage())! + } + else + assert(false); +} + + +const wxImage& ImageBuffer::getRawImage(const std::string& name) +{ + if (auto it = imagesRaw_.find(name); + it != imagesRaw_.end()) + return it->second; + + assert(false); + return wxNullImage; +} + + +const wxImage& ImageBuffer::getHqScaledImage(const std::string& name) +{ + //test: this function is first called about 220ms after ImageBuffer::ImageBuffer() has ended + // => should be enough time to finish xBRZ scaling in parallel (which takes 50ms) + //debug perf: extra 800-1000ms during startup + if (hqScaler_) + { + imagesScaled_ = hqScaler_->waitAndGetResult(); + hqScaler_.reset(); + } + + if (auto it = imagesScaled_.find(name); + it != imagesScaled_.end()) + return it->second; + + assert(false); + return wxNullImage; +} + + +const wxImage& ImageBuffer::getImage(const std::string& name, int maxWidth /*optional*/, int maxHeight /*optional*/) +{ + const wxImage& rawImg = getRawImage(name); + + const wxSize dpiSize(dipToScreen(rawImg.GetWidth ()), + dipToScreen(rawImg.GetHeight())); + + int outHeight = dpiSize.y; + if (maxWidth >= 0 && maxWidth < dpiSize.x) + outHeight = numeric::intDivRound(maxWidth * rawImg.GetHeight(), rawImg.GetWidth()); + + if (maxHeight >= 0 && maxHeight < outHeight) + outHeight = maxHeight; + + const OutImageKey imgKey{name, outHeight}; + + auto it = imagesOut_.find(imgKey); + if (it == imagesOut_.end()) + { + if (rawImg.GetHeight() >= outHeight) //=> skip needless xBRZ upscaling + it = imagesOut_.emplace(imgKey, shrinkImage(rawImg, -1 /*maxWidth*/, outHeight)).first; + else if (rawImg.GetHeight() >= 0.9 * outHeight) //almost there: also no need for xBRZ-scale + it = imagesOut_.emplace(imgKey, bilinearScale(rawImg, numeric::intDivRound(outHeight * rawImg.GetWidth(), rawImg.GetHeight()), outHeight)).first; + else //however: for 125% DPI scaling, "2xBRZ + bilinear downscale" gives a better result than mere "125% bilinear upscale" + it = imagesOut_.emplace(imgKey, shrinkImage(getHqScaledImage(name), -1 /*maxWidth*/, outHeight)).first; + } + return it->second; +} + + +std::optional globalImageBuffer; +} + + +void zen::imageResourcesInit(const Zstring& zipPath) //throw FileError +{ + assert(runningOnMainThread()); //wxWidgets is not thread-safe! + assert(!globalImageBuffer); + globalImageBuffer.emplace(zipPath); //throw FileError +} + + +void zen::imageResourcesCleanup() +{ + assert(runningOnMainThread()); //wxWidgets is not thread-safe! + assert(globalImageBuffer); + globalImageBuffer.reset(); +} + + +const wxImage& zen::loadImage(const std::string& name, int maxWidth /*optional*/, int maxHeight /*optional*/) +{ + assert(runningOnMainThread()); //wxWidgets is not thread-safe! + assert(globalImageBuffer); + if (globalImageBuffer) + return globalImageBuffer->getImage(name, maxWidth, maxHeight); + return wxNullImage; +} + + +const wxImage& zen::loadImage(const std::string& name, int maxSize) +{ + return loadImage(name, maxSize, maxSize); +} diff --git a/wx+/image_resources.h b/wx+/image_resources.h new file mode 100644 index 0000000..0aa4dce --- /dev/null +++ b/wx+/image_resources.h @@ -0,0 +1,24 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef IMAGE_RESOURCES_H_8740257825342532457 +#define IMAGE_RESOURCES_H_8740257825342532457 + +#include +#include + + +namespace zen +{ +//pass resources .zip file at application startup +void imageResourcesInit(const Zstring& zipPath); //throw FileError +void imageResourcesCleanup(); + +const wxImage& loadImage(const std::string& name, int maxWidth /*optional*/, int maxHeight /*optional*/); +const wxImage& loadImage(const std::string& name, int maxSize = -1); +} + +#endif //IMAGE_RESOURCES_H_8740257825342532457 diff --git a/wx+/image_tools.cpp b/wx+/image_tools.cpp new file mode 100644 index 0000000..13de0a7 --- /dev/null +++ b/wx+/image_tools.cpp @@ -0,0 +1,506 @@ +// ***************************************************************************** +// * 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 "image_tools.h" +#include +#include +#include +#include +//#include +#include +#include +#include + +using namespace zen; + + +namespace +{ +template +void copyImageBlock(const unsigned char* src, int srcWidth, + /**/ unsigned char* trg, int trgWidth, int blockWidth, int blockHeight) +{ + assert(srcWidth >= blockWidth && trgWidth >= blockWidth); + const int srcPitch = srcWidth * PixBytes; + const int trgPitch = trgWidth * PixBytes; + const int blockPitch = blockWidth * PixBytes; + for (int y = 0; y < blockHeight; ++y) + std::memcpy(trg + y * trgPitch, src + y * srcPitch, blockPitch); +} + + +//...what wxImage::Resize() wants to be when it grows up +void copySubImage(const wxImage& src, wxPoint srcPos, + /**/ wxImage& trg, wxPoint trgPos, wxSize blockSize) +{ + auto pointClamp = [](const wxPoint& pos, const wxImage& img) -> wxPoint + { + return { + std::clamp(pos.x, 0, img.GetWidth ()), + std::clamp(pos.y, 0, img.GetHeight())}; + }; + auto subtract = [](const wxPoint& lhs, const wxPoint& rhs) { return wxSize{lhs.x - rhs.x, lhs.y - rhs.y}; }; + //work around yet another wxWidgets screw up: WTF does "operator-(wxPoint, wxPoint)" return wxPoint instead of wxSize!?? + + const wxPoint trgPos2 = pointClamp(trgPos, trg); + const wxPoint trgPos2End = pointClamp(trgPos + blockSize, trg); + + blockSize = subtract(trgPos2End, trgPos2); + srcPos += subtract(trgPos2, trgPos); + trgPos = trgPos2; + if (blockSize.x <= 0 || blockSize.y <= 0) + return; + + const wxPoint srcPos2 = pointClamp(srcPos, src); + const wxPoint srcPos2End = pointClamp(srcPos + blockSize, src); + + blockSize = subtract(srcPos2End, srcPos2); + trgPos += subtract(srcPos2, srcPos); + srcPos = srcPos2; + if (blockSize.x <= 0 || blockSize.y <= 0) + return; + //what if target block size is bigger than source block size? should we clear the area that is not copied from source? + + copyImageBlock<3>(src.GetData() + 3 * (srcPos.x + srcPos.y * src.GetWidth()), src.GetWidth(), + trg.GetData() + 3 * (trgPos.x + trgPos.y * trg.GetWidth()), trg.GetWidth(), + blockSize.x, blockSize.y); + + copyImageBlock<1>(src.GetAlpha() + srcPos.x + srcPos.y * src.GetWidth(), src.GetWidth(), + trg.GetAlpha() + trgPos.x + trgPos.y * trg.GetWidth(), trg.GetWidth(), + blockSize.x, blockSize.y); +} + + +void copyImageLayover(const wxImage& src, + /**/ wxImage& trg, wxPoint trgPos) +{ + const int srcWidth = src.GetWidth (); + const int srcHeight = src.GetHeight(); + const int trgWidth = trg.GetWidth(); + + assert(0 <= trgPos.x && trgPos.x + srcWidth <= trgWidth ); //draw area must be a + assert(0 <= trgPos.y && trgPos.y + srcHeight <= trg.GetHeight()); //subset of target image! + + const unsigned char* srcRgb = src.GetData(); + const unsigned char* srcAlpha = src.GetAlpha(); + + for (int y = 0; y < srcHeight; ++y) + { + unsigned char* trgRgb = trg.GetData () + 3 * (trgPos.x + (trgPos.y + y) * trgWidth); + unsigned char* trgAlpha = trg.GetAlpha() + trgPos.x + (trgPos.y + y) * trgWidth; + + for (int x = 0; x < srcWidth; ++x) + { + const unsigned char w1 = *srcAlpha; //alpha-composition interpreted as weighted average + const unsigned char w2 = numeric::intDivRound(*trgAlpha * (255 - w1), 255); + const unsigned char wSum = w1 + w2; + + auto calcColor = [w1, w2, wSum](unsigned char colsrc, unsigned char colTrg) + { + if (w1 == 0) return colTrg; + if (w2 == 0) return colsrc; + + //https://en.wikipedia.org/wiki/Alpha_compositing + //Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html + // => srgbEncode((srgbDecode(colsrc) * w1 + srgbDecode(colTrg) * w2) / wSum) + return static_cast(numeric::intDivRound(colsrc * w1 + colTrg * w2, int(wSum))); + }; + trgRgb[0] = calcColor(srcRgb[0], trgRgb[0]); + trgRgb[1] = calcColor(srcRgb[1], trgRgb[1]); + trgRgb[2] = calcColor(srcRgb[2], trgRgb[2]); + + *trgAlpha = wSum; + + srcRgb += 3; + trgRgb += 3; + ++srcAlpha; + ++trgAlpha; + } + } +} +} + + +wxImage zen::stackImages(const wxImage& img1, const wxImage& img2, ImageStackLayout dir, ImageStackAlignment align, int gap) +{ + assert(gap >= 0); + gap = std::max(0, gap); + + const int img1Width = img1.GetWidth (); + const int img1Height = img1.GetHeight(); + const int img2Width = img2.GetWidth (); + const int img2Height = img2.GetHeight(); + + const wxSize newSize = dir == ImageStackLayout::horizontal ? + wxSize(img1Width + gap + img2Width, std::max(img1Height, img2Height)) : + wxSize(std::max(img1Width, img2Width), img1Height + gap + img2Height); + + wxImage output(newSize); + output.SetAlpha(); + std::memset(output.GetAlpha(), wxIMAGE_ALPHA_TRANSPARENT, newSize.x * newSize.y); + + auto calcPos = [&](int imageExtent, int totalExtent) + { + switch (align) + { + case ImageStackAlignment::center: + return (totalExtent - imageExtent) / 2; + case ImageStackAlignment::left: //or top + return 0; + case ImageStackAlignment::right: //or bottom + return totalExtent - imageExtent; + } + assert(false); + return 0; + }; + + switch (dir) + { + case ImageStackLayout::horizontal: + copySubImage(img1, wxPoint(), output, wxPoint(0, calcPos(img1Height, newSize.y)), img1.GetSize()); + copySubImage(img2, wxPoint(), output, wxPoint(img1Width + gap, calcPos(img2Height, newSize.y)), img2.GetSize()); + break; + + case ImageStackLayout::vertical: + copySubImage(img1, wxPoint(), output, wxPoint(calcPos(img1Width, newSize.x), 0), img1.GetSize()); + copySubImage(img2, wxPoint(), output, wxPoint(calcPos(img2Width, newSize.x), img1Height + gap), img2.GetSize()); + break; + } + return output; +} + + +wxImage zen::createImageFromText(const wxString& text, const wxFont& font, const wxColor& col, ImageStackAlignment textAlign) +{ + wxMemoryDC dc; //the context used for bitmaps + setScaleFactor(dc, getScreenDpiScale()); + dc.SetFont(font); //the font parameter of GetTextExtent() is not evaluated on OS X, wxWidgets 2.9.5, so apply it to the DC directly! + + std::vector> lineInfo; //text + extent + for (const wxString& line : splitCpy(text, L'\n', SplitOnEmpty::allow)) + lineInfo.emplace_back(line, dc.GetTextExtent(line)); //GetTextExtent() returns (0, 0) for empty string! + //------------------------------------------------------------------------------------------------ + + int maxWidth = 0; + int lineHeight = 0; + for (const auto& [lineText, lineSize] : lineInfo) + { + maxWidth = std::max(maxWidth, lineSize.GetWidth()); + lineHeight = std::max(lineHeight, lineSize.GetHeight()); + } + if (maxWidth == 0 || lineHeight == 0) + return wxNullImage; + + const bool darkMode = relativeContrast(col, *wxBLACK) > //wxSystemSettings::GetAppearance().IsDark() ? + relativeContrast(col, *wxWHITE); //=> no, make it text color-dependent + //small but noticeable difference; due to "ClearType"? + + wxBitmap newBitmap(wxsizeToScreen(maxWidth), + wxsizeToScreen(static_cast(lineHeight * lineInfo.size()))); //seems we don't need to pass 24-bit depth here even for high-contrast color schemes + newBitmap.SetScaleFactor(getScreenDpiScale()); + { + dc.SelectObject(newBitmap); //copies scale factor from wxBitmap + ZEN_ON_SCOPE_EXIT(dc.SelectObject(wxNullBitmap)); + + if (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft) + dc.SetLayoutDirection(wxLayout_RightToLeft); //handle e.g. "weak" bidi characters: -> arrows in hebrew/arabic + + dc.SetBackground(darkMode ? *wxBLACK_BRUSH : *wxWHITE_BRUSH); + dc.Clear(); + + dc.SetTextBackground(darkMode ? *wxBLACK : *wxWHITE); //for proper alpha-channel calculation + dc.SetTextForeground(darkMode ? *wxWHITE : *wxBLACK); // + + int posY = 0; + for (const auto& [lineText, lineSize] : lineInfo) + { + if (!lineText.empty()) + switch (textAlign) + { + case ImageStackAlignment::left: + dc.DrawText(lineText, wxPoint(0, posY)); + break; + case ImageStackAlignment::right: + dc.DrawText(lineText, wxPoint(maxWidth - lineSize.GetWidth(), posY)); + break; + case ImageStackAlignment::center: + dc.DrawText(lineText, wxPoint((maxWidth - lineSize.GetWidth()) / 2, posY)); + break; + } + posY += lineHeight; + } + } + + wxImage output(newBitmap.ConvertToImage()); + output.SetAlpha(); + //wxDC::DrawLabel() doesn't respect alpha channel => calculate alpha values manually: + + unsigned char* rgb = output.GetData(); + unsigned char* alpha = output.GetAlpha(); + const int pixelCount = output.GetWidth() * output.GetHeight(); + + const unsigned char r = col.Red (); // + const unsigned char g = col.Green(); //getting RGB involves virtual function calls! + const unsigned char b = col.Blue (); // + + //Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html + //=> however wxDC::DrawText most likely applied alpha in gamma-encoded sRGB => following simple calculations should be fine: + + if (darkMode) //black(0,0,0) becomes wxIMAGE_ALPHA_TRANSPARENT(0), white(255,255,255) becomes wxIMAGE_ALPHA_OPAQUE(255) + for (int i = 0; i < pixelCount; ++i) + { + *alpha++ = static_cast(numeric::intDivRound(rgb[0] + rgb[1] + rgb[2], 3)); //mixed-mode arithmetics! + *rgb++ = r; // + *rgb++ = g; //apply actual text color + *rgb++ = b; // + } + else //black(0,0,0) becomes wxIMAGE_ALPHA_OPAQUE(255), white(255,255,255) becomes wxIMAGE_ALPHA_TRANSPARENT(0) + for (int i = 0; i < pixelCount; ++i) + { + *alpha++ = static_cast(numeric::intDivRound(3 * 255 - rgb[0] - rgb[1] - rgb[2], 3)); //mixed-mode arithmetics! + *rgb++ = r; // + *rgb++ = g; //apply actual text color + *rgb++ = b; // + } + + return output; +} + + +wxImage zen::layOver(const wxImage& back, const wxImage& front, int alignment) +{ + if (!front.IsOk()) return back; + assert(front.HasAlpha() && back.HasAlpha()); + + const wxSize newSize(std::max(back.GetWidth(), front.GetWidth()), + std::max(back.GetHeight(), front.GetHeight())); + + auto calcNewPos = [&](const wxImage& img) + { + wxPoint newPos; + if (alignment & wxALIGN_RIGHT) //note: wxALIGN_LEFT == 0! + newPos.x = newSize.GetWidth() - img.GetWidth(); + else if (alignment & wxALIGN_CENTER_HORIZONTAL) + newPos.x = (newSize.GetWidth() - img.GetWidth()) / 2; + + if (alignment & wxALIGN_BOTTOM) //note: wxALIGN_TOP == 0! + newPos.y = newSize.GetHeight() - img.GetHeight(); + else if (alignment & wxALIGN_CENTER_VERTICAL) + newPos.y = (newSize.GetHeight() - img.GetHeight()) / 2; + + return newPos; + }; + + wxImage output(newSize); + output.SetAlpha(); + std::memset(output.GetAlpha(), wxIMAGE_ALPHA_TRANSPARENT, newSize.x * newSize.y); + + copySubImage(back, wxPoint(), output, calcNewPos(back), back.GetSize()); + //use resizeCanvas()? might return ref-counted copy! + + //can't use wxMemoryDC and wxDC::DrawBitmap(): no alpha channel support on wxGTK! + copyImageLayover(front, output, calcNewPos(front)); + + return output; +} + + +wxImage zen::resizeCanvas(const wxImage& img, wxSize newSize, int alignment) +{ + if (newSize == img.GetSize()) + return img; //caveat: wxImage is ref-counted *without* copy on write + + wxPoint newPos; + if (alignment & wxALIGN_RIGHT) //note: wxALIGN_LEFT == 0! + newPos.x = newSize.GetWidth() - img.GetWidth(); + else if (alignment & wxALIGN_CENTER_HORIZONTAL) + newPos.x = numeric::intDivFloor(newSize.GetWidth() - img.GetWidth(), 2); //consistency: round down negative values, too! + + if (alignment & wxALIGN_BOTTOM) //note: wxALIGN_TOP == 0! + newPos.y = newSize.GetHeight() - img.GetHeight(); + else if (alignment & wxALIGN_CENTER_VERTICAL) + newPos.y = numeric::intDivFloor(newSize.GetHeight() - img.GetHeight(), 2); //consistency: round down negative values, too! + + wxImage output(newSize); + output.SetAlpha(); + std::memset(output.GetAlpha(), wxIMAGE_ALPHA_TRANSPARENT, newSize.x * newSize.y); + + copySubImage(img, wxPoint(), output, newPos, img.GetSize()); + //about 50x faster than e.g. wxImage::Resize!!! surprise :> + return output; +} + + +wxImage zen::bilinearScale(const wxImage& img, int width, int height) +{ + assert(img.HasAlpha()); + + const auto pixRead = [rgb = img.GetData(), alpha = img.GetAlpha(), srcWidth = img.GetSize().x](int x, int y) + { + const int idx = y * srcWidth + x; + + return [a = int(alpha[idx]), pix = rgb + idx * 3](int channel) + { + if (channel == 3) + return a; + //Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html + return pix[channel] * a; + }; + }; + + wxImage imgOut(width, height); + imgOut.SetAlpha(); + + const auto pixWrite = [rgb = imgOut.GetData(), alpha = imgOut.GetAlpha()](const auto& interpolate) mutable + { + const double a = interpolate(3); + if (a <= 0.0) + { + *alpha++ = 0; + rgb += 3; //don't care about color + } + else + { + *alpha++ = xbrz::byteRound(a); + *rgb++ = xbrz::byteRound(interpolate(0) / a); //r + *rgb++ = xbrz::byteRound(interpolate(1) / a); //g + *rgb++ = xbrz::byteRound(interpolate(2) / a); //b + } + }; + + xbrz::bilinearScale(pixRead, //PixReader pixRead + img.GetSize().x, //int srcWidth + img.GetSize().y, //int srcHeight + pixWrite, //PixWriter pixWrite + width, //int trgWidth + height, //int trgHeight + 0, //int yFirst + height); //int yLast + return imgOut; + //return img.Scale(width, height, wxIMAGE_QUALITY_BILINEAR); +} + + +wxImage zen::shrinkImage(const wxImage& img, int maxWidth /*optional*/, int maxHeight /*optional*/) +{ + wxSize newSize = img.GetSize(); + + if (0 <= maxWidth && maxWidth < newSize.x) + { + newSize.x = maxWidth; + newSize.y = numeric::intDivRound(maxWidth * img.GetHeight(), img.GetWidth()); + } + if (0 <= maxHeight && maxHeight < newSize.y) + { + newSize.x = numeric::intDivRound(maxHeight * img.GetWidth(), img.GetHeight()); //avoid loss of precision + newSize.y = maxHeight; + } + + if (newSize == img.GetSize()) + return img; + + return bilinearScale(img, newSize.x, newSize.y); //looks sharper than wxIMAGE_QUALITY_HIGH! +} + + +void zen::convertToVanillaImage(wxImage& img) +{ + if (!img.HasAlpha()) + { + const int width = img.GetWidth (); + const int height = img.GetHeight(); + if (width <= 0 || height <= 0) return; + + unsigned char maskR = 0; + unsigned char maskG = 0; + unsigned char maskB = 0; + const bool haveMask = img.HasMask() && img.GetOrFindMaskColour(&maskR, &maskG, &maskB); + //check for mask before calling wxImage::GetOrFindMaskColour() to skip needlessly searching for new mask color + + img.SetAlpha(); + ::memset(img.GetAlpha(), wxIMAGE_ALPHA_OPAQUE, width * height); + + //wxWidgets, as always, tries to be more clever than it really is and fucks up wxStaticBitmap if wxBitmap is fully opaque: + img.GetAlpha()[width * height - 1] = 254; + + if (haveMask) + { + img.SetMask(false); + unsigned char* alpha = img.GetAlpha(); + const unsigned char* rgb = img.GetData(); + + const int pixelCount = width * height; + for (int i = 0; i < pixelCount; ++i) + { + const unsigned char r = *rgb++; + const unsigned char g = *rgb++; + const unsigned char b = *rgb++; + + if (r == maskR && + g == maskG && + b == maskB) + alpha[i] = wxIMAGE_ALPHA_TRANSPARENT; + } + } + } + else + { + assert(!img.HasMask()); + } +} + + +wxImage zen::rectangleImage(wxSize size, const wxColor& col) +{ + assert(col.IsSolid()); + wxImage img(size); + + const unsigned char r = col.Red (); // + const unsigned char g = col.Green(); //getting RGB involves virtual function calls! + const unsigned char b = col.Blue (); // + + unsigned char* rgb = img.GetData(); + const int pixelCount = size.GetWidth() * size.GetHeight(); + for (int i = 0; i < pixelCount; ++i) + { + *rgb++ = r; + *rgb++ = g; + *rgb++ = b; + } + convertToVanillaImage(img); + return img; +} + + +wxImage zen::rectangleImage(wxSize size, const wxColor& innerCol, const wxColor& borderCol, int borderWidth) +{ + assert(innerCol.IsSolid() && borderCol.IsSolid()); + assert(borderWidth > 0); + wxImage img = rectangleImage(size, borderCol); + + const int heightInner = size.GetHeight() - 2 * borderWidth; + const int widthInner = size.GetWidth () - 2 * borderWidth; + + const unsigned char r = innerCol.Red (); // + const unsigned char g = innerCol.Green(); //getting RGB involves virtual function calls! + const unsigned char b = innerCol.Blue (); // + + if (widthInner > 0 && heightInner > 0 && innerCol != borderCol) + //copyImageLayover(rectangleImage({widthInner, heightInner}, innerCol), img, {borderWidth, borderWidth}); => inline: + for (int y = 0; y < heightInner; ++y) + { + unsigned char* rgb = img.GetData () + 3 * (borderWidth + (borderWidth + y) * size.GetWidth()); + + for (int x = 0; x < widthInner; ++x) + { + *rgb++ = r; + *rgb++ = g; + *rgb++ = b; + } + } + + return img; +} diff --git a/wx+/image_tools.h b/wx+/image_tools.h new file mode 100644 index 0000000..9cb5b71 --- /dev/null +++ b/wx+/image_tools.h @@ -0,0 +1,144 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef IMAGE_TOOLS_H_45782456427634254 +#define IMAGE_TOOLS_H_45782456427634254 + +#include +#include +#include +#include + + +namespace zen +{ +enum class ImageStackLayout +{ + horizontal, + vertical +}; + +enum class ImageStackAlignment //one-dimensional unlike wxAlignment +{ + center, + left, + right, + top = left, + bottom = right, +}; +wxImage stackImages(const wxImage& img1, const wxImage& img2, ImageStackLayout dir, ImageStackAlignment align, int gap = 0); + +wxImage createImageFromText(const wxString& text, const wxFont& font, const wxColor& col, ImageStackAlignment textAlign = ImageStackAlignment::left); //center/left/right + +wxImage layOver(const wxImage& back, const wxImage& front, int alignment = wxALIGN_CENTER); + +wxImage greyScale(const wxImage& img); //greyscale + brightness adaption +wxImage greyScaleIfDisabled(const wxImage& img, bool enabled); + +void adjustBrightness(wxImage& img, int targetLevel); +double getAvgBrightness(const wxImage& img); //in [0, 255] +void brighten(wxImage& img, int level); //level: delta per channel in points + +void convertToVanillaImage(wxImage& img); //add alpha channel if missing + remove mask if existing + +//wxColor gradient(const wxColor& from, const wxColor& to, double fraction); //maps fraction within [0, 1] to an intermediate color + +//wxColor hsvColor(double h, double s, double v); //h within [0, 360), s, v within [0, 1] + +//does *not* fuck up alpha channel like naive bilinear implementations, e.g. wxImage::Scale() +wxImage bilinearScale(const wxImage& img, int width, int height); + +wxImage shrinkImage(const wxImage& img, int maxWidth /*optional*/, int maxHeight /*optional*/); +inline wxImage shrinkImage(const wxImage& img, int maxSize) { return shrinkImage(img, maxSize, maxSize); } + +wxImage resizeCanvas(const wxImage& img, wxSize newSize, int alignment); + +wxImage rectangleImage(wxSize size, const wxColor& col); +wxImage rectangleImage(wxSize size, const wxColor& innerCol, const wxColor& borderCol, int borderWidth); + + + + + + + + + + +//################################### implementation ################################### + +inline +wxImage greyScale(const wxImage& img) //TODO support gamma-decoding and perceptual colors!? +{ + wxImage output = img.ConvertToGreyscale(1.0 / 3, 1.0 / 3, 1.0 / 3); //treat all channels equally + adjustBrightness(output, 160); + return output; +} + + +inline +wxImage greyScaleIfDisabled(const wxImage& img, bool enabled) +{ + if (enabled) //avoid ternary WTF + return img; + else + return greyScale(img); +} + + +inline +double getAvgBrightness(const wxImage& img) //TODO: consider gamma-encoded sRGB!? +{ + const int pixelCount = img.GetWidth() * img.GetHeight(); + auto pixBegin = img.GetData(); + + if (pixelCount > 0 && pixBegin) + { + auto pixEnd = pixBegin + 3 * pixelCount; //RGB + + if (img.HasAlpha()) + { + const unsigned char* alphaFirst = img.GetAlpha(); + + //calculate average weighted by alpha channel + double dividend = 0; + for (auto it = pixBegin; it != pixEnd; ++it) + dividend += *it * static_cast(alphaFirst[(it - pixBegin) / 3]); + + const double divisor = 3.0 * std::accumulate(alphaFirst, alphaFirst + pixelCount, 0.0); + + return numeric::isNull(divisor) ? 0 : dividend / divisor; + } + else + return std::accumulate(pixBegin, pixEnd, 0.0) / (3.0 * pixelCount); + } + return 0; +} + + +inline +void brighten(wxImage& img, int level) +{ + if (auto pixBegin = img.GetData()) + { + const int pixelCount = img.GetWidth() * img.GetHeight(); + auto pixEnd = pixBegin + 3 * pixelCount; //RGB + if (level > 0) + std::for_each(pixBegin, pixEnd, [level](unsigned char& c) { c = static_cast(std::min(c + level, 255)); }); + else + std::for_each(pixBegin, pixEnd, [level](unsigned char& c) { c = static_cast(std::max(c + level, 0)); }); + } +} + + +inline +void adjustBrightness(wxImage& img, int targetLevel) +{ + brighten(img, targetLevel - getAvgBrightness(img)); +} +} + +#endif //IMAGE_TOOLS_H_45782456427634254 diff --git a/wx+/no_flicker.h b/wx+/no_flicker.h new file mode 100644 index 0000000..6cc78b6 --- /dev/null +++ b/wx+/no_flicker.h @@ -0,0 +1,158 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef NO_FLICKER_H_893421590321532 +#define NO_FLICKER_H_893421590321532 + +#include +#include +#include +#include +#include +#include +#include +#include "color_tools.h" + + +namespace zen +{ +namespace +{ +void setText(wxTextCtrl& control, const wxString& newText, bool* additionalLayoutChange = nullptr) +{ + const wxString& label = control.GetValue(); //perf: don't call twice! + if (additionalLayoutChange && !*additionalLayoutChange && control.IsShown()) //never revert from true to false! + *additionalLayoutChange = label.length() != newText.length(); //avoid screen flicker: update layout only when necessary + + if (label != newText) + control.ChangeValue(newText); +} + + +void setText(wxStaticText& control, const wxString& newText, bool* additionalLayoutChange = nullptr) +{ + //wxControl::EscapeMnemonics() (& -> &&) => wxControl::GetLabelText/SetLabelText + //e.g. "filenames in the sync progress dialog": https://sourceforge.net/p/freefilesync/bugs/279/ + + const wxString& label = control.GetLabelText(); //perf: don't call twice! + if (additionalLayoutChange && !*additionalLayoutChange && control.IsShown()) //"better" or overkill(?): IsShownOnScreen() + *additionalLayoutChange = label.length() != newText.length(); //avoid screen flicker: update layout only when necessary + + if (label != newText) + control.SetLabelText(newText); +} + + +void setTextWithUrls(wxRichTextCtrl& richCtrl, const wxString& newText) +{ + enum class BlockType + { + text, + url, + }; + std::vector> blocks; + + for (auto it = newText.begin();;) + { + constexpr std::wstring_view urlPrefix = L"https://"; + const auto itUrl = std::search(it, newText.end(), urlPrefix.begin(), urlPrefix.end()); + if (it != itUrl) + blocks.emplace_back(BlockType::text, wxString(it, itUrl)); + + if (itUrl == newText.end()) + break; + + auto itUrlEnd = std::find_if(itUrl, newText.end(), [](wchar_t c) { return isWhiteSpace(c); }); + blocks.emplace_back(BlockType::url, wxString(itUrl, itUrlEnd)); + it = itUrlEnd; + } + richCtrl.BeginSuppressUndo(); + ZEN_ON_SCOPE_EXIT(richCtrl.EndSuppressUndo()); + + //fix mouse scroll speed: why the FUCK is this even necessary! + richCtrl.SetLineHeight(richCtrl.GetCharHeight()); + + //get rid of margins and space between text blocks/"paragraphs" + richCtrl.SetMargins({0, 0}); + richCtrl.BeginParagraphSpacing(0, 0); + ZEN_ON_SCOPE_EXIT(richCtrl.EndParagraphSpacing()); + + richCtrl.Clear(); + + wxRichTextAttr urlStyle; + urlStyle.SetTextColour(enhanceContrast(*wxBLUE, //primarily needed for dark mode! + wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), 5 /*contrastRatioMin*/)); //W3C recommends >= 4.5 + urlStyle.SetFontUnderlined(true); + + for (auto& [type, text] : blocks) + switch (type) + { + case BlockType::text: + if (endsWith(text, L"\n\n")) //bug: multiple newlines before a URL are condensed to only one; + //Why? fuck knows why! no such issue with double newlines *after* URL => hack this shit + text.RemoveLast().Append(ZERO_WIDTH_SPACE).Append(L'\n'); + + richCtrl.WriteText(text); + break; + + case BlockType::url: + { + richCtrl.BeginStyle(urlStyle); + ZEN_ON_SCOPE_EXIT(richCtrl.EndStyle()); + richCtrl.BeginURL(text); + ZEN_ON_SCOPE_EXIT(richCtrl.EndURL()); + richCtrl.WriteText(text); + } + break; + } + + //register only once! => use a global function pointer, so that Unbind() works correctly: + using LaunchUrlFun = void(*)(wxTextUrlEvent& event); + static const LaunchUrlFun launchUrl = [](wxTextUrlEvent& event) { wxLaunchDefaultBrowser(event.GetString()); }; + + [[maybe_unused]] const bool unbindOk1 = richCtrl.Unbind(wxEVT_TEXT_URL, launchUrl); + if (std::any_of(blocks.begin(), blocks.end(), [](const auto& item) { return item.first == BlockType::url; })) + /**/richCtrl.Bind(wxEVT_TEXT_URL, launchUrl); + + struct UserData : public wxObject + { + explicit UserData(wxRichTextCtrl& rtc) : richCtrl(rtc) {} + wxRichTextCtrl& richCtrl; + }; + using KeyEventsFun = void(*)(wxKeyEvent& event); + static const KeyEventsFun onKeyEvents = [](wxKeyEvent& event) + { + wxRichTextCtrl& richCtrl2 = dynamic_cast(event.GetEventUserData())->richCtrl; //unclear if we can rely on event.GetEventObject() == richCtrl + + //CTRL/SHIFT + INS is broken for wxRichTextCtrl on Windows/Linux (apparently never was a thing on macOS) + if (event.ControlDown()) + switch (event.GetKeyCode()) + { + case WXK_INSERT: + case WXK_NUMPAD_INSERT: + assert(richCtrl2.CanCopy()); //except when no selection + richCtrl2.Copy(); + return; + } + + if (event.ShiftDown()) + switch (event.GetKeyCode()) + { + case WXK_INSERT: + case WXK_NUMPAD_INSERT: + assert(richCtrl2.CanPaste()); //except wxTE_READONLY + richCtrl2.Paste(); + return; + } + event.Skip(); + }; + [[maybe_unused]] const bool unbindOk2 = richCtrl.Unbind(wxEVT_KEY_DOWN, onKeyEvents); + /**/ richCtrl. Bind(wxEVT_KEY_DOWN, onKeyEvents, wxID_ANY, wxID_ANY, new UserData(richCtrl) /*pass ownership*/); +} +} +} + +#endif //NO_FLICKER_H_893421590321532 diff --git a/wx+/popup_dlg.cpp b/wx+/popup_dlg.cpp new file mode 100644 index 0000000..e288513 --- /dev/null +++ b/wx+/popup_dlg.cpp @@ -0,0 +1,398 @@ +// ***************************************************************************** +// * 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 "popup_dlg.h" +#include +#include +#include +#include +#include +#include "bitmap_button.h" +#include "no_flicker.h" +#include "window_layout.h" +#include "image_resources.h" +#include "popup_dlg_generated.h" +#include "taskbar.h" +#include "window_tools.h" + + +using namespace zen; + + +namespace +{ +void setBestInitialSize(wxRichTextCtrl& ctrl, const wxString& text, wxSize maxSize) +{ + const int scrollbarWidth = dipToWxsize(25); /*not only scrollbar, but also left/right padding (on macOS)! + better use slightly larger than exact value (Windows: 17, Linux(CentOS): 14, macOS: 25) + => worst case: minor increase in rowCount (no big deal) + slightly larger bestSize.x (good!) */ + + if (maxSize.x <= scrollbarWidth) //implicitly checks for non-zero, too! + return; + + const int rowGap = 0; + int maxLineWidth = 0; + int rowHeight = 0; //alternative: just call ctrl.GetCharHeight()!? + int rowCount = 0; + bool haveLineWrap = false; + + auto evalLineExtent = [&](const wxSize& sz) -> bool //return true when done + { + assert(rowHeight == 0 || rowHeight == sz.y + rowGap); //all rows *should* have same height + rowHeight = std::max(rowHeight, sz.y + rowGap); + maxLineWidth = std::max(maxLineWidth, sz.x); + + const int wrappedRows = numeric::intDivCeil(sz.x, maxSize.x - scrollbarWidth); //round up: consider line-wraps! + rowCount += wrappedRows; + if (wrappedRows > 1) + haveLineWrap = true; + + return rowCount * rowHeight >= maxSize.y; + }; + + for (auto it = text.begin();;) + { + auto itEnd = std::find(it, text.end(), L'\n'); + wxString line(it, itEnd); + if (line.empty()) + line = L' '; //GetTextExtent() returns (0, 0) for empty strings! + + wxSize sz = ctrl.GetTextExtent(line); //exactly gives row height, but does *not* consider newlines + if (evalLineExtent(sz)) + break; + + if (itEnd == text.end()) + break; + it = itEnd + 1; + } + + int extraWidth = 0; + if (haveLineWrap) //compensate for trivial intDivCeil() not... + extraWidth += ctrl.GetTextExtent(L"FreeFileSync").x / 2; //...understanding line wrap algorithm + + const wxSize bestSize(std::min(maxLineWidth + scrollbarWidth /*1*/+ extraWidth, maxSize.x), + std::min(rowHeight * (rowCount + 1 /*2*/), maxSize.y)); + //1: wxWidgets' layout algorithm sucks: e.g. shows scrollbar *nedlessly* => extra line wrap increases height => scrollbar suddenly *needed*: catch 22! + //2: add some vertical space just for looks (*instead* of using border gap)! Extra space needed anyway to avoid scrollbars on Windows (2 px) and macOS (11 px) + + ctrl.SetMinSize(bestSize); //alas, SetMinClientSize() is just not working! +#if 0 + std::cerr << "rowCount " << rowCount << "\n" << + "maxLineWidth " << maxLineWidth << "\n" << + "rowHeight " << rowHeight << "\n" << + "haveLineWrap " << haveLineWrap << "\n" << + "scrollbarWidth " << scrollbarWidth << "\n\n"; +#endif +} +} + + +int zen::getTextCtrlHeight(wxTextCtrl& ctrl, double rowCount) +{ + const int rowHeight = + ctrl.GetTextExtent(L"X").GetHeight(); + + return std::round( + 2 + + rowHeight * rowCount); +} + + +class zen::StandardPopupDialog : public PopupDialogGenerated +{ +public: + StandardPopupDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, + const wxString& labelAccept, // + const wxString& labelAccept2, //optional, except: if "decline" or "accept2" is passed, so must be "accept" + const wxString& labelDecline) : // + PopupDialogGenerated(parent), + checkBoxValue_(cfg.checkBoxValue), + buttonToDisableWhenChecked_(cfg.buttonToDisableWhenChecked) + { + + //ensure wxWidgets' and our high-DPI handling are still matching + assert(GetDPIScaleFactor() == getScreenDpiScale()); + + if (type != DialogInfoType::info) + try + { + taskbar_.emplace(parent); //throw TaskbarNotAvailable + switch (type) + { + case DialogInfoType::info: + break; + case DialogInfoType::warning: + taskbar_->setStatus(Taskbar::Status::warning); + break; + case DialogInfoType::error: + taskbar_->setStatus(Taskbar::Status::error); + break; + } + } + catch (TaskbarNotAvailable&) {} + + + wxImage iconTmp; + wxString titleTmp; + switch (type) + { + case DialogInfoType::info: + //"Information" is meaningless as caption text! + //confirmation doesn't use info icon + //iconTmp = loadImage("msg_info"); + break; + case DialogInfoType::warning: + iconTmp = loadImage("msg_warning"); + titleTmp = _("Warning"); + break; + case DialogInfoType::error: + iconTmp = loadImage("msg_error"); + titleTmp = _("Error"); + break; + } + if (cfg.icon.IsOk()) + iconTmp = cfg.icon; + + if (!cfg.title.empty()) + titleTmp = cfg.title; + //----------------------------------------------- + if (iconTmp.IsOk()) + setImage(*m_bitmapMsgType, iconTmp); + + if (!parent || !parent->IsShownOnScreen()) + titleTmp = wxTheApp->GetAppDisplayName() + (!titleTmp.empty() ? SPACED_DASH + titleTmp : wxString()); + SetTitle(titleTmp); + + int maxWidth = dipToWxsize(500); + int maxHeight = dipToWxsize(400); //try to determine better value based on actual display resolution: + if (parent) + if (const int disPos = wxDisplay::GetFromWindow(parent); //window must be visible + disPos != wxNOT_FOUND) + maxHeight = wxDisplay(disPos).GetClientArea().GetHeight() * 2 / 3; + + assert(!cfg.textMain.empty() || !cfg.textDetail.empty()); + if (!cfg.textMain.empty()) + { + setMainInstructionFont(*m_staticTextMain); + m_staticTextMain->SetLabelText(cfg.textMain); + m_staticTextMain->Wrap(maxWidth); //call *after* SetLabel() + } + else + m_staticTextMain->Hide(); + + if (!cfg.textDetail.empty()) + { + const wxString& text = trimCpy(cfg.textDetail); + setBestInitialSize(*m_richTextDetail, text, wxSize(maxWidth, maxHeight)); + setTextWithUrls(*m_richTextDetail, text); + } + else + m_richTextDetail->Hide(); + + if (checkBoxValue_) + { + assert(contains(cfg.checkBoxLabel, L'&')); + m_checkBoxCustom->SetLabel(cfg.checkBoxLabel); + m_checkBoxCustom->SetValue(*checkBoxValue_); + } + else + m_checkBoxCustom->Hide(); + + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& event) { onLocalKeyEvent(event); }); //dialog-specific local key events + + //play sound reminder when waiting for user confirmation + if (!cfg.soundFileAlertPending.empty()) + { + timer_.Bind(wxEVT_TIMER, [this, parent, alertSoundPath = cfg.soundFileAlertPending](wxTimerEvent& event) + { + //wxWidgets shows modal error dialog by default => "no, wxWidgets, NO!" + wxLog* oldLogTarget = wxLog::SetActiveTarget(new wxLogStderr); //transfer and receive ownership! + ZEN_ON_SCOPE_EXIT(delete wxLog::SetActiveTarget(oldLogTarget)); + + wxSound::Play(utfTo(alertSoundPath), wxSOUND_ASYNC); + + RequestUserAttention(wxUSER_ATTENTION_INFO); + /* wxUSER_ATTENTION_INFO: flashes window 3 times, unconditionally + wxUSER_ATTENTION_ERROR: flashes without limit, but *only* if not in foreground (FLASHW_TIMERNOFG) :( */ + if (parent) + if (auto tlw = dynamic_cast(&getRootWindow(*parent))) + tlw->RequestUserAttention(wxUSER_ATTENTION_INFO); //top-level window needed for the taskbar flash! + }); + timer_.Start(60'000 /*unit: [ms]*/); + } + + //------------------------------------------------------------------------------ + + auto setButtonImage = [&](wxButton& button, ConfirmationButton3 btnType) + { + auto it = cfg.buttonImages.find(btnType); + if (it != cfg.buttonImages.end()) + setImage(button, it->second); //caveat: image + text at the same time not working on GTK < 2.6 + }; + setButtonImage(*m_buttonAccept, ConfirmationButton3::accept); + setButtonImage(*m_buttonAccept2, ConfirmationButton3::accept2); + setButtonImage(*m_buttonDecline, ConfirmationButton3::decline); + setButtonImage(*m_buttonCancel, ConfirmationButton3::cancel); + + + if (cfg.disabledButtons.contains(ConfirmationButton3::accept )) m_buttonAccept ->Disable(); + if (cfg.disabledButtons.contains(ConfirmationButton3::accept2)) m_buttonAccept2->Disable(); + if (cfg.disabledButtons.contains(ConfirmationButton3::decline)) m_buttonDecline->Disable(); + assert(!cfg.disabledButtons.contains(ConfirmationButton3::cancel)); + assert(!cfg.disabledButtons.contains(cfg.buttonToDisableWhenChecked)); + + + StdButtons stdBtns; + stdBtns.setAffirmative(m_buttonAccept); + if (labelAccept.empty()) //notification dialog + { + assert(labelAccept2.empty() && labelDecline.empty()); + m_buttonAccept->SetLabel(_("Close")); //UX Guide: use "Close" for errors, warnings and windows in which users can't make changes (no ampersand!) + m_buttonAccept2->Hide(); + m_buttonDecline->Hide(); + m_buttonCancel ->Hide(); + } + else + { + assert(contains(labelAccept, L"&")); + m_buttonAccept->SetLabel(labelAccept); + stdBtns.setCancel(m_buttonCancel); + + if (labelDecline.empty()) //confirmation dialog(YES/CANCEL) + m_buttonDecline->Hide(); + else //confirmation dialog(YES/NO/CANCEL) + { + assert(contains(labelDecline, L"&")); + m_buttonDecline->SetLabel(labelDecline); + stdBtns.setNegative(m_buttonDecline); + + //m_buttonConfirm->SetId(wxID_IGNORE); -> setting id after button creation breaks "mouse snap to" functionality + //m_buttonDecline->SetId(wxID_RETRY); -> also wxWidgets docs seem to hide some info: "Normally, the identifier should be provided on creation and should not be modified subsequently." + } + + if (labelAccept2.empty()) + m_buttonAccept2->Hide(); + else + { + assert(contains(labelAccept2, L"&")); + m_buttonAccept2->SetLabel(labelAccept2); + stdBtns.setAffirmativeAll(m_buttonAccept2); + } + } + //set std order after button visibility was set + setStandardButtonLayout(*bSizerStdButtons, stdBtns); + + updateGui(); + + + GetSizer()->SetSizeHints(this); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + Show(); //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //Hide(); -> avoids old position flash before Center() on GNOME but causes hang on KDE? https://freefilesync.org/forum/viewtopic.php?t=10103#p42404 +#endif + Center(); //apply *after* dialog size change! + + + Raise(); //[!] popup may be triggered by ffs_batch job running in the background! + + if (m_buttonAccept->IsEnabled()) + m_buttonAccept->SetFocus(); + else if (m_buttonAccept2->IsEnabled()) + m_buttonAccept2->SetFocus(); + else + m_buttonCancel->SetFocus(); + } + +private: + void onClose (wxCloseEvent& event) override { EndModal(static_cast(ConfirmationButton3::cancel)); } + void onCancel(wxCommandEvent& event) override { EndModal(static_cast(ConfirmationButton3::cancel)); } + + void onButtonAccept(wxCommandEvent& event) override + { + if (checkBoxValue_) + *checkBoxValue_ = m_checkBoxCustom->GetValue(); + EndModal(static_cast(ConfirmationButton3::accept)); + } + + void onButtonAccept2(wxCommandEvent& event) override + { + if (checkBoxValue_) + *checkBoxValue_ = m_checkBoxCustom->GetValue(); + EndModal(static_cast(ConfirmationButton3::accept2)); + } + + void onButtonDecline(wxCommandEvent& event) override + { + if (checkBoxValue_) + *checkBoxValue_ = m_checkBoxCustom->GetValue(); + EndModal(static_cast(ConfirmationButton3::decline)); + } + + void onLocalKeyEvent(wxKeyEvent& event) + { + switch (event.GetKeyCode()) + { + case WXK_ESCAPE: //handle case where cancel button is hidden! + EndModal(static_cast(ConfirmationButton3::cancel)); + return; + } + event.Skip(); + } + + void onCheckBoxClick(wxCommandEvent& event) override { updateGui(); event.Skip(); } + + void updateGui() + { + switch (buttonToDisableWhenChecked_) + { + case ConfirmationButton3::accept: m_buttonAccept ->Enable(!m_checkBoxCustom->GetValue()); break; + case ConfirmationButton3::accept2: m_buttonAccept2->Enable(!m_checkBoxCustom->GetValue()); break; + case ConfirmationButton3::decline: m_buttonDecline->Enable(!m_checkBoxCustom->GetValue()); break; + case ConfirmationButton3::cancel: break; + } + } + + bool* checkBoxValue_; + const ConfirmationButton3 buttonToDisableWhenChecked_; + std::optional taskbar_; + wxTimer timer_; +}; + +//######################################################################################## + +void zen::showNotificationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg) +{ + StandardPopupDialog dlg(parent, type, cfg, wxString() /*labelAccept*/, wxString() /*labelAccept2*/, wxString() /*labelDecline*/); + dlg.ShowModal(); +} + + +ConfirmationButton zen::showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept) +{ + StandardPopupDialog dlg(parent, type, cfg, labelAccept, wxString() /*labelAccept2*/, wxString() /*labelDecline*/); + return static_cast(dlg.ShowModal()); +} + + +ConfirmationButton2 zen::showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept, const wxString& labelAccept2) +{ + StandardPopupDialog dlg(parent, type, cfg, labelAccept, labelAccept2, wxString() /*labelDecline*/); + return static_cast(dlg.ShowModal()); +} + + +ConfirmationButton3 zen::showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept, const wxString& labelAccept2, const wxString& labelDecline) +{ + StandardPopupDialog dlg(parent, type, cfg, labelAccept, labelAccept2, labelDecline); + return static_cast(dlg.ShowModal()); +} + + +QuestionButton2 zen::showQuestionDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelYes, const wxString& labelNo) +{ + StandardPopupDialog dlg(parent, type, cfg, labelYes, wxString() /*labelAccept2*/, labelNo); + return static_cast(dlg.ShowModal()); +} diff --git a/wx+/popup_dlg.h b/wx+/popup_dlg.h new file mode 100644 index 0000000..92a60b4 --- /dev/null +++ b/wx+/popup_dlg.h @@ -0,0 +1,103 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef POPUP_DLG_H_820780154723456 +#define POPUP_DLG_H_820780154723456 + +#include +#include +#include +#include +#include +#include +#include + + +namespace zen +{ +//parent window, optional: support correct dialog placement above parent on multiple monitor systems +//this module requires error, warning and info image files in Icons.zip, see + +enum class DialogInfoType +{ + info, + warning, + error, +}; + +enum class ConfirmationButton3 +{ + cancel, + accept, + accept2, + decline, +}; +enum class ConfirmationButton +{ + cancel = static_cast(ConfirmationButton3::cancel), //[!] Clang requires "static_cast" + accept = static_cast(ConfirmationButton3::accept), // +}; +enum class ConfirmationButton2 +{ + cancel = static_cast(ConfirmationButton3::cancel), + accept = static_cast(ConfirmationButton3::accept), + accept2 = static_cast(ConfirmationButton3::accept2), +}; +enum class QuestionButton2 +{ + cancel = static_cast(ConfirmationButton3::cancel), + yes = static_cast(ConfirmationButton3::accept), + no = static_cast(ConfirmationButton3::decline), +}; + +struct PopupDialogCfg; + +void showNotificationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg); +ConfirmationButton showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept); +ConfirmationButton2 showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept, const wxString& labelAccept2); +ConfirmationButton3 showConfirmationDialog(wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelAccept, const wxString& labelAccept2, const wxString& labelDecline); +QuestionButton2 showQuestionDialog (wxWindow* parent, DialogInfoType type, const PopupDialogCfg& cfg, const wxString& labelYes, const wxString& labelNo); + +//---------------------------------------------------------------------------------------------------------------- +class StandardPopupDialog; + +struct PopupDialogCfg +{ + PopupDialogCfg& setIcon (const wxImage& bmp ) { icon = bmp; return *this; } + PopupDialogCfg& setTitle (const wxString& label) { title = label; return *this; } + PopupDialogCfg& setMainInstructions (const wxString& label) { textMain = label; return *this; } //set at least one of these! + PopupDialogCfg& setDetailInstructions(const wxString& label) { textDetail = label; return *this; } // + PopupDialogCfg& disableButton(ConfirmationButton3 button) { disabledButtons.insert(button); return *this; } + PopupDialogCfg& setButtonImage(ConfirmationButton3 button, const wxImage& img) { buttonImages.emplace(button, img); return *this; } + PopupDialogCfg& alertWhenPending(const Zstring& soundFilePath) { soundFileAlertPending = soundFilePath; return *this; } + PopupDialogCfg& setCheckBox(bool& value, const wxString& label, ConfirmationButton3 disableWhenChecked = ConfirmationButton3::cancel) + { + checkBoxValue = &value; + checkBoxLabel = label; + buttonToDisableWhenChecked = disableWhenChecked; + return *this; + } + +private: + friend class StandardPopupDialog; + + wxImage icon; + wxString title; + wxString textMain; + wxString textDetail; + std::unordered_set disabledButtons; + std::unordered_map buttonImages; + Zstring soundFileAlertPending; + bool* checkBoxValue = nullptr; //in/out + wxString checkBoxLabel; + ConfirmationButton3 buttonToDisableWhenChecked = ConfirmationButton3::cancel; +}; + + +int getTextCtrlHeight(wxTextCtrl& ctrl, double rowCount); +} + +#endif //POPUP_DLG_H_820780154723456 diff --git a/wx+/popup_dlg_generated.cpp b/wx+/popup_dlg_generated.cpp new file mode 100644 index 0000000..1741abc --- /dev/null +++ b/wx+/popup_dlg_generated.cpp @@ -0,0 +1,124 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#include "popup_dlg_generated.h" + +/////////////////////////////////////////////////////////////////////////// + +PopupDialogGenerated::PopupDialogGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxSize( -1, -1 ), wxDefaultSize ); + this->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_BTNFACE ) ); + + wxBoxSizer* bSizer24; + bSizer24 = new wxBoxSizer( wxVERTICAL ); + + wxPanel* m_panel33; + m_panel33 = new wxPanel( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + m_panel33->SetBackgroundColour( wxSystemSettings::GetColour( wxSYS_COLOUR_WINDOW ) ); + + wxBoxSizer* bSizer165; + bSizer165 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapMsgType = new wxStaticBitmap( m_panel33, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizer165->Add( m_bitmapMsgType, 0, wxALL, 10 ); + + wxBoxSizer* bSizer16; + bSizer16 = new wxBoxSizer( wxVERTICAL ); + + + bSizer16->Add( 0, 10, 0, 0, 5 ); + + m_staticTextMain = new wxStaticText( m_panel33, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMain->Wrap( -1 ); + bSizer16->Add( m_staticTextMain, 0, wxBOTTOM|wxRIGHT, 10 ); + + m_richTextDetail = new wxRichTextCtrl( m_panel33, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_READONLY|wxBORDER_NONE|wxVSCROLL|wxWANTS_CHARS ); + bSizer16->Add( m_richTextDetail, 1, wxEXPAND, 5 ); + + + bSizer165->Add( bSizer16, 1, wxEXPAND, 5 ); + + + m_panel33->SetSizer( bSizer165 ); + m_panel33->Layout(); + bSizer165->Fit( m_panel33 ); + bSizer24->Add( m_panel33, 1, wxEXPAND, 5 ); + + wxStaticLine* m_staticline6; + m_staticline6 = new wxStaticLine( this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL ); + bSizer24->Add( m_staticline6, 0, wxEXPAND, 5 ); + + wxBoxSizer* bSizer25; + bSizer25 = new wxBoxSizer( wxVERTICAL ); + + m_checkBoxCustom = new wxCheckBox( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer25->Add( m_checkBoxCustom, 0, wxALIGN_CENTER_HORIZONTAL|wxALL, 5 ); + + bSizerStdButtons = new wxBoxSizer( wxHORIZONTAL ); + + m_buttonAccept = new wxButton( this, wxID_YES, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + + m_buttonAccept->SetDefault(); + bSizerStdButtons->Add( m_buttonAccept, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT|wxLEFT, 5 ); + + m_buttonAccept2 = new wxButton( this, wxID_YESTOALL, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonAccept2, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonDecline = new wxButton( this, wxID_NO, _("dummy"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonDecline, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT, 5 ); + + m_buttonCancel = new wxButton( this, wxID_CANCEL, _("Cancel"), wxDefaultPosition, wxSize( -1, -1 ), 0 ); + bSizerStdButtons->Add( m_buttonCancel, 0, wxALIGN_CENTER_VERTICAL|wxBOTTOM|wxRIGHT, 5 ); + + + bSizer25->Add( bSizerStdButtons, 0, wxALIGN_RIGHT, 5 ); + + + bSizer24->Add( bSizer25, 0, wxEXPAND, 5 ); + + + this->SetSizer( bSizer24 ); + this->Layout(); + bSizer24->Fit( this ); + + // Connect Events + this->Connect( wxEVT_CLOSE_WINDOW, wxCloseEventHandler( PopupDialogGenerated::onClose ) ); + m_checkBoxCustom->Connect( wxEVT_COMMAND_CHECKBOX_CLICKED, wxCommandEventHandler( PopupDialogGenerated::onCheckBoxClick ), NULL, this ); + m_buttonAccept->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PopupDialogGenerated::onButtonAccept ), NULL, this ); + m_buttonAccept2->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PopupDialogGenerated::onButtonAccept2 ), NULL, this ); + m_buttonDecline->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PopupDialogGenerated::onButtonDecline ), NULL, this ); + m_buttonCancel->Connect( wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler( PopupDialogGenerated::onCancel ), NULL, this ); +} + +PopupDialogGenerated::~PopupDialogGenerated() +{ +} + +TooltipDlgGenerated::TooltipDlgGenerated( wxWindow* parent, wxWindowID id, const wxString& title, const wxPoint& pos, const wxSize& size, long style ) : wxDialog( parent, id, title, pos, size, style ) +{ + this->SetSizeHints( wxDefaultSize, wxDefaultSize ); + + wxBoxSizer* bSizer158; + bSizer158 = new wxBoxSizer( wxHORIZONTAL ); + + m_bitmapLeft = new wxStaticBitmap( this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0 ); + bSizer158->Add( m_bitmapLeft, 0, wxALL|wxALIGN_CENTER_VERTICAL, 5 ); + + m_staticTextMain = new wxStaticText( this, wxID_ANY, _("dummy"), wxDefaultPosition, wxDefaultSize, 0 ); + m_staticTextMain->Wrap( 600 ); + bSizer158->Add( m_staticTextMain, 0, wxALL|wxALIGN_CENTER_HORIZONTAL|wxALIGN_CENTER_VERTICAL, 5 ); + + + this->SetSizer( bSizer158 ); + this->Layout(); + bSizer158->Fit( this ); +} + +TooltipDlgGenerated::~TooltipDlgGenerated() +{ +} diff --git a/wx+/popup_dlg_generated.h b/wx+/popup_dlg_generated.h new file mode 100644 index 0000000..f931bab --- /dev/null +++ b/wx+/popup_dlg_generated.h @@ -0,0 +1,89 @@ +/////////////////////////////////////////////////////////////////////////// +// C++ code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) +// http://www.wxformbuilder.org/ +// +// PLEASE DO *NOT* EDIT THIS FILE! +/////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "zen/i18n.h" + +/////////////////////////////////////////////////////////////////////////// + + +/////////////////////////////////////////////////////////////////////////////// +/// Class PopupDialogGenerated +/////////////////////////////////////////////////////////////////////////////// +class PopupDialogGenerated : public wxDialog +{ +private: + +protected: + wxStaticBitmap* m_bitmapMsgType; + wxStaticText* m_staticTextMain; + wxRichTextCtrl* m_richTextDetail; + wxCheckBox* m_checkBoxCustom; + wxBoxSizer* bSizerStdButtons; + wxButton* m_buttonAccept; + wxButton* m_buttonAccept2; + wxButton* m_buttonDecline; + wxButton* m_buttonCancel; + + // Virtual event handlers, override them in your derived class + virtual void onClose( wxCloseEvent& event ) { event.Skip(); } + virtual void onCheckBoxClick( wxCommandEvent& event ) { event.Skip(); } + virtual void onButtonAccept( wxCommandEvent& event ) { event.Skip(); } + virtual void onButtonAccept2( wxCommandEvent& event ) { event.Skip(); } + virtual void onButtonDecline( wxCommandEvent& event ) { event.Skip(); } + virtual void onCancel( wxCommandEvent& event ) { event.Skip(); } + + +public: + + PopupDialogGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = _("dummy"), const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER ); + + ~PopupDialogGenerated(); + +}; + +/////////////////////////////////////////////////////////////////////////////// +/// Class TooltipDlgGenerated +/////////////////////////////////////////////////////////////////////////////// +class TooltipDlgGenerated : public wxDialog +{ +private: + +protected: + +public: + wxStaticBitmap* m_bitmapLeft; + wxStaticText* m_staticTextMain; + + TooltipDlgGenerated( wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = wxEmptyString, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = wxDEFAULT_DIALOG_STYLE ); + + ~TooltipDlgGenerated(); + +}; + diff --git a/wx+/rtl.h b/wx+/rtl.h new file mode 100644 index 0000000..c7c3f38 --- /dev/null +++ b/wx+/rtl.h @@ -0,0 +1,112 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RTL_H_0183487180058718273432148 +#define RTL_H_0183487180058718273432148 + +#include +#include +#include +#include "dc.h" + + +namespace zen +{ +//functions supporting right-to-left GUI layout +void drawBitmapRtlMirror (wxDC& dc, const wxImage& img, const wxRect& rect, int alignment, std::optional& buffer); +void drawBitmapRtlNoMirror(wxDC& dc, const wxImage& img, const wxRect& rect, int alignment); +//wxDC::DrawIcon DOES mirror by default -> implement RTL support when needed + +wxImage mirrorIfRtl(const wxImage& img); + +//manual text flow correction: https://www.w3.org/International/articles/inline-bidi-markup/ + + + + + + + + +//---------------------- implementation ------------------------ +namespace impl +{ +//don't use wxDC::DrawLabel: +// - expensive GetTextExtent() call even when passing an empty string!!! +// - 1-off alignment bugs! +inline +void drawBitmapAligned(wxDC& dc, const wxImage& img, const wxRect& rect, int alignment) +{ + wxPoint pt = rect.GetTopLeft(); + if (alignment & wxALIGN_RIGHT) //note: wxALIGN_LEFT == 0! + pt.x += rect.width - screenToWxsize(img.GetWidth()); + else if (alignment & wxALIGN_CENTER_HORIZONTAL) + pt.x += (rect.width - screenToWxsize(img.GetWidth())) / 2; + + if (alignment & wxALIGN_BOTTOM) //note: wxALIGN_TOP == 0! + pt.y += rect.height - screenToWxsize(img.GetHeight()); + else if (alignment & wxALIGN_CENTER_VERTICAL) + pt.y += (rect.height - screenToWxsize(img.GetHeight())) / 2; + + dc.DrawBitmap(toScaledBitmap(img), pt); +} +} + + +inline +void drawBitmapRtlMirror(wxDC& dc, const wxImage& img, const wxRect& rect, int alignment, std::optional& buffer) +{ + switch (dc.GetLayoutDirection()) + { + case wxLayout_LeftToRight: + return impl::drawBitmapAligned(dc, img, rect, alignment); + + case wxLayout_RightToLeft: + if (rect.GetWidth() > 0 && rect.GetHeight() > 0) + { + if (!buffer || buffer->GetSize() != rect.GetSize()) //[!] since we do a mirror, width needs to match exactly! + buffer.emplace(rect.GetSize()); + + if (buffer->GetScaleFactor() != dc.GetContentScaleFactor()) //needed here? + buffer->SetScaleFactor(dc.GetContentScaleFactor()); // + + wxMemoryDC memDc(*buffer); //copies scale factor from wxBitmap + memDc.Blit(wxPoint(0, 0), rect.GetSize(), &dc, rect.GetTopLeft()); //blit in: background is mirrored due to memDc/dc having different layout direction! + + impl::drawBitmapAligned(memDc, img, wxRect(0, 0, rect.width, rect.height), alignment); + //note: we cannot simply use memDc.SetLayoutDirection(wxLayout_RightToLeft) due to some strange 1 pixel bug! 2022-04-04: maybe fixed in wxWidgets 3.1.6? + + dc.Blit(rect.GetTopLeft(), rect.GetSize(), &memDc, wxPoint(0, 0)); //blit out: mirror once again + } + break; + + case wxLayout_Default: //CAVEAT: wxPaintDC/wxMemoryDC on wxGTK/wxMAC does not implement SetLayoutDirection()!!! => GetLayoutDirection() == wxLayout_Default + if (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft) + return impl::drawBitmapAligned(dc, img.Mirror(), rect, alignment); + else + return impl::drawBitmapAligned(dc, img, rect, alignment); + } +} + + +inline +void drawBitmapRtlNoMirror(wxDC& dc, const wxImage& img, const wxRect& rect, int alignment) +{ + return impl::drawBitmapAligned(dc, img, rect, alignment); //wxDC::DrawBitmap does NOT mirror by default +} + + +inline +wxImage mirrorIfRtl(const wxImage& img) +{ + if (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft) + return img.Mirror(); + else + return img; +} +} + +#endif //RTL_H_0183487180058718273432148 diff --git a/wx+/std_button_layout.h b/wx+/std_button_layout.h new file mode 100644 index 0000000..2a2fd7c --- /dev/null +++ b/wx+/std_button_layout.h @@ -0,0 +1,142 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STD_BUTTON_LAYOUT_H_183470321478317214 +#define STD_BUTTON_LAYOUT_H_183470321478317214 + +#include +#include +#include "dc.h" + + +namespace zen +{ +struct StdButtons +{ + StdButtons& setAffirmative (wxButton* btn) { btnYes = btn; return *this; } + StdButtons& setAffirmativeAll(wxButton* btn) { btnYes2 = btn; return *this; } + StdButtons& setNegative (wxButton* btn) { btnNo = btn; return *this; } + StdButtons& setCancel (wxButton* btn) { btnCancel = btn; return *this; } + + wxButton* btnYes = nullptr; + wxButton* btnYes2 = nullptr; + wxButton* btnNo = nullptr; + wxButton* btnCancel = nullptr; +}; + +void setStandardButtonLayout(wxBoxSizer& sizer, const StdButtons& buttons = StdButtons()); +//sizer width will change! => call wxWindow::Fit and wxWindow::Dimensions + + +inline +constexpr int getMenuIconDipSize() +{ + return 20; +} + + +inline +int getDefaultButtonHeight() +{ + const int defaultHeight = wxButton::GetDefaultSize().GetHeight(); //buffered by wxWidgets + return std::max(defaultHeight, dipToWxsize(31)); //default button height is much too small => increase! +} + + + + + + + + + + +//--------------- impelementation ------------------------------------------- +inline +void setStandardButtonLayout(wxBoxSizer& sizer, const StdButtons& buttons) +{ + assert(sizer.GetOrientation() == wxHORIZONTAL); + + //GNOME Human Interface Guidelines: https://developer.gnome.org/hig-book/3.2/hig-book.html#alert-spacing + const int spaceH = dipToWxsize( 6); //OK + const int spaceRimH = dipToWxsize(12); //OK + const int spaceRimV = dipToWxsize(12); //OK + + StdButtons buttonsTmp = buttons; + + auto detach = [&](wxButton*& btn) + { + if (btn) + { + assert(btn->GetContainingSizer() == &sizer); + if (btn->IsShown() && sizer.Detach(btn)) + return; + + assert(false); //why is it hidden!? + btn = nullptr; + } + }; + + detach(buttonsTmp.btnYes); + detach(buttonsTmp.btnYes2); + detach(buttonsTmp.btnNo); + detach(buttonsTmp.btnCancel); + + + //"All your fixed-size spacers are belong to us!" => have a clean slate: consider repeated setStandardButtonLayout() calls + for (size_t pos = sizer.GetItemCount(); pos-- > 0;) + if (wxSizerItem& item = *sizer.GetItem(pos); + item.IsSpacer() && item.GetProportion() == 0 && item.GetSize().y == 0) + { + [[maybe_unused]] const bool rv = sizer.Detach(pos); + assert(rv); + } + + //set border on left considering existing items + if (!sizer.IsEmpty()) //for yet another retarded reason wxWidgets will have wxSizer::GetItem(0) cause an assert rather than just return nullptr as documented + if (wxSizerItem& item = *sizer.GetItem(static_cast(0)); + item.IsShown()) + { + assert(item.GetBorder() <= spaceRimV); //pragmatic check: other controls in the sizer should not have a larger border + + if (const int flag = item.GetFlag(); + flag & wxLEFT) + item.SetFlag(flag & ~wxLEFT); + + sizer.Prepend(spaceRimH, 0); + } + + + bool settingFirstButton = true; + auto attach = [&](wxButton* btn) + { + if (btn) + { + assert(btn->GetMinSize().GetHeight() == -1); //let OS or this routine do the sizing! note: OS X does not allow changing the (visible!) button height! + btn->SetMinSize({-1, getDefaultButtonHeight()}); + + if (settingFirstButton) + settingFirstButton = false; + else + sizer.Add(spaceH, 0); + sizer.Add(btn, 0, wxTOP | wxBOTTOM | wxALIGN_CENTER_VERTICAL, spaceRimV); + } + }; + + sizer.Add(spaceRimH, 0); + attach(buttonsTmp.btnNo); + attach(buttonsTmp.btnCancel); + attach(buttonsTmp.btnYes2); + attach(buttonsTmp.btnYes); + + sizer.Add(spaceRimH, 0); + + //OS X: there should be at least one button following the gap after the "dangerous" no-button + assert(buttonsTmp.btnYes || buttonsTmp.btnCancel); +} +} + +#endif //STD_BUTTON_LAYOUT_H_183470321478317214 diff --git a/wx+/taskbar.cpp b/wx+/taskbar.cpp new file mode 100644 index 0000000..2cc3150 --- /dev/null +++ b/wx+/taskbar.cpp @@ -0,0 +1,19 @@ +// ***************************************************************************** +// * 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 "taskbar.h" + + +using namespace zen; + + +class Taskbar::Impl {}; + +Taskbar::Taskbar(wxWindow* window) { throw TaskbarNotAvailable(); } +Taskbar::~Taskbar() {} + +void Taskbar::setStatus(Status status) {} +void Taskbar::setProgress(double fraction) {} diff --git a/wx+/taskbar.h b/wx+/taskbar.h new file mode 100644 index 0000000..f660013 --- /dev/null +++ b/wx+/taskbar.h @@ -0,0 +1,42 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TASKBAR_H_98170845709124456 +#define TASKBAR_H_98170845709124456 + +#include +#include + + +namespace zen +{ +class TaskbarNotAvailable {}; + +class Taskbar +{ +public: + Taskbar(wxWindow* window); //throw TaskbarNotAvailable + ~Taskbar(); + + enum class Status + { + normal, + indeterminate, + warning, + error, + paused, + }; + + void setStatus(Status status); //noexcept + void setProgress(double fraction); //between [0, 1]; noexcept + +private: + class Impl; + const std::unique_ptr pimpl_; +}; +} + +#endif //TASKBAR_H_98170845709124456 diff --git a/wx+/toggle_button.h b/wx+/toggle_button.h new file mode 100644 index 0000000..29aea60 --- /dev/null +++ b/wx+/toggle_button.h @@ -0,0 +1,89 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TOGGLE_BUTTON_H_8173024810574556 +#define TOGGLE_BUTTON_H_8173024810574556 + +#include +#include + + +namespace zen +{ +class ToggleButton : public wxBitmapButton +{ +public: + //wxBitmapButton constructor + ToggleButton(wxWindow* parent, + wxWindowID id, + const wxBitmap& bitmap, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = 0, + const wxValidator& validator = wxDefaultValidator, + const wxString& name = wxASCII_STR(wxButtonNameStr)) : + wxBitmapButton(parent, id, bitmap, pos, size, style, validator, name) {} + + //wxButton constructor + ToggleButton(wxWindow* parent, + wxWindowID id, + const wxString& label, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize, + long style = 0, + const wxValidator& validator = wxDefaultValidator, + const wxString& name = wxASCII_STR(wxButtonNameStr)) : + wxBitmapButton(parent, id, + //(FreeFileSync_x86_64:77379): Gtk-CRITICAL **: 11:04:31.752: IA__gtk_widget_modify_style: assertion 'GTK_IS_WIDGET (widget)' failed + rectangleImage({1, 1}, *wxRED), + pos, size, style, validator, name) + { + SetLabel(label); + } + + void init(const wxImage& imgActive, + const wxImage& imgInactive); + + void setActive(bool value); + bool isActive() const { return active_; } + void toggle() { setActive(!active_); } + +private: + bool active_ = false; + wxImage imgActive_; + wxImage imgInactive_; +}; + + + + + + + +//######################## implementation ######################## +inline +void ToggleButton::init(const wxImage& imgActive, + const wxImage& imgInactive) +{ + imgActive_ = imgActive; + imgInactive_ = imgInactive; + + setImage(*this, active_ ? imgActive_ : imgInactive_); +} + + +inline +void ToggleButton::setActive(bool value) +{ + if (active_ != value) + { + active_ = value; + setImage(*this, active_ ? imgActive_ : imgInactive_); + } +} +} + +#endif //TOGGLE_BUTTON_H_8173024810574556 diff --git a/wx+/tooltip.cpp b/wx+/tooltip.cpp new file mode 100644 index 0000000..007abbf --- /dev/null +++ b/wx+/tooltip.cpp @@ -0,0 +1,120 @@ +// ***************************************************************************** +// * 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 "tooltip.h" +#include +#include +#include +#include +#include +#include +#include "bitmap_button.h" +#include "dc.h" + #include + +using namespace zen; + + +namespace +{ +const int TIP_WINDOW_OFFSET_DIP = 20; +} + + +class Tooltip::TooltipDlgGenerated : public wxDialog +{ +public: + TooltipDlgGenerated(wxWindow* parent) : //Suse Linux/X11: needs parent window, else there are z-order issues + wxDialog(parent, wxID_ANY, L"" /*title*/, wxDefaultPosition, wxDefaultSize, wxSIMPLE_BORDER /*style*/) + //wxSIMPLE_BORDER side effect: removes title bar on KDE + { + SetSizeHints(wxDefaultSize, wxDefaultSize); + SetExtraStyle(this->GetExtraStyle() | wxWS_EX_TRANSIENT); + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_INFOBK)); //both required: on Ubuntu background is black, foreground white! + SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_INFOTEXT)); // + + wxBoxSizer* bSizer158 = new wxBoxSizer(wxHORIZONTAL); + bitmapLeft_ = new wxStaticBitmap(this, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxDefaultSize, 0); + bSizer158->Add(bitmapLeft_, 0, wxALL | wxALIGN_CENTER_VERTICAL, 5); + + staticTextMain_ = new wxStaticText(this, wxID_ANY, wxString(), wxDefaultPosition, wxDefaultSize, 0); + bSizer158->Add(staticTextMain_, 0, wxALL | wxALIGN_CENTER_HORIZONTAL | wxALIGN_CENTER_VERTICAL, 5); + + SetSizer(bSizer158); + + } + + bool AcceptsFocus() const override { return false; } //any benefit? + + wxStaticText* staticTextMain_ = nullptr; + wxStaticBitmap* bitmapLeft_ = nullptr; +}; + + +void Tooltip::show(const wxString& text, wxPoint mousePos, const wxImage* img) +{ + if (!tipWindow_) + tipWindow_ = new TooltipDlgGenerated(&parent_); //ownership passed to parent + + const wxImage& newImg = img ? *img : wxNullImage; + + const bool imgChanged = !newImg.IsSameAs(lastUsedImg_); + const bool txtChanged = text != lastUsedText_; + + if (imgChanged) + { + lastUsedImg_ = newImg; + setImage(*tipWindow_->bitmapLeft_, newImg); + // tipWindow_->Refresh(); //needed if bitmap size changed! ->??? + } + + if (txtChanged) + { + lastUsedText_ = text; + tipWindow_->staticTextMain_->SetLabelText(text); + + tipWindow_->staticTextMain_->Wrap(dipToWxsize(600)); + } + + if (imgChanged || txtChanged) + //tipWindow_->Dimensions(); -> apparently not needed!? + tipWindow_->GetSizer()->SetSizeHints(tipWindow_); //~=Fit() + SetMinSize() +#ifdef __WXGTK3__ + //GTK3 size calculation requires visible window: https://github.com/wxWidgets/wxWidgets/issues/16088 + //=> call wxWindow::Show() to "execute" +#endif + + const wxPoint newPos = mousePos + wxPoint(wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft ? + - dipToWxsize(TIP_WINDOW_OFFSET_DIP) - tipWindow_->GetSize().GetWidth() : + dipToWxsize(TIP_WINDOW_OFFSET_DIP), + dipToWxsize(TIP_WINDOW_OFFSET_DIP)); + + if (newPos != tipWindow_->GetScreenPosition()) + tipWindow_->Move(newPos); + //caveat: possible endless loop! mouse pointer must NOT be within tipWindow! + //else it will trigger a wxEVT_LEAVE_WINDOW on middle grid which will hide the window, causing the window to be shown again via this method, etc. + + if (!tipWindow_->IsShown()) + tipWindow_->Show(); +} + + +void Tooltip::hide() +{ + if (tipWindow_) + { +#if GTK_MAJOR_VERSION == 2 //the tooltip sometimes turns blank or is not shown again after it was hidden: e.g. drag-selection on middle grid + //=> no such issues on GTK3! + tipWindow_->Destroy(); //apply brute force: + tipWindow_ = nullptr; // + lastUsedImg_ = wxNullImage; + +#elif GTK_MAJOR_VERSION == 3 + tipWindow_->Hide(); +#else +#error unknown GTK version! +#endif + } +} diff --git a/wx+/tooltip.h b/wx+/tooltip.h new file mode 100644 index 0000000..e20cb3d --- /dev/null +++ b/wx+/tooltip.h @@ -0,0 +1,35 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TOOLTIP_H_8912740832170515 +#define TOOLTIP_H_8912740832170515 + +#include +#include + + +namespace zen +{ +class Tooltip +{ +public: + Tooltip(wxWindow& parent) : parent_(parent) {} //parent needs to live at least as long as this instance! + + void show(const wxString& text, + wxPoint mousePos, //absolute screen coordinates + const wxImage* img = nullptr); + void hide(); + +private: + class TooltipDlgGenerated; + TooltipDlgGenerated* tipWindow_ = nullptr; + wxWindow& parent_; + wxImage lastUsedImg_; + wxString lastUsedText_; //needed, considering "SetLabelText(textFixed)" +}; +} + +#endif //TOOLTIP_H_8912740832170515 diff --git a/wx+/window_layout.h b/wx+/window_layout.h new file mode 100644 index 0000000..dee29fb --- /dev/null +++ b/wx+/window_layout.h @@ -0,0 +1,84 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef WINDOW_LAYOUT_H_23849632846734343234532 +#define WINDOW_LAYOUT_H_23849632846734343234532 + +#include +#include + #include +#include "color_tools.h" +#include "dc.h" + + +namespace zen +{ +//set portable font size in multiples of the operating system's default font size +void setRelativeFontSize(wxWindow& control, double factor); +void setMainInstructionFont(wxWindow& control); //following Windows/Gnome/OS X guidelines + +void setDefaultWidth(wxSpinCtrl& m_spinCtrl); + + + + + + + + +//###################### implementation ##################### +inline +void setRelativeFontSize(wxWindow& control, double factor) +{ + wxFont font = control.GetFont(); + font.SetPointSize(std::round(wxNORMAL_FONT->GetPointSize() * factor)); + control.SetFont(font); +} + + +inline +void setMainInstructionFont(wxWindow& control) +{ + wxFont font = control.GetFont(); + font.SetPointSize(std::round(wxNORMAL_FONT->GetPointSize() * 12.0 / 11)); + font.SetWeight(wxFONTWEIGHT_BOLD); + + control.SetFont(font); +} + + +inline +void setDefaultWidth(wxSpinCtrl& m_spinCtrl) +{ +#ifdef __WXGTK3__ + //there's no way to set width using GTK's CSS! => + m_spinCtrl.InvalidateBestSize(); + ::gtk_entry_set_width_chars(GTK_ENTRY(m_spinCtrl.m_widget), 3); + +#if 0 //apparently not needed!? + if (::gtk_check_version(3, 12, 0) == NULL) + ::gtk_entry_set_max_width_chars(GTK_ENTRY(m_spinCtrl.m_widget), 3); +#endif + + //get rid of excessive default width on old GTK3 3.14 (Debian); + //gtk_entry_set_width_chars() not working => mitigate + m_spinCtrl.SetMinSize({dipToWxsize(100), -1}); //must be wider than gtk_entry_set_width_chars(), or it breaks newer GTK e.g. 3.22! + +#if 0 //generic property syntax: + GValue bval = G_VALUE_INIT; + ::g_value_init(&bval, G_TYPE_BOOLEAN); + ::g_value_set_boolean(&bval, false); + ZEN_ON_SCOPE_EXIT(::g_value_unset(&bval)); + ::g_object_set_property(G_OBJECT(m_spinCtrl.m_widget), "visibility", &bval); +#endif +#else + m_spinCtrl.SetMinSize({dipToWxsize(70), -1}); +#endif + +} +} + +#endif //WINDOW_LAYOUT_H_23849632846734343234532 diff --git a/wx+/window_tools.h b/wx+/window_tools.h new file mode 100644 index 0000000..5be28fc --- /dev/null +++ b/wx+/window_tools.h @@ -0,0 +1,273 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FOCUS_1084731021985757843 +#define FOCUS_1084731021985757843 + +#include +#include + + +namespace zen +{ +//pretty much the same like "bool wxWindowBase::IsDescendant(wxWindowBase* child) const" but without the obvious misnomer +inline +bool isComponentOf(const wxWindow* child, const wxWindow* top) +{ + for (const wxWindow* wnd = child; wnd != nullptr; wnd = wnd->GetParent()) + if (wnd == top) + return true; + return false; +} + + +inline +wxWindow& getRootWindow(wxWindow& child) +{ + wxWindow* root = &child; + for (;;) + if (wxWindow* parent = root->GetParent()) + root = parent; + else + return *root; +} + + +inline +wxTopLevelWindow* getTopLevelWindow(wxWindow* child) +{ + for (wxWindow* wnd = child; wnd != nullptr; wnd = wnd->GetParent()) + if (auto tlw = dynamic_cast(wnd)) //why does wxWidgets use wxWindows::IsTopLevel() ?? + return tlw; + return nullptr; +} + + +/* Preserving input focus has to be more clever than: + wxWindow* oldFocus = wxWindow::FindFocus(); + ZEN_ON_SCOPE_EXIT(if (oldFocus) oldFocus->SetFocus()); + +=> wxWindow::SetFocus() internally calls Win32 ::SetFocus, which calls ::SetActiveWindow, which - lord knows why - changes the foreground window to the focus window + even if the user is currently busy using a different app! More curiosity: this foreground focus stealing happens only during the *first* SetFocus() after app start! + It also can be avoided by changing focus back and forth with some other app after start => wxWidgets bug or Win32 feature??? */ + +inline +void setFocusIfActive(wxWindow& win) //don't steal keyboard focus when currently using a different foreground application +{ + if (wxTopLevelWindow* topWin = getTopLevelWindow(&win)) + if (topWin->IsActive()) //Linux/macOS: already behaves just like ::GetForegroundWindow() on Windows! + win.SetFocus(); +} + + +struct FocusPreserver +{ + FocusPreserver() + { + if (wxWindow* win = wxWindow::FindFocus()) + setFocus(win); + } + + ~FocusPreserver() + { + //wxTopLevelWindow::IsActive() does NOT call Win32 ::GetActiveWindow()! + //Instead it checks if ::GetFocus() is set somewhere inside the top level + //Note: Both Win32 active and focus windows are *thread-local* values, while foreground window is global! https://devblogs.microsoft.com/oldnewthing/20131016-00/?p=2913 + + if (oldFocusId_ != wxID_ANY) + if (wxWindow* oldFocusWin = wxWindow::FindWindowById(oldFocusId_)) + { + assert(oldFocusWin->IsEnabled()); //only enabled windows can have focus, so why wouldn't it be anymore? + setFocusIfActive(*oldFocusWin); + } + } + + wxWindowID getFocusId() const { return oldFocusId_; } + + void setFocus(wxWindow* win) + { + oldFocusId_ = win->GetId(); + assert(oldFocusId_ != wxID_ANY); + } + + void dismiss() { oldFocusId_ = wxID_ANY; } + +private: + wxWindowID oldFocusId_ = wxID_ANY; + //don't store wxWindow* which may be dangling during ~FocusPreserver()! + //test: click on delete folder pair and immediately press F5 => focus window (= FP del button) is defer-deleted during sync +}; + + +class WindowLayout +{ +public: + struct Dimensions + { + std::optional size; + std::optional pos; + bool isMaximized = false; + }; + static void setInitial(wxTopLevelWindow& topWin, const Dimensions& dim, wxSize defaultSize) + { + initialDims_[&topWin] = dim; + + wxSize newSize = defaultSize; + std::optional newPos; + //set dialog size and position: + // - width/height are invalid if the window is minimized (eg x,y = -32000; width = 160, height = 28) + // - multi-monitor setup: dialog may be placed on second monitor which is currently turned off + if (dim.size && + dim.size->GetWidth () > 0 && + dim.size->GetHeight() > 0) + { + if (dim.pos) + { + //calculate how much of the dialog will be visible on screen + const int dlgArea = dim.size->GetWidth() * dim.size->GetHeight(); + int dlgAreaMaxVisible = 0; + + const int monitorCount = wxDisplay::GetCount(); + for (int i = 0; i < monitorCount; ++i) + { + wxRect overlap = wxDisplay(i).GetClientArea().Intersect(wxRect(*dim.pos, *dim.size)); + dlgAreaMaxVisible = std::max(dlgAreaMaxVisible, overlap.GetWidth() * overlap.GetHeight()); + } + + if (dlgAreaMaxVisible > 0.1 * dlgArea //at least 10% of the dialog should be visible! + ) + { + newSize = *dim.size; + newPos = dim.pos; + } + } + else + newSize = *dim.size; + } + + //old comment: "wxGTK's wxWindow::SetSize seems unreliable and behaves like a wxWindow::SetClientSize + // => use wxWindow::SetClientSize instead (for the record: no such issue on Windows/macOS) + //2018-10-15: Weird new problem on CentOS/Ubuntu: SetClientSize() + SetPosition() fail to set correct dialog *position*, but SetSize() + SetPosition() do! + // => old issues with SetSize() seem to be gone... => revert to SetSize() + if (newPos) + topWin.SetSize(wxRect(*newPos, newSize)); + else + { + topWin.SetSize(newSize); + topWin.Center(); + } + + if (dim.isMaximized) //no real need to support both maximize and full screen functions + { + topWin.Maximize(true); + } + + +#if 0 //wxWidgets alternative: apparently no benefits (not even on Wayland! but strange decisions: why restore the minimized state!???) + class GeoSerializer : public wxTopLevelWindow::GeometrySerializer + { + public: + GeoSerializer(const std::string& l) + { + split(l, ' ', [&](const std::string_view phrase) + { + assert(phrase.empty() || contains(phrase, '=')); + if (contains(phrase, '=')) + valuesByName_[utfTo(beforeFirst(phrase, '=', IfNotFoundReturn::none))] = + /**/ stringTo(afterFirst(phrase, '=', IfNotFoundReturn::none)); + }); + } + + bool SaveField(const wxString& name, int value) const /*NO, this must not be const!*/ override { return false; } + + bool RestoreField(const wxString& name, int* value) /*const: yes, this MAY(!) be const*/ override + { + auto it = valuesByName_.find(name); + if (it == valuesByName_.end()) + return false; + * value = it->second; + return true; + } + private: + std::unordered_map valuesByName_; + } serializer(layout); + + if (!topWin.RestoreToGeometry(serializer)) //apparently no-fail as long as GeometrySerializer::RestoreField is! + assert(false); +#endif + } + + //destructive! changes window size! + static Dimensions getBeforeClose(wxTopLevelWindow& topWin) + { + //we need to portably retrieve non-iconized, non-maximized size and position + // non-portable: Win32 GetWindowPlacement(); wxWidgets take: wxTopLevelWindow::SaveGeometry/RestoreToGeometry() + if (topWin.IsIconized()) + topWin.Iconize(false); + + bool isMaximized = false; + if (topWin.IsMaximized()) //evaluate AFTER uniconizing! + { + topWin.Maximize(false); + isMaximized = true; + } + + std::optional size = topWin.GetSize(); + std::optional pos = topWin.GetPosition(); + + if (isMaximized) + if (!topWin.IsShown() //=> Win: can't trust size GetSize()/GetPosition(): still at full screen size! + //wxGTK: returns full screen size and strange position (65/-4) + //OS X 10.9 (but NO issue on 10.11!) returns full screen size and strange position (0/-22) + || pos->y < 0 + ) + { + size = std::nullopt; + pos = std::nullopt; + } + + //reuse previous values if current ones are not available: + if (const auto it = initialDims_.find(&topWin); + it != initialDims_.end()) + { + if (!size) + size = it->second.size; + + if (!pos) + pos = it->second.pos; + } + + return {size, pos, isMaximized}; + +#if 0 //wxWidgets alternative: apparently no benefits (not even on Wayland! but strange decisions: why restore the minimized state!???) + struct : wxTopLevelWindow::GeometrySerializer + { + bool SaveField(const wxString& name, int value) const /*NO, this must not be const!*/ override + { + layout_ += utfTo(name) + '=' + numberTo(value) + ' '; + return true; + } + + bool RestoreField(const wxString& name, int* value) /*const: yes, this MAY(!) be const*/ override { return false; } + + mutable //wxWidgets people: 1. learn when and when not to use const for input/output functions! see SaveField/RestoreField() + // 2. learn flexible software design: why are input/output tied up in a single GeometrySerializer implementation? + std::string layout_; + } serializer; + + if (topWin.SaveGeometry(serializer)) //apparently no-fail as long as GeometrySerializer::SaveField is! + return serializer.layout_; + else + assert(false); +#endif + } + +private: + inline static std::unordered_map initialDims_; +}; +} + +#endif //FOCUS_1084731021985757843 diff --git a/xBRZ/src/xbrz.cpp b/xBRZ/src/xbrz.cpp new file mode 100644 index 0000000..654266c --- /dev/null +++ b/xBRZ/src/xbrz.cpp @@ -0,0 +1,1363 @@ +// **************************************************************************** +// * This file is part of the xBRZ project. It is distributed under * +// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * +// * Copyright (C) Zenju (zenju AT gmx DOT de) - All Rights Reserved * +// * * +// * Additionally and as a special exception, the author gives permission * +// * to link the code of this program with the following libraries * +// * (or with modified versions that use the same licenses), and distribute * +// * linked combinations including the two: MAME, FreeFileSync, Snes9x, ePSXe * +// * * +// * You must obey the GNU General Public License in all respects for all of * +// * the code used other than MAME, FreeFileSync, Snes9x, ePSXe. * +// * If you modify this file, you may extend this exception to your version * +// * of the file, but you are not obligated to do so. If you do not wish to * +// * do so, delete this exception statement from your version. * +// **************************************************************************** + +#include "xbrz.h" +#include +#include +#include //std::sqrt +#include +#include "xbrz_tools.h" + +using namespace xbrz; + + +namespace +{ +//blend front color with opacity M / N over opaque background: https://en.wikipedia.org/wiki/Alpha_compositing +//Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html +template inline +uint32_t gradientRGB(uint32_t pixFront, uint32_t pixBack) +{ + static_assert(0 < M && M < N && N <= 1000); + + auto calcColor = [](unsigned char colFront, unsigned char colBack) + { + return static_cast(uintDivRound(colFront * M + colBack * (N - M), N)); + }; + + return makePixel(calcColor(getRed (pixFront), getRed (pixBack)), + calcColor(getGreen(pixFront), getGreen(pixBack)), + calcColor(getBlue (pixFront), getBlue (pixBack))); +} + + +//find intermediate color between two colors with alpha channels (=> NO alpha blending!!!) +//Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html +template inline +uint32_t gradientARGB(uint32_t pixFront, uint32_t pixBack) +{ + static_assert(0 < M && M < N && N <= 1000); + + const unsigned int weightFront = getAlpha(pixFront) * M; + const unsigned int weightBack = getAlpha(pixBack) * (N - M); + const unsigned int weightSum = weightFront + weightBack; + if (weightSum == 0) + return 0; + + auto calcColor = [=](unsigned char colFront, unsigned char colBack) + { + return static_cast(uintDivRound(colFront * weightFront + colBack * weightBack, weightSum)); + }; + + return makePixel(static_cast(uintDivRound(weightSum, N)), + calcColor(getRed (pixFront), getRed (pixBack)), + calcColor(getGreen(pixFront), getGreen(pixBack)), + calcColor(getBlue (pixFront), getBlue (pixBack))); +} + + +//inline +//double fastSqrt(double n) +//{ +// __asm //speeds up xBRZ by about 9% compared to std::sqrt which internally uses the same assembler instructions but adds some "fluff" +// { +// fld n +// fsqrt +// } +//} +// + + +#if defined __GNUC__ + #define FORCE_INLINE __attribute__((always_inline)) inline +#else + #define FORCE_INLINE inline +#endif + + +enum RotationDegree //clock-wise +{ + ROT_0, + ROT_90, + ROT_180, + ROT_270 +}; + +//calculate input matrix coordinates after rotation at compile time +template +struct MatrixRotation; + +template +struct MatrixRotation +{ + static const size_t I_old = I; + static const size_t J_old = J; +}; + +template //(i, j) = (row, col) indices, N = size of (square) matrix +struct MatrixRotation +{ + static const size_t I_old = N - 1 - MatrixRotation(rotDeg - 1), I, J, N>::J_old; //old coordinates before rotation! + static const size_t J_old = MatrixRotation(rotDeg - 1), I, J, N>::I_old; // +}; + + +template +class OutputMatrix +{ +public: + OutputMatrix(uint32_t* out, int outWidth) : //access matrix area, top-left at position "out" for image with given width + out_(out), + outWidth_(outWidth) {} + + template + uint32_t& ref() const + { + static const size_t I_old = MatrixRotation::I_old; + static const size_t J_old = MatrixRotation::J_old; + return *(out_ + J_old + I_old * outWidth_); + } + +private: + uint32_t* out_; + const int outWidth_; +}; + + +template inline +T square(T value) { return value * value; } + + +#if 0 +inline +double distRGB(uint32_t pix1, uint32_t pix2) +{ + const double r_diff = static_cast(getRed (pix1)) - getRed (pix2); + const double g_diff = static_cast(getGreen(pix1)) - getGreen(pix2); + const double b_diff = static_cast(getBlue (pix1)) - getBlue (pix2); + + //euklidean RGB distance + return std::sqrt(square(r_diff) + square(g_diff) + square(b_diff)); +} +#endif + + +inline +double distYCbCr(uint32_t pix1, uint32_t pix2, double /*testAttribute*/) +{ + //https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion + //Y'CbCr conversion is a matrix multiplication => take advantage of linearity by subtracting first! + //NOTE: input is gamma-encoded RGB! => what does this mean for the output distance!?? + const int r_diff = static_cast(getRed (pix1)) - getRed (pix2); //defer division by 255 to after matrix multiplication + const int g_diff = static_cast(getGreen(pix1)) - getGreen(pix2); // + const int b_diff = static_cast(getBlue (pix1)) - getBlue (pix2); //substraction for int is noticeable faster than for double! + + //const double k_b = 0.0722; //ITU-R BT.709 conversion + //const double k_r = 0.2126; // + const double k_b = 0.0593; //ITU-R BT.2020 conversion + const double k_r = 0.2627; // + const double k_g = 1 - k_b - k_r; + + const double scale_b = 0.5 / (1 - k_b); + const double scale_r = 0.5 / (1 - k_r); + + const double y = k_r * r_diff + k_g * g_diff + k_b * b_diff; //[!], analog YCbCr! + const double c_b = scale_b * (b_diff - y); + const double c_r = scale_r * (r_diff - y); + + //we skip division by 255 to have similar range like other distance functions + return std::sqrt(square(y) + square(c_b) + square(c_r)); +} + + +inline +double distYCbCrBuffered(uint32_t pix1, uint32_t pix2, double /*testAttribute*/) +{ + //30% perf boost compared to plain distYCbCr()! + //consumes 64 MB memory; using double is only 2% faster, but takes 128 MB + static const std::vector diffToDist = [] + { + std::vector tmp; + + for (uint32_t i = 0; i < 256 * 256 * 256; ++i) //startup time: 114 ms on Intel Core i5 (four cores) + { + const int r_diff = static_cast(getByte<2>(i)) * 2; + const int g_diff = static_cast(getByte<1>(i)) * 2; + const int b_diff = static_cast(getByte<0>(i)) * 2; + + const double k_b = 0.0593; //ITU-R BT.2020 conversion + const double k_r = 0.2627; // + const double k_g = 1 - k_b - k_r; + + const double scale_b = 0.5 / (1 - k_b); + const double scale_r = 0.5 / (1 - k_r); + + const double y = k_r * r_diff + k_g * g_diff + k_b * b_diff; //[!], analog YCbCr! + const double c_b = scale_b * (b_diff - y); + const double c_r = scale_r * (r_diff - y); + + tmp.push_back(static_cast(std::sqrt(square(y) + square(c_b) + square(c_r)))); + } + return tmp; + }(); + + //if (pix1 == pix2) -> 8% perf degradation! + // return 0; + //if (pix1 < pix2) + // std::swap(pix1, pix2); -> 30% perf degradation!!! + + const int r_diff = static_cast(getRed (pix1)) - getRed (pix2); + const int g_diff = static_cast(getGreen(pix1)) - getGreen(pix2); + const int b_diff = static_cast(getBlue (pix1)) - getBlue (pix2); + + const size_t index = (static_cast(r_diff / 2) << 16) | //slightly reduce precision (division by 2) to squeeze value into single byte + (static_cast(g_diff / 2) << 8) | + (static_cast(b_diff / 2)); + +#if 0 //attention: the following calculation creates an asymmetric color distance!!! (e.g. r_diff=46 will be unpacked as 45, but r_diff=-46 unpacks to -47 + const size_t index = (((r_diff + 0xFF) / 2) << 16) | //slightly reduce precision (division by 2) to squeeze value into single byte + (((g_diff + 0xFF) / 2) << 8) | + (( b_diff + 0xFF) / 2); +#endif + return diffToDist[index]; +} + + + + +enum BlendType +{ + BLEND_NONE = 0, + BLEND_NORMAL, //a normal indication to blend + BLEND_DOMINANT, //a strong indication to blend + //attention: BlendType must fit into the value range of 2 bit!!! +}; + +struct BlendResult +{ + BlendType + blend_e, blend_f, + blend_h, blend_i; +}; + + +struct Kernel_3x3 +{ + uint32_t + a, b, c, + d, e, f, + g, h, i; +}; + +struct Kernel_4x4 : Kernel_3x3 +{ + uint32_t j, k, l, m, n, o, p; +}; +/* input kernel for preprocessing step: + + ----------------- + | A | B | C | P | + |---|---|---|---| + | D | E | F | O | evaluate the four corners between E, F, H, I + |---|---|---|---| input pixel is at position E + | G | H | I | N | + |---|---|---|---| + | J | K | L | M | + ----------------- */ + +template +FORCE_INLINE //detect blend direction +BlendResult preProcessCorners(const Kernel_4x4& ker, const xbrz::ScalerCfg& cfg) //result: E, F, H, I corners of "GradientType" +{ + if ((ker.e == ker.f && + ker.h == ker.i) || + (ker.e == ker.h && + ker.f == ker.i)) + return {}; + + auto dist = [&](uint32_t pix1, uint32_t pix2) { return ColorDistance::dist(pix1, pix2, cfg.testAttribute); }; + + const double hf = dist(ker.g, ker.e) + dist(ker.e, ker.c) + dist(ker.k, ker.i) + dist(ker.i, ker.o) + cfg.centerDirectionBias * dist(ker.h, ker.f); + const double ei = dist(ker.d, ker.h) + dist(ker.h, ker.l) + dist(ker.b, ker.f) + dist(ker.f, ker.n) + cfg.centerDirectionBias * dist(ker.e, ker.i); + + BlendResult result = {}; + if (hf < ei) //test sample: 70% of values max(hf, ei) / min(hf, ei) are between 1.1 and 3.7 with median being 1.8 + { + const bool dominantGradient = cfg.dominantDirectionThreshold * hf < ei; + if (ker.e != ker.f && ker.e != ker.h) + result.blend_e = dominantGradient ? BLEND_DOMINANT : BLEND_NORMAL; + + if (ker.i != ker.h && ker.i != ker.f) + result.blend_i = dominantGradient ? BLEND_DOMINANT : BLEND_NORMAL; + } + else if (ei < hf) + { + const bool dominantGradient = cfg.dominantDirectionThreshold * ei < hf; + if (ker.h != ker.e && ker.h != ker.i) + result.blend_h = dominantGradient ? BLEND_DOMINANT : BLEND_NORMAL; + + if (ker.f != ker.e && ker.f != ker.i) + result.blend_f = dominantGradient ? BLEND_DOMINANT : BLEND_NORMAL; + } + return result; +} + +#define DEF_GETTER(x) template uint32_t inline get_##x(const Kernel_3x3& ker) { return ker.x; } +//we cannot and NEED NOT write "ker.##x" since ## concatenates preprocessor tokens but "." is not a token +DEF_GETTER(a) DEF_GETTER(b) DEF_GETTER(c) +DEF_GETTER(d) DEF_GETTER(e) DEF_GETTER(f) +DEF_GETTER(g) DEF_GETTER(h) DEF_GETTER(i) +#undef DEF_GETTER + +#define DEF_GETTER(x, y) template <> inline uint32_t get_##x(const Kernel_3x3& ker) { return ker.y; } +DEF_GETTER(a, g) DEF_GETTER(b, d) DEF_GETTER(c, a) +DEF_GETTER(d, h) DEF_GETTER(e, e) DEF_GETTER(f, b) +DEF_GETTER(g, i) DEF_GETTER(h, f) DEF_GETTER(i, c) +#undef DEF_GETTER + +#define DEF_GETTER(x, y) template <> inline uint32_t get_##x(const Kernel_3x3& ker) { return ker.y; } +DEF_GETTER(a, i) DEF_GETTER(b, h) DEF_GETTER(c, g) +DEF_GETTER(d, f) DEF_GETTER(e, e) DEF_GETTER(f, d) +DEF_GETTER(g, c) DEF_GETTER(h, b) DEF_GETTER(i, a) +#undef DEF_GETTER + +#define DEF_GETTER(x, y) template <> inline uint32_t get_##x(const Kernel_3x3& ker) { return ker.y; } +DEF_GETTER(a, c) DEF_GETTER(b, f) DEF_GETTER(c, i) +DEF_GETTER(d, b) DEF_GETTER(e, e) DEF_GETTER(f, h) +DEF_GETTER(g, a) DEF_GETTER(h, d) DEF_GETTER(i, g) +#undef DEF_GETTER + + +//compress four blend types into a single byte +//inline BlendType getTopL (unsigned char b) { return static_cast(0x3 & b); } +inline BlendType getTopR (unsigned char b) { return static_cast(0x3 & (b >> 2)); } +inline BlendType getBottomR(unsigned char b) { return static_cast(0x3 & (b >> 4)); } +inline BlendType getBottomL(unsigned char b) { return static_cast(0x3 & (b >> 6)); } + +inline void clearAddTopL(unsigned char& b, BlendType bt) { b = static_cast(bt); } +inline void addTopR (unsigned char& b, BlendType bt) { b |= (bt << 2); } //buffer is assumed to be initialized before preprocessing! +inline void addBottomR (unsigned char& b, BlendType bt) { b |= (bt << 4); } //e.g. via clearAddTopL() +inline void addBottomL (unsigned char& b, BlendType bt) { b |= (bt << 6); } // + +inline bool blendingNeeded(unsigned char b) +{ + static_assert(BLEND_NONE == 0); + return b != 0; +} + +template inline +unsigned char rotateBlendInfo(unsigned char b) { return b; } +template <> inline unsigned char rotateBlendInfo(unsigned char b) { return ((b << 2) | (b >> 6)) & 0xff; } +template <> inline unsigned char rotateBlendInfo(unsigned char b) { return ((b << 4) | (b >> 4)) & 0xff; } +template <> inline unsigned char rotateBlendInfo(unsigned char b) { return ((b << 6) | (b >> 2)) & 0xff; } + + +/* input kernel area naming convention: +------------- +| A | B | C | +|---|---|---| +| D | E | F | input pixel is at position E +|---|---|---| +| G | H | I | +------------- */ + +template +FORCE_INLINE //perf: quite worth it! +void blendPixel(const Kernel_3x3& ker, + uint32_t* target, int trgWidth, + unsigned char blendInfo, //result of preprocessing all four corners of pixel "E" + const xbrz::ScalerCfg& cfg) +{ + //#define a get_a(ker) +#define b get_b(ker) +#define c get_c(ker) +#define d get_d(ker) +#define e get_e(ker) +#define f get_f(ker) +#define g get_g(ker) +#define h get_h(ker) +#define i get_i(ker) + + + const unsigned char blend = rotateBlendInfo(blendInfo); + + if (getBottomR(blend) >= BLEND_NORMAL) + { + auto eq = [&](uint32_t pix1, uint32_t pix2) { return ColorDistance::dist(pix1, pix2, cfg.testAttribute) < cfg.equalColorTolerance; }; + auto dist = [&](uint32_t pix1, uint32_t pix2) { return ColorDistance::dist(pix1, pix2, cfg.testAttribute); }; + + const bool doLineBlend = [&]() -> bool + { + if (getBottomR(blend) >= BLEND_DOMINANT) + return true; + + //make sure there is no second blending in an adjacent rotation for this pixel: handles insular pixels, mario eyes + if (getTopR(blend) != BLEND_NONE && !eq(e, g)) //but support double-blending for 90° corners + return false; + if (getBottomL(blend) != BLEND_NONE && !eq(e, c)) + return false; + + //no full blending for L-shapes; blend corner only (handles "mario mushroom eyes") + if (!eq(e, i) && eq(g, h) && eq(h, i) && eq(i, f) && eq(f, c)) + return false; + + return true; + }(); + + const uint32_t px = dist(e, f) <= dist(e, h) ? f : h; //choose most similar color + + OutputMatrix out(target, trgWidth); + + if (doLineBlend) + { + const double fg = dist(f, g); //test sample: 70% of values max(fg, hc) / min(fg, hc) are between 1.1 and 3.7 with median being 1.9 + const double hc = dist(h, c); // + + const bool haveShallowLine = cfg.steepDirectionThreshold * fg <= hc && e != g && d != g; + const bool haveSteepLine = cfg.steepDirectionThreshold * hc <= fg && e != c && b != c; + + if (haveShallowLine) + { + if (haveSteepLine) + Scaler::blendLineSteepAndShallow(px, out); + else + Scaler::blendLineShallow(px, out); + } + else + { + if (haveSteepLine) + Scaler::blendLineSteep(px, out); + else + Scaler::blendLineDiagonal(px, out); + } + } + else + Scaler::blendCorner(px, out); + } + + //#undef a +#undef b +#undef c +#undef d +#undef e +#undef f +#undef g +#undef h +#undef i +} + + +class OobReaderTransparent +{ +public: + OobReaderTransparent(const uint32_t* src, int srcWidth, int srcHeight, int y) : + s_m1(0 <= y - 1 && y - 1 < srcHeight ? src + srcWidth * (y - 1) : nullptr), + s_0 (0 <= y && y < srcHeight ? src + srcWidth * y : nullptr), + s_p1(0 <= y + 1 && y + 1 < srcHeight ? src + srcWidth * (y + 1) : nullptr), + s_p2(0 <= y + 2 && y + 2 < srcHeight ? src + srcWidth * (y + 2) : nullptr), + srcWidth_(srcWidth) {} + + void readPonm(Kernel_4x4& ker, int x) const //(x, y) is at kernel position E + { + [[likely]] if (const int x_p2 = x + 2; 0 <= x_p2 && x_p2 < srcWidth_) + { + ker.p = s_m1 ? s_m1[x_p2] : 0; + ker.o = s_0 ? s_0 [x_p2] : 0; + ker.n = s_p1 ? s_p1[x_p2] : 0; + ker.m = s_p2 ? s_p2[x_p2] : 0; + } + else + { + ker.p = 0; + ker.o = 0; + ker.n = 0; + ker.m = 0; + } + } + +private: + const uint32_t* const s_m1; + const uint32_t* const s_0; + const uint32_t* const s_p1; + const uint32_t* const s_p2; + const int srcWidth_; +}; + + +class OobReaderDuplicate +{ +public: + OobReaderDuplicate(const uint32_t* src, int srcWidth, int srcHeight, int y) : + s_m1(src + srcWidth * std::clamp(y - 1, 0, srcHeight - 1)), + s_0 (src + srcWidth * std::clamp(y, 0, srcHeight - 1)), + s_p1(src + srcWidth * std::clamp(y + 1, 0, srcHeight - 1)), + s_p2(src + srcWidth * std::clamp(y + 2, 0, srcHeight - 1)), + srcWidth_(srcWidth) {} + + void readPonm(Kernel_4x4& ker, int x) const //(x, y) is at kernel position E + { + const int x_p2 = std::clamp(x + 2, 0, srcWidth_ - 1); + ker.p = s_m1[x_p2]; + ker.o = s_0 [x_p2]; + ker.n = s_p1[x_p2]; + ker.m = s_p2[x_p2]; + } + +private: + const uint32_t* const s_m1; + const uint32_t* const s_0; + const uint32_t* const s_p1; + const uint32_t* const s_p2; + const int srcWidth_; +}; + + +inline +void fillBlock(uint32_t* trg, int trgWidth, uint32_t col, int blockSize) +{ + for (int y = 0; y < blockSize; ++y, trg += trgWidth) + // std::fill(trg, trg + blockSize, col); + for (int x = 0; x < blockSize; ++x) + trg[x] = col; +} + + +template //scaler policy: see "Scaler2x" reference implementation +void scaleImage(const uint32_t* src, uint32_t* trg, int srcWidth, int srcHeight, const xbrz::ScalerCfg& cfg, int yFirst, int yLast) +{ + yFirst = std::max(yFirst, 0); + yLast = std::min(yLast, srcHeight); + if (yFirst >= yLast || srcWidth <= 0) + return; + + const int trgWidth = srcWidth * Scaler::scale; + + //(ab)use space of "sizeof(uint32_t) * srcWidth * Scaler::scale" at the end of the image as temporary + //buffer for "on the fly preprocessing" without risk of accidental overwriting before accessing + unsigned char* const preProcBuf = reinterpret_cast(trg + yLast * Scaler::scale * trgWidth) - srcWidth; + + //initialize preprocessing buffer for first row of current stripe: detect upper left and right corner blending + //this cannot be optimized for adjacent processing stripes; we must not allow for a memory race condition! + { + const OobReader oobReader(src, srcWidth, srcHeight, yFirst - 1); + + //initialize at position x = -1 + Kernel_4x4 ker4 = {}; + oobReader.readPonm(ker4, -4); //hack: read a, d, g, j at x = -1 + ker4.a = ker4.p; + ker4.d = ker4.o; + ker4.g = ker4.n; + ker4.j = ker4.m; + + oobReader.readPonm(ker4, -3); + ker4.b = ker4.p; + ker4.e = ker4.o; + ker4.h = ker4.n; + ker4.k = ker4.m; + + oobReader.readPonm(ker4, -2); + ker4.c = ker4.p; + ker4.f = ker4.o; + ker4.i = ker4.n; + ker4.l = ker4.m; + + oobReader.readPonm(ker4, -1); + + { + const BlendResult res = preProcessCorners(ker4, cfg); + clearAddTopL(preProcBuf[0], res.blend_i); //set 1st known corner for (0, yFirst) + } + + for (int x = 0; x < srcWidth; ++x) + { + ker4.a = ker4.b; //shift previous kernel to the left + ker4.d = ker4.e; // ----------------- + ker4.g = ker4.h; // | A | B | C | P | + ker4.j = ker4.k; // |---|---|---|---| + /**/ // | D | E | F | O | (x, yFirst - 1) is at position E + ker4.b = ker4.c; // |---|---|---|---| + ker4.e = ker4.f; // | G | H | I | N | + ker4.h = ker4.i; // |---|---|---|---| + ker4.k = ker4.l; // | J | K | L | M | + /**/ // ----------------- + ker4.c = ker4.p; + ker4.f = ker4.o; + ker4.i = ker4.n; + ker4.l = ker4.m; + + oobReader.readPonm(ker4, x); + + /* preprocessing blend result: + --------- + | E | F | evaluate corner between E, F, H, I + |---+---| current input pixel is at position E + | H | I | + --------- */ + const BlendResult res = preProcessCorners(ker4, cfg); + addTopR(preProcBuf[x], res.blend_h); //set 2nd known corner for (x, yFirst) + + if (x + 1 < srcWidth) + clearAddTopL(preProcBuf[x + 1], res.blend_i); //set 1st known corner for (x + 1, yFirst) + } + } + //------------------------------------------------------------------------------------ + + for (int y = yFirst; y < yLast; ++y) + { + uint32_t* out = trg + Scaler::scale * y * trgWidth; //consider MT "striped" access + + const OobReader oobReader(src, srcWidth, srcHeight, y); + + //initialize at position x = -1 + Kernel_4x4 ker4 = {}; + oobReader.readPonm(ker4, -4); //hack: read a, d, g, j at x = -1 + ker4.a = ker4.p; + ker4.d = ker4.o; + ker4.g = ker4.n; + ker4.j = ker4.m; + + oobReader.readPonm(ker4, -3); + ker4.b = ker4.p; + ker4.e = ker4.o; + ker4.h = ker4.n; + ker4.k = ker4.m; + + oobReader.readPonm(ker4, -2); + ker4.c = ker4.p; + ker4.f = ker4.o; + ker4.i = ker4.n; + ker4.l = ker4.m; + + oobReader.readPonm(ker4, -1); + + unsigned char blend_xy1 = 0; //corner blending for current (x, y + 1) position + { + const BlendResult res = preProcessCorners(ker4, cfg); + clearAddTopL(blend_xy1, res.blend_i); //set 1st known corner for (0, y + 1) and buffer for use on next column + + addBottomL(preProcBuf[0], res.blend_f); //set 3rd known corner for (0, y) + } + + for (int x = 0; x < srcWidth; ++x, out += Scaler::scale) + { + ker4.a = ker4.b; //shift previous kernel to the left + ker4.d = ker4.e; // ----------------- + ker4.g = ker4.h; // | A | B | C | P | + ker4.j = ker4.k; // |---|---|---|---| + /**/ // | D | E | F | O | (x, y) is at position E + ker4.b = ker4.c; // |---|---|---|---| + ker4.e = ker4.f; // | G | H | I | N | + ker4.h = ker4.i; // |---|---|---|---| + ker4.k = ker4.l; // | J | K | L | M | + /**/ // ----------------- + ker4.c = ker4.p; + ker4.f = ker4.o; + ker4.i = ker4.n; + ker4.l = ker4.m; + + oobReader.readPonm(ker4, x); + + //evaluate the four corners on bottom-right of current pixel + unsigned char blend_xy = preProcBuf[x]; //for current (x, y) position + { + /* preprocessing blend result: + --------- + | E | F | evaluate corner between E, F, H, I + |---+---| current input pixel is at position E + | H | I | + --------- */ + const BlendResult res = preProcessCorners(ker4, cfg); + addBottomR(blend_xy, res.blend_e); //all four corners of (x, y) have been determined at this point due to processing sequence! + + addTopR(blend_xy1, res.blend_h); //set 2nd known corner for (x, y + 1) + preProcBuf[x] = blend_xy1; //store on current buffer position for use on next row + + [[likely]] if (x + 1 < srcWidth) + { + //blend_xy1 -> blend_x1y1 + clearAddTopL(blend_xy1, res.blend_i); //set 1st known corner for (x + 1, y + 1) and buffer for use on next column + + addBottomL(preProcBuf[x + 1], res.blend_f); //set 3rd known corner for (x + 1, y) + } + } + + //fill block of size scale * scale with the given color + fillBlock(out, trgWidth, ker4.e, Scaler::scale); + + //place *after* preprocessing step, to not overwrite the results while processing the last pixel! + + //blend all four corners of current pixel + if (blendingNeeded(blend_xy)) + { + const Kernel_3x3& ker3 = ker4; //"The Things We Do for [Perf]" + blendPixel(ker3, out, trgWidth, blend_xy, cfg); + blendPixel(ker3, out, trgWidth, blend_xy, cfg); + blendPixel(ker3, out, trgWidth, blend_xy, cfg); + blendPixel(ker3, out, trgWidth, blend_xy, cfg); + } + } + } +} + +//------------------------------------------------------------------------------------ + +template +struct Scaler2x : public ColorGradient +{ + static const int scale = 2; + + template //bring template function into scope for GCC + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) { ColorGradient::template alphaGrad(pixBack, pixFront); } + + + template + static void blendLineShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + } + + template + static void blendLineSteep(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + } + + template + static void blendLineSteepAndShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<1, 0>(), col); + alphaGrad<1, 4>(out.template ref<0, 1>(), col); + alphaGrad<5, 6>(out.template ref<1, 1>(), col); //[!] fixes 7/8 used in xBR + } + + template + static void blendLineDiagonal(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 2>(out.template ref<1, 1>(), col); + } + + template + static void blendCorner(uint32_t col, OutputMatrix& out) + { + //model a round corner + alphaGrad<21, 100>(out.template ref<1, 1>(), col); //exact: 1 - pi/4 = 0.2146018366 + } +}; + + +template +struct Scaler3x : public ColorGradient +{ + static const int scale = 3; + + template //bring template function into scope for GCC + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) { ColorGradient::template alphaGrad(pixBack, pixFront); } + + + template + static void blendLineShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + + alphaGrad<3, 4>(out.template ref(), col); + out.template ref() = col; + } + + template + static void blendLineSteep(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + out.template ref<2, scale - 1>() = col; + } + + template + static void blendLineSteepAndShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<2, 0>(), col); + alphaGrad<1, 4>(out.template ref<0, 2>(), col); + alphaGrad<3, 4>(out.template ref<2, 1>(), col); + alphaGrad<3, 4>(out.template ref<1, 2>(), col); + out.template ref<2, 2>() = col; + } + + template + static void blendLineDiagonal(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 8>(out.template ref<1, 2>(), col); //conflict with other rotations for this odd scale + alphaGrad<1, 8>(out.template ref<2, 1>(), col); + alphaGrad<7, 8>(out.template ref<2, 2>(), col); // + } + + template + static void blendCorner(uint32_t col, OutputMatrix& out) + { + //model a round corner + alphaGrad<45, 100>(out.template ref<2, 2>(), col); //exact: 0.4545939598 + //alphaGrad<3, 100>(out.template ref<2, 1>(), col); //0.02826017254 -> negligible + avoid overlap with other rotations at this scale + //alphaGrad<3, 100>(out.template ref<1, 2>(), col); //0.02826017254 + } +}; + + +template +struct Scaler4x : public ColorGradient +{ + static const int scale = 4; + + template //bring template function into scope for GCC + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) { ColorGradient::template alphaGrad(pixBack, pixFront); } + + + template + static void blendLineShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + + alphaGrad<3, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendLineSteep(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + alphaGrad<3, 4>(out.template ref<3, scale - 2>(), col); + + out.template ref<2, scale - 1>() = col; + out.template ref<3, scale - 1>() = col; + } + + template + static void blendLineSteepAndShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<3, 4>(out.template ref<3, 1>(), col); + alphaGrad<3, 4>(out.template ref<1, 3>(), col); + alphaGrad<1, 4>(out.template ref<3, 0>(), col); + alphaGrad<1, 4>(out.template ref<0, 3>(), col); + + alphaGrad<1, 3>(out.template ref<2, 2>(), col); //[!] fixes 1/4 used in xBR + + out.template ref<3, 3>() = col; + out.template ref<3, 2>() = col; + out.template ref<2, 3>() = col; + } + + template + static void blendLineDiagonal(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 2>(out.template ref(), col); + alphaGrad<1, 2>(out.template ref(), col); + out.template ref() = col; + } + + template + static void blendCorner(uint32_t col, OutputMatrix& out) + { + //model a round corner + alphaGrad<68, 100>(out.template ref<3, 3>(), col); //exact: 0.6848532563 + alphaGrad< 9, 100>(out.template ref<3, 2>(), col); //0.08677704501 + alphaGrad< 9, 100>(out.template ref<2, 3>(), col); //0.08677704501 + } +}; + + +template +struct Scaler5x : public ColorGradient +{ + static const int scale = 5; + + template //bring template function into scope for GCC + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) { ColorGradient::template alphaGrad(pixBack, pixFront); } + + + template + static void blendLineShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + + alphaGrad<3, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + + out.template ref() = col; + out.template ref() = col; + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendLineSteep(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + alphaGrad<1, 4>(out.template ref<4, scale - 3>(), col); + + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + alphaGrad<3, 4>(out.template ref<3, scale - 2>(), col); + + out.template ref<2, scale - 1>() = col; + out.template ref<3, scale - 1>() = col; + out.template ref<4, scale - 1>() = col; + out.template ref<4, scale - 2>() = col; + } + + template + static void blendLineSteepAndShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + + alphaGrad<2, 3>(out.template ref<3, 3>(), col); + + out.template ref<2, scale - 1>() = col; + out.template ref<3, scale - 1>() = col; + out.template ref<4, scale - 1>() = col; + + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendLineDiagonal(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 8>(out.template ref(), col); //conflict with other rotations for this odd scale + alphaGrad<1, 8>(out.template ref(), col); + alphaGrad<1, 8>(out.template ref(), col); // + + alphaGrad<7, 8>(out.template ref<4, 3>(), col); + alphaGrad<7, 8>(out.template ref<3, 4>(), col); + + out.template ref<4, 4>() = col; + } + + template + static void blendCorner(uint32_t col, OutputMatrix& out) + { + //model a round corner + alphaGrad<86, 100>(out.template ref<4, 4>(), col); //exact: 0.8631434088 + alphaGrad<23, 100>(out.template ref<4, 3>(), col); //0.2306749731 + alphaGrad<23, 100>(out.template ref<3, 4>(), col); //0.2306749731 + //alphaGrad<2, 100>(out.template ref<4, 2>(), col); //0.01676812367 -> negligible + avoid overlap with other rotations at this scale + //alphaGrad<2, 100>(out.template ref<2, 4>(), col); //0.01676812367 + } +}; + + +template +struct Scaler6x : public ColorGradient +{ + static const int scale = 6; + + template //bring template function into scope for GCC + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) { ColorGradient::template alphaGrad(pixBack, pixFront); } + + + template + static void blendLineShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + + alphaGrad<3, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + + out.template ref() = col; + out.template ref() = col; + out.template ref() = col; + out.template ref() = col; + + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendLineSteep(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + alphaGrad<1, 4>(out.template ref<4, scale - 3>(), col); + + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + alphaGrad<3, 4>(out.template ref<3, scale - 2>(), col); + alphaGrad<3, 4>(out.template ref<5, scale - 3>(), col); + + out.template ref<2, scale - 1>() = col; + out.template ref<3, scale - 1>() = col; + out.template ref<4, scale - 1>() = col; + out.template ref<5, scale - 1>() = col; + + out.template ref<4, scale - 2>() = col; + out.template ref<5, scale - 2>() = col; + } + + template + static void blendLineSteepAndShallow(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 4>(out.template ref<0, scale - 1>(), col); + alphaGrad<1, 4>(out.template ref<2, scale - 2>(), col); + alphaGrad<3, 4>(out.template ref<1, scale - 1>(), col); + alphaGrad<3, 4>(out.template ref<3, scale - 2>(), col); + + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<1, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + alphaGrad<3, 4>(out.template ref(), col); + + out.template ref<2, scale - 1>() = col; + out.template ref<3, scale - 1>() = col; + out.template ref<4, scale - 1>() = col; + out.template ref<5, scale - 1>() = col; + + out.template ref<4, scale - 2>() = col; + out.template ref<5, scale - 2>() = col; + + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendLineDiagonal(uint32_t col, OutputMatrix& out) + { + alphaGrad<1, 2>(out.template ref(), col); + alphaGrad<1, 2>(out.template ref(), col); + alphaGrad<1, 2>(out.template ref(), col); + + out.template ref() = col; + out.template ref() = col; + out.template ref() = col; + } + + template + static void blendCorner(uint32_t col, OutputMatrix& out) + { + //model a round corner + alphaGrad<97, 100>(out.template ref<5, 5>(), col); //exact: 0.9711013910 + alphaGrad<42, 100>(out.template ref<4, 5>(), col); //0.4236372243 + alphaGrad<42, 100>(out.template ref<5, 4>(), col); //0.4236372243 + alphaGrad< 6, 100>(out.template ref<5, 3>(), col); //0.05652034508 + alphaGrad< 6, 100>(out.template ref<3, 5>(), col); //0.05652034508 + } +}; + +//------------------------------------------------------------------------------------ + +struct ColorDistanceRGB +{ + static double dist(uint32_t pix1, uint32_t pix2, double testAttribute) + { + return distYCbCrBuffered(pix1, pix2, testAttribute); + + //if (pix1 == pix2) //about 4% perf boost + // return 0; + //return distYCbCr(pix1, pix2, luminanceWeight); + } +}; + +struct ColorDistanceARGB +{ + static double dist(uint32_t pix1, uint32_t pix2, double testAttribute) + { + const double a1 = getAlpha(pix1) / 255.0 ; + const double a2 = getAlpha(pix2) / 255.0 ; + + /* Requirements for a color distance handling alpha channel: with a1, a2 in [0, 1] + + 1. if a1 = a2, distance should be: a1 * distYCbCr() + 2. if a1 = 0, distance should be: a2 * distYCbCr(black, white) = a2 * 255 + 3. if a1 = 1, ??? maybe: 255 * (1 - a2) + a2 * distYCbCr() + + std::min(a1, a2) * distYCbCrBuffered(pix1, pix2) + 255 * abs(a1 - a2); + + alternative? std::sqrt(a1 * a2 * square(distYCbCrBuffered(pix1, pix2)) + square(255 * (a1 - a2))); */ + + //=> following code is 15% faster: + const double d = distYCbCrBuffered(pix1, pix2, testAttribute); + if (a1 < a2) + return a1 * d + 255 * (a2 - a1); + else + return a2 * d + 255 * (a1 - a2); + } +}; + + +struct ColorDistanceUnbufferedARGB +{ + static double dist(uint32_t pix1, uint32_t pix2, double testAttribute) + { + const double a1 = getAlpha(pix1) / 255.0 ; + const double a2 = getAlpha(pix2) / 255.0 ; + + const double d = distYCbCr(pix1, pix2, testAttribute); + if (a1 < a2) + return a1 * d + 255 * (a2 - a1); + else + return a2 * d + 255 * (a1 - a2); + } +}; + + +struct ColorGradientRGB +{ + template + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) + { + pixBack = gradientRGB(pixFront, pixBack); + } +}; + +struct ColorGradientARGB +{ + template + static void alphaGrad(uint32_t& pixBack, uint32_t pixFront) + { + pixBack = gradientARGB(pixFront, pixBack); + } +}; +} + + +void xbrz::scale(size_t factor, const uint32_t* src, uint32_t* trg, int srcWidth, int srcHeight, ColorFormat colFmt, const xbrz::ScalerCfg& cfg, int yFirst, int yLast) +{ + if (factor == 1) + { + std::copy(src + yFirst * srcWidth, src + yLast * srcWidth, trg); + return; + } + + static_assert(SCALE_FACTOR_MAX == 6); + switch (colFmt) + { + case ColorFormat::rgb: + switch (factor) + { + case 2: return scaleImage, ColorDistanceRGB, OobReaderDuplicate>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 3: return scaleImage, ColorDistanceRGB, OobReaderDuplicate>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 4: return scaleImage, ColorDistanceRGB, OobReaderDuplicate>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 5: return scaleImage, ColorDistanceRGB, OobReaderDuplicate>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 6: return scaleImage, ColorDistanceRGB, OobReaderDuplicate>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + } + break; + + case ColorFormat::argb: + switch (factor) + { + case 2: return scaleImage, ColorDistanceARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 3: return scaleImage, ColorDistanceARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 4: return scaleImage, ColorDistanceARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 5: return scaleImage, ColorDistanceARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 6: return scaleImage, ColorDistanceARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + } + break; + + case ColorFormat::argbUnbuffered: + switch (factor) + { + case 2: return scaleImage, ColorDistanceUnbufferedARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 3: return scaleImage, ColorDistanceUnbufferedARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 4: return scaleImage, ColorDistanceUnbufferedARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 5: return scaleImage, ColorDistanceUnbufferedARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + case 6: return scaleImage, ColorDistanceUnbufferedARGB, OobReaderTransparent>(src, trg, srcWidth, srcHeight, cfg, yFirst, yLast); + } + break; + } + assert(false); +} + + +bool xbrz::equalColorTest2(uint32_t col1, uint32_t col2, ColorFormat colFmt, double equalColorTolerance, double testAttribute) +{ + switch (colFmt) + { + case ColorFormat::rgb: + return ColorDistanceRGB::dist(col1, col2, testAttribute) < equalColorTolerance; + case ColorFormat::argb: + return ColorDistanceARGB::dist(col1, col2, testAttribute) < equalColorTolerance; + case ColorFormat::argbUnbuffered: + return ColorDistanceUnbufferedARGB::dist(col1, col2, testAttribute) < equalColorTolerance; + } + assert(false); + return false; +} + + +void xbrz::bilinearScale(const uint32_t* src, int srcWidth, int srcHeight, + /**/ uint32_t* trg, int trgWidth, int trgHeight) +{ + const auto pixRead = [src, srcWidth](int x, int y) + { + const uint32_t pixSrc = src[y * srcWidth + x]; + + return [pixSrc, a = int(getAlpha(pixSrc))](int channel) + { + if (channel == 3) + return a; + + //Limitation: alpha should be applied in gamma-decoded linear RGB space: https://ssp.impulsetrain.com/gamma-premult.html + return getByte(pixSrc, channel) * a; + }; + }; + + const auto pixWrite = [trg](const auto& interpolate) mutable + { + const double a = interpolate(3); + if (a <= 0.0) + * trg++ = 0; + else + * trg++ = makePixel(byteRound(a), + byteRound(interpolate(2) / a), //r + byteRound(interpolate(1) / a), //g + byteRound(interpolate(0) / a)); //b + }; + + bilinearScale(pixRead, srcWidth, srcHeight, + pixWrite, trgWidth, trgHeight, 0, trgHeight); +} + + +void xbrz::nearestNeighborScale(const uint32_t* src, int srcWidth, int srcHeight, + /**/ uint32_t* trg, int trgWidth, int trgHeight) +{ + const auto pixRead = [src, srcWidth](int x, int y) { return src[y * srcWidth + x]; }; + + const auto pixWrite = [trg](uint32_t pix) mutable { *trg++ = pix; }; + + nearestNeighborScale(pixRead, srcWidth, srcHeight, + pixWrite, trgWidth, trgHeight, 0, trgHeight); +} + + +#if 0 +//#include +void bilinearScaleCpu(const uint32_t* src, int srcWidth, int srcHeight, + /**/ uint32_t* trg, int trgWidth, int trgHeight) +{ + const int TASK_GRANULARITY = 16; + + concurrency::task_group tg; + + for (int i = 0; i < trgHeight; i += TASK_GRANULARITY) + tg.run([=] + { + const int iLast = std::min(i + TASK_GRANULARITY, trgHeight); + bilinearScale(src, srcWidth, srcHeight, srcWidth * sizeof(uint32_t), + trg, trgWidth, trgHeight, trgWidth * sizeof(uint32_t), + i, iLast, [](uint32_t pix) { return pix; }); + }); + tg.wait(); +} + + +//Perf: AMP vs CPU: merely ~10% shorter runtime (scaling 1280x800 -> 1920x1080) +//#include +void bilinearScaleAmp(const uint32_t* src, int srcWidth, int srcHeight, //throw concurrency::runtime_exception + /**/ uint32_t* trg, int trgWidth, int trgHeight) +{ + //C++ AMP reference: https://docs.microsoft.com/en-us/cpp/parallel/amp/reference/reference-cpp-amp + //introduction to C++ AMP: https://docs.microsoft.com/en-us/archive/msdn-magazine/2012/april/c-a-code-based-introduction-to-c-amp + using namespace concurrency; + //TODO: pitch + + if (srcHeight <= 0 || srcWidth <= 0) return; + + const float scaleX = static_cast(trgWidth ) / srcWidth; + const float scaleY = static_cast(trgHeight) / srcHeight; + + array_view srcView(srcHeight, srcWidth, src); + array_view< uint32_t, 2> trgView(trgHeight, trgWidth, trg); + trgView.discard_data(); + + parallel_for_each(trgView.extent, [=](index<2> idx) restrict(amp) //throw ? + { + const int y = idx[0]; + const int x = idx[1]; + //Perf notes: + // -> float-based calculation is (almost) 2x as fas as double! + // -> no noticeable improvement via tiling: https://docs.microsoft.com/en-us/archive/msdn-magazine/2012/april/c-amp-introduction-to-tiling-in-c-amp + // -> no noticeable improvement with restrict(amp,cpu) + // -> iterating over y-axis only is significantly slower! + // -> pre-calculating x,y-dependent variables in a buffer + array_view<> is ~ 20 % slower! + const int y1 = srcHeight * y / trgHeight; + int y2 = y1 + 1; + if (y2 == srcHeight) --y2; + + const float yy1 = y / scaleY - y1; + const float y2y = 1 - yy1; + //------------------------------------- + const int x1 = srcWidth * x / trgWidth; + int x2 = x1 + 1; + if (x2 == srcWidth) --x2; + + const float xx1 = x / scaleX - x1; + const float x2x = 1 - xx1; + //------------------------------------- + const float x2xy2y = x2x * y2y; + const float xx1y2y = xx1 * y2y; + const float x2xyy1 = x2x * yy1; + const float xx1yy1 = xx1 * yy1; + + auto interpolate = [=](int offset) + { + /* + https://en.wikipedia.org/wiki/Bilinear_interpolation + (c11(x2 - x) + c21(x - x1)) * (y2 - y ) + + (c12(x2 - x) + c22(x - x1)) * (y - y1) + */ + const auto c11 = (srcView(y1, x1) >> (8 * offset)) & 0xff; + const auto c21 = (srcView(y1, x2) >> (8 * offset)) & 0xff; + const auto c12 = (srcView(y2, x1) >> (8 * offset)) & 0xff; + const auto c22 = (srcView(y2, x2) >> (8 * offset)) & 0xff; + + return c11 * x2xy2y + c21 * xx1y2y + + c12 * x2xyy1 + c22 * xx1yy1; + }; + + const float bi = interpolate(0); + const float gi = interpolate(1); + const float ri = interpolate(2); + const float ai = interpolate(3); + + const auto b = static_cast(bi + 0.5f); + const auto g = static_cast(gi + 0.5f); + const auto r = static_cast(ri + 0.5f); + const auto a = static_cast(ai + 0.5f); + + trgView(y, x) = (a << 24) | (r << 16) | (g << 8) | b; + }); + trgView.synchronize(); //throw ? +} +#endif diff --git a/xBRZ/src/xbrz.h b/xBRZ/src/xbrz.h new file mode 100644 index 0000000..f92b65c --- /dev/null +++ b/xBRZ/src/xbrz.h @@ -0,0 +1,78 @@ +// **************************************************************************** +// * This file is part of the xBRZ project. It is distributed under * +// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * +// * Copyright (C) Zenju (zenju AT gmx DOT de) - All Rights Reserved * +// * * +// * Additionally and as a special exception, the author gives permission * +// * to link the code of this program with the following libraries * +// * (or with modified versions that use the same licenses), and distribute * +// * linked combinations including the two: MAME, FreeFileSync, Snes9x, ePSXe * +// * * +// * You must obey the GNU General Public License in all respects for all of * +// * the code used other than MAME, FreeFileSync, Snes9x, ePSXe. * +// * If you modify this file, you may extend this exception to your version * +// * of the file, but you are not obligated to do so. If you do not wish to * +// * do so, delete this exception statement from your version. * +// **************************************************************************** + +#ifndef XBRZ_HEADER_3847894708239054 +#define XBRZ_HEADER_3847894708239054 + +#include //size_t +#include //uint32_t +#include +#include "xbrz_config.h" + + +namespace xbrz +{ +/* ------------------------------------------------------------------------- + | xBRZ: "Scale by rules" - high quality image upscaling filter by Zenju | + ------------------------------------------------------------------------- + using a modified approach of xBR: https://forums.libretro.com/t/xbr-algorithm-tutorial/123 + - new rule set preserving small image features + - highly optimized for performance + - support alpha channel + - support multithreading + - support 64-bit architectures + - support processing image slices + - support scaling up to 6xBRZ */ + +enum class ColorFormat //from high bits -> low bits, 8 bit per channel +{ + rgb, //8 bit for each red, green, blue, upper 8 bits unused + argb, //including alpha channel, BGRA byte order on little-endian machines + argbUnbuffered, //like ARGB, but without the one-time buffer creation overhead (ca. 100 - 300 ms) at the expense of a slightly slower scaling time +}; + +const int SCALE_FACTOR_MAX = 6; + +/* +-> map source (srcWidth * srcHeight) to target (scale * width x scale * height) image, optionally processing a half-open slice of rows [yFirst, yLast) only +-> if your emulator changes only a few image slices during each cycle (e.g. DOSBox) then there's no need to run xBRZ on the complete image: + Just make sure you enlarge the source image slice by 2 rows on top and 2 on bottom (this is the additional range the xBRZ algorithm is using during analysis) + CAVEAT: If there are multiple changed slices, make sure they do not overlap after adding these additional rows in order to avoid a memory race condition + in the target image data if you are using multiple threads for processing each enlarged slice! + +THREAD-SAFETY: - parts of the same image may be scaled by multiple threads as long as the [yFirst, yLast) ranges do not overlap! + - there is a minor inefficiency for the first row of a slice, so avoid processing single rows only; suggestion: process at least 8-16 rows +*/ +void scale(size_t factor, //valid range: 2 - SCALE_FACTOR_MAX + const uint32_t* src, uint32_t* trg, int srcWidth, int srcHeight, + ColorFormat colFmt, + const ScalerCfg& cfg = ScalerCfg(), + int yFirst = 0, int yLast = std::numeric_limits::max()); //slice of source image + +//BGRA byte order +void bilinearScale(const uint32_t* src, int srcWidth, int srcHeight, + /**/ uint32_t* trg, int trgWidth, int trgHeight); + +void nearestNeighborScale(const uint32_t* src, int srcWidth, int srcHeight, + /**/ uint32_t* trg, int trgWidth, int trgHeight); + + +//parameter tuning +bool equalColorTest2(uint32_t col1, uint32_t col2, ColorFormat colFmt, double equalColorTolerance, double testAttribute); +} + +#endif diff --git a/xBRZ/src/xbrz_config.h b/xBRZ/src/xbrz_config.h new file mode 100644 index 0000000..bd7deff --- /dev/null +++ b/xBRZ/src/xbrz_config.h @@ -0,0 +1,35 @@ +// **************************************************************************** +// * This file is part of the xBRZ project. It is distributed under * +// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * +// * Copyright (C) Zenju (zenju AT gmx DOT de) - All Rights Reserved * +// * * +// * Additionally and as a special exception, the author gives permission * +// * to link the code of this program with the following libraries * +// * (or with modified versions that use the same licenses), and distribute * +// * linked combinations including the two: MAME, FreeFileSync, Snes9x, ePSXe * +// * * +// * You must obey the GNU General Public License in all respects for all of * +// * the code used other than MAME, FreeFileSync, Snes9x, ePSXe. * +// * If you modify this file, you may extend this exception to your version * +// * of the file, but you are not obligated to do so. If you do not wish to * +// * do so, delete this exception statement from your version. * +// **************************************************************************** + +#ifndef XBRZ_CONFIG_HEADER_284578425345 +#define XBRZ_CONFIG_HEADER_284578425345 + +//do NOT include any headers here! used by xBRZ_dll!!! + +namespace xbrz +{ +struct ScalerCfg +{ + double equalColorTolerance = 30; + double centerDirectionBias = 4; + double dominantDirectionThreshold = 3.6; + double steepDirectionThreshold = 2.2; + double testAttribute = 0; //unused; test new parameters +}; +} + +#endif diff --git a/xBRZ/src/xbrz_tools.h b/xBRZ/src/xbrz_tools.h new file mode 100644 index 0000000..0ce9187 --- /dev/null +++ b/xBRZ/src/xbrz_tools.h @@ -0,0 +1,248 @@ +// **************************************************************************** +// * This file is part of the xBRZ project. It is distributed under * +// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0 * +// * Copyright (C) Zenju (zenju AT gmx DOT de) - All Rights Reserved * +// * * +// * Additionally and as a special exception, the author gives permission * +// * to link the code of this program with the following libraries * +// * (or with modified versions that use the same licenses), and distribute * +// * linked combinations including the two: MAME, FreeFileSync, Snes9x, ePSXe * +// * * +// * You must obey the GNU General Public License in all respects for all of * +// * the code used other than MAME, FreeFileSync, Snes9x, ePSXe. * +// * If you modify this file, you may extend this exception to your version * +// * of the file, but you are not obligated to do so. If you do not wish to * +// * do so, delete this exception statement from your version. * +// **************************************************************************** + +#ifndef XBRZ_TOOLS_H_825480175091875 +#define XBRZ_TOOLS_H_825480175091875 + +#include +#include +#include +#include + + +namespace xbrz +{ +template +inline unsigned char getByte(uint32_t val) { return static_cast((val >> (8 * N)) & 0xff); } +inline unsigned char getByte(uint32_t val, int n) { return static_cast((val >> (8 * n)) & 0xff); } + +inline unsigned char getAlpha(uint32_t pix) { return getByte<3>(pix); } +inline unsigned char getRed (uint32_t pix) { return getByte<2>(pix); } +inline unsigned char getGreen(uint32_t pix) { return getByte<1>(pix); } +inline unsigned char getBlue (uint32_t pix) { return getByte<0>(pix); } + +inline uint32_t makePixel(uint32_t a, uint32_t r, uint32_t g, uint32_t b) { return (a << 24) | (r << 16) | (g << 8) | b; } +inline uint32_t makePixel( uint32_t r, uint32_t g, uint32_t b) { return (r << 16) | (g << 8) | b; } + +inline uint32_t rgb555to888(uint16_t pix) { return ((pix & 0x7C00) << 9) | ((pix & 0x03E0) << 6) | ((pix & 0x001F) << 3); } +inline uint32_t rgb565to888(uint16_t pix) { return ((pix & 0xF800) << 8) | ((pix & 0x07E0) << 5) | ((pix & 0x001F) << 3); } + +inline uint16_t rgb888to555(uint32_t pix) { return static_cast(((pix & 0xF80000) >> 9) | ((pix & 0x00F800) >> 6) | ((pix & 0x0000F8) >> 3)); } +inline uint16_t rgb888to565(uint32_t pix) { return static_cast(((pix & 0xF80000) >> 8) | ((pix & 0x00FC00) >> 5) | ((pix & 0x0000F8) >> 3)); } + + +template inline +void unscaledCopy(PixReader pixRead /* (int x, int y) -> uint32_t */, + PixWriter pixWrite /* (uint32_t pix) */, int width, int height) +{ + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + pixWrite(pixRead(x, y)); +} + + +//nearest-neighbor (going over target image - slow for upscaling, since source is read multiple times missing out on cache! Fast for similar image sizes!) +template +void nearestNeighborScale(PixReader pixRead /* (int x, int y) -> uint32_t */, int srcWidth, int srcHeight, + PixWriter pixWrite /* (uint32_t pix) */, int trgWidth, int trgHeight, + int yFirst, int yLast) +{ + yFirst = std::max(yFirst, 0); + yLast = std::min(yLast, trgHeight); + if (yFirst >= yLast || srcHeight <= 0 || srcWidth <= 0) return; + + for (int y = yFirst; y < yLast; ++y) + { + const int ySrc = srcHeight * y / trgHeight; + + for (int x = 0; x < trgWidth; ++x) + { + const int xSrc = srcWidth * x / trgWidth; + pixWrite(pixRead(xSrc, ySrc)); + } + } +} + + +inline +unsigned char byteRound(double v) //std::round is prohibitively expensive! +{ + //assert(v >= 0); + return static_cast(std::min(v + 0.5, 255.0)); +} + + +#if 0 +inline +unsigned char byteCeil(double v) +{ + //assert(v >= 0); + if (v >= 255.0) return 255; + unsigned char i = static_cast(v); + return v == i ? i : i + 1; +} +#endif + + +inline +unsigned int uintDivRound(unsigned int num, unsigned int den) +{ + assert(den != 0); + return (num + den / 2) / den; +} + + +//caveat: treats alpha channel like regular color! => caller needs to pre/de-multiply alpha! +template +void bilinearScale(PixReader pixRead /* (int x, int y) -> Function */, int srcWidth, int srcHeight, + PixWriter pixWrite /* ( const Function& interpolate ) */, int trgWidth, int trgHeight, + int yFirst, int yLast) +{ + yFirst = std::max(yFirst, 0); + yLast = std::min(yLast, trgHeight); + if (yFirst >= yLast || srcHeight <= 0 || srcWidth <= 0) + return; + + const double scaleX = static_cast(trgWidth ) / srcWidth; + const double scaleY = static_cast(trgHeight) / srcHeight; + + //perf notes: + // -> double-based calculation is (slightly) faster than float + // -> pre-calculation gives significant boost; std::vector<> memory allocation is negligible! + struct CoeffsX + { + int x1 = 0; + int x2 = 0; + double xx1 = 0; + double x2x = 0; + }; + std::vector buf(trgWidth); + for (int x = 0; x < trgWidth; ++x) + { + const int x1 = srcWidth * x / trgWidth; + int x2 = x1 + 1; + if (x2 == srcWidth) + --x2; + + const double xx1 = x / scaleX - x1; + const double x2x = 1 - xx1; + + buf[x] = {x1, x2, xx1, x2x}; + } + + for (int y = yFirst; y < yLast; ++y) + { + const int y1 = srcHeight * y / trgHeight; + int y2 = y1 + 1; + if (y2 == srcHeight) + --y2; + + const double yy1 = y / scaleY - y1; + const double y2y = 1 - yy1; + + for (int x = 0; x < trgWidth; ++x) + { + //perf: do NOT "simplify" the variable layout without measurement! + const CoeffsX& bufX = buf[x]; + const int x1 = bufX.x1; + const int x2 = bufX.x2; + const double xx1 = bufX.xx1; + const double x2x = bufX.x2x; + + const double x2xy2y = x2x * y2y; + const double xx1y2y = xx1 * y2y; + const double x2xyy1 = x2x * yy1; + const double xx1yy1 = xx1 * yy1; + + auto pix11 = pixRead(x1, y1); + auto pix21 = pixRead(x2, y1); + auto pix12 = pixRead(x1, y2); + auto pix22 = pixRead(x2, y2); + + auto interpolate = [&](int channel) + { + /* https://en.wikipedia.org/wiki/Bilinear_interpolation + (c11(x2 - x) + c21(x - x1)) * (y2 - y ) + + (c12(x2 - x) + c22(x - x1)) * (y - y1) */ + return pix11(channel) * x2xy2y + pix21(channel) * xx1y2y + + pix12(channel) * x2xyy1 + pix22(channel) * xx1yy1; + }; + pixWrite(std::move(interpolate)); + } + } +} + + +#if 0 +//nearest-neighbor (going over source image - fast for upscaling, since source is read only once +template +void nearestNeighborScaleOverSource(const PixSrc* src, int srcWidth, int srcHeight, int srcPitch /*[bytes]*/, + /**/ PixTrg* trg, int trgWidth, int trgHeight, int trgPitch /*[bytes]*/, + int yFirst, int yLast, PixConverter pixCvrt /*convert PixSrc to PixTrg*/) +{ + static_assert(std::is_integral_v, "PixSrc* is expected to be cast-able to char*"); + static_assert(std::is_integral_v, "PixTrg* is expected to be cast-able to char*"); + + static_assert(std::is_same_v, "PixConverter returning wrong pixel format"); + + if (srcPitch < srcWidth * static_cast(sizeof(PixSrc)) || + trgPitch < trgWidth * static_cast(sizeof(PixTrg))) + { + assert(false); + return; + } + + yFirst = std::max(yFirst, 0); + yLast = std::min(yLast, srcHeight); + if (yFirst >= yLast || trgWidth <= 0 || trgHeight <= 0) return; + + for (int y = yFirst; y < yLast; ++y) + { + //mathematically: ySrc = floor(srcHeight * yTrg / trgHeight) + // => search for integers in: [ySrc, ySrc + 1) * trgHeight / srcHeight + + //keep within for loop to support MT input slices! + const int yTrgFirst = ( y * trgHeight + srcHeight - 1) / srcHeight; //=ceil(y * trgHeight / srcHeight) + const int yTrgLast = ((y + 1) * trgHeight + srcHeight - 1) / srcHeight; //=ceil(((y + 1) * trgHeight) / srcHeight) + const int blockHeight = yTrgLast - yTrgFirst; + + if (blockHeight > 0) + { + const PixSrc* srcLine = byteAdvance(src, y * srcPitch); + /**/ PixTrg* trgLine = byteAdvance(trg, yTrgFirst * trgPitch); + int xTrgFirst = 0; + + for (int x = 0; x < srcWidth; ++x) + { + const int xTrgLast = ((x + 1) * trgWidth + srcWidth - 1) / srcWidth; + const int blockWidth = xTrgLast - xTrgFirst; + if (blockWidth > 0) + { + xTrgFirst = xTrgLast; + + const auto trgPix = pixCvrt(srcLine[x]); + fillBlock(trgLine, trgPitch, trgPix, blockWidth, blockHeight); + trgLine += blockWidth; + } + } + } + } +} +#endif +} + +#endif //XBRZ_TOOLS_H_825480175091875 diff --git a/zen/argon2.cpp b/zen/argon2.cpp new file mode 100644 index 0000000..333128c --- /dev/null +++ b/zen/argon2.cpp @@ -0,0 +1,977 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +/* The code in this file, except for zen::zargon2(), is from PuTTY: + + PuTTY is copyright 1997-2022 Simon Tatham. + + Portions copyright Robert de Bath, Joris van Rantwijk, Delian + Delchev, Andreas Schultz, Jeroen Massar, Wez Furlong, Nicolas Barry, + Justin Bradford, Ben Harris, Malcolm Smith, Ahmad Khalifa, Markus + Kuhn, Colin Watson, Christopher Staite, Lorenz Diener, Christian + Brabandt, Jeff Smith, Pavel Kryukov, Maxim Kuznetsov, Svyatoslav + Kuzmich, Nico Williams, Viktor Dukhovni, Josh Dersch, Lars Brinkhoff, + and CORE SDI S.A. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation files + (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + +#include "argon2.h" +#include +#include + +#if defined __GNUC__ //including clang + #pragma GCC diagnostic ignored "-Wimplicit-fallthrough" //"this statement may fall through" + #pragma GCC diagnostic ignored "-Wcast-align" //"cast from 'char *' to 'blake2b *' increases required alignment from 1 to 8" +#endif + +/* + * Implementation of the Argon2 password hash function. + * + * My sources for the algorithm description and test vectors (the latter in + * test/cryptsuite.py) were the reference implementation on Github, and also + * the Internet-Draft description: + * + * https://github.com/P-H-C/phc-winner-argon2 + * https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-argon2-13 + */ + + +/* ---------------------------------------------------------------------- + * + * A sort of 'abstract base class' or 'interface' or 'trait' which is + * the common feature of all types that want to accept data formatted + * using the SSH binary conventions of uint32, string, mpint etc. + */ +typedef struct BinarySink BinarySink; + +struct BinarySink +{ + void (*write)(BinarySink* sink, const void* data, size_t len); + void (*writefmtv)(BinarySink* sink, const char* fmt, va_list ap); + BinarySink* binarysink_; +}; + +#define BinarySink_INIT(obj, writefn) \ + ((obj)->binarysink_->write = (writefn), \ + (obj)->binarysink_->writefmtv = NULL, \ + (obj)->binarysink_->binarysink_ = (obj)->binarysink_) + +#define BinarySink_DELEGATE_IMPLEMENTATION BinarySink *binarysink_ + +#define BinarySink_DELEGATE_INIT(obj, othersink) ((obj)->binarysink_ = BinarySink_UPCAST(othersink)) + +#define BinarySink_DOWNCAST(object, type) \ + TYPECHECK((object) == ((type *)0)->binarysink_, \ + ((type *)(((char *)(object)) - offsetof(type, binarysink_)))) + +#define BinarySink_IMPLEMENTATION BinarySink binarysink_[1] + +/* Return a pointer to the object of structure type 'type' whose field + * with name 'field' is pointed at by 'object'. */ +#define container_of(object, type, field) \ + TYPECHECK(object == &((type *)0)->field, \ + ((type *)(((char *)(object)) - offsetof(type, field)))) + + +static void no_op(void* /*ptr*/, size_t /*size*/) {} + +static void (*const volatile maybe_read)(void* ptr, size_t size) = no_op; + +void smemclr(void* b, size_t n) +{ + if (b && n > 0) + { + /* + * Zero out the memory. + */ + memset(b, 0, n); + + /* + * Call the above function pointer, which (for all the + * compiler knows) might check that we've really zeroed the + * memory. + */ + maybe_read(b, n); + } +} + +void* safemalloc(size_t factor1, size_t factor2, size_t addend) +{ + if (factor1 > SIZE_MAX / factor2) + return nullptr; + size_t product = factor1 * factor2; + + if (addend > SIZE_MAX) + return nullptr; + if (product > SIZE_MAX - addend) + return nullptr; + size_t size = product + addend; + + if (size == 0) + size = 1; + + return malloc(size); +} + +void safefree(void* ptr) +{ + if (ptr) + free(ptr); +} + + +#define snmalloc safemalloc +#define smalloc(z) safemalloc(z,1,0) + +#define snewn(n, type) ((type *)snmalloc((n), sizeof(type), 0)) +#define snew(type) ((type *) smalloc (sizeof (type)) ) + +#define sfree safefree + + +/* + * A small structure wrapping up a (pointer, length) pair so that it + * can be conveniently passed to or from a function. + */ +typedef struct ptrlen +{ + const void* ptr; + size_t len; +} ptrlen; + + +struct ssh_hash +{ + //const ssh_hashalg* vt; + BinarySink_DELEGATE_IMPLEMENTATION; +}; + + +static inline void PUT_32BIT_LSB_FIRST(void* vp, uint32_t value) +{ + uint8_t* p = (uint8_t*)vp; + p[0] = (uint8_t)((value ) & 0xff); + p[1] = (uint8_t)((value >> 8) & 0xff); + p[2] = (uint8_t)((value >> 16) & 0xff); + p[3] = (uint8_t)((value >> 24) & 0xff); +} + + +static inline uint64_t GET_64BIT_LSB_FIRST(const void* vp) +{ + const uint8_t* p = (const uint8_t*)vp; + return (((uint64_t)p[0] ) | ((uint64_t)p[1] << 8) | + ((uint64_t)p[2] << 16) | ((uint64_t)p[3] << 24) | + ((uint64_t)p[4] << 32) | ((uint64_t)p[5] << 40) | + ((uint64_t)p[6] << 48) | ((uint64_t)p[7] << 56)); +} + + +static inline void PUT_64BIT_LSB_FIRST(void* vp, uint64_t value) +{ + uint8_t* p = (uint8_t*)vp; + p[0] = (uint8_t)((value ) & 0xff); + p[1] = (uint8_t)((value >> 8) & 0xff); + p[2] = (uint8_t)((value >> 16) & 0xff); + p[3] = (uint8_t)((value >> 24) & 0xff); + p[4] = (uint8_t)((value >> 32) & 0xff); + p[5] = (uint8_t)((value >> 40) & 0xff); + p[6] = (uint8_t)((value >> 48) & 0xff); + p[7] = (uint8_t)((value >> 56) & 0xff); +} + + +static void BinarySink_put_uint32_le(BinarySink* bs, unsigned long val) +{ + unsigned char data[4]; + PUT_32BIT_LSB_FIRST(data, val); + bs->write(bs, data, sizeof(data)); +} + +static void BinarySink_put_stringpl_le(BinarySink* bs, ptrlen pl) +{ + /* Check that the string length fits in a uint32, without doing a + * potentially implementation-defined shift of more than 31 bits */ + assert((pl.len >> 31) < 2); + + BinarySink_put_uint32_le(bs, pl.len); + bs->write(bs, pl.ptr, pl.len); +} + + +#define TYPECHECK(to_check, to_return) \ + (sizeof(to_check) ? (to_return) : (to_return)) + + +#define BinarySink_UPCAST(object) \ + TYPECHECK((object)->binarysink_ == (BinarySink *)0, \ + (object)->binarysink_) + +#define put_uint32_le(bs, val) \ + BinarySink_put_uint32_le(BinarySink_UPCAST(bs), val) +#define put_stringpl_le(bs, val) \ + BinarySink_put_stringpl_le(BinarySink_UPCAST(bs), val) + + +static inline uint32_t GET_32BIT_LSB_FIRST(const void* vp) +{ + const uint8_t* p = (const uint8_t*)vp; + return (((uint32_t)p[0] ) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)); +} + + +void memxor(uint8_t* out, const uint8_t* in1, const uint8_t* in2, size_t size) +{ + switch (size & 15) + { + case 0: + while (size >= 16) + { + size -= 16; + *out++ = *in1++ ^ *in2++; + case 15: + *out++ = *in1++ ^ *in2++; + case 14: + *out++ = *in1++ ^ *in2++; + case 13: + *out++ = *in1++ ^ *in2++; + case 12: + *out++ = *in1++ ^ *in2++; + case 11: + *out++ = *in1++ ^ *in2++; + case 10: + *out++ = *in1++ ^ *in2++; + case 9: + *out++ = *in1++ ^ *in2++; + case 8: + *out++ = *in1++ ^ *in2++; + case 7: + *out++ = *in1++ ^ *in2++; + case 6: + *out++ = *in1++ ^ *in2++; + case 5: + *out++ = *in1++ ^ *in2++; + case 4: + *out++ = *in1++ ^ *in2++; + case 3: + *out++ = *in1++ ^ *in2++; + case 2: + *out++ = *in1++ ^ *in2++; + case 1: + *out++ = *in1++ ^ *in2++; + } + } +} + + +/* RFC 7963 section 2.1 */ +enum { R1 = 32, R2 = 24, R3 = 16, R4 = 63 }; + +/* RFC 7693 section 2.6 */ +static const uint64_t iv[] = +{ + 0x6a09e667f3bcc908, /* floor(2^64 * frac(sqrt(2))) */ + 0xbb67ae8584caa73b, /* floor(2^64 * frac(sqrt(3))) */ + 0x3c6ef372fe94f82b, /* floor(2^64 * frac(sqrt(5))) */ + 0xa54ff53a5f1d36f1, /* floor(2^64 * frac(sqrt(7))) */ + 0x510e527fade682d1, /* floor(2^64 * frac(sqrt(11))) */ + 0x9b05688c2b3e6c1f, /* floor(2^64 * frac(sqrt(13))) */ + 0x1f83d9abfb41bd6b, /* floor(2^64 * frac(sqrt(17))) */ + 0x5be0cd19137e2179, /* floor(2^64 * frac(sqrt(19))) */ +}; + +/* RFC 7693 section 2.7 */ +static const unsigned char sigma[][16] = +{ + { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, + {11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4}, + { 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8}, + { 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13}, + { 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9}, + {12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11}, + {13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10}, + { 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5}, + {10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0}, + /* This array recycles if you have more than 10 rounds. BLAKE2b + * has 12, so we repeat the first two rows again. */ + { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, +}; + +static inline uint64_t ror(uint64_t x, unsigned rotation) +{ + unsigned lshift = 63 & -rotation, rshift = 63 & rotation; + return (x << lshift) | (x >> rshift); +} + +static inline void g_half(uint64_t v[16], unsigned a, unsigned b, unsigned c, + unsigned d, uint64_t x, unsigned r1, unsigned r2) +{ + v[a] += v[b] + x; + v[d] ^= v[a]; + v[d] = ror(v[d], r1); + v[c] += v[d]; + v[b] ^= v[c]; + v[b] = ror(v[b], r2); +} + +static inline void g(uint64_t v[16], unsigned a, unsigned b, unsigned c, + unsigned d, uint64_t x, uint64_t y) +{ + g_half(v, a, b, c, d, x, R1, R2); + g_half(v, a, b, c, d, y, R3, R4); +} + +static inline void f(uint64_t h[8], uint64_t m[16], uint64_t offset_hi, + uint64_t offset_lo, unsigned final) +{ + uint64_t v[16]; + memcpy(v, h, 8 * sizeof(*v)); + memcpy(v + 8, iv, 8 * sizeof(*v)); + v[12] ^= offset_lo; + v[13] ^= offset_hi; + v[14] ^= -(uint64_t)final; + for (unsigned round = 0; round < 12; round++) + { + const unsigned char* s = sigma[round]; + g(v, 0, 4, 8, 12, m[s[ 0]], m[s[ 1]]); + g(v, 1, 5, 9, 13, m[s[ 2]], m[s[ 3]]); + g(v, 2, 6, 10, 14, m[s[ 4]], m[s[ 5]]); + g(v, 3, 7, 11, 15, m[s[ 6]], m[s[ 7]]); + g(v, 0, 5, 10, 15, m[s[ 8]], m[s[ 9]]); + g(v, 1, 6, 11, 12, m[s[10]], m[s[11]]); + g(v, 2, 7, 8, 13, m[s[12]], m[s[13]]); + g(v, 3, 4, 9, 14, m[s[14]], m[s[15]]); + } + for (unsigned i = 0; i < 8; i++) + h[i] ^= v[i] ^ v[i+8]; + smemclr(v, sizeof(v)); +} + + +static inline void f_outer(uint64_t h[8], uint8_t blk[128], uint64_t offset_hi, + uint64_t offset_lo, unsigned final) +{ + uint64_t m[16]; + for (unsigned i = 0; i < 16; i++) + m[i] = GET_64BIT_LSB_FIRST(blk + 8*i); + f(h, m, offset_hi, offset_lo, final); + smemclr(m, sizeof(m)); +} + + +typedef struct blake2b +{ + uint64_t h[8]; + unsigned hashlen; + + uint8_t block[128]; + size_t used; + uint64_t lenhi, lenlo; + + BinarySink_IMPLEMENTATION; + ssh_hash hash; +} blake2b; + + +static void blake2b_reset(ssh_hash* hash) +{ + blake2b* s = container_of(hash, blake2b, hash); + + /* Initialise the hash to the standard IV */ + memcpy(s->h, iv, sizeof(s->h)); + + /* XOR in the parameters: secret key length (here always 0) in + * byte 1, and hash length in byte 0. */ + s->h[0] ^= 0x01010000 ^ s->hashlen; + + s->used = 0; + s->lenhi = s->lenlo = 0; +} + + +static void blake2b_digest(ssh_hash* hash, uint8_t* digest) +{ + blake2b* s = container_of(hash, blake2b, hash); + + memset(s->block + s->used, 0, sizeof(s->block) - s->used); + f_outer(s->h, s->block, s->lenhi, s->lenlo, 1); + + uint8_t hash_pre[128]; + for (unsigned i = 0; i < 8; i++) + PUT_64BIT_LSB_FIRST(hash_pre + 8*i, s->h[i]); + memcpy(digest, hash_pre, s->hashlen); + smemclr(hash_pre, sizeof(hash_pre)); +} + + +static void blake2b_free(ssh_hash* hash) +{ + blake2b* s = container_of(hash, blake2b, hash); + + smemclr(s, sizeof(*s)); + sfree(s); +} + + +static void blake2b_write(BinarySink* bs, const void* vp, size_t len) +{ + blake2b* s = BinarySink_DOWNCAST(bs, blake2b); + const uint8_t* p = (const uint8_t*)vp; + + while (len > 0) + { + if (s->used == sizeof(s->block)) + { + f_outer(s->h, s->block, s->lenhi, s->lenlo, 0); + s->used = 0; + } + + size_t chunk = sizeof(s->block) - s->used; + if (chunk > len) + chunk = len; + + memcpy(s->block + s->used, p, chunk); + s->used += chunk; + p += chunk; + len -= chunk; + + s->lenlo += chunk; + s->lenhi += (s->lenlo < chunk); + } +} + + +static inline ssh_hash* ssh_hash_reset(ssh_hash* h) +{ + blake2b_reset(h); + return h; +} + + +static ssh_hash* blake2b_new_inner(unsigned hashlen) +{ + assert(hashlen <= 64); + + blake2b* s = snew(struct blake2b); + //s->hash.vt = &ssh_blake2b; + s->hashlen = hashlen; + BinarySink_INIT(s, blake2b_write); + BinarySink_DELEGATE_INIT(&s->hash, s); + return &s->hash; +} + + +ssh_hash* blake2b_new_general(unsigned hashlen) +{ + ssh_hash* h = blake2b_new_inner(hashlen); + ssh_hash_reset(h); + return h; +} + +/* ---------------------------------------------------------------------- + * Argon2 defines a hash-function family that's an extension of BLAKE2b to + * generate longer output digests, by repeatedly outputting half of a BLAKE2 + * hash output and then re-hashing the whole thing until there are 64 or fewer + * bytes left to output. The spec calls this H' (a variant of the original + * hash it calls H, which is the unmodified BLAKE2b). + */ + +static ssh_hash* hprime_new(unsigned length) +{ + ssh_hash* h = blake2b_new_general(length > 64 ? 64 : length); + put_uint32_le(h, length); + return h; +} + +void BinarySink_put_data(BinarySink* bs, const void* data, size_t len) +{ + bs->write(bs, data, len); +} + +#define put_data(bs, val, len) BinarySink_put_data(BinarySink_UPCAST(bs), val, len) + +static inline void ssh_hash_final(ssh_hash* h, unsigned char* out) +{ + blake2b_digest(h, out); + blake2b_free(h); +} + +static void hprime_final(ssh_hash* h, unsigned length, void* vout) +{ + uint8_t* out = (uint8_t*)vout; + + while (length > 64) + { + uint8_t hashbuf[64]; + ssh_hash_final(h, hashbuf); + + memcpy(out, hashbuf, 32); + out += 32; + length -= 32; + + h = blake2b_new_general(length > 64 ? 64 : length); + put_data(h, hashbuf, 64); + + smemclr(hashbuf, sizeof(hashbuf)); + } + + ssh_hash_final(h, out); +} + +/* ---------------------------------------------------------------------- + * Argon2's own mixing function G, which operates on 1Kb blocks of data. + * + * The definition of G in the spec takes two 1Kb blocks as input and produces + * a 1Kb output block. The first thing that happens to the input blocks is + * that they get XORed together, and then only the XOR output is used, so you + * could perfectly well regard G as a 1Kb->1Kb function. + */ + +static inline uint64_t trunc32(uint64_t x) +{ + return x & 0xFFFFFFFF; +} + +/* Internal function similar to the BLAKE2b round, which mixes up four 64-bit + * words */ +static inline void GB(uint64_t* a, uint64_t* b, uint64_t* c, uint64_t* d) +{ + *a += *b + 2 * trunc32(*a) * trunc32(*b); + *d = ror(*d ^ *a, 32); + *c += *d + 2 * trunc32(*c) * trunc32(*d); + *b = ror(*b ^ *c, 24); + *a += *b + 2 * trunc32(*a) * trunc32(*b); + *d = ror(*d ^ *a, 16); + *c += *d + 2 * trunc32(*c) * trunc32(*d); + *b = ror(*b ^ *c, 63); +} + +/* Higher-level internal function which mixes up sixteen 64-bit words. This is + * applied to different subsets of the 128 words in a kilobyte block, and the + * API here is designed to make it easy to apply in the circumstances the spec + * requires. In every call, the sixteen words form eight pairs adjacent in + * memory, whose addresses are in arithmetic progression. So the 16 input + * words are in[0], in[1], in[instep], in[instep+1], ..., in[7*instep], + * in[7*instep+1], and the 16 output words similarly. */ +static inline void P(uint64_t* out, unsigned outstep, + uint64_t* in, unsigned instep) +{ + for (unsigned i = 0; i < 8; i++) + { + out[i*outstep] = in[i*instep]; + out[i*outstep+1] = in[i*instep+1]; + } + + GB(out+0*outstep+0, out+2*outstep+0, out+4*outstep+0, out+6*outstep+0); + GB(out+0*outstep+1, out+2*outstep+1, out+4*outstep+1, out+6*outstep+1); + GB(out+1*outstep+0, out+3*outstep+0, out+5*outstep+0, out+7*outstep+0); + GB(out+1*outstep+1, out+3*outstep+1, out+5*outstep+1, out+7*outstep+1); + + GB(out+0*outstep+0, out+2*outstep+1, out+5*outstep+0, out+7*outstep+1); + GB(out+0*outstep+1, out+3*outstep+0, out+5*outstep+1, out+6*outstep+0); + GB(out+1*outstep+0, out+3*outstep+1, out+4*outstep+0, out+6*outstep+1); + GB(out+1*outstep+1, out+2*outstep+0, out+4*outstep+1, out+7*outstep+0); +} + +/* The full G function, taking input blocks X and Y. The result of G is most + * often XORed into an existing output block, so this API is designed with + * that in mind: the mixing function's output is always XORed into whatever + * 1Kb of data is already at 'out'. */ +static void G_xor(uint8_t* out, const uint8_t* X, const uint8_t* Y) +{ + uint64_t R[128], Q[128], Z[128]; + + for (unsigned i = 0; i < 128; i++) + R[i] = GET_64BIT_LSB_FIRST(X + 8*i) ^ GET_64BIT_LSB_FIRST(Y + 8*i); + + for (unsigned i = 0; i < 8; i++) + P(Q+16*i, 2, R+16*i, 2); + + for (unsigned i = 0; i < 8; i++) + P(Z+2*i, 16, Q+2*i, 16); + + for (unsigned i = 0; i < 128; i++) + PUT_64BIT_LSB_FIRST(out + 8*i, + GET_64BIT_LSB_FIRST(out + 8*i) ^ R[i] ^ Z[i]); + + smemclr(R, sizeof(R)); + smemclr(Q, sizeof(Q)); + smemclr(Z, sizeof(Z)); +} + +/* ---------------------------------------------------------------------- + * The main Argon2 function. + */ + +static void argon2_internal(uint32_t p, uint32_t T, uint32_t m, uint32_t t, + uint32_t y, ptrlen P, ptrlen S, ptrlen K, ptrlen X, + uint8_t* out) +{ + /* + * Start by hashing all the input data together: the four string arguments + * (password P, salt S, optional secret key K, optional associated data + * X), plus all the parameters for the function's memory and time usage. + * + * The output of this hash is the sole input to the subsequent mixing + * step: Argon2 does not preserve any more entropy from the inputs, it + * just makes it extra painful to get the final answer. + */ + uint8_t h0[64]; + { + ssh_hash* h = blake2b_new_general(64); + put_uint32_le(h, p); + put_uint32_le(h, T); + put_uint32_le(h, m); + put_uint32_le(h, t); + put_uint32_le(h, 0x13); /* hash function version number */ + put_uint32_le(h, y); + put_stringpl_le(h, P); + put_stringpl_le(h, S); + put_stringpl_le(h, K); + put_stringpl_le(h, X); + ssh_hash_final(h, h0); + } + + struct blk { uint8_t data[1024]; }; + + /* + * Array of 1Kb blocks. The total size is (approximately) m, the + * caller-specified parameter for how much memory to use; the blocks are + * regarded as a rectangular array of p rows ('lanes') by q columns, where + * p is the 'parallelism' input parameter (the lanes can be processed + * concurrently up to a point) and q is whatever makes the product pq come + * to m. + * + * Additionally, each row is divided into four equal 'segments', which are + * important to the way the algorithm decides which blocks to use as input + * to each step of the function. + * + * The term 'slice' refers to a whole set of vertically aligned segments, + * i.e. slice 0 is the whole left quarter of the array, and slice 3 the + * whole right quarter. + */ + size_t SL = m / (4*p); /* segment length: # of 1Kb blocks in a segment */ + size_t q = 4 * SL; /* width of the array: 4 segments times SL */ + size_t mprime = q * p; /* total size of the array, approximately m */ + + /* Allocate the memory. */ + struct blk* B = snewn(mprime, struct blk); + memset(B, 0, mprime * sizeof(struct blk)); + + /* + * Initial setup: fill the first two full columns of the array with data + * expanded from the starting hash h0. Each block is the result of using + * the long-output hash function H' to hash h0 itself plus the block's + * coordinates in the array. + */ + for (size_t i = 0; i < p; i++) + { + ssh_hash* h = hprime_new(1024); + put_data(h, h0, 64); + put_uint32_le(h, 0); + put_uint32_le(h, i); + hprime_final(h, 1024, B[i].data); + } + for (size_t i = 0; i < p; i++) + { + ssh_hash* h = hprime_new(1024); + put_data(h, h0, 64); + put_uint32_le(h, 1); + put_uint32_le(h, i); + hprime_final(h, 1024, B[i+p].data); + } + + /* + * Declarations for the main loop. + * + * The basic structure of the main loop is going to involve processing the + * array one whole slice (vertically divided quarter) at a time. Usually + * we'll write a new value into every single block in the slice, except + * that in the initial slice on the first pass, we've already written + * values into the first two columns during the initial setup above. So + * 'jstart' indicates the starting index in each segment we process; it + * starts off as 2 so that we don't overwrite the initial setup, and then + * after the first slice is done, we set it to 0, and it stays there. + * + * d_mode indicates whether we're being data-dependent (true) or + * data-independent (false). In the hybrid Argon2id mode, we start off + * independent, and then once we've mixed things up enough, switch over to + * dependent mode to force long serial chains of computation. + */ + size_t jstart = 2; + bool d_mode = (y == 0); + struct blk out2i, tmp2i, in2i; + + /* Outermost loop: t whole passes from left to right over the array */ + for (size_t pass = 0; pass < t; pass++) + { + + /* Within that, we process the array in its four main slices */ + for (unsigned slice = 0; slice < 4; slice++) + { + + /* In Argon2id mode, if we're half way through the first pass, + * this is the moment to switch d_mode from false to true */ + if (pass == 0 && slice == 2 && y == 2) + d_mode = true; + + /* Loop over every segment in the slice (i.e. every row). So i is + * the y-coordinate of each block we process. */ + for (size_t i = 0; i < p; i++) + { + + /* And within that segment, process the blocks from left to + * right, starting at 'jstart' (usually 0, but 2 in the first + * slice). */ + for (size_t jpre = jstart; jpre < SL; jpre++) + { + + /* j is the x-coordinate of each block we process, made up + * of the slice number and the index 'jpre' within the + * segment. */ + size_t j = slice * SL + jpre; + + /* jm1 is j-1 (mod q) */ + uint32_t jm1 = (j == 0 ? q-1 : j-1); + + /* + * Construct two 32-bit pseudorandom integers J1 and J2. + * This is the part of the algorithm that varies between + * the data-dependent and independent modes. + */ + uint32_t J1, J2; + if (d_mode) + { + /* + * Data-dependent: grab the first 64 bits of the block + * to the left of this one. + */ + J1 = GET_32BIT_LSB_FIRST(B[i + p * jm1].data); + J2 = GET_32BIT_LSB_FIRST(B[i + p * jm1].data + 4); + } + else + { + /* + * Data-independent: generate pseudorandom data by + * hashing a sequence of preimage blocks that include + * all our input parameters, plus the coordinates of + * this point in the algorithm (array position and + * pass number) to make all the hash outputs distinct. + * + * The hash we use is G itself, applied twice. So we + * generate 1Kb of data at a time, which is enough for + * 128 (J1,J2) pairs. Hence we only need to do the + * hashing if our index within the segment is a + * multiple of 128, or if we're at the very start of + * the algorithm (in which case we started at 2 rather + * than 0). After that we can just keep picking data + * out of our most recent hash output. + */ + if (jpre == jstart || jpre % 128 == 0) + { + /* + * Hash preimage is mostly zeroes, with a + * collection of assorted integer values we had + * anyway. + */ + memset(in2i.data, 0, sizeof(in2i.data)); + PUT_64BIT_LSB_FIRST(in2i.data + 0, pass); + PUT_64BIT_LSB_FIRST(in2i.data + 8, i); + PUT_64BIT_LSB_FIRST(in2i.data + 16, slice); + PUT_64BIT_LSB_FIRST(in2i.data + 24, mprime); + PUT_64BIT_LSB_FIRST(in2i.data + 32, t); + PUT_64BIT_LSB_FIRST(in2i.data + 40, y); + PUT_64BIT_LSB_FIRST(in2i.data + 48, jpre / 128 + 1); + + /* + * Now apply G twice to generate the hash output + * in out2i. + */ + memset(tmp2i.data, 0, sizeof(tmp2i.data)); + G_xor(tmp2i.data, tmp2i.data, in2i.data); + memset(out2i.data, 0, sizeof(out2i.data)); + G_xor(out2i.data, out2i.data, tmp2i.data); + } + + /* + * Extract J1 and J2 from the most recent hash output + * (whether we've just computed it or not). + */ + J1 = GET_32BIT_LSB_FIRST( + out2i.data + 8 * (jpre % 128)); + J2 = GET_32BIT_LSB_FIRST( + out2i.data + 8 * (jpre % 128) + 4); + } + + /* + * Now convert J1 and J2 into the index of an existing + * block of the array to use as input to this step. This + * is fairly fiddly. + * + * The easy part: the y-coordinate of the input block is + * obtained by reducing J2 mod p, except that at the very + * start of the algorithm (processing the first slice on + * the first pass) we simply use the same y-coordinate as + * our output block. + * + * Note that it's safe to use the ordinary % operator + * here, without any concern for timing side channels: in + * data-independent mode J2 is not correlated to any + * secrets, and in data-dependent mode we're going to be + * giving away side-channel data _anyway_ when we use it + * as an array index (and by assumption we don't care, + * because it's already massively randomised from the real + * inputs). + */ + uint32_t index_l = (pass == 0 && slice == 0) ? i : J2 % p; + + /* + * The hard part: which block in this array row do we use? + * + * First, we decide what the possible candidates are. This + * requires some case analysis, and depends on whether the + * array row is the same one we're writing into or not. + * + * If it's not the same row: we can't use any block from + * the current slice (because the segments within a slice + * have to be processable in parallel, so in a concurrent + * implementation those blocks are potentially in the + * process of being overwritten by other threads). But the + * other three slices are fair game, except that in the + * first pass, slices to the right of us won't have had + * any values written into them yet at all. + * + * If it is the same row, we _are_ allowed to use blocks + * from the current slice, but only the ones before our + * current position. + * + * In both cases, we also exclude the individual _column_ + * just to the left of the current one. (The block + * immediately to our left is going to be the _other_ + * input to G, but the spec also says that we avoid that + * column even in a different row.) + * + * All of this means that we end up choosing from a + * cyclically contiguous interval of blocks within this + * lane, but the start and end points require some thought + * to get them right. + */ + + /* Start position is the beginning of the _next_ slice + * (containing data from the previous pass), unless we're + * on pass 0, where the start position has to be 0. */ + uint32_t Wstart = (pass == 0 ? 0 : (slice + 1) % 4 * SL); + + /* End position splits up by cases. */ + uint32_t Wend; + if (index_l == i) + { + /* Same lane as output: we can use anything up to (but + * not including) the block immediately left of us. */ + Wend = jm1; + } + else + { + /* Different lane from output: we can use anything up + * to the previous slice boundary, or one less than + * that if we're at the very left edge of our slice + * right now. */ + Wend = SL * slice; + if (jpre == 0) + Wend = (Wend + q-1) % q; + } + + /* Total number of blocks available to choose from */ + uint32_t Wsize = (Wend + q - Wstart) % q; + + /* Fiddly computation from the spec that chooses from the + * available blocks, in a deliberately non-uniform + * fashion, using J1 as pseudorandom input data. Output is + * zz which is the index within our contiguous interval. */ + uint32_t x = ((uint64_t)J1 * J1) >> 32; + uint32_t y2 = ((uint64_t)Wsize * x) >> 32; + uint32_t zz = Wsize - 1 - y2; + + /* And index_z is the actual x coordinate of the block we + * want. */ + uint32_t index_z = (Wstart + zz) % q; + + /* Phew! Combine that block with the one immediately to + * our left, and XOR over the top of whatever is already + * in our current output block. */ + G_xor(B[i + p * j].data, B[i + p * jm1].data, + B[index_l + p * index_z].data); + } + } + + /* We've finished processing a slice. Reset jstart to 0. It will + * onily _not_ have been 0 if this was pass 0 slice 0, in which + * case it still had its initial value of 2 to avoid the starting + * data. */ + jstart = 0; + } + } + + /* + * The main output is all done. Final output works by taking the XOR of + * all the blocks in the rightmost column of the array, and then using + * that as input to our long hash H'. The output of _that_ is what we + * deliver to the caller. + */ + + struct blk C = B[p * (q-1)]; + for (size_t i = 1; i < p; i++) + memxor(C.data, C.data, B[i + p * (q-1)].data, 1024); + + { + ssh_hash* h = hprime_new(T); + put_data(h, C.data, 1024); + hprime_final(h, T, out); + } + + /* + * Clean up. + */ + smemclr(out2i.data, sizeof(out2i.data)); + smemclr(tmp2i.data, sizeof(tmp2i.data)); + smemclr(in2i.data, sizeof(in2i.data)); + smemclr(C.data, sizeof(C.data)); + smemclr(B, mprime * sizeof(struct blk)); + sfree(B); +} + + +std::string zen::zargon2(zen::Argon2Flavor flavour, uint32_t mem, uint32_t passes, uint32_t parallel, uint32_t taglen, + const std::string_view password, const std::string_view salt) +{ + std::string output(taglen, '\0'); + argon2_internal(parallel, taglen, mem, passes, static_cast(flavour), + {.ptr = password.data(), .len = password.size()}, + {.ptr = salt .data(), .len = salt .size()}, + {.ptr = "", .len = 0}, + {.ptr = "", .len = 0}, reinterpret_cast(output.data())); + return output; +} diff --git a/zen/argon2.h b/zen/argon2.h new file mode 100644 index 0000000..5d2bb52 --- /dev/null +++ b/zen/argon2.h @@ -0,0 +1,20 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ARGON2_H_0175896874598102356081374 +#define ARGON2_H_0175896874598102356081374 + +#include + +namespace zen +{ +enum class Argon2Flavor { d, i, id }; + +std::string zargon2(zen::Argon2Flavor flavour, uint32_t mem, uint32_t passes, uint32_t parallel, uint32_t taglen, + const std::string_view password, const std::string_view salt); +} + +#endif //ARGON2_H_0175896874598102356081374 diff --git a/zen/base64.h b/zen/base64.h new file mode 100644 index 0000000..3874f03 --- /dev/null +++ b/zen/base64.h @@ -0,0 +1,176 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BASE64_H_08473021856321840873021487213453214 +#define BASE64_H_08473021856321840873021487213453214 + +#include +#include +#include "type_traits.h" + + +namespace zen +{ +/* https://en.wikipedia.org/wiki/Base64 + + Usage: + const std::string input = "Sample text"; + std::string output; + zen::encodeBase64(input.begin(), input.end(), std::back_inserter(output)); + //output contains "U2FtcGxlIHRleHQ=" */ + +template +OutputIterator encodeBase64(InputIterator first, InputIterator last, OutputIterator result); //nothrow! + +template +OutputIterator decodeBase64(InputIterator first, InputIterator last, OutputIterator result); //nothrow! + +std::string stringEncodeBase64(const std::string_view& str); +std::string stringDecodeBase64(const std::string_view& str); + + + + + + + + + + +//------------------------- implementation ------------------------------- +namespace impl +{ +//64 chars for base64 encoding + padding char +constexpr char ENCODING_MIME[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; +constexpr signed char DECODING_MIME[] = +{ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, 64, -1, -1, + -1, 0, 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, -1, -1, -1, -1, -1, + -1, 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, -1, -1, -1, -1, -1 +}; +const unsigned char INDEX_PAD = 64; //index of "=" +} + + +template inline +OutputIterator encodeBase64(InputIterator first, InputIterator last, OutputIterator result) +{ + using namespace impl; + static_assert(sizeof(typename std::iterator_traits::value_type) == 1); + static_assert(std::size(ENCODING_MIME) == 64 + 1 + 1); + static_assert(arrayHash(ENCODING_MIME) == 1616767125); + + while (first != last) + { + const unsigned char a = static_cast(*first++); + *result++ = ENCODING_MIME[a >> 2]; + + if (first == last) + { + *result++ = ENCODING_MIME[((a & 0x3) << 4)]; + *result++ = ENCODING_MIME[INDEX_PAD]; + *result++ = ENCODING_MIME[INDEX_PAD]; + break; + } + const unsigned char b = static_cast(*first++); + *result++ = ENCODING_MIME[((a & 0x3) << 4) | (b >> 4)]; + + if (first == last) + { + *result++ = ENCODING_MIME[((b & 0xf) << 2)]; + *result++ = ENCODING_MIME[INDEX_PAD]; + break; + } + const unsigned char c = static_cast(*first++); + *result++ = ENCODING_MIME[((b & 0xf) << 2) | (c >> 6)]; + *result++ = ENCODING_MIME[c & 0x3f]; + } + return result; +} + + +template inline +OutputIterator decodeBase64(InputIterator first, InputIterator last, OutputIterator result) +{ + using namespace impl; + static_assert(sizeof(typename std::iterator_traits::value_type) == 1); + static_assert(std::size(DECODING_MIME) == 128); + static_assert(arrayHash(DECODING_MIME)== 1169145114); + + const unsigned char INDEX_END = INDEX_PAD + 1; + + auto readIndex = [&]() -> unsigned char //return index within [0, 64] or INDEX_END if end of input + { + for (;;) + { + if (first == last) + return INDEX_END; + + const unsigned char ch = static_cast(*first++); + if (ch < std::size(DECODING_MIME)) //we're in lower ASCII table half + { + const int index = DECODING_MIME[ch]; + if (0 <= index && index <= static_cast(INDEX_PAD)) //skip all unknown characters (including carriage return, line-break, tab) + return static_cast(index); + } + } + }; + + for (;;) + { + const unsigned char index1 = readIndex(); + const unsigned char index2 = readIndex(); + if (index1 >= INDEX_PAD || index2 >= INDEX_PAD) + { + assert(index1 == INDEX_END && index2 == INDEX_END); + break; + } + *result++ = static_cast((index1 << 2) | (index2 >> 4)); + + const unsigned char index3 = readIndex(); + if (index3 >= INDEX_PAD) //padding + { + assert(index3 == INDEX_PAD); + break; + } + *result++ = static_cast(((index2 & 0xf) << 4) | (index3 >> 2)); + + const unsigned char index4 = readIndex(); + if (index4 >= INDEX_PAD) //padding + { + assert(index4 == INDEX_PAD); + break; + } + *result++ = static_cast(((index3 & 0x3) << 6) | index4); + } + return result; +} + + +inline +std::string stringEncodeBase64(const std::string_view& str) +{ + std::string out; + encodeBase64(str.begin(), str.end(), std::back_inserter(out)); + return out; +} + + +inline +std::string stringDecodeBase64(const std::string_view& str) +{ + std::string out; + decodeBase64(str.begin(), str.end(), std::back_inserter(out)); + return out; +} +} + +#endif //BASE64_H_08473021856321840873021487213453214 diff --git a/zen/basic_math.h b/zen/basic_math.h new file mode 100644 index 0000000..9080d07 --- /dev/null +++ b/zen/basic_math.h @@ -0,0 +1,342 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BASIC_MATH_H_3472639843265675 +#define BASIC_MATH_H_3472639843265675 + +#include +#include +#include +#include "type_traits.h" + + +namespace numeric +{ +template auto dist(T a, T b); +template int sign(T value); //returns one of {-1, 0, 1} +template bool isNull(T value); //...definitively fishy... + +template //precondition: range must be sorted! +auto roundToGrid(T val, InputIterator first, InputIterator last); + +template auto intDivRound(N numerator, D denominator); +template auto intDivCeil (N numerator, D denominator); +template auto intDivFloor(N numerator, D denominator); + +template +constexpr T power(T value); + +double radToDeg(double rad); //convert unit [rad] into [°] +double degToRad(double degree); //convert unit [°] into [rad] + +template +double arithmeticMean(InputIterator first, InputIterator last); + +template +double median(RandomAccessIterator first, RandomAccessIterator last); //note: invalidates input range! + +template +double stdDeviation(InputIterator first, InputIterator last, double* mean = nullptr); //estimate standard deviation (and thereby arithmetic mean) + +//median absolute deviation: "mad / 0.6745" is a robust measure for standard deviation of a normal distribution +template +double mad(RandomAccessIterator first, RandomAccessIterator last); //note: invalidates input range! + +template +double norm2(InputIterator first, InputIterator last); + +//---------------------------------------------------------------------------------- + + + + + + + + + + + + + +//################# inline implementation ######################### +template inline +auto dist(T a, T b) //return type might be different than T, e.g. std::chrono::duration instead of std::chrono::time_point +{ + return a > b ? a - b : b - a; +} + + +template inline +int sign(T value) //returns one of {-1, 0, 1} +{ + static_assert(std::is_signed_v); + return value < 0 ? -1 : (value > 0 ? 1 : 0); +} + +/* +part of C++11 now! +template inline +std::pair minMaxElement(InputIterator first, InputIterator last, Compare compLess) +{ + //by factor 1.5 to 3 faster than boost::minmax_element (=two-step algorithm) for built-in types! + + InputIterator itMin = first; + InputIterator itMax = first; + + if (first != last) + { + auto minVal = *itMin; //nice speedup on 64 bit! + auto maxVal = *itMax; // + for (;;) + { + ++first; + if (first == last) + break; + const auto val = *first; + + if (compLess(maxVal, val)) + { + itMax = first; + maxVal = val; + } + else if (compLess(val, minVal)) + { + itMin = first; + minVal = val; + } + } + } + return {itMin, itMax}; +} + + +template inline +std::pair minMaxElement(InputIterator first, InputIterator last) +{ + return minMaxElement(first, last, std::less()); +} +*/ + +template inline +auto roundToGrid(T val, InputIterator first, InputIterator last) +{ + assert(std::is_sorted(first, last)); + if (first == last) + return static_cast(val); + + InputIterator it = std::lower_bound(first, last, val); + if (it == last) + return *--last; + if (it == first) + return *first; + + const auto nextVal = *it; + const auto prevVal = *--it; + return val - prevVal < nextVal - val ? prevVal : nextVal; +} + + +template inline +bool isNull(T value) +{ + return abs(value) <= std::numeric_limits::epsilon(); //epsilon is 0 für integral types => less-equal +} + + +template inline +auto intDivRound(N num, D den) +{ + using namespace zen; + static_assert(isInteger&& isInteger); + static_assert(isSignedInt == isSignedInt); //until further + assert(den != 0); + if constexpr (isSignedInt) + { + if ((num < 0) != (den < 0)) + return (num - den / 2) / den; + } + return (num + den / 2) / den; +} + + +template inline +auto intDivCeil(N num, D den) +{ + using namespace zen; + static_assert(isInteger&& isInteger); + static_assert(isSignedInt == isSignedInt); //until further + assert(den != 0); + if constexpr (isSignedInt) + { + if ((num < 0) != (den < 0)) + return num / den; + + if (num < 0 && den < 0) + num += 2; //return (num + den + 1) / den + } + return (num + den - 1) / den; +} + + +template inline +auto intDivFloor(N num, D den) +{ + using namespace zen; + static_assert(isInteger&& isInteger); + static_assert(isSignedInt == isSignedInt); //until further + assert(den != 0); + if constexpr (isSignedInt) + { + if ((num < 0) != (den < 0)) + { + if (num < 0) + num += 2; //return (num - den + 1) / den + + return (num - den - 1) / den; + } + } + return num / den; +} + + +namespace +{ +template struct PowerImpl; +//let's use non-recursive specializations to help the compiler +template struct PowerImpl<2, T> { static constexpr T result(T value) { return value * value; } }; +template struct PowerImpl<3, T> { static constexpr T result(T value) { return value * value * value; } }; +} + +template inline +constexpr T power(T value) +{ + return PowerImpl::result(value); +} + + +inline +double radToDeg(double rad) +{ + return rad * (180.0 / std::numbers::pi); +} + + +inline +double degToRad(double degree) +{ + return degree / (180.0 / std::numbers::pi); +} + + +template inline +double arithmeticMean(InputIterator first, InputIterator last) +{ + size_t n = 0; //avoid random-access requirement for iterator! + double sum_xi = 0; + + for (; first != last; ++first, ++n) + sum_xi += *first; + + return n == 0 ? 0 : sum_xi / n; +} + + +template inline +double median(RandomAccessIterator first, RandomAccessIterator last) //note: invalidates input range! +{ + const size_t n = last - first; + if (n == 0) + return 0; + + std::nth_element(first, first + n / 2, last); //complexity: O(n) + const double midVal = *(first + n / 2); + + if (n % 2 != 0) + return midVal; + else //n is even and >= 2 in this context: return mean of two middle values + return 0.5 * (*std::max_element(first, first + n / 2) + midVal); //this operation is the reason why median() CANNOT support a comparison predicate!!! +} + + +template inline +double mad(RandomAccessIterator first, RandomAccessIterator last) //note: invalidates input range! +{ + //https://en.wikipedia.org/wiki/Median_absolute_deviation + const size_t n = last - first; + if (n == 0) + return 0; + + const double m = median(first, last); + + //the second median needs to operate on absolute residuals => avoid transforming input range which may have less than double precision! + auto lessMedAbs = [m](double lhs, double rhs) { return abs(lhs - m) < abs(rhs - m); }; + + std::nth_element(first, first + n / 2, last, lessMedAbs); //complexity: O(n) + const double midVal = abs(*(first + n / 2) - m); + + if (n % 2 != 0) + return midVal; + else //n is even and >= 2 in this context: return mean of two middle values + return 0.5 * (abs(*std::max_element(first, first + n / 2, lessMedAbs) - m) + midVal); +} + + +template inline +double stdDeviation(InputIterator first, InputIterator last, double* arithMean) +{ + //implementation minimizing rounding errors, see: https://en.wikipedia.org/wiki/Standard_deviation + //combined with technique avoiding overflow, see: https://www.netlib.org/blas/dnrm2.f -> only 10% performance degradation + + size_t n = 0; + double mean = 0; + double q = 0; + double scale = 1; + + for (; first != last; ++first) + { + ++n; + const double val = *first - mean; + + if (abs(val) > scale) + { + q = (n - 1.0) / n + q * power<2>(scale / val); + scale = abs(val); + } + else + q += (n - 1.0) * power<2>(val / scale) / n; + + mean += val / n; + } + + if (arithMean) + *arithMean = mean; + + return n <= 1 ? 0 : std::sqrt(q / (n - 1)) * scale; +} + + +template inline +double norm2(InputIterator first, InputIterator last) +{ + double result = 0; + double scale = 1; + for (; first != last; ++first) + { + const double tmp = abs(*first); + if (tmp > scale) + { + result = 1 + result * power<2>(scale / tmp); + scale = tmp; + } + else + result += power<2>(tmp / scale); + } + return std::sqrt(result) * scale; +} +} + +#endif //BASIC_MATH_H_3472639843265675 diff --git a/zen/build_info.h b/zen/build_info.h new file mode 100644 index 0000000..86ff303 --- /dev/null +++ b/zen/build_info.h @@ -0,0 +1,34 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef BUILD_INFO_H_5928539285603428657 +#define BUILD_INFO_H_5928539285603428657 + + + +namespace zen +{ +enum class BuildArch +{ + bit32, + bit64, + +#ifdef __LP64__ + program = bit64 +#else + program = bit32 +#endif +}; + +static_assert((BuildArch::program == BuildArch::bit32 ? 32 : 64) == sizeof(void*) * 8); + + +//harmonize with os_arch enum in update_checks table: +constexpr const char* cpuArchName = BuildArch::program == BuildArch::bit32 ? "i686": "x86-64"; + +} + +#endif //BUILD_INFO_H_5928539285603428657 diff --git a/zen/crc.h b/zen/crc.h new file mode 100644 index 0000000..009ff9d --- /dev/null +++ b/zen/crc.h @@ -0,0 +1,110 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CRC_H_23489275827847235 +#define CRC_H_23489275827847235 + +#include "type_traits.h" + + +namespace zen +{ +uint16_t getCrc16(const std::string_view& str); +uint32_t getCrc32(const std::string_view& str); +template uint16_t getCrc16(ByteIterator first, ByteIterator last); +template uint32_t getCrc32(ByteIterator first, ByteIterator last); + + + + +//------------------------- implementation ------------------------------- +inline uint16_t getCrc16(const std::string_view& str) { return getCrc16(str.begin(), str.end()); } +inline uint32_t getCrc32(const std::string_view& str) { return getCrc32(str.begin(), str.end()); } + + +template inline +uint16_t getCrc16(ByteIterator first, ByteIterator last) //http://www.sunshine2k.de/articles/coding/crc/understanding_crc.html +{ + static_assert(sizeof(typename std::iterator_traits::value_type) == 1); + + uint16_t crc = 0; + std::for_each(first, last, [&](unsigned char b) + { + constexpr uint16_t crcTable[] = + { + 0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440, + 0xcc01, 0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40, 0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, + 0xd801, 0x18c0, 0x1980, 0xd941, 0x1b00, 0xdbc1, 0xda81, 0x1a40, 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41, + 0x1400, 0xd4c1, 0xd581, 0x1540, 0xd701, 0x17c0, 0x1680, 0xd641, 0xd201, 0x12c0, 0x1380, 0xd341, 0x1100, 0xd1c1, 0xd081, 0x1040, + 0xf001, 0x30c0, 0x3180, 0xf141, 0x3300, 0xf3c1, 0xf281, 0x3240, 0x3600, 0xf6c1, 0xf781, 0x3740, 0xf501, 0x35c0, 0x3480, 0xf441, + 0x3c00, 0xfcc1, 0xfd81, 0x3d40, 0xff01, 0x3fc0, 0x3e80, 0xfe41, 0xfa01, 0x3ac0, 0x3b80, 0xfb41, 0x3900, 0xf9c1, 0xf881, 0x3840, + 0x2800, 0xe8c1, 0xe981, 0x2940, 0xeb01, 0x2bc0, 0x2a80, 0xea41, 0xee01, 0x2ec0, 0x2f80, 0xef41, 0x2d00, 0xedc1, 0xec81, 0x2c40, + 0xe401, 0x24c0, 0x2580, 0xe541, 0x2700, 0xe7c1, 0xe681, 0x2640, 0x2200, 0xe2c1, 0xe381, 0x2340, 0xe101, 0x21c0, 0x2080, 0xe041, + 0xa001, 0x60c0, 0x6180, 0xa141, 0x6300, 0xa3c1, 0xa281, 0x6240, 0x6600, 0xa6c1, 0xa781, 0x6740, 0xa501, 0x65c0, 0x6480, 0xa441, + 0x6c00, 0xacc1, 0xad81, 0x6d40, 0xaf01, 0x6fc0, 0x6e80, 0xae41, 0xaa01, 0x6ac0, 0x6b80, 0xab41, 0x6900, 0xa9c1, 0xa881, 0x6840, + 0x7800, 0xb8c1, 0xb981, 0x7940, 0xbb01, 0x7bc0, 0x7a80, 0xba41, 0xbe01, 0x7ec0, 0x7f80, 0xbf41, 0x7d00, 0xbdc1, 0xbc81, 0x7c40, + 0xb401, 0x74c0, 0x7580, 0xb541, 0x7700, 0xb7c1, 0xb681, 0x7640, 0x7200, 0xb2c1, 0xb381, 0x7340, 0xb101, 0x71c0, 0x7080, 0xb041, + 0x5000, 0x90c1, 0x9181, 0x5140, 0x9301, 0x53c0, 0x5280, 0x9241, 0x9601, 0x56c0, 0x5780, 0x9741, 0x5500, 0x95c1, 0x9481, 0x5440, + 0x9c01, 0x5cc0, 0x5d80, 0x9d41, 0x5f00, 0x9fc1, 0x9e81, 0x5e40, 0x5a00, 0x9ac1, 0x9b81, 0x5b40, 0x9901, 0x59c0, 0x5880, 0x9841, + 0x8801, 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40, 0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0, 0x4c80, 0x8c41, + 0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 0x8201, 0x42c0, 0x4380, 0x8341, 0x4100, 0x81c1, 0x8081, 0x4040 + }; + static_assert(std::size(crcTable) == 256); + static_assert(arrayHash(crcTable) == 728085957); + + crc = (crc >> 8) ^ crcTable[(crc ^ b) & 0xFF]; + }); + return crc; +} + + +template inline +uint32_t getCrc32(ByteIterator first, ByteIterator last) //https://en.wikipedia.org/wiki/Cyclic_redundancy_check +{ + static_assert(sizeof(typename std::iterator_traits::value_type) == 1); + + uint32_t crc = 0xFFFFFFFF; + std::for_each(first, last, [&](unsigned char b) + { + constexpr uint32_t crcTable[] = + { + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, + 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, + 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, + 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, + 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, + 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, + 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, + 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, + 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, + 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, + 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, + 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, + 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, + 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, + 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, + 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, + 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, + 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, + 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, + 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, + 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, + 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, + 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, + 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d + }; + static_assert(std::size(crcTable) == 256); + static_assert(arrayHash(crcTable) == 2988069445); + + crc = (crc >> 8) ^ crcTable[(crc ^ b) & 0xFF]; + }); + return crc ^ 0xFFFFFFFF; +} +} + +#endif //CRC_H_23489275827847235 diff --git a/zen/dir_watcher.cpp b/zen/dir_watcher.cpp new file mode 100644 index 0000000..b553350 --- /dev/null +++ b/zen/dir_watcher.cpp @@ -0,0 +1,158 @@ +// ***************************************************************************** +// * 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 "dir_watcher.h" +#include "thread.h" +#include "scope_guard.h" +#include "file_access.h" + + #include + #include + #include //fcntl + #include //close + #include //NAME_MAX + #include "file_traverser.h" + + +using namespace zen; + + +struct DirWatcher::Impl +{ + int notifDescr = 0; + std::unordered_map watchedPaths; //watch descriptor and (sub-)directory paths -> owned by "notifDescr" +}; + + +DirWatcher::DirWatcher(const Zstring& dirPath) : //throw FileError + baseDirPath_(dirPath), + pimpl_(std::make_unique()) +{ + //get all subdirectories + std::vector fullFolderList {baseDirPath_}; + { + std::function traverse; + + traverse = [&traverse, &fullFolderList](const Zstring& path) //throw FileError + { + traverseFolder(path, nullptr, + [&](const FolderInfo& fi ) + { + fullFolderList.push_back(fi.fullPath); + traverse(fi.fullPath); //throw FileError + }, + nullptr /*don't traverse into symlinks (analog to Windows)*/); //throw FileError + }; + + traverse(baseDirPath_); //throw FileError + } + + //init + pimpl_->notifDescr = ::inotify_init(); + if (pimpl_->notifDescr == -1) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(baseDirPath_)), "inotify_init"); + + ZEN_ON_SCOPE_FAIL( ::close(pimpl_->notifDescr); ); + + //set non-blocking mode + const int flags = ::fcntl(pimpl_->notifDescr, F_GETFL); + if (flags == -1) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(baseDirPath_)), "fcntl(F_GETFL)"); + + if (::fcntl(pimpl_->notifDescr, F_SETFL, flags | O_NONBLOCK) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(baseDirPath_)), "fcntl(F_SETFL, O_NONBLOCK)"); + + //add watches + for (const Zstring& subDirPath : fullFolderList) + { + int wd = ::inotify_add_watch(pimpl_->notifDescr, subDirPath.c_str(), + IN_ONLYDIR | //"Only watch pathname if it is a directory." + IN_DONT_FOLLOW | //don't follow symbolic links + IN_CREATE | + IN_MODIFY | + IN_CLOSE_WRITE | + IN_DELETE | + IN_DELETE_SELF | + IN_MOVED_FROM | + IN_MOVED_TO | + IN_MOVE_SELF); + if (wd == -1) + { + const ErrorCode ec = getLastError(); //copy before directly/indirectly making other system calls! + if (ec == ENOSPC) //fix misleading system message "No space left on device" + throw FileError(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(subDirPath)), + formatSystemError("inotify_add_watch", L"ENOSPC", + L"The user limit on the total number of inotify watches was reached or the kernel failed to allocate a needed resource.")); + + throw FileError(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(subDirPath)), formatSystemError("inotify_add_watch", ec)); + } + + pimpl_->watchedPaths.emplace(wd, subDirPath); + } +} + + +DirWatcher::~DirWatcher() +{ + ::close(pimpl_->notifDescr); //associated watches are removed automatically! +} + + +std::vector DirWatcher::fetchChanges(const std::function& requestUiUpdate, std::chrono::milliseconds cbInterval) //throw FileError +{ + std::vector buf(512 * (sizeof(inotify_event) + NAME_MAX + 1)); + + ssize_t bytesRead = 0; + do + { + //non-blocking call, see O_NONBLOCK + bytesRead = ::read(pimpl_->notifDescr, buf.data(), buf.size()); + } + while (bytesRead < 0 && errno == EINTR); //"Interrupted function call; When this happens, you should try the call again." + + if (bytesRead < 0) + { + if (errno == EAGAIN) //this error is ignored in all inotify wrappers I found + return std::vector(); + + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot monitor directory %x."), L"%x", fmtPath(baseDirPath_)), "read"); + } + + std::vector output; + + ssize_t bytePos = 0; + while (bytePos < bytesRead) + { + inotify_event& evt = reinterpret_cast(buf[bytePos]); + + if (evt.len != 0) //exclude case: deletion of "self", already reported by parent directory watch + { + auto it = pimpl_->watchedPaths.find(evt.wd); + if (it != pimpl_->watchedPaths.end()) + { + //Note: evt.len is NOT the size of the evt.name c-string, but the array size including all padding 0 characters! + //It may be even 0 in which case evt.name must not be used! + const Zstring itemPath = appendPath(it->second, evt.name); + + if ((evt.mask & IN_CREATE) || + (evt.mask & IN_MOVED_TO)) + output.push_back({ChangeType::create, itemPath}); + else if ((evt.mask & IN_MODIFY) || + (evt.mask & IN_CLOSE_WRITE)) + output.push_back({ChangeType::update, itemPath}); + else if ((evt.mask & IN_DELETE ) || + (evt.mask & IN_DELETE_SELF) || + (evt.mask & IN_MOVE_SELF ) || + (evt.mask & IN_MOVED_FROM)) + output.push_back({ChangeType::remove, itemPath}); + } + } + bytePos += sizeof(inotify_event) + evt.len; + } + + return output; +} + diff --git a/zen/dir_watcher.h b/zen/dir_watcher.h new file mode 100644 index 0000000..8c82707 --- /dev/null +++ b/zen/dir_watcher.h @@ -0,0 +1,72 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DIR_WATCHER_348577025748023458 +#define DIR_WATCHER_348577025748023458 + +#include +#include +#include +#include "file_error.h" + + +namespace zen +{ +//Windows: ReadDirectoryChangesW https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw +//Linux: inotify https://linux.die.net/man/7/inotify +//macOS: kqueue https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/kqueue.2.html + +//watch directory including subdirectories +/* +!Note handling of directories!: + Windows: removal of top watched directory is NOT notified when watching the dir handle, e.g. brute force usb stick removal, + (watchting for GUID_DEVINTERFACE_WPD OTOH works fine!) + however manual unmount IS notified (e.g. USB stick removal, then re-insert), but watching is stopped! + Renaming of top watched directory handled incorrectly: Not notified(!) + additional changes in subfolders + now do report FILE_ACTION_MODIFIED for directory (check that should prevent this fails!) + + Linux: newly added subdirectories are reported but not automatically added for watching! -> reset Dirwatcher! + removal of base directory is NOT notified! + + macOS: everything works as expected; renaming of base directory is also detected + + Overcome all issues portably: check existence of top watched directory externally + reinstall watch after changes in directory structure (added directories) are detected +*/ +class DirWatcher +{ +public: + explicit DirWatcher(const Zstring& dirPath); //throw FileError + ~DirWatcher(); + + enum class ChangeType + { + create, // + update, //informal: use for debugging/logging only! + remove, // + baseFolderUnavailable, //1. not existing or 2. can't access + }; + + struct Change + { + ChangeType type = ChangeType::create; + Zstring itemPath; + }; + + //extract accumulated changes since last call + std::vector fetchChanges(const std::function& requestUiUpdate, std::chrono::milliseconds cbInterval); //throw FileError + +private: + DirWatcher (const DirWatcher&) = delete; + DirWatcher& operator=(const DirWatcher&) = delete; + + const Zstring baseDirPath_; + + struct Impl; + const std::unique_ptr pimpl_; +}; +} + +#endif diff --git a/zen/error_log.h b/zen/error_log.h new file mode 100644 index 0000000..2e5c4eb --- /dev/null +++ b/zen/error_log.h @@ -0,0 +1,127 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ERROR_LOG_H_8917590832147915 +#define ERROR_LOG_H_8917590832147915 + +#include +#include +#include "time.h" +#include "i18n.h" +#include "zstring.h" + + +namespace zen +{ +enum MessageType +{ + MSG_TYPE_INFO = 0x1, + MSG_TYPE_WARNING = 0x2, + MSG_TYPE_ERROR = 0x4, +}; + +struct LogEntry +{ + time_t time = 0; + MessageType type = MSG_TYPE_ERROR; + Zstringc message; //conserve memory (=> avoid std::string SSO overhead!) +}; + +std::string formatMessage(const LogEntry& entry); + +using ErrorLog = std::vector; + +void logMsg(ErrorLog& log, const std::wstring& msg, MessageType type, time_t time = std::time(nullptr)); + +struct ErrorLogStats +{ + int infos = 0; + int warnings = 0; + int errors = 0; +}; +ErrorLogStats getStats(const ErrorLog& log); + + + + + + + +//######################## implementation ########################## +inline +void logMsg(ErrorLog& log, const std::wstring& msg, MessageType type, time_t time) +{ + log.push_back({time, type, utfTo(msg)}); +} + + +inline +ErrorLogStats getStats(const ErrorLog& log) +{ + ErrorLogStats count; + for (const LogEntry& entry : log) + switch (entry.type) + { + case MSG_TYPE_INFO: + ++count.infos; + break; + case MSG_TYPE_WARNING: + ++count.warnings; + break; + case MSG_TYPE_ERROR: + ++count.errors; + break; + } + assert(std::ssize(log) == count.infos + count.warnings + count.errors); + return count; +} + + +inline +std::wstring getMessageTypeLabel(MessageType type) +{ + switch (type) + { + case MSG_TYPE_INFO: + return _("Info"); + case MSG_TYPE_WARNING: + return _("Warning"); + case MSG_TYPE_ERROR: + return _("Error"); + } + assert(false); + return std::wstring(); +} + + +inline +std::string formatMessage(const LogEntry& entry) +{ + std::string msgFmt = '[' + utfTo(formatTime(formatTimeTag, getLocalTime(entry.time))) + "] " + utfTo(getMessageTypeLabel(entry.type)) + ": "; + const size_t prefixLen = unicodeLength(msgFmt); //consider Unicode! + + const Zstringc msg = trimCpy(entry.message); + static_assert(std::is_same_v, "no worries about copying as long as we're using a ref-counted string!"); + assert(msg == entry.message); //trimming shouldn't be needed usually!? + + for (auto it = msg.begin(); it != msg.end(); ) + if (*it == '\n') + { + msgFmt += *it++; + msgFmt.append(prefixLen, ' '); + //skip duplicate newlines + for (; it != msg.end() && *it == '\n'; ++it) + ; + } + else + msgFmt += *it++; + + msgFmt += '\n'; + return msgFmt; +} +} + +#endif //ERROR_LOG_H_8917590832147915 diff --git a/zen/extra_log.h b/zen/extra_log.h new file mode 100644 index 0000000..3928d0f --- /dev/null +++ b/zen/extra_log.h @@ -0,0 +1,84 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef EXTRA_LOG_H_601673246392441846218957402563 +#define EXTRA_LOG_H_601673246392441846218957402563 + +#include "error_log.h" +#include "thread.h" + +/* log errors in "exceptional situations" when no other means are available, e.g. + - while an exception is in flight + - cleanup errors + - nothrow GUI functions */ + +namespace zen +{ +namespace impl +{ +class ExtraLog +{ +public: + ~ExtraLog() + { + assert(reportOutstandingLog_); + if (!log_.empty() && reportOutstandingLog_) + reportOutstandingLog_(log_); + } + + void init(const std::function& reportOutstandingLog) + { + assert(!reportOutstandingLog_); + reportOutstandingLog_ = reportOutstandingLog; + } + + ErrorLog fetchLog() { return std::exchange(log_, ErrorLog()); } + + void logError(const std::wstring& msg) { logMsg(log_, msg, MessageType::MSG_TYPE_ERROR); } //nothrow! + +private: + ErrorLog log_; + std::function reportOutstandingLog_; +}; + +inline constinit Global> globalExtraLog; + +template +auto accessExtraLog(Function fun) +{ + globalExtraLog.setOnce([] { return std::make_unique>(); }); + + if (auto protExtraLog = impl::globalExtraLog.get()) + protExtraLog->access([&](ExtraLog& log) { fun(log); }); + else + assert(false); //access after global shutdown!? => SOL! +} +} + +inline +void initExtraLog(const std::function& reportOutstandingLog /*nothrow! runs during global shutdown!*/) +{ + impl::accessExtraLog([&](impl::ExtraLog& el) { el.init(reportOutstandingLog); }); +} + + +inline +ErrorLog fetchExtraLog() +{ + ErrorLog output; + impl::accessExtraLog([&](impl::ExtraLog& el) { output = el.fetchLog(); }); + return output; +} + + +inline +void logExtraError(const std::wstring& msg) //nothrow! +{ + impl::accessExtraLog([&](impl::ExtraLog& el) { el.logError(msg); }); +} +} + +#endif //EXTRA_LOG_H_601673246392441846218957402563 diff --git a/zen/file_access.cpp b/zen/file_access.cpp new file mode 100644 index 0000000..0e07ddf --- /dev/null +++ b/zen/file_access.cpp @@ -0,0 +1,768 @@ +// ***************************************************************************** +// * 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 "file_access.h" +#include +#include +#include "file_traverser.h" +#include "scope_guard.h" +#include "symlink_target.h" +#include "file_io.h" +#include "crc.h" +#include "guid.h" +#include "ring_buffer.h" + + #include //statfs + #ifdef HAVE_SELINUX + #include + #endif + + + #include //open, close, AT_SYMLINK_NOFOLLOW, UTIME_OMIT + #include + +using namespace zen; + + +namespace +{ + + +struct SysErrorCode : public zen::SysError +{ + SysErrorCode(const std::string& functionName, ErrorCode ec) : SysError(formatSystemError(functionName, ec)), errorCode(ec) {} + + const ErrorCode errorCode; +}; + + +ItemType getItemTypeImpl(const Zstring& itemPath) //throw SysErrorCode +{ + struct stat itemInfo = {}; + if (::lstat(itemPath.c_str(), &itemInfo) != 0) + throw SysErrorCode("lstat", errno); + + if (S_ISLNK(itemInfo.st_mode)) + return ItemType::symlink; + if (S_ISDIR(itemInfo.st_mode)) + return ItemType::folder; + return ItemType::file; //S_ISREG || S_ISCHR || S_ISBLK || S_ISFIFO || S_ISSOCK +} + + +std::variant getItemTypeIfExistsImpl(const Zstring& itemPath) //throw SysError +{ + try + { + //fast check: 1. perf 2. expected by getFolderStatusNonBlocking() + return getItemTypeImpl(itemPath); //throw SysErrorCode + } + catch (const SysErrorCode& e) //let's dig deeper, but *only* if error code sounds like "not existing" + { + const std::optional& parentPath = getParentFolderPath(itemPath); + if (!parentPath) //device root => quick access test + throw; + if (e.errorCode == ENOENT) + { + const std::variant parentTypeOrPath = getItemTypeIfExistsImpl(*parentPath); //throw SysError + + if (const ItemType* parentType = std::get_if(&parentTypeOrPath)) + { + if (*parentType == ItemType::file /*obscure, but possible*/) + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(*parentPath)))); + + const Zstring itemName = getItemName(itemPath); + assert(!itemName.empty()); + + try + { + traverseFolder(*parentPath, //throw FileError + [&](const FileInfo& fi) { if (fi.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }, + [&](const FolderInfo& fi) { if (fi.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }, + [&](const SymlinkInfo& si) { if (si.itemName == itemName) throw SysError(_("Temporary access error:") + L' ' + e.toString()); }); + //- case-sensitive comparison! itemPath must be normalized! + //- finding the item after getItemType() previously failed is exceptional + } + catch (const FileError& e2) { throw SysError(replaceCpy(e2.toString(), L"\n\n", L'\n')); } + + return *parentPath; + } + else + return parentTypeOrPath; + } + else + throw; + } +} +} + + +ItemType zen::getItemType(const Zstring& itemPath) //throw FileError +{ + try + { + return getItemTypeImpl(itemPath); //throw SysErrorCode + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(itemPath)), e.toString()); } +} + + +std::optional zen::getItemTypeIfExists(const Zstring& itemPath) //throw FileError +{ + try + { + const std::variant typeOrPath = getItemTypeIfExistsImpl(itemPath); //throw SysError + if (const ItemType* type = std::get_if(&typeOrPath)) + return *type; + else + return std::nullopt; + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(itemPath)), e.toString()); + } +} + + +namespace +{ +} + + +//- symlink handling: follow +//- returns < 0 if not available +//- folderPath does not need to exist (yet) +int64_t zen::getFreeDiskSpace(const Zstring& folderPath) //throw FileError +{ + try + { + const Zstring existingPath = [&] + { + const std::variant typeOrPath = getItemTypeIfExistsImpl(folderPath); //throw SysError + if (std::get_if(&typeOrPath)) + return folderPath; + else + return std::get(typeOrPath); + }(); + struct statfs info = {}; + if (::statfs(existingPath.c_str(), &info) != 0) //follows symlinks! + THROW_LAST_SYS_ERROR("statfs"); + //Linux: "Fields that are undefined for a particular file system are set to 0." + //macOS: "Fields that are undefined for a particular file system are set to -1." - mkay :> + if (makeSigned(info.f_bsize) <= 0 || + makeSigned(info.f_bavail) <= 0) + return -1; + + return static_cast(info.f_bsize) * info.f_bavail; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine free disk space for %x."), L"%x", fmtPath(folderPath)), e.toString()); } +} + + +uint64_t zen::getFileSize(const Zstring& filePath) //throw FileError +{ + try + { + struct stat fileInfo = {}; + if (::stat(filePath.c_str(), &fileInfo) != 0) + THROW_LAST_SYS_ERROR("stat"); + + return fileInfo.st_size; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(filePath)), e.toString()); } +} + + +Zstring zen::getTempFolderPath() //throw FileError +{ + if (const std::optional tempDirPath = getEnvironmentVar("TMPDIR")) + return *tempDirPath; + //TMPDIR not set on CentOS 7, WTF! + return P_tmpdir; //usually resolves to "/tmp" +} + + + +namespace +{ +} + + +void zen::removeFilePlain(const Zstring& filePath) //throw FileError +{ + try + { + if (::unlink(filePath.c_str()) != 0) + THROW_LAST_SYS_ERROR("unlink"); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(filePath)), e.toString()); } +} + + + +void zen::removeDirectoryPlain(const Zstring& dirPath) //throw FileError +{ + try + { + if (::rmdir(dirPath.c_str()) != 0) + THROW_LAST_SYS_ERROR("rmdir"); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(dirPath)), e.toString()); } +} + + +void zen::removeSymlinkPlain(const Zstring& linkPath) //throw FileError +{ + try + { + if (::unlink(linkPath.c_str()) != 0) + THROW_LAST_SYS_ERROR("unlink"); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete symbolic link %x."), L"%x", fmtPath(linkPath)), e.toString()); } +} + + +namespace +{ +void removeDirectoryImpl(const Zstring& folderPath) //throw FileError +{ + std::vector folderPaths; + { + std::vector filePaths; + std::vector symlinkPaths; + + //get all files and directories from current directory (WITHOUT subdirectories!) + traverseFolder(folderPath, + [&](const FileInfo& fi) { filePaths.push_back(fi.fullPath); }, + [&](const FolderInfo& fi) { folderPaths.push_back(fi.fullPath); }, + [&](const SymlinkInfo& si) { symlinkPaths.push_back(si.fullPath); }); //throw FileError + + for (const Zstring& filePath : filePaths) + removeFilePlain(filePath); //throw FileError + + for (const Zstring& symlinkPath : symlinkPaths) + removeSymlinkPlain(symlinkPath); //throw FileError + } //=> save stack space and allow deletion of extremely deep hierarchies! + + //delete directories recursively + for (const Zstring& subFolderPath : folderPaths) + removeDirectoryImpl(subFolderPath); //throw FileError; call recursively to correctly handle symbolic links + + removeDirectoryPlain(folderPath); //throw FileError +} +} + + +void zen::removeDirectoryPlainRecursion(const Zstring& dirPath) //throw FileError +{ + try + { + if (getItemTypeImpl(dirPath) == ItemType::symlink) //throw SysErrorCode + removeSymlinkPlain(dirPath); //throw FileError + else + removeDirectoryImpl(dirPath); //throw FileError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot delete directory %x."), L"%x", fmtPath(dirPath)), e.toString()); } +} + + +namespace +{ +std::wstring generateMoveErrorMsg(const Zstring& pathFrom, const Zstring& pathTo) +{ + if (getParentFolderPath(pathFrom) == getParentFolderPath(pathTo)) //pure "rename" + return replaceCpy(replaceCpy(_("Cannot rename %x to %y."), + L"%x", fmtPath(pathFrom)), + L"%y", fmtPath(getItemName(pathTo))); + else //"move" or "move + rename" + return trimCpy(replaceCpy(replaceCpy(_("Cannot move %x to %y."), + L"%x", L'\n' + fmtPath(pathFrom)), + L"%y", L'\n' + fmtPath(pathTo))); +} + +/* Usage overview: (avoid circular pattern!) + + moveAndRenameItem() --> moveAndRenameFileSub() + | /|\ + \|/ | + Fix8Dot3NameClash() */ + +//wrapper for file system rename function: +void moveAndRenameFileSub(const Zstring& pathFrom, const Zstring& pathTo, bool replaceExisting) //throw FileError, ErrorMoveUnsupported, ErrorTargetExisting +{ + auto getErrorMsg = [&] { return generateMoveErrorMsg(pathFrom, pathTo); }; + + //rename() will never fail with EEXIST, but always (atomically) overwrite! + //=> equivalent to SetFileInformationByHandle() + FILE_RENAME_INFO::ReplaceIfExists or ::MoveFileEx() + MOVEFILE_REPLACE_EXISTING + //Linux: renameat2() with RENAME_NOREPLACE -> still new, probably buggy + //macOS: no solution https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man2/rename.2.html + if (!replaceExisting) + { + struct stat sourceInfo = {}; + if (::lstat(pathFrom.c_str(), &sourceInfo) != 0) + throw FileError(getErrorMsg(), formatSystemError("lstat(source)", errno)); + + struct stat targetInfo = {}; + if (::lstat(pathTo.c_str(), &targetInfo) != 0) + { + if (errno != ENOENT) + throw FileError(getErrorMsg(), formatSystemError("lstat(target)", errno)); + } + else + { + if (sourceInfo.st_dev != targetInfo.st_dev || + sourceInfo.st_ino != targetInfo.st_ino) + throw ErrorTargetExisting(getErrorMsg(), replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(pathTo)))); + //else: continue with a rename in case + //caveat: if we have a hardlink referenced by two different paths, the source one will be unlinked => fine, but not exactly a "rename"... + } + } + + if (::rename(pathFrom.c_str(), pathTo.c_str()) != 0) + { + const int ec = errno; //copy before making other system calls! + std::wstring errorDescr = formatSystemError("rename", ec); + + if (ec == EXDEV) + throw ErrorMoveUnsupported(getErrorMsg(), errorDescr); + + if (ec == EINVAL) + for (const Zchar c : getItemName(pathTo)) + if (contains(fileNameForbiddenChars, c)) + { + errorDescr += L' ' + replaceCpy(_("Unsupported character %x"), L"%x", L"'" + utfTo(c) + L"'"); + break; + } + + throw FileError(getErrorMsg(), errorDescr); + } +} + + +} + + +//rename file: no copying!!! +void zen::moveAndRenameItem(const Zstring& pathFrom, const Zstring& pathTo, bool replaceExisting) //throw FileError, ErrorMoveUnsupported, ErrorTargetExisting +{ + try + { + moveAndRenameFileSub(pathFrom, pathTo, replaceExisting); //throw FileError, ErrorMoveUnsupported, ErrorTargetExisting + } + catch (ErrorTargetExisting&) + { + throw; + } +} + +namespace +{ +void setWriteTimeNative(const Zstring& itemPath, const timespec& modTime, ProcSymlink procSl) //throw FileError +{ + /* [2013-05-01] sigh, we can't use utimensat() on NTFS volumes on Ubuntu: silent failure!!! what morons are programming this shit??? + => fallback to "retarded-idiot version"! -- DarkByte + + [2015-03-09] + - cannot reproduce issues with NTFS and utimensat() on Ubuntu + - utimensat() is supposed to obsolete utime/utimes and is also used by "cp" and "touch" + => let's give utimensat another chance: + using open()/futimens() for regular files and utimensat(AT_SYMLINK_NOFOLLOW) for symlinks is consistent with "cp" and "touch"! + cp: https://github.com/coreutils/coreutils/blob/master/src/cp.c + => utimens: https://github.com/coreutils/gnulib/blob/master/lib/utimens.c + touch: https://github.com/coreutils/coreutils/blob/master/src/touch.c + => fdutimensat: https://github.com/coreutils/gnulib/blob/master/lib/fdutimensat.c */ + const timespec newTimes[2] + { + {.tv_sec = ::time(nullptr)}, //access time; don't use UTIME_NOW/UTIME_OMIT: more bugs! https://freefilesync.org/forum/viewtopic.php?t=1701 + modTime, + }; + //test: even modTime == 0 is correctly applied (no NOOP!) test2: same behavior for "utime()" + + //hell knows why files on gvfs-mounted Samba shares fail to open(O_WRONLY) returning EOPNOTSUPP: + //https://freefilesync.org/forum/viewtopic.php?t=2803 => utimensat() works (but not for gvfs SFTP) + if (::utimensat(AT_FDCWD /*'dirfd' ignored for absolute paths*/, itemPath.c_str(), newTimes, procSl == ProcSymlink::asLink ? AT_SYMLINK_NOFOLLOW : 0) == 0) + return; + const ErrorCode ecUtimensat = errno; + try + { + if (procSl == ProcSymlink::asLink) + { + if (getItemTypeImpl(itemPath) == ItemType::symlink) //throw SysErrorCode + throw SysError(formatSystemError("utimensat(AT_SYMLINK_NOFOLLOW)", ecUtimensat)); //use lutimes()? just a wrapper around utimensat()! + //else: fall back + } + + //in other cases utimensat() returns EINVAL for CIFS/NTFS drives, but open+futimens works: https://freefilesync.org/forum/viewtopic.php?t=387 + //2017-07-04: O_WRONLY | O_APPEND seems to avoid EOPNOTSUPP on gvfs SFTP! + const int fdFile = ::open(itemPath.c_str(), O_WRONLY | O_APPEND | O_CLOEXEC); + if (fdFile == -1) + THROW_LAST_SYS_ERROR("open"); + ZEN_ON_SCOPE_EXIT(::close(fdFile)); + + if (::futimens(fdFile, newTimes) != 0) + THROW_LAST_SYS_ERROR("futimens"); + + //need more fallbacks? e.g. futimes()? careful, bugs! futimes() rounds instead of truncates when falling back on utime()! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write modification time of %x."), L"%x", fmtPath(itemPath)), e.toString()); } +} + + +} + + +void zen::setFileTime(const Zstring& filePath, time_t modTime, ProcSymlink procSl) //throw FileError +{ + setWriteTimeNative(filePath, timetToNativeFileTime(modTime), + procSl); //throw FileError +} + + +bool zen::supportsPermissions(const Zstring& dirPath) //throw FileError +{ + return true; +} + + +namespace +{ +#ifdef HAVE_SELINUX +//copy SELinux security context +void copySecurityContext(const Zstring& source, const Zstring& target, ProcSymlink procSl) //throw FileError +{ + security_context_t contextSource = nullptr; + const int rv = procSl == ProcSymlink::follow ? + ::getfilecon (source.c_str(), &contextSource) : + ::lgetfilecon(source.c_str(), &contextSource); + if (rv < 0) + { + if (errno == ENODATA || //no security context (allegedly) is not an error condition on SELinux + errno == EOPNOTSUPP) //extended attributes are not supported by the filesystem + return; + + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read security context of %x."), L"%x", fmtPath(source)), "getfilecon"); + } + ZEN_ON_SCOPE_EXIT(::freecon(contextSource)); + + { + security_context_t contextTarget = nullptr; + const int rv2 = procSl == ProcSymlink::follow ? + ::getfilecon(target.c_str(), &contextTarget) : + ::lgetfilecon(target.c_str(), &contextTarget); + if (rv2 < 0) + { + if (errno == EOPNOTSUPP) + return; + //else: still try to set security context + } + else + { + ZEN_ON_SCOPE_EXIT(::freecon(contextTarget)); + + if (::strcmp(contextSource, contextTarget) == 0) //nothing to do + return; + } + } + + const int rv3 = procSl == ProcSymlink::follow ? + ::setfilecon(target.c_str(), contextSource) : + ::lsetfilecon(target.c_str(), contextSource); + if (rv3 < 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write security context of %x."), L"%x", fmtPath(target)), "setfilecon"); +} +#endif +} + + +//copy permissions for files, directories or symbolic links: requires admin rights +void zen::copyItemPermissions(const Zstring& sourcePath, const Zstring& targetPath, ProcSymlink procSl) //throw FileError +{ + +#ifdef HAVE_SELINUX //copy SELinux security context + copySecurityContext(sourcePath, targetPath, procSl); //throw FileError +#endif + + struct stat fileInfo = {}; + if (procSl == ProcSymlink::follow) + { + if (::stat(sourcePath.c_str(), &fileInfo) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read permissions of %x."), L"%x", fmtPath(sourcePath)), "stat"); + + if (::chown(targetPath.c_str(), fileInfo.st_uid, fileInfo.st_gid) != 0) // may require admin rights! + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(targetPath)), "chown"); + + if (::chmod(targetPath.c_str(), fileInfo.st_mode) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(targetPath)), "chmod"); + } + else + { + if (::lstat(sourcePath.c_str(), &fileInfo) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read permissions of %x."), L"%x", fmtPath(sourcePath)), "lstat"); + + if (::lchown(targetPath.c_str(), fileInfo.st_uid, fileInfo.st_gid) != 0) // may require admin rights! + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(targetPath)), "lchown"); + + try + { + if (getItemTypeImpl(targetPath) != ItemType::symlink && //throw SysErrorCode + //setting access permissions doesn't make sense for symlinks on Linux: there is no lchmod() + ::chmod(targetPath.c_str(), fileInfo.st_mode) != 0) + THROW_LAST_SYS_ERROR("chmod"); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write permissions of %x."), L"%x", fmtPath(targetPath)), e.toString()); } + } + +} + + +void zen::createDirectory(const Zstring& dirPath) //throw FileError, ErrorTargetExisting +{ + try + { + //don't allow creating irregular folders! + const Zstring dirName = getItemName(dirPath); + + //e.g. "...." https://social.technet.microsoft.com/Forums/windows/en-US/ffee2322-bb6b-4fdf-86f9-8f93cf1fa6cb/ + if (std::all_of(dirName.begin(), dirName.end(), [](Zchar c) { return c == Zstr('.'); })) + /**/throw SysError(replaceCpy(L"Invalid folder name %x.", L"%x", fmtPath(dirName))); + +#if 0 //not appreciated: https://freefilesync.org/forum/viewtopic.php?t=7509 + if (startsWith(dirName, Zstr(' ')) || //Windows can access these just fine once created! + endsWith (dirName, Zstr(' '))) // + throw SysError(replaceCpy(L"Invalid folder name %x starts/ends with space character.", L"%x", fmtPath(dirName))); +#endif + + const mode_t mode = S_IRWXU | S_IRWXG | S_IRWXO; //0777 => consider umask! + + if (::mkdir(dirPath.c_str(), mode) != 0) + { + const int ec = errno; //copy before making other system calls! + std::wstring errorDescr = formatSystemError("mkdir", ec); + + if (ec == EEXIST) + throw ErrorTargetExisting(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(dirPath)), errorDescr); + + if (ec == EINVAL) + for (const Zchar c : getItemName(dirPath)) + if (contains(fileNameForbiddenChars, c)) + { + errorDescr += L' ' + replaceCpy(_("Unsupported character %x"), L"%x", L"'" + utfTo(c) + L"'"); + break; + } + + throw SysError(errorDescr); + } + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(dirPath)), e.toString()); } +} + + +void zen::createDirectoryIfMissingRecursion(const Zstring& dirPath) //throw FileError +{ + auto getItemType2 = [&](const Zstring& itemPath) //throw FileError + { + try + { return getItemTypeImpl(itemPath); } //throw SysErrorCode + catch (const SysErrorCode& e) //need to add context! + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(dirPath)), + replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(getParentFolderPath(itemPath) ? getItemName(itemPath) : itemPath)) + L'\n' + + e.toString()); + } + }; + + try + { + //- path most likely already exists (see: versioning, base folder, log file path) => check first + //- do NOT use getItemTypeIfExists()! race condition when multiple threads are calling createDirectoryIfMissingRecursion(): https://freefilesync.org/forum/viewtopic.php?t=10137#p38062 + //- find first existing + accessible parent folder (backwards iteration): + Zstring dirPathEx = dirPath; + RingBuffer dirNames; //caveat: 1. might have been created in the meantime 2. getItemType2() may have failed with access error + for (;;) + try + { + if (getItemType2(dirPathEx) == ItemType::file /*obscure, but possible*/) //throw FileError + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(dirPathEx)))); + break; + } + catch (FileError&) //not yet existing or access error + { + const std::optional& parentPath = getParentFolderPath(dirPathEx); + if (!parentPath)//device root => quick access test + throw; + dirNames.push_front(getItemName(dirPathEx)); + dirPathEx = *parentPath; + } + //----------------------------------------------------------- + + Zstring dirPathNew = dirPathEx; + for (const Zstring& dirName : dirNames) + try + { + dirPathNew = appendPath(dirPathNew, dirName); + + createDirectory(dirPathNew); //throw FileError + } + catch (FileError&) + { + try + { + if (getItemType2(dirPathNew) == ItemType::file /*obscure, but possible*/) //throw FileError + throw SysError(replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(getItemName(dirPathNew)))); + else + continue; //already existing => possible, if createDirectoryIfMissingRecursion() is run in parallel + } + catch (FileError&) {} //not yet existing or access error + + throw; + } + } + catch (const SysError& e) + { + throw FileError(replaceCpy(_("Cannot create directory %x."), L"%x", fmtPath(dirPath)), e.toString()); + } +} + + +void zen::copyDirectoryAttributes(const Zstring& sourcePath, const Zstring& targetPath) //throw FileError +{ + //do NOT copy attributes for volume root paths which return as: FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM | FILE_ATTRIBUTE_DIRECTORY + //https://freefilesync.org/forum/viewtopic.php?t=5550 + if (!getParentFolderPath(sourcePath)) //=> root path + return; + +} + + +void zen::copySymlink(const Zstring& sourcePath, const Zstring& targetPath) //throw FileError +{ + SymlinkRawContent linkContent{}; + try //harmonize with NativeFileSystem::equalSymlinkContentForSameAfsType() + { + linkContent = getSymlinkRawContent_impl(sourcePath); //throw SysError; accept broken symlinks + + if (::symlink(linkContent.targetPath.c_str(), targetPath.c_str()) != 0) + THROW_LAST_SYS_ERROR("symlink"); + } + catch (const SysError& e) + { + throw FileError(replaceCpy(replaceCpy(_("Cannot copy symbolic link %x to %y."), L"%x", L'\n' + fmtPath(sourcePath)), L"%y", L'\n' + fmtPath(targetPath)), e.toString()); + } + + //allow only consistent objects to be created -> don't place before ::symlink(); targetPath may already exist! + ZEN_ON_SCOPE_FAIL(try { removeSymlinkPlain(targetPath); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + //file times: essential for syncing a symlink: enforce this! (don't just try!) + struct stat sourceInfo = {}; + if (::lstat(sourcePath.c_str(), &sourceInfo) != 0) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(sourcePath)), "lstat"); + + setWriteTimeNative(targetPath, sourceInfo.st_mtim, ProcSymlink::asLink); //throw FileError +} + + +FileCopyResult zen::copyNewFile(const Zstring& sourceFile, const Zstring& targetFile, //throw FileError, ErrorTargetExisting, (ErrorFileLocked), X + const IoCallback& notifyUnbufferedIO /*throw X*/) +{ + int64_t totalBytesNotified = 0; + IOCallbackDivider notifyIoDiv(notifyUnbufferedIO, totalBytesNotified); + + FileInputPlain fileIn(sourceFile); //throw FileError, (ErrorFileLocked -> Windows-only) + + const struct stat& sourceInfo = fileIn.getStatBuffered(); //throw FileError + + //analog to "cp" which copies "mode" (considering umask) by default: + const mode_t mode = (sourceInfo.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO)) | + S_IWUSR;//macOS: S_IWUSR apparently needed to write extended attributes (see copyfile() function) + //Linux: not needed even for the setFileTime() below! (tested with source file having different user/group!) + + //=> need copyItemPermissions() only for "chown" and umask-agnostic permissions + const int fdTarget = ::open(targetFile.c_str(), O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, mode); + if (fdTarget == -1) + { + const int ec = errno; //copy before making other system calls! + const std::wstring errorMsg = replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(targetFile)); + std::wstring errorDescr = formatSystemError("open", ec); + + if (ec == EEXIST) + throw ErrorTargetExisting(errorMsg, errorDescr); + + if (ec == EINVAL) + for (const Zchar c : getItemName(targetFile)) + if (contains(fileNameForbiddenChars, c)) + { + errorDescr += L' ' + replaceCpy(_("Unsupported character %x"), L"%x", L"'" + utfTo(c) + L"'"); + break; + } + + throw FileError(errorMsg, errorDescr); + } + FileOutputPlain fileOut(fdTarget, targetFile); //pass ownership + + //preallocate disk space + reduce fragmentation + fileOut.reserveSpace(sourceInfo.st_size); //throw FileError + + unbufferedStreamCopy([&](void* buffer, size_t bytesToRead) + { + const size_t bytesRead = fileIn.tryRead(buffer, bytesToRead); //throw FileError, (ErrorFileLocked) + notifyIoDiv(bytesRead); //throw X + return bytesRead; + }, + fileIn.getBlockSize() /*throw FileError*/, + + [&](const void* buffer, size_t bytesToWrite) + { + const size_t bytesWritten = fileOut.tryWrite(buffer, bytesToWrite); //throw FileError + notifyIoDiv(bytesWritten); //throw X + return bytesWritten; + }, + fileOut.getBlockSize() /*throw FileError*/); //throw FileError, X + + //possible improvement: copy_file_range() performs an in-kernel copy: https://github.com/coreutils/coreutils/blob/17479ef60c8edbd2fe8664e31a7f69704f0cd221/src/copy.c#L342 + +#if 0 + //clean file system cache: needed at all? no user complaints at all so far!!! + //posix_fadvise(POSIX_FADV_DONTNEED) does nothing, unless data was already read from/written to disk: https://insights.oetiker.ch/linux/fadvise/ + // => should be "most" of the data at this point => good enough? + if (::posix_fadvise(fileIn.getHandle(), 0 /*offset*/, 0 /*len*/, POSIX_FADV_DONTNEED) != 0) //"len == 0" means "end of the file" + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(sourceFile)), "posix_fadvise(POSIX_FADV_DONTNEED)"); + if (::posix_fadvise(fileOut.getHandle(), 0 /*offset*/, 0 /*len*/, POSIX_FADV_DONTNEED) != 0) //"len == 0" means "end of the file" + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(targetFile)), "posix_fadvise(POSIX_FADV_DONTNEED)"); +#endif + + + const auto targetFileIdx = fileOut.getStatBuffered().st_ino; //throw FileError + + //close output file handle before setting file time; also good place to catch errors when closing stream! + fileOut.close(); //throw FileError + //========================================================================================================== + //take over fileOut ownership => from this point on, WE are responsible for calling removeFilePlain() on error!! + // not needed *currently*! see below: ZEN_ON_SCOPE_FAIL(try { removeFilePlain(targetFile); } catch (FileError&) {}); + //=========================================================================================================== + std::optional errorModTime; + try + { + /* we cannot set the target file times (::futimes) while the file descriptor is still open after a write operation: + this triggers bugs on Samba shares where the modification time is set to current time instead. + Linux: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=340236 + http://comments.gmane.org/gmane.linux.file-systems.cifs/2854 + macOS: https://freefilesync.org/forum/viewtopic.php?t=356 */ + setWriteTimeNative(targetFile, sourceInfo.st_mtim, ProcSymlink::follow); //throw FileError + } + catch (const FileError& e) { errorModTime = e; /*might slice derived class?*/ } + + return + { + .fileSize = makeUnsigned(sourceInfo.st_size), + .sourceModTime = sourceInfo.st_mtim, + .sourceFileIdx = sourceInfo.st_ino, + .targetFileIdx = targetFileIdx, + .errorModTime = errorModTime, + }; +} + + diff --git a/zen/file_access.h b/zen/file_access.h new file mode 100644 index 0000000..42ee06c --- /dev/null +++ b/zen/file_access.h @@ -0,0 +1,99 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_ACCESS_H_8017341345614857 +#define FILE_ACCESS_H_8017341345614857 + +#include "file_path.h" //we'll need this later anyway! +#include "file_error.h" +#include "serialize.h" //IoCallback + #include + +namespace zen +{ +//note: certain functions require COM initialization! (vista_file_op.h) + +//FAT/FAT32: "Why does the timestamp of a file *increase* by up to 2 seconds when I copy it to a USB thumb drive?" +const int FAT_FILE_TIME_PRECISION_SEC = 2; //https://devblogs.microsoft.com/oldnewthing/?p=83 +//https://web.archive.org/web/20141127143832/http://support.microsoft.com/kb/127830 + +using FileIndex = ino_t; +using FileTimeNative = timespec; + +inline time_t nativeFileTimeToTimeT(const timespec& ft) { return ft.tv_sec; } //follow File Explorer: always round down! +inline timespec timetToNativeFileTime(time_t utcTime) { return {.tv_sec = utcTime}; } + +enum class ItemType +{ + file, + folder, + symlink, +}; +//(hopefully) fast: does not distinguish between error/not existing +ItemType getItemType(const Zstring& itemPath); //throw FileError +//execute potentially SLOW folder traversal but distinguish error/not existing: +// - all child item path parts must correspond to folder traversal +// => we can conclude whether an item is *not* existing anymore by doing a *case-sensitive* name search => potentially SLOW! +std::optional getItemTypeIfExists(const Zstring& itemPath); //throw FileError + +inline bool itemExists(const Zstring& itemPath) { return static_cast(getItemTypeIfExists(itemPath)); } //throw FileError + +enum class ProcSymlink +{ + asLink, + follow +}; +void setFileTime(const Zstring& filePath, time_t modTime, ProcSymlink procSl); //throw FileError + + +int64_t getFreeDiskSpace(const Zstring& folderPath); //throw FileError, returns < 0 if not available +//- symlink handling: follow +//- returns < 0 if not available +//- folderPath does not need to exist (yet) + +//symlink handling: follow +uint64_t getFileSize(const Zstring& filePath); //throw FileError + +//get per-user directory designated for temporary files: +Zstring getTempFolderPath(); //throw FileError + +void removeFilePlain (const Zstring& filePath); //throw FileError; ERROR if not existing +void removeSymlinkPlain (const Zstring& linkPath); //throw FileError; ERROR if not existing +void removeDirectoryPlain(const Zstring& dirPath ); //throw FileError; ERROR if not existing +void removeDirectoryPlainRecursion(const Zstring& dirPath); //throw FileError; ERROR if not existing + +void moveAndRenameItem(const Zstring& pathFrom, const Zstring& pathTo, bool replaceExisting); //throw FileError, ErrorMoveUnsupported, ErrorTargetExisting + +bool supportsPermissions(const Zstring& dirPath); //throw FileError, follows symlinks +//copy permissions for files, directories or symbolic links: requires admin rights +void copyItemPermissions(const Zstring& sourcePath, const Zstring& targetPath, ProcSymlink procSl); //throw FileError + +void createDirectory(const Zstring& dirPath); //throw FileError, ErrorTargetExisting + +//creates directories recursively if not existing +void createDirectoryIfMissingRecursion(const Zstring& dirPath); //throw FileError + +//symlink handling: follow +//expects existing source/target directories +void copyDirectoryAttributes(const Zstring& sourcePath, const Zstring& targetPath); //throw FileError + +void copySymlink(const Zstring& sourcePath, const Zstring& targetPath); //throw FileError + +struct FileCopyResult +{ + uint64_t fileSize = 0; + FileTimeNative sourceModTime = {}; + FileIndex sourceFileIdx = 0; + FileIndex targetFileIdx = 0; + std::optional errorModTime; //failure to set modification time +}; + +FileCopyResult copyNewFile(const Zstring& sourceFile, const Zstring& targetFile, //throw FileError, ErrorTargetExisting, ErrorFileLocked, X + //accummulated delta != file size! consider ADS, sparse, compressed files + const IoCallback& notifyUnbufferedIO /*throw X*/); +} + +#endif //FILE_ACCESS_H_8017341345614857 diff --git a/zen/file_error.h b/zen/file_error.h new file mode 100644 index 0000000..93c95f9 --- /dev/null +++ b/zen/file_error.h @@ -0,0 +1,50 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_ERROR_H_839567308565656789 +#define FILE_ERROR_H_839567308565656789 + +#include "sys_error.h" //we'll need this later anyway! + + +namespace zen +{ +class FileError //A high-level exception class giving detailed context information for end users +{ +public: + explicit FileError(const std::wstring& msg) : msg_(msg) {} + FileError(const std::wstring& msg, const std::wstring& details) : msg_(msg + L"\n\n" + details) {} + virtual ~FileError() {} + + const std::wstring& toString() const { return msg_; } + +private: + std::wstring msg_; +}; + +#define DEFINE_NEW_FILE_ERROR(X) struct X : public zen::FileError { X(const std::wstring& msg) : FileError(msg) {} X(const std::wstring& msg, const std::wstring& descr) : FileError(msg, descr) {} }; + +DEFINE_NEW_FILE_ERROR(ErrorTargetExisting) +DEFINE_NEW_FILE_ERROR(ErrorFileLocked) +DEFINE_NEW_FILE_ERROR(ErrorMoveUnsupported) +DEFINE_NEW_FILE_ERROR(RecycleBinUnavailable) + + +//CAVEAT: thread-local Win32 error code is easily overwritten => evaluate *before* making any (indirect) system calls: +//-> MinGW + Win XP: "throw" statement allocates memory to hold the exception object => error code is cleared +//-> VC 2015, Debug: std::wstring allocator internally calls ::FlsGetValue() => error code is cleared +// https://connect.microsoft.com/VisualStudio/feedback/details/1775690/calling-operator-new-may-set-lasterror-to-0 +#define THROW_LAST_FILE_ERROR(msg, functionName) \ + do { const ErrorCode ecInternal = getLastError(); throw FileError(msg, formatSystemError(functionName, ecInternal)); } while (false) + +//----------- facilitate usage of std::wstring for error messages -------------------- + +inline std::wstring fmtPath(const std::wstring& displayPath) { return L'"' + displayPath + L'"'; } +inline std::wstring fmtPath(const Zstring& displayPath) { return fmtPath(utfTo(displayPath)); } +inline std::wstring fmtPath(const wchar_t* displayPath) { return fmtPath(std::wstring(displayPath)); } //resolve overload ambiguity +} + +#endif //FILE_ERROR_H_839567308565656789 diff --git a/zen/file_io.cpp b/zen/file_io.cpp new file mode 100644 index 0000000..0a119b4 --- /dev/null +++ b/zen/file_io.cpp @@ -0,0 +1,345 @@ +// ***************************************************************************** +// * 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 "file_io.h" + #include + #include //open + #include //close, read, write + +using namespace zen; + + +size_t FileBase::getBlockSize() //throw FileError +{ + if (blockSizeBuf_ == 0) + { + /* - statfs::f_bsize - "optimal transfer block size" + - stat::st_blksize - "blocksize for file system I/O. Writing in smaller chunks may cause an inefficient read-modify-rewrite." + + e.g. local disk: f_bsize 4096 st_blksize 4096 + USB memory: f_bsize 32768 st_blksize 32768 */ + const auto st_blksize = getStatBuffered().st_blksize; //throw FileError + if (st_blksize > 0) //st_blksize is signed! + blockSizeBuf_ = st_blksize; // + + blockSizeBuf_ = std::max(blockSizeBuf_, defaultBlockSize); + //ha, convergent evolution! https://github.com/coreutils/coreutils/blob/master/src/ioblksize.h#L74 + } + return blockSizeBuf_; +} + + +const struct stat& FileBase::getStatBuffered() //throw FileError +{ + if (!statBuf_) + try + { + if (hFile_ == invalidFileHandle) + throw SysError(L"Contract error: getStatBuffered() called after close()."); + + struct stat fileInfo = {}; + if (::fstat(hFile_, &fileInfo) != 0) + THROW_LAST_SYS_ERROR("fstat"); + statBuf_ = std::move(fileInfo); + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(filePath_)), e.toString()); } + + return *statBuf_; +} + + +FileBase::~FileBase() +{ + if (hFile_ != invalidFileHandle) + try + { + close(); //throw FileError + } + catch (const FileError& e) { logExtraError(e.toString()); } +} + + +void FileBase::close() //throw FileError +{ + try + { + if (hFile_ == invalidFileHandle) + throw SysError(L"Contract error: close() called more than once."); + if (::close(hFile_) != 0) + THROW_LAST_SYS_ERROR("close"); + hFile_ = invalidFileHandle; //do NOT set on error! => ~FileOutputPlain() still wants to (try to) delete the file! + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getFilePath())), e.toString()); } +} + +//---------------------------------------------------------------------------------------------------- + +namespace +{ + std::pair +openHandleForRead(const Zstring& filePath) //throw FileError, ErrorFileLocked +{ + try + { + //caveat: check for file types that block during open(): character device, block device, named pipe + struct stat fileInfo = {}; + if (::stat(filePath.c_str(), &fileInfo) != 0) //follows symlinks + THROW_LAST_SYS_ERROR("stat"); + + if (!S_ISREG(fileInfo.st_mode) && + !S_ISDIR(fileInfo.st_mode) && //open() will fail with "EISDIR: Is a directory" => nice + !S_ISLNK(fileInfo.st_mode)) //?? shouldn't be possible after successful stat() + { + const std::wstring typeName = [m = fileInfo.st_mode] + { + std::wstring name = + S_ISCHR (m) ? L"character device" : //e.g. /dev/null + S_ISBLK (m) ? L"block device" : //e.g. /dev/sda1 + S_ISFIFO(m) ? L"FIFO, named pipe" : + S_ISSOCK(m) ? L"socket" : L""; //doesn't block but open() error is unclear: "ENXIO: No such device or address" + if (!name.empty()) + name += L", "; + return name + printNumber(L"0%06o", m & S_IFMT); + }(); + throw SysError(_("Unsupported item type.") + L" [" + typeName + L']'); + } + + //don't use O_DIRECT: https://yarchive.net/comp/linux/o_direct.html + const int fdFile = ::open(filePath.c_str(), O_RDONLY | O_CLOEXEC); + if (fdFile == -1) //don't check "< 0" -> docu seems to allow "-2" to be a valid file handle + THROW_LAST_SYS_ERROR("open"); + return {fdFile /*pass ownership*/, fileInfo}; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(filePath)), e.toString()); } +} +} + + +FileInputPlain::FileInputPlain(const Zstring& filePath) : + FileInputPlain(openHandleForRead(filePath), filePath) {} //throw FileError, ErrorFileLocked + + +FileInputPlain::FileInputPlain(const std::pair& fileDetails, const Zstring& filePath) : + FileInputPlain(fileDetails.first, filePath) +{ + setStatBuffered(fileDetails.second); +} + + +FileInputPlain::FileInputPlain(FileHandle handle, const Zstring& filePath) : + FileBase(handle, filePath) +{ + //optimize read-ahead on input file: + if (::posix_fadvise(getHandle(), 0 /*offset*/, 0 /*len*/, POSIX_FADV_SEQUENTIAL) != 0) //"len == 0" means "end of the file" + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(filePath)), "posix_fadvise(POSIX_FADV_SEQUENTIAL)"); + + /* - POSIX_FADV_SEQUENTIAL is like POSIX_FADV_NORMAL, but with twice the read-ahead buffer size + - POSIX_FADV_NOREUSE "since kernel 2.6.18 this flag is a no-op" WTF!? + - POSIX_FADV_DONTNEED may be used to clear the OS file system cache (offset and len must be page-aligned!) + => does nothing, unless data was already written to disk: https://insights.oetiker.ch/linux/fadvise/ + - POSIX_FADV_WILLNEED: issue explicit read-ahead; almost the same as readahead(), but with weaker error checking + https://unix.stackexchange.com/questions/681188/difference-between-posix-fadvise-and-readahead + + clear file system cache manually: sync; echo 3 > /proc/sys/vm/drop_caches */ + +} + + +//may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! +size_t FileInputPlain::tryRead(void* buffer, size_t bytesToRead) //throw FileError, ErrorFileLocked +{ + if (bytesToRead == 0) //"read() with a count of 0 returns zero" => indistinguishable from end of file! => check! + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + assert(bytesToRead % getBlockSize() == 0); + try + { + ssize_t bytesRead = 0; + do + { + bytesRead = ::read(getHandle(), buffer, bytesToRead); + } + while (bytesRead < 0 && errno == EINTR); //Compare copy_reg() in copy.c: ftp://ftp.gnu.org/gnu/coreutils/coreutils-8.23.tar.xz + //EINTR is not checked on macOS' copyfile: https://opensource.apple.com/source/copyfile/copyfile-173.40.2/copyfile.c.auto.html + //read() on macOS: https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man2/read.2.html + //if ::read is interrupted (EINTR) right in the middle, it will return successfully with "bytesRead < bytesToRead" + + if (bytesRead < 0) + THROW_LAST_SYS_ERROR("read"); + + ASSERT_SYSERROR(makeUnsigned(bytesRead) <= bytesToRead); //better safe than sorry + return bytesRead; //"zero indicates end of file" + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot read file %x."), L"%x", fmtPath(getFilePath())), e.toString()); } +} + +//---------------------------------------------------------------------------------------------------- + +namespace +{ +FileBase::FileHandle openHandleForWrite(const Zstring& filePath) //throw FileError, ErrorTargetExisting +{ + try + { + //check for named pipe, etc.? not needed, open() + O_WRONLY should fail fast + + const mode_t lockFileMode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH; //0666 => umask will be applied implicitly! + + //O_EXCL contains a race condition on NFS file systems: https://linux.die.net/man/2/open + const int fdFile = ::open(filePath.c_str(), //const char* pathname + O_CREAT | //int flags + /*access == FileOutput::ACC_OVERWRITE ? O_TRUNC : */ O_EXCL | O_WRONLY | O_CLOEXEC, + lockFileMode); //mode_t mode + if (fdFile == -1) + { + const int ec = errno; //copy before making other system calls! + std::wstring errorDescr = formatSystemError("open", ec); + + if (ec == EEXIST) + throw ErrorTargetExisting(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(filePath)), errorDescr); + + if (ec == EINVAL) + for (const Zchar c : getItemName(filePath)) + if (contains(fileNameForbiddenChars, c)) + { + errorDescr += L' ' + replaceCpy(_("Unsupported character %x"), L"%x", L"'" + utfTo(c) + L"'"); + break; + } + + throw SysError(errorDescr); + } + return fdFile; //pass ownership + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(filePath)), e.toString()); } +} +} + + +FileOutputPlain::FileOutputPlain(const Zstring& filePath) : + FileOutputPlain(openHandleForWrite(filePath), filePath) {} //throw FileError, ErrorTargetExisting + + +FileOutputPlain::FileOutputPlain(FileHandle handle, const Zstring& filePath) : + FileBase(handle, filePath) +{ +} + + +FileOutputPlain::~FileOutputPlain() +{ + + if (getHandle() != invalidFileHandle) //not finalized => clean up garbage + try + { + //"deleting while handle is open" == FILE_FLAG_DELETE_ON_CLOSE + if (::unlink(getFilePath().c_str()) != 0) + THROW_LAST_SYS_ERROR("unlink"); + } + catch (const SysError& e) + { + logExtraError(replaceCpy(_("Cannot delete file %x."), L"%x", fmtPath(getFilePath())) + L"\n\n" + e.toString()); + } +} + + +void FileOutputPlain::reserveSpace(uint64_t expectedSize) //throw FileError +{ + //NTFS: "If you set the file allocation info [...] the file contents will be forced into nonresident data, even if it would have fit inside the MFT." + if (expectedSize < 1024) //https://docs.microsoft.com/en-us/archive/blogs/askcore/the-four-stages-of-ntfs-file-growth + return; + + try + { +#if 0 /* fallocate(FALLOC_FL_KEEP_SIZE): + - perf: no real benefit (in a quick and dirty local test) + - breaks Btrfs compression: https://freefilesync.org/forum/viewtopic.php?t=10356 + - apparently not even used by cp: https://github.com/coreutils/coreutils/blob/17479ef60c8edbd2fe8664e31a7f69704f0cd221/src/copy.c#LL1234C5-L1234C5 */ + + //don't use ::posix_fallocate which uses horribly inefficient fallback if FS doesn't support it (EOPNOTSUPP) and changes files size! + //FALLOC_FL_KEEP_SIZE => allocate only, file size is NOT changed! + if (::fallocate(getHandle(), //int fd + FALLOC_FL_KEEP_SIZE, //int mode + 0, //off_t offset + expectedSize) != 0) //off_t len + if (errno != EOPNOTSUPP) //possible, unlike with posix_fallocate() + THROW_LAST_SYS_ERROR("fallocate"); +#endif + + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getFilePath())), e.toString()); } +} + + +//may return short! CONTRACT: bytesToWrite > 0 +size_t FileOutputPlain::tryWrite(const void* buffer, size_t bytesToWrite) //throw FileError +{ + if (bytesToWrite == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + assert(bytesToWrite % getBlockSize() == 0 || bytesToWrite < getBlockSize()); + try + { + ssize_t bytesWritten = 0; + do + { + bytesWritten = ::write(getHandle(), buffer, bytesToWrite); + } + while (bytesWritten < 0 && errno == EINTR); + //write() on macOS: https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man2/write.2.html + //if ::write() is interrupted (EINTR) right in the middle, it will return successfully with "bytesWritten < bytesToWrite"! + + if (bytesWritten <= 0) + { + if (bytesWritten == 0) //comment in safe-read.c suggests to treat this as an error due to buggy drivers + errno = ENOSPC; + + THROW_LAST_SYS_ERROR("write"); + } + + ASSERT_SYSERROR(makeUnsigned(bytesWritten) <= bytesToWrite); //better safe than sorry + return bytesWritten; + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot write file %x."), L"%x", fmtPath(getFilePath())), e.toString()); } +} + +//---------------------------------------------------------------------------------------------------- + +std::string zen::getFileContent(const Zstring& filePath, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + FileInputPlain fileIn(filePath); //throw FileError, ErrorFileLocked + + return unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + const size_t bytesRead = fileIn.tryRead(buffer, bytesToRead); //throw FileError; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesRead); //throw X! + return bytesRead; + }, + fileIn.getBlockSize()); //throw FileError, X +} + + +void zen::setFileContent(const Zstring& filePath, const std::string_view byteStream, const IoCallback& notifyUnbufferedIO /*throw X*/) //throw FileError, X +{ + const Zstring tmpFilePath = getPathWithTempName(filePath); + + FileOutputPlain tmpFile(tmpFilePath); //throw FileError, (ErrorTargetExisting) + + tmpFile.reserveSpace(byteStream.size()); //throw FileError + + unbufferedSave(byteStream, [&](const void* buffer, size_t bytesToWrite) + { + const size_t bytesWritten = tmpFile.tryWrite(buffer, bytesToWrite); //throw FileError; may return short! CONTRACT: bytesToWrite > 0 + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesWritten); //throw X! + return bytesWritten; + }, + tmpFile.getBlockSize()); //throw FileError, X + + tmpFile.close(); //throw FileError + //take over ownership: + ZEN_ON_SCOPE_FAIL( try { removeFilePlain(tmpFilePath); } + catch (const FileError& e) { logExtraError(e.toString()); }); + + //operation finished: move temp file transactionally + moveAndRenameItem(tmpFilePath, filePath, true /*replaceExisting*/); //throw FileError, (ErrorMoveUnsupported), (ErrorTargetExisting) +} diff --git a/zen/file_io.h b/zen/file_io.h new file mode 100644 index 0000000..838b502 --- /dev/null +++ b/zen/file_io.h @@ -0,0 +1,181 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_IO_H_89578342758342572345 +#define FILE_IO_H_89578342758342572345 + +#include "file_access.h" +#include "serialize.h" +#include "crc.h" +#include "guid.h" + + +namespace zen +{ + const char LINE_BREAK[] = "\n"; //since OS X Apple uses newline, too + +/* OS-buffered file I/O: + - sequential read/write accesses + - better error reporting + - long path support + - follows symlinks */ +class FileBase +{ +public: + using FileHandle = int; + static const int invalidFileHandle = -1; + + FileHandle getHandle() { return hFile_; } + + const Zstring& getFilePath() const { return filePath_; } + + size_t getBlockSize(); //throw FileError + + static constexpr size_t defaultBlockSize = 256 * 1024; + + void close(); //throw FileError -> good place to catch errors when closing stream, otherwise called in ~FileBase()! + + const struct stat& getStatBuffered(); //throw FileError + +protected: + FileBase(FileHandle handle, const Zstring& filePath) : hFile_(handle), filePath_(filePath) {} + ~FileBase(); + + void setStatBuffered(const struct stat& fileInfo) { statBuf_ = fileInfo; } + +private: + FileBase (const FileBase&) = delete; + FileBase& operator=(const FileBase&) = delete; + + FileHandle hFile_ = invalidFileHandle; + const Zstring filePath_; + size_t blockSizeBuf_ = 0; + std::optional statBuf_; +}; + +//----------------------------------------------------------------------------------------------- + +class FileInputPlain : public FileBase +{ +public: + FileInputPlain( const Zstring& filePath); //throw FileError, ErrorFileLocked + FileInputPlain(FileHandle handle, const Zstring& filePath); //takes ownership! + + //may return short, only 0 means EOF! CONTRACT: bytesToRead > 0! + size_t tryRead(void* buffer, size_t bytesToRead); //throw FileError, ErrorFileLocked + +private: + FileInputPlain(const std::pair& fileDetails, const Zstring& filePath); +}; + + +class FileOutputPlain : public FileBase +{ +public: + FileOutputPlain( const Zstring& filePath); //throw FileError, ErrorTargetExisting + FileOutputPlain(FileHandle handle, const Zstring& filePath); //takes ownership! + ~FileOutputPlain(); + + //preallocate disk space & reduce fragmentation + void reserveSpace(uint64_t expectedSize); //throw FileError + + //may return short! CONTRACT: bytesToWrite > 0 + size_t tryWrite(const void* buffer, size_t bytesToWrite); //throw FileError + + //close() when done, or else file is considered incomplete and will be deleted! + +private: +}; + +//-------------------------------------------------------------------- + +namespace impl +{ +inline +auto makeTryRead(FileInputPlain& fip, const IoCallback& notifyUnbufferedIO /*throw X*/) +{ + return [&](void* buffer, size_t bytesToRead) + { + const size_t bytesRead = fip.tryRead(buffer, bytesToRead); //throw FileError, ErrorFileLocked; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesRead); //throw X + return bytesRead; + }; +} + + +inline +auto makeTryWrite(FileOutputPlain& fop, const IoCallback& notifyUnbufferedIO /*throw X*/) +{ + return [&](const void* buffer, size_t bytesToWrite) + { + const size_t bytesWritten = fop.tryWrite(buffer, bytesToWrite); //throw FileError + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesWritten); //throw X + return bytesWritten; + }; +} +} + +//-------------------------------------------------------------------- + +class FileInputBuffered +{ +public: + FileInputBuffered(const Zstring& filePath, const IoCallback& notifyUnbufferedIO /*throw X*/) : //throw FileError, ErrorFileLocked + fileIn_(filePath), //throw FileError, ErrorFileLocked + notifyUnbufferedIO_(notifyUnbufferedIO) {} + + //return "bytesToRead" bytes unless end of stream! + size_t read(void* buffer, size_t bytesToRead) { return streamIn_.read(buffer, bytesToRead); } //throw FileError, ErrorFileLocked, X + +private: + FileInputPlain fileIn_; + const IoCallback notifyUnbufferedIO_; //throw X + + BufferedInputStream> + streamIn_{impl::makeTryRead(fileIn_, notifyUnbufferedIO_), fileIn_.getBlockSize()}; //throw FileError +}; + + +class FileOutputBuffered +{ +public: + FileOutputBuffered(const Zstring& filePath, const IoCallback& notifyUnbufferedIO /*throw X*/) : //throw FileError, ErrorTargetExisting + fileOut_(filePath), //throw FileError, ErrorTargetExisting + notifyUnbufferedIO_(notifyUnbufferedIO) {} + + void write(const void* buffer, size_t bytesToWrite) { streamOut_.write(buffer, bytesToWrite); } //throw FileError, X + + void finalize() //throw FileError, X + { + streamOut_.flushBuffer(); //throw FileError, X + fileOut_.close(); //throw FileError + } + +private: + FileOutputPlain fileOut_; + const IoCallback notifyUnbufferedIO_; //throw X + + BufferedOutputStream> + streamOut_{impl::makeTryWrite(fileOut_, notifyUnbufferedIO_), fileOut_.getBlockSize()}; //throw FileError +}; +//----------------------------------------------------------------------------------------------- + +//stream I/O convenience functions: + +inline +Zstring getPathWithTempName(const Zstring& filePath) //generate (hopefully) unique file name +{ + const Zstring shortGuid_ = printNumber(Zstr("%04x"), static_cast(getCrc16(generateGUID()))); + return filePath + Zstr('.') + shortGuid_ + Zstr(".tmp"); +} + +[[nodiscard]] std::string getFileContent(const Zstring& filePath, const IoCallback& notifyUnbufferedIO /*throw X*/); //throw FileError, X + +//overwrites if existing + transactional! :) +void setFileContent(const Zstring& filePath, const std::string_view bytes, const IoCallback& notifyUnbufferedIO /*throw X*/); //throw FileError, X +} + +#endif //FILE_IO_H_89578342758342572345 diff --git a/zen/file_path.cpp b/zen/file_path.cpp new file mode 100644 index 0000000..bb77922 --- /dev/null +++ b/zen/file_path.cpp @@ -0,0 +1,200 @@ +// ***************************************************************************** +// * 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 "file_path.h" +#include "zstring.h" + +using namespace zen; + + +std::optional zen::parsePathComponents(const Zstring& itemPath) +{ + auto doParse = [&](int sepCountVolumeRoot, bool rootWithSep) -> std::optional + { + assert(sepCountVolumeRoot > 0); + const Zstring itemPathPf = appendSeparator(itemPath); //simplify analysis of root without separator, e.g. \\server-name\share + + for (auto it = itemPathPf.begin(); it != itemPathPf.end(); ++it) + if (*it == FILE_NAME_SEPARATOR) + if (--sepCountVolumeRoot == 0) + { + Zstring rootPath(itemPathPf.begin(), rootWithSep ? it + 1 : it); + + Zstring relPath(it + 1, itemPathPf.end()); + trim(relPath, TrimSide::both, [](Zchar c) { return c == FILE_NAME_SEPARATOR; }); + + return PathComponents{std::move(rootPath), std::move(relPath)}; + } + return {}; + }; + + std::optional pc; //"/media/zenju/" and "/Volumes/" should not fail to parse + + if (!pc && startsWith(itemPath, "/mnt/")) //e.g. /mnt/DEVICE_NAME + pc = doParse(3 /*sepCountVolumeRoot*/, false /*rootWithSep*/); + + if (!pc && startsWith(itemPath, "/media/")) //Ubuntu: e.g. /media/zenju/DEVICE_NAME + if (const std::optional username = getEnvironmentVar("USER")) + if (startsWith(itemPath, std::string("/media/") + *username + "/")) + pc = doParse(4 /*sepCountVolumeRoot*/, false /*rootWithSep*/); + + if (!pc && startsWith(itemPath, "/run/media/")) //CentOS, Suse: e.g. /run/media/zenju/DEVICE_NAME + if (const std::optional username = getEnvironmentVar("USER")) + if (startsWith(itemPath, std::string("/run/media/") + *username + "/")) + pc = doParse(5 /*sepCountVolumeRoot*/, false /*rootWithSep*/); + + if (!pc && startsWith(itemPath, "/run/user/")) //Ubuntu, e.g.: /run/user/1000/gvfs/smb-share:server=192.168.62.145,share=folder + { + Zstring tmp(itemPath.begin() + strLength("/run/user/"), itemPath.end()); + tmp = beforeFirst(tmp, "/gvfs/", IfNotFoundReturn::none); + if (!tmp.empty() && std::all_of(tmp.begin(), tmp.end(), [](const char c) { return isDigit(c); })) + /**/pc = doParse(6 /*sepCountVolumeRoot*/, false /*rootWithSep*/); + } + + + if (!pc && startsWith(itemPath, "/")) + pc = doParse(1 /*sepCountVolumeRoot*/, true /*rootWithSep*/); + + return pc; +} + + +std::optional zen::getParentFolderPath(const Zstring& itemPath) +{ + if (const std::optional pc = parsePathComponents(itemPath)) + { + if (pc->relPath.empty()) + return std::nullopt; + + return appendPath(pc->rootPath, beforeLast(pc->relPath, FILE_NAME_SEPARATOR, IfNotFoundReturn::none)); + } + assert(itemPath.empty()); + return std::nullopt; +} + + +Zstring zen::getFileExtension(const ZstringView filePath) +{ + const ZstringView fileName = afterLast(filePath, FILE_NAME_SEPARATOR, IfNotFoundReturn::all); + return Zstring(afterLast(fileName, Zstr('.'), IfNotFoundReturn::none)); +} + + +Zstring zen::appendSeparator(Zstring path) //support rvalue references! +{ + assert(!endsWith(path, FILE_NAME_SEPARATOR == Zstr('/') ? Zstr('\\' ) : Zstr('/' ))); + + if (!endsWith(path, FILE_NAME_SEPARATOR)) + path += FILE_NAME_SEPARATOR; + return path; //returning a by-value parameter => RVO if possible, r-value otherwise! +} + + +bool zen::isValidRelPath(const Zstring& relPath) +{ + //relPath is expected to use FILE_NAME_SEPARATOR! + if constexpr (FILE_NAME_SEPARATOR != Zstr('/' )) if (contains(relPath, Zstr('/' ))) return false; + if constexpr (FILE_NAME_SEPARATOR != Zstr('\\')) if (contains(relPath, Zstr('\\'))) return false; + + const Zchar doubleSep[] = {FILE_NAME_SEPARATOR, FILE_NAME_SEPARATOR, 0}; + return !startsWith(relPath, FILE_NAME_SEPARATOR) && !endsWith(relPath, FILE_NAME_SEPARATOR) && + !contains(relPath, doubleSep); +} + + +Zstring zen::appendPath(const Zstring& basePath, const Zstring& relPath) +{ + assert(isValidRelPath(relPath)); + if (relPath.empty()) + return basePath; //with or without path separator, e.g. C:\ or C:\folder + + //assert(!basePath.empty()); + if (basePath.empty()) //basePath might be a relative path, too! + return relPath; + + if (endsWith(basePath, FILE_NAME_SEPARATOR)) + return basePath + relPath; + + Zstring output = basePath; + output.reserve(basePath.size() + 1 + relPath.size()); //append all three strings using a single memory allocation + return std::move(output) + FILE_NAME_SEPARATOR + relPath; // +} + + +/* https://docs.microsoft.com/de-de/windows/desktop/Intl/handling-sorting-in-your-applications + + Perf test: compare strings 10 mio times; 64 bit build + ----------------------------------------------------- + string a = "Fjk84$%kgfj$%T\\\\Gffg\\gsdgf\\fgsx----------d-" + string b = "fjK84$%kgfj$%T\\\\gfFg\\gsdgf\\fgSy----------dfdf" + + Windows (UTF16 wchar_t) + 4 ns | wcscmp + 67 ns | CompareStringOrdinalFunc+ + bIgnoreCase + 314 ns | LCMapString + wmemcmp + + OS X (UTF8 char) + 6 ns | strcmp + 98 ns | strcasecmp + 120 ns | strncasecmp + std::min(sizeLhs, sizeRhs); + 856 ns | CFStringCreateWithCString + CFStringCompare(kCFCompareCaseInsensitive) + 1110 ns | CFStringCreateWithCStringNoCopy + CFStringCompare(kCFCompareCaseInsensitive) + ________________________ + time per call | function */ + +std::weak_ordering zen::compareNativePath(const Zstring& lhs, const Zstring& rhs) +{ + assert(!contains(lhs, Zchar('\0'))); //don't expect embedded nulls! + assert(!contains(rhs, Zchar('\0'))); // + + return lhs <=> rhs; + +} + + +namespace +{ + constinit Global> globalEnvVars; +} + + +std::optional zen::getEnvironmentVar(const ZstringView name) +{ + /* const char* buffer = ::getenv(name); => NO! *not* thread-safe: returns pointer to internal memory! + might change after setenv(), allegedly possible even after another getenv()! + + getenv_s() to the rescue!? not implemented on GCC, apparently *still* not threadsafe!!! + + => *eff* this: make a global copy during start up! */ + globalEnvVars.setOnce([] + { + assert(runningOnMainThread()); + + auto envVars = std::make_unique>(); + if (char** line = environ) + for (; *line; ++line) + { + const std::string_view l(*line); + envVars->emplace(beforeFirst(l, '=', IfNotFoundReturn::all), + afterFirst(l, '=', IfNotFoundReturn::none)); + } + + return envVars; + }); + + if (std::shared_ptr> envVars = globalEnvVars.get()) + { + if (const auto it = envVars->find(name); + it != envVars->end()) + return it->second; + } + else + assert(false); //access during global shutdown => SOL! + + return {}; +} + + diff --git a/zen/file_path.h b/zen/file_path.h new file mode 100644 index 0000000..9446ad7 --- /dev/null +++ b/zen/file_path.h @@ -0,0 +1,60 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILE_PATH_H_3984678473567247567 +#define FILE_PATH_H_3984678473567247567 + +#include "zstring.h" + + +namespace zen +{ + const Zchar FILE_NAME_SEPARATOR = '/'; + +/* forbidden characters in file names: + Windows: <>:"/\|?* https://docs.microsoft.com/de-de/windows/win32/fileio/naming-a-file#naming-conventions + Linux: / + macOS: : +*/ +const Zchar fileNameForbiddenChars[] = Zstr(R"(<>:"/\|?*)"); + + +struct PathComponents +{ + Zstring rootPath; //itemPath = rootPath + (FILE_NAME_SEPARATOR?) + relPath + Zstring relPath; // +}; +std::optional parsePathComponents(const Zstring& itemPath); //no value on error + +std::optional getParentFolderPath(const Zstring& itemPath); +inline Zstring getItemName(const Zstring& itemPath) { return afterLast(itemPath, FILE_NAME_SEPARATOR, IfNotFoundReturn::all); } + +Zstring getFileExtension(const ZstringView filePath); + +Zstring appendSeparator(Zstring path); //support rvalue references! + +bool isValidRelPath(const Zstring& relPath); + +Zstring appendPath(const Zstring& basePath, const Zstring& relPath); + +//------------------------------------------------------------------------------------------ +/* Compare *local* file paths: + Windows: igore case (but distinguish Unicode normalization forms!) + Linux: byte-wise comparison + macOS: ignore case + Unicode normalization forms */ +std::weak_ordering compareNativePath(const Zstring& lhs, const Zstring& rhs); + +inline bool equalNativePath(const Zstring& lhs, const Zstring& rhs) { return compareNativePath(lhs, rhs) == std::weak_ordering::equivalent; } + +struct LessNativePath { bool operator()(const Zstring& lhs, const Zstring& rhs) const { return compareNativePath(lhs, rhs) < 0; } }; +//------------------------------------------------------------------------------------------ + +std::optional getEnvironmentVar(const ZstringView name); + + +} + +#endif //FILE_PATH_H_3984678473567247567 diff --git a/zen/file_traverser.cpp b/zen/file_traverser.cpp new file mode 100644 index 0000000..75075b8 --- /dev/null +++ b/zen/file_traverser.cpp @@ -0,0 +1,79 @@ +// ***************************************************************************** +// * 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 "file_traverser.h" +#include "file_error.h" +#include "file_access.h" + + + #include + #include + +using namespace zen; + + +void zen::traverseFolder(const Zstring& dirPath, + const std::function& onFile, + const std::function& onFolder, + const std::function& onSymlink) //throw FileError +{ + DIR* folder = ::opendir(dirPath.c_str()); //directory must NOT end with path separator, except "/" + if (!folder) + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot open directory %x."), L"%x", fmtPath(dirPath)), "opendir"); + ZEN_ON_SCOPE_EXIT(::closedir(folder)); //never close nullptr handles! -> crash + + for (;;) + { + errno = 0; + const dirent* dirEntry = ::readdir(folder); //don't use readdir_r(), see comment in native.cpp + if (!dirEntry) + { + if (errno == 0) //errno left unchanged => no more items + return; + + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(dirPath)), "readdir"); + //don't retry but restart dir traversal on error! https://devblogs.microsoft.com/oldnewthing/20140612-00/?p=753/ + } + + //don't return "." and ".." + const char* itemNameRaw = dirEntry->d_name; + + if (itemNameRaw[0] == '.' && + (itemNameRaw[1] == 0 || (itemNameRaw[1] == '.' && itemNameRaw[2] == 0))) + continue; + + const Zstring& itemName = itemNameRaw; + if (itemName.empty()) //checks result of normalizeUtfForPosix, too! + throw FileError(replaceCpy(_("Cannot read directory %x."), L"%x", fmtPath(dirPath)), formatSystemError("readdir", L"", L"Folder contains an item without name.")); + + const Zstring& itemPath = appendPath(dirPath, itemName); + + struct stat statData = {}; + if (::lstat(itemPath.c_str(), &statData) != 0) //lstat() does not resolve symlinks + THROW_LAST_FILE_ERROR(replaceCpy(_("Cannot read file attributes of %x."), L"%x", fmtPath(itemPath)), "lstat"); + + if (S_ISLNK(statData.st_mode)) //on Linux there is no distinction between file and directory symlinks! + { + if (onSymlink) + onSymlink({ itemName, itemPath, statData.st_mtime}); + } + else if (S_ISDIR(statData.st_mode)) //a directory + { + if (onFolder) + onFolder({itemName, itemPath}); + } + else //a file or named pipe, etc. S_ISREG, S_ISCHR, S_ISBLK, S_ISFIFO, S_ISSOCK + { + if (onFile) + onFile({itemName, itemPath, makeUnsigned(statData.st_size), statData.st_mtime}); + /* It may be a good idea to not check "S_ISREG(statData.st_mode)" explicitly and to not issue an error message on other types to support these scenarios: + - RTS setup watch (essentially wants to read directories only) + - removeDirectory (wants to delete everything; pipes can be deleted just like files via "unlink") + + However an "open" on a pipe will block (https://sourceforge.net/p/freefilesync/bugs/221/), so the copy routines better be smart! */ + } + } +} diff --git a/zen/file_traverser.h b/zen/file_traverser.h new file mode 100644 index 0000000..8f7d836 --- /dev/null +++ b/zen/file_traverser.h @@ -0,0 +1,43 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FILER_TRAVERSER_H_127463214871234 +#define FILER_TRAVERSER_H_127463214871234 + +#include +#include "file_error.h" + +namespace zen +{ +struct FileInfo +{ + Zstring itemName; + Zstring fullPath; + uint64_t fileSize = 0; //[bytes] + time_t modTime = 0; //number of seconds since Jan. 1st 1970 GMT +}; + +struct FolderInfo +{ + Zstring itemName; + Zstring fullPath; +}; + +struct SymlinkInfo +{ + Zstring itemName; + Zstring fullPath; + time_t modTime = 0; //number of seconds since Jan. 1st 1970 GMT +}; + +//- non-recursive +void traverseFolder(const Zstring& dirPath, + const std::function& onFile, /*optional*/ + const std::function& onFolder,/*optional*/ + const std::function& onSymlink/*optional*/); //throw FileError +} + +#endif //FILER_TRAVERSER_H_127463214871234 diff --git a/zen/format_unit.cpp b/zen/format_unit.cpp new file mode 100644 index 0000000..f684fac --- /dev/null +++ b/zen/format_unit.cpp @@ -0,0 +1,236 @@ +// ***************************************************************************** +// * 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 "format_unit.h" +#include "basic_math.h" +#include "sys_error.h" +#include "i18n.h" +#include "time.h" +#include "globals.h" +#include "utf.h" + + #include + #include + #include //thousands separator + #include "utf.h" // + +using namespace zen; + + +std::wstring zen::formatTwoDigitPrecision(double value) +{ + //print two digits: 0,1 | 1,1 | 11 + if (std::abs(value) < 9.95) //9.99 must not be formatted as "10.0" + return printNumber(L"%.1f", value); + + return formatNumber(std::llround(value)); +} + + +std::wstring zen::formatThreeDigitPrecision(double value) +{ + //print three digits: 0,01 | 0,11 | 1,11 | 11,1 | 111 + if (std::abs(value) < 9.995) //9.999 must not be formatted as "10.00" + return printNumber(L"%.2f", value); + if (std::abs(value) < 99.95) //99.99 must not be formatted as "100.0" + return printNumber(L"%.1f", value); + + return formatNumber(std::llround(value)); +} + + +std::wstring zen::formatFilesizeShort(int64_t size) +{ + //if (size < 0) return _("Error"); -> really? + + if (std::abs(size) <= 999) + return _P("1 byte", "%x bytes", static_cast(size)); + + double sizeInUnit = static_cast(size); + + auto formatUnit = [&](const std::wstring& unitTxt) { return replaceCpy(unitTxt, L"%x", formatThreeDigitPrecision(sizeInUnit)); }; + + sizeInUnit /= bytesPerKilo; + if (std::abs(sizeInUnit) < 999.5) + return formatUnit(_("%x KB")); + + sizeInUnit /= bytesPerKilo; + if (std::abs(sizeInUnit) < 999.5) + return formatUnit(_("%x MB")); + + sizeInUnit /= bytesPerKilo; + if (std::abs(sizeInUnit) < 999.5) + return formatUnit(_("%x GB")); + + sizeInUnit /= bytesPerKilo; + if (std::abs(sizeInUnit) < 999.5) + return formatUnit(_("%x TB")); + + sizeInUnit /= bytesPerKilo; + return formatUnit(_("%x PB")); +} + + +namespace +{ +enum class UnitRemTime +{ + sec, + min, + hour, + day +}; + + +std::wstring formatUnitTime(int val, UnitRemTime unit) +{ + switch (unit) + { + case UnitRemTime::sec: return _P("1 sec", "%x sec", val); + case UnitRemTime::min: return _P("1 min", "%x min", val); + case UnitRemTime::hour: return _P("1 hour", "%x hours", val); + case UnitRemTime::day: return _P("1 day", "%x days", val); + } + assert(false); + return _("Error"); +} + + +template +std::wstring roundToBlock(double timeInHigh, + UnitRemTime unitHigh, const int (&stepsHigh)[M], + int unitLowPerHigh, + UnitRemTime unitLow, const int (&stepsLow)[N]) +{ + assert(unitLowPerHigh > 0); + const double granularity = 0.1; + const double timeInLow = timeInHigh * unitLowPerHigh; + const int blockSizeLow = granularity * timeInHigh < 1 ? + numeric::roundToGrid(granularity * timeInLow, std::begin(stepsLow), std::end(stepsLow)): + numeric::roundToGrid(granularity * timeInHigh, std::begin(stepsHigh), std::end(stepsHigh)) * unitLowPerHigh; + const int roundedtimeInLow = std::lround(timeInLow / blockSizeLow) * blockSizeLow; + + std::wstring output = formatUnitTime(roundedtimeInLow / unitLowPerHigh, unitHigh); + if (unitLowPerHigh > blockSizeLow) + output += L' ' + formatUnitTime(roundedtimeInLow % unitLowPerHigh, unitLow); + return output; +} +} + + +std::wstring zen::formatRemainingTime(double timeInSec) +{ + const int steps10[] = {1, 2, 5, 10}; + const int steps24[] = {1, 2, 3, 4, 6, 8, 12, 24}; + const int steps60[] = {1, 2, 5, 10, 15, 20, 30, 60}; + + //determine preferred unit + double timeInUnit = timeInSec; + if (timeInUnit <= 60) + return roundToBlock(timeInUnit, UnitRemTime::sec, steps60, 1, UnitRemTime::sec, steps60); + + timeInUnit /= 60; + if (timeInUnit <= 60) + return roundToBlock(timeInUnit, UnitRemTime::min, steps60, 60, UnitRemTime::sec, steps60); + + timeInUnit /= 60; + if (timeInUnit <= 24) + return roundToBlock(timeInUnit, UnitRemTime::hour, steps24, 60, UnitRemTime::min, steps60); + + timeInUnit /= 24; + return roundToBlock(timeInUnit, UnitRemTime::day, steps10, 24, UnitRemTime::hour, steps24); + //note: for 10% granularity steps10 yields a valid blocksize only up to timeInUnit == 100! + //for larger time sizes this results in a finer granularity than expected: 10 days -> should not be a problem considering "usual" remaining time for synchronization +} + + +std::wstring zen::formatProgressPercent(double fraction, int decPlaces) +{ + if (decPlaces == 0) //special case for perf + return numberTo(static_cast(std::floor(fraction * 100))) + L'%'; + + //round down! don't show 100% when not actually done: https://freefilesync.org/forum/viewtopic.php?t=9781 + const double blocks = std::pow(10, decPlaces); + const double percent = std::floor(fraction * 100 * blocks) / blocks; + + assert(0 <= decPlaces && decPlaces <= 9); + wchar_t format[] = L"%.0f" L"%%" /*literal %: need to localize?*/; + format[2] += static_cast(std::clamp(decPlaces, 0, 9)); + + return printNumber(format, percent); +} + + + + +std::wstring zen::formatNumber(int64_t n) +{ + //::setlocale (LC_ALL, ""); -> see localization.cpp::wxWidgetsLocale + static_assert(sizeof(long long int) == sizeof(n)); + return printNumber(L"%'lld", n); //considers grouping (') +} + + +std::wstring zen::formatUtcToLocalTime(time_t utcTime) +{ + auto fmtFallback = [utcTime] //don't take "no" for an answer! + { + if (const TimeComp tc = getUtcTime(utcTime); + tc != TimeComp()) + { + wchar_t buf[128] = {}; //the only way to format abnormally large or invalid modTime: std::strftime() will fail! + if (const int rv = std::swprintf(buf, std::size(buf), L"%d-%02d-%02d %02d:%02d:%02d GMT", tc.year, tc.month, tc.day, tc.hour, tc.minute, tc.second); + 0 < rv && rv < std::ssize(buf)) + return std::wstring(buf, rv); + } + + return L"time_t = " + numberTo(utcTime); + }; + + const TimeComp& loc = getLocalTime(utcTime); //returns TimeComp() on error + + /*const*/ std::wstring dateTimeFmt = utfTo(formatTime(Zstr("%x %X"), loc)); + if (dateTimeFmt.empty()) + return fmtFallback(); + + return dateTimeFmt; +} + + + + +WeekDay impl::getFirstDayOfWeekImpl() //throw SysError +{ + /* testing: change locale via command line + --------------------------------------- + LC_TIME=en_DK.utf8 => Monday + LC_TIME=en_US.utf8 => Sunday */ + const char* firstDay = ::nl_langinfo(_NL_TIME_FIRST_WEEKDAY); //[1-Sunday, 7-Saturday] + ASSERT_SYSERROR(firstDay && 1 <= *firstDay && *firstDay <= 7); + + const int weekDayStartSunday = *firstDay; //[1-Sunday, 7-Saturday] + const int weekDayStartMonday = (weekDayStartSunday - 2 + 7) % 7; //[0-Monday, 6-Sunday] 7 == 0 in Z_7 + + return static_cast(weekDayStartMonday); +} + + +WeekDay zen::getFirstDayOfWeek() +{ + static const WeekDay weekDay = [] + { + try + { + return impl::getFirstDayOfWeekImpl(); //throw SysError + } + catch (const SysError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to get first day of the week." + "\n\n" + + utfTo(e.toString())); + } + }(); + return weekDay; +} diff --git a/zen/format_unit.h b/zen/format_unit.h new file mode 100644 index 0000000..6a8f6b7 --- /dev/null +++ b/zen/format_unit.h @@ -0,0 +1,44 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef FMT_UNIT_8702184019487324 +#define FMT_UNIT_8702184019487324 + +#include +#include + + +namespace zen +{ + const int bytesPerKilo = 1000; +std::wstring formatFilesizeShort(int64_t filesize); +std::wstring formatRemainingTime(double timeInSec); +std::wstring formatProgressPercent(double fraction /*[0, 1]*/, int decPlaces = 0 /*[0, 9]*/); //rounded down! +std::wstring formatUtcToLocalTime(time_t utcTime); //like File Explorer would... + +std::wstring formatTwoDigitPrecision (double value); //format with fixed number of digits +std::wstring formatThreeDigitPrecision(double value); //(unless value is too large) + +std::wstring formatNumber(int64_t n); //format integer number including thousands separator + + + +enum class WeekDay +{ + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + sunday, +}; +WeekDay getFirstDayOfWeek(); + +namespace impl { WeekDay getFirstDayOfWeekImpl(); } //throw SysError +} + +#endif diff --git a/zen/globals.h b/zen/globals.h new file mode 100644 index 0000000..1a11037 --- /dev/null +++ b/zen/globals.h @@ -0,0 +1,308 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GLOBALS_H_8013740213748021573485 +#define GLOBALS_H_8013740213748021573485 + +#include +#include +#include +#include "scope_guard.h" + + +namespace zen +{ +/* Solve static destruction order fiasco by providing shared ownership and serialized access to global variables + + => e.g. accesses to "Global::get()" during process shutdown: _("") used by message in debug_minidump.cpp or by some detached thread assembling an error message! + => use trivially-destructible POD only!!! + + ATTENTION: function-static globals have the compiler generate "magic statics" == compiler-genenerated locking code which will crash or leak memory when accessed after global is "dead" + => "solved" by FunStatGlobal, but we can't have "too many" of these... */ +class PodSpinMutex +{ +public: + bool tryLock(); + void lock(); + void unlock(); + bool isLocked(); + +private: + std::atomic_flag flag_{}; /* => avoid potential contention with worker thread during Global<> construction! + - "For an atomic_flag with static storage duration, this guarantees static initialization:" => just what the doctor ordered! + - "[default initialization] initializes std::atomic_flag to clear state" - since C++20 => + - "std::atomic_flag is [...] guaranteed to be lock-free" + - interestingly, is_trivially_constructible_v<> is false, thanks to constexpr! https://developercommunity.visualstudio.com/content/problem/416343/stdatomic-no-longer-is-trivially-constructible.html */ +}; + + +#define GLOBAL_RUN_ONCE(X) \ + struct ZEN_CONCAT(GlobalInitializer, __LINE__) \ + { \ + ZEN_CONCAT(GlobalInitializer, __LINE__)() { X; } \ + } ZEN_CONCAT(globalInitializer, __LINE__) + + +template +class Global //don't use for function-scope statics! +{ +public: + consteval Global() {}; //demand static zero-initialization! + + ~Global() + { + static_assert(std::is_trivially_destructible_v, "this memory needs to live forever"); + + pod_.spinLock.lock(); + std::shared_ptr* oldInst = std::exchange(pod_.inst, nullptr); + pod_.destroyed = true; + pod_.spinLock.unlock(); + + delete oldInst; + } + + std::shared_ptr get() //=> return std::shared_ptr to let instance life time be handled by caller (MT usage!) + { + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (pod_.inst) + return *pod_.inst; + return nullptr; + } + + void set(std::unique_ptr&& newInst) + { + std::shared_ptr* tmpInst = nullptr; + if (newInst) + tmpInst = new std::shared_ptr(std::move(newInst)); + { + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (!pod_.destroyed) + std::swap(pod_.inst, tmpInst); + else + assert(false); + + pod_.initialized = true; + } + delete tmpInst; + } + + //for initialization via a frequently-called function (which may be running on parallel threads) + template + void setOnce(Function getInitialValue /*-> std::unique_ptr*/) + { + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (!pod_.initialized) + { + assert(!pod_.inst); + if (!pod_.destroyed) + { + if (std::unique_ptr newInst = getInitialValue()) //throw ? + pod_.inst = new std::shared_ptr(std::move(newInst)); + } + else + assert(false); + + pod_.initialized = true; + } + } + +private: + struct Pod + { + PodSpinMutex spinLock; //rely entirely on static zero-initialization! => avoid potential contention with worker thread during Global<> construction! + //serialize access: can't use std::mutex: has non-trival destructor + std::shared_ptr* inst = nullptr; + bool initialized = false; + bool destroyed = false; + } pod_; +}; + +//=================================================================================================================== +//=================================================================================================================== + +struct CleanUpEntry +{ + using CleanUpFunction = void (*)(void* callbackData); + CleanUpFunction cleanUpFun = nullptr; + void* callbackData = nullptr; + CleanUpEntry* prev = nullptr; +}; +void registerGlobalForDestruction(CleanUpEntry& entry); + + +template +class FunStatGlobal +{ +public: + consteval FunStatGlobal() {}; //demand static zero-initialization! + + //No ~FunStatGlobal(): required to avoid generation of magic statics code for a function-scope static! + + std::shared_ptr get() + { + static_assert(std::is_trivially_destructible_v, "this class must not generate code for magic statics!"); + + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (pod_.inst) + return *pod_.inst; + return nullptr; + } + + void set(std::unique_ptr&& newInst) + { + std::shared_ptr* tmpInst = nullptr; + if (newInst) + tmpInst = new std::shared_ptr(std::move(newInst)); + { + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (!pod_.destroyed) + std::swap(pod_.inst, tmpInst); + else + assert(false); + + registerDestruction(); + } + delete tmpInst; + } + + template + void setOnce(Function getInitialValue /*-> std::unique_ptr*/) + { + pod_.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(pod_.spinLock.unlock()); + + if (!pod_.cleanUpEntry.cleanUpFun) + { + assert(!pod_.inst); + if (!pod_.destroyed) + { + if (std::unique_ptr newInst = getInitialValue()) //throw ? + pod_.inst = new std::shared_ptr(std::move(newInst)); + } + else + assert(false); + + registerDestruction(); + } + } + +private: + void destruct() + { + static_assert(std::is_trivially_destructible_v, "this memory needs to live forever"); + + pod_.spinLock.lock(); + std::shared_ptr* oldInst = std::exchange(pod_.inst, nullptr); + pod_.destroyed = true; + pod_.spinLock.unlock(); + + delete oldInst; + } + + //call while holding pod_.spinLock + void registerDestruction() + { + assert(pod_.spinLock.isLocked()); + + if (!pod_.cleanUpEntry.cleanUpFun) + { + pod_.cleanUpEntry.callbackData = this; + pod_.cleanUpEntry.cleanUpFun = [](void* callbackData) + { + static_cast(callbackData)->destruct(); + }; + + registerGlobalForDestruction(pod_.cleanUpEntry); + } + } + + struct Pod + { + PodSpinMutex spinLock; //rely entirely on static zero-initialization! => avoid potential contention with worker thread during Global<> construction! + //serialize access; can't use std::mutex: has non-trival destructor + std::shared_ptr* inst = nullptr; + CleanUpEntry cleanUpEntry; + bool destroyed = false; + } pod_; +}; + + +inline +void registerGlobalForDestruction(CleanUpEntry& entry) +{ + static struct + { + PodSpinMutex spinLock; + CleanUpEntry* head = nullptr; + } cleanUpList; + + static_assert(std::is_trivially_destructible_v, "we must not generate code for magic statics!"); + + cleanUpList.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(cleanUpList.spinLock.unlock()); + + std::atexit([] + { + cleanUpList.spinLock.lock(); + ZEN_ON_SCOPE_EXIT(cleanUpList.spinLock.unlock()); + + (*cleanUpList.head->cleanUpFun)(cleanUpList.head->callbackData); + cleanUpList.head = cleanUpList.head->prev; //nicely clean up in reverse order of construction + }); + + entry.prev = cleanUpList.head; + cleanUpList.head = &entry; + +} + +//------------------------------------------------------------------------------------------ + +inline +bool PodSpinMutex::tryLock() +{ + return !flag_.test_and_set(std::memory_order_acquire); +} + + + + +inline +void PodSpinMutex::lock() +{ + while (!tryLock()) + flag_.wait(true, std::memory_order_relaxed); +} + + +inline +void PodSpinMutex::unlock() +{ + flag_.clear(std::memory_order_release); + flag_.notify_one(); +} + + +inline +bool PodSpinMutex::isLocked() +{ + if (!tryLock()) + return true; + unlock(); + return false; +} +} + +#endif //GLOBALS_H_8013740213748021573485 diff --git a/zen/guid.h b/zen/guid.h new file mode 100644 index 0000000..79d457f --- /dev/null +++ b/zen/guid.h @@ -0,0 +1,54 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef GUID_H_80425780237502345 +#define GUID_H_80425780237502345 + + #include //open + #include //close, getentropy + #include + //#include -> uuid_generate(), uuid_unparse(); avoid additional dependency for "sudo apt-get install uuid-dev" + + +namespace zen +{ +inline +std::string generateGUID() //creates a 16-byte GUID +{ + std::string guid(16, '\0'); + +#ifndef __GLIBC_PREREQ +#error Where is Glibc? +#endif + +#if __GLIBC_PREREQ(2, 25) //getentropy() requires Glibc 2.25 (ldd --version) PS: CentOS 7 is on 2.17 + if (::getentropy(guid.data(), guid.size()) != 0) //"The maximum permitted value for the length argument is 256" + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to generate GUID." + "\n\n" + + utfTo(formatSystemError("getentropy", errno))); +#else + //keep fd open and thread_local? NO! susceptible to global destruction fiasco: e.g. used by setFileContent() + getPathWithTempName() by globalShutdownTasks + const int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (fd == -1) + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to generate GUID." + "\n\n" + + utfTo(formatSystemError("open", errno))); + ZEN_ON_SCOPE_EXIT(::close(fd)); + + for (size_t offset = 0; offset < guid.size(); ) + { + const ssize_t bytesRead = ::read(fd, guid.data() + offset, guid.size() - offset); + if (bytesRead <= 0) //0 means EOF => error in this context (should check for buffer overflow, too?) + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Failed to generate GUID." + "\n\n" + + utfTo(formatSystemError("read", bytesRead < 0 ? errno : EIO))); + offset += bytesRead; + assert(offset <= guid.size()); + } +#endif + return guid; + +} +} + +#endif //GUID_H_80425780237502345 diff --git a/zen/http.cpp b/zen/http.cpp new file mode 100644 index 0000000..ddf0300 --- /dev/null +++ b/zen/http.cpp @@ -0,0 +1,555 @@ +// ***************************************************************************** +// * 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 "http.h" + + #include //DON'T include directly! + #include "stream_buffer.h" + +using namespace zen; + + +const int HTTP_ACCESS_TIMEOUT_SEC = 20; + +const size_t HTTP_BLOCK_SIZE_DOWNLOAD = 64 * 1024; +//- InternetReadFile() is buffered + prefetching +//- libcurl returns blocks of only 16 kB as returned by recv() even if we request larger blocks via CURLOPT_BUFFERSIZE + const size_t HTTP_STREAM_BUFFER_SIZE = 1024 * 1024; //unit: [byte] + //stream buffer should be big enough to facilitate prefetching during alternating read/write operations => e.g. see serialize.h::unbufferedStreamCopy() + + + + +class HttpInputStream::Impl +{ +public: + Impl(const Zstring& url, + const std::string* postBuf, //issue POST if bound, GET otherwise + const std::string& contentType, //required for POST + const IoCallback& onPostBytesSent /*throw X*/, + bool disableGetCache, //not relevant for POST (= never cached) + const Zstring& userAgent, + const Zstring& caCertFilePath /*optional: enable certificate validation*/) //throw SysError, X + { + ZEN_ON_SCOPE_FAIL(cleanup()); //destructor call would lead to member double clean-up!!! + + assert(postBuf || !onPostBytesSent); + + const Zstring urlFmt = afterFirst(url, Zstr("://"), IfNotFoundReturn::none); + const Zstring server = beforeFirst(urlFmt, Zstr('/'), IfNotFoundReturn::all); + const Zstring page = Zstr('/') + afterFirst(urlFmt, Zstr('/'), IfNotFoundReturn::none); + + const bool useTls = [&] + { + if (startsWithAsciiNoCase(url, "http://")) + return false; + if (startsWithAsciiNoCase(url, "https://")) + return true; + throw SysError(L"URL uses unexpected protocol."); + }(); + + std::unordered_map headers; + + assert(postBuf || contentType.empty()); + if (postBuf && !contentType.empty()) + headers["Content-Type"] = contentType; + + if (!postBuf /*=> HTTP GET*/ && disableGetCache) //libcurl doesn't cache internally, so it should be enough to set this header + headers["Cache-Control"] = "no-cache"; //= similar to WinInet's INTERNET_FLAG_RELOAD + //caveat: INTERNET_FLAG_RELOAD issues "Pragma: no-cache" instead if "request is going through a proxy" + + + auto promHeader = std::make_shared>(); + std::future futHeader = promHeader->get_future(); + + auto postBytesSent = std::make_shared>(0); + + worker_ = InterruptibleThread([asyncStreamOut = this->asyncStreamIn_, promHeader, headers = std::move(headers), postBytesSent, + server, useTls, caCertFilePath, userAgent = utfTo(userAgent), + postBuf = postBuf ? std::optional(*postBuf) : std::nullopt, //[!] life-time! + serverRelPath = utfTo(page)] + { + setCurrentThreadName(Zstr("Istream ") + server); + + bool headerReceived = false; + try + { + std::vector curlHeaders; + for (const auto& [name, value] : headers) + curlHeaders.push_back(name + ": " + value); + + std::vector extraOptions {{CURLOPT_USERAGENT, userAgent.c_str()}}; + //CURLOPT_FOLLOWLOCATION already off by default :) + + std::function buf)> readRequest; + if (postBuf) + { + readRequest = [&, postBufStream{MemoryStreamIn(*postBuf)}](std::span buf) mutable + { + const size_t bytesRead = postBufStream.read(buf.data(), buf.size()); + * postBytesSent += bytesRead; + return bytesRead; + }; + extraOptions.emplace_back(CURLOPT_POST, 1); + extraOptions.emplace_back(CURLOPT_POSTFIELDSIZE_LARGE, postBuf->size()); //avoid HTTP chunked transfer encoding? + } + + //careful with these callbacks! First receive HTTP header without blocking, + //and only then allow AsyncStreamBuffer::write() which can block! + + std::string headerBuf; + auto onHeaderData = [&](const std::string_view& headerLine) + { + if (headerReceived) + throw SysError(L"Unexpected header data after end of HTTP header."); + + //"The callback will be called once for each header and only complete header lines are passed on to the callback" (including \r\n at the end) + headerBuf += headerLine; + + if (headerLine == "\r\n") + { + headerReceived = true; + promHeader->set_value(std::move(headerBuf)); + } + }; + + HttpSession httpSession(server, useTls, caCertFilePath); //throw SysError + + auto writeResponse = [&](std::span buf) + { + if (!headerReceived) + throw SysError(L"Received HTTP body without header."); + + asyncStreamOut->write(buf.data(), buf.size()); //throw ThreadStopRequest + }; + + httpSession.perform(serverRelPath, + curlHeaders, extraOptions, + writeResponse /*throw ThreadStopRequest*/, + readRequest, + onHeaderData /*throw SysError*/, + HTTP_ACCESS_TIMEOUT_SEC); //throw SysError, ThreadStopRequest + + if (!headerReceived) + throw SysError(L"HTTP response is missing header."); + + asyncStreamOut->closeStream(); + } + catch (SysError&) //let ThreadStopRequest pass through! + { + if (!headerReceived) + promHeader->set_exception(std::current_exception()); + + asyncStreamOut->setWriteError(std::current_exception()); + } + }); + + //------------------------------------------------------------------------------------ + if (postBuf && onPostBytesSent) + { + int64_t bytesReported = 0; + while (futHeader.wait_for(std::chrono::milliseconds(25)) == std::future_status::timeout) + { + const int64_t bytesDelta = *postBytesSent /*atomic shared access!*/- bytesReported; + bytesReported += bytesDelta; + onPostBytesSent(bytesDelta); //throw X + } + } + //------------------------------------------------------------------------------------ + + const std::string headBuf = futHeader.get(); //throw SysError + //parse header: https://www.w3.org/Protocols/HTTP/1.0/spec.html#Request-Line + const std::string_view& statusBuf = beforeFirst(headBuf, "\r\n", IfNotFoundReturn::all); + const std::string_view& headersBuf = afterFirst (headBuf, "\r\n", IfNotFoundReturn::none); + + const std::vector statusItems = splitCpy(statusBuf, ' ', SplitOnEmpty::allow); //HTTP-Version SP Status-Code SP Reason-Phrase CRLF + if (statusItems.size() < 2 || !startsWith(statusItems[0], "HTTP/")) + throw SysError(L"Invalid HTTP response: \"" + utfTo(statusBuf) + L'"'); + + statusCode_ = stringTo(statusItems[1]); + + split(headersBuf, '\n', [&](const std::string_view line) + { + if (!line.empty()) //careful: actual line separator is "\r\n"! + responseHeaders_.emplace(trimCpy(beforeFirst(line, ':', IfNotFoundReturn::all)), + trimCpy(afterFirst (line, ':', IfNotFoundReturn::none))); + }); + /* let's NOT consider "Content-Length" header: + - may be unavailable ("Transfer-Encoding: chunked") + - may refer to compressed data size ("Content-Encoding: gzip") */ + } + + ~Impl() { cleanup(); } + + const int getStatusCode() const { return statusCode_; } + + const std::string* getHeader(const std::string& name) const + { + auto it = responseHeaders_.find(name); + return it != responseHeaders_.end() ? &it->second : nullptr; + } + + size_t getBlockSize() const { return HTTP_BLOCK_SIZE_DOWNLOAD; } + + size_t tryRead(void* buffer, size_t bytesToRead) //throw SysError; may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + { + return asyncStreamIn_->tryRead(buffer, bytesToRead); //throw SysError + //no need for asyncStreamIn_->checkWriteErrors(): once end of stream is reached, asyncStreamOut->closeStream() was called => no errors occured + } + +private: + Impl (const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + + void cleanup() + { + asyncStreamIn_->setReadError(std::make_exception_ptr(ThreadStopRequest())); + } + + std::shared_ptr asyncStreamIn_ = std::make_shared(HTTP_STREAM_BUFFER_SIZE); + InterruptibleThread worker_; + int statusCode_ = 0; + std::unordered_map responseHeaders_; +}; + + +HttpInputStream::HttpInputStream(std::unique_ptr&& pimpl) : pimpl_(std::move(pimpl)) {} + +HttpInputStream::~HttpInputStream() {} + +size_t HttpInputStream::tryRead(void* buffer, size_t bytesToRead) { return pimpl_->tryRead(buffer, bytesToRead); } + +size_t HttpInputStream::getBlockSize() const { return pimpl_->getBlockSize(); } + +std::string HttpInputStream::readAll(const IoCallback& notifyUnbufferedIO /*throw X*/) //throw SysError, X +{ + return unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + const size_t bytesRead = pimpl_->tryRead(buffer, bytesToRead); //throw SysError; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! + if (notifyUnbufferedIO) notifyUnbufferedIO(bytesRead); //throw X! + return bytesRead; + }, + pimpl_->getBlockSize()); //throw SysError, X +} + + +namespace +{ +std::unique_ptr sendHttpRequestImpl(const Zstring& url, + const std::string* postBuf /*issue POST if bound, GET otherwise*/, + const std::string& contentType, //required for POST + const IoCallback& onPostBytesSent /*throw X*/, + const Zstring& userAgent, + const Zstring& caCertFilePath /*optional: enable certificate validation*/) //throw SysError, X +{ + Zstring urlRed = url; + //"A user agent should not automatically redirect a request more than five times, since such redirections usually indicate an infinite loop." + for (int redirects = 0; redirects < 6; ++redirects) + { + auto response = std::make_unique(urlRed, postBuf, contentType, onPostBytesSent, false /*disableGetCache*/, + userAgent, caCertFilePath); //throw SysError, X + + //https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_Redirection + const int httpStatus = response->getStatusCode(); + if (httpStatus / 100 == 3) //e.g. 301, 302, 303, 307... we're not too greedy since we check location, too! + { + const std::string* value = response->getHeader("Location"); + if (!value || value->empty()) + throw SysError(L"Unresolvable redirect. No target Location."); + + urlRed = utfTo(*value); + } + else + { + if (httpStatus != 200) //HTTP_STATUS_OK + { +#if 0 //beneficial to add error details? + std::wstring errorDetails; + try + { + errorDetails = utfTo(HttpInputStream(std::move(response)).readAll(nullptr /*notifyUnbufferedIO*/)); //throw SysError + } + catch (const SysError& e) { errorDetails = e.toString(); } +#endif + throw SysError(formatHttpError(httpStatus) /*+ L' ' + errorDetails*/); //e.g. "HTTP status 404: Not found." + } + + return response; + } + } + throw SysError(L"Too many redirects."); +} + + +//encode for "application/x-www-form-urlencoded" +std::string urlencode(const std::string_view& str) +{ + std::string output; + for (const char c : str) //follow PHP spec: https://github.com/php/php-src/blob/e99d5d39239c611e1e7304e79e88545c4e71a073/ext/standard/url.c#L455 + if (c == ' ') + output += '+'; + else if (('0' <= c && c <= '9') || + ('A' <= c && c <= 'Z') || + ('a' <= c && c <= 'z') || + c == '-' || c == '.' || c == '_') //note: "~" is encoded by PHP! + output += c; + else + { + const auto [high, low] = hexify(c); + output += '%'; + output += high; + output += low; + } + return output; +} + + +std::string urldecode(const std::string_view& str) +{ + std::string output; + for (size_t i = 0; i < str.size(); ++i) + { + const char c = str[i]; + if (c == '+') + output += ' '; + else if (c == '%' && str.size() - i >= 3 && + isHexDigit(str[i + 1]) && + isHexDigit(str[i + 2])) + { + output += unhexify(str[i + 1], str[i + 2]); + i += 2; + } + else + output += c; + } + return output; +} +} + + +std::string zen::xWwwFormUrlEncode(const std::vector>& paramPairs) +{ + std::string output; + for (const auto& [name, value] : paramPairs) + output += urlencode(name) + '=' + urlencode(value) + '&'; + //encode both key and value: https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1 + if (!output.empty()) + output.pop_back(); + return output; +} + + +std::vector> zen::xWwwFormUrlDecode(const std::string_view str) +{ + std::vector> output; + + split(str, '&', [&](const std::string_view nvPair) + { + if (!nvPair.empty()) + output.emplace_back(urldecode(beforeFirst(nvPair, '=', IfNotFoundReturn::all)), + urldecode(afterFirst (nvPair, '=', IfNotFoundReturn::none))); + }); + return output; +} + + +HttpInputStream zen::sendHttpGet(const Zstring& url, const Zstring& userAgent, const Zstring& caCertFilePath) //throw SysError +{ + return sendHttpRequestImpl(url, nullptr /*postBuf*/, "" /*contentType*/, nullptr /*onPostBytesSent*/, userAgent, caCertFilePath); //throw SysError +} + + +HttpInputStream zen::sendHttpPost(const Zstring& url, const std::vector>& postParams, + const IoCallback& notifyUnbufferedIO /*throw X*/, + const Zstring& userAgent, + const Zstring& caCertFilePath) //throw SysError, X +{ + return sendHttpPost(url, xWwwFormUrlEncode(postParams), "application/x-www-form-urlencoded", notifyUnbufferedIO, userAgent, caCertFilePath); //throw SysError, X +} + + + +HttpInputStream zen::sendHttpPost(const Zstring& url, + const std::string& postBuf, + const std::string& contentType, + const IoCallback& notifyUnbufferedIO /*throw X*/, + const Zstring& userAgent, + const Zstring& caCertFilePath) //throw SysError, X +{ + return sendHttpRequestImpl(url, &postBuf, contentType, notifyUnbufferedIO, userAgent, caCertFilePath); //throw SysError, X +} + + +bool zen::internetIsAlive() //noexcept +{ + try + { + auto response = std::make_unique(Zstr("https://www.google.com/"), //https more appropriate than http for testing? (different ports!) + nullptr /*postParams*/, + "" /*contentType*/, + nullptr /*onPostBytesSent*/, + true /*disableGetCache*/, + Zstr("FreeFileSync"), + Zstring() /*caCertFilePath*/); //throw SysError + const int statusCode = response->getStatusCode(); + + //attention: google.com might redirect to https://consent.google.com => don't follow, just return "true"!!! + return statusCode / 100 == 2 || //e.g. 200 + statusCode / 100 == 3; //e.g. 301, 302, 303, 307... when in doubt, consider internet alive! + } + catch (SysError&) { return false; } +} + + +std::wstring zen::formatHttpError(int sc) +{ + const wchar_t* statusDescr = [&] //https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + { + switch (sc) + { + case 300: return L"Multiple choices."; + case 301: return L"Moved permanently."; + case 302: return L"Moved temporarily."; + case 303: return L"See other"; + case 304: return L"Not modified."; + case 305: return L"Use proxy."; + case 306: return L"Switch proxy."; + case 307: return L"Temporary redirect."; + case 308: return L"Permanent redirect."; + + case 400: return L"Bad request."; + case 401: return L"Unauthorized."; + case 402: return L"Payment required."; + case 403: return L"Forbidden."; + case 404: return L"Not found."; + case 405: return L"Method not allowed."; + case 406: return L"Not acceptable."; + case 407: return L"Proxy authentication required."; + case 408: return L"Request timeout."; + case 409: return L"Conflict."; + case 410: return L"Gone."; + case 411: return L"Length required."; + case 412: return L"Precondition failed."; + case 413: return L"Payload too large."; + case 414: return L"URI too long."; + case 415: return L"Unsupported media type."; + case 416: return L"Range not satisfiable."; + case 417: return L"Expectation failed."; + case 418: return L"I'm a teapot."; + case 421: return L"Misdirected request."; + case 422: return L"Unprocessable entity."; + case 423: return L"Locked."; + case 424: return L"Failed dependency."; + case 425: return L"Too early."; + case 426: return L"Upgrade required."; + case 428: return L"Precondition required."; + case 429: return L"Too many requests."; + case 431: return L"Request header fields too large."; + case 451: return L"Unavailable for legal reasons."; + + case 500: return L"Internal server error."; + case 501: return L"Not implemented."; + case 502: return L"Bad gateway."; + case 503: return L"Service unavailable."; + case 504: return L"Gateway timeout."; + case 505: return L"HTTP version not supported."; + case 506: return L"Variant also negotiates."; + case 507: return L"Insufficient storage."; + case 508: return L"Loop detected."; + case 510: return L"Not extended."; + case 511: return L"Network authentication required."; + + //Cloudflare errors regarding origin server: + case 520: return L"Unknown error (Cloudflare)"; + case 521: return L"Web server is down (Cloudflare)"; + case 522: return L"Connection timed out (Cloudflare)"; + case 523: return L"Origin is unreachable (Cloudflare)"; + case 524: return L"A timeout occurred (Cloudflare)"; + case 525: return L"SSL handshake failed (Cloudflare)"; + case 526: return L"Invalid SSL certificate (Cloudflare)"; + case 527: return L"Railgun error (Cloudflare)"; + case 530: return L"Origin DNS error (Cloudflare)"; + + default: return L""; + } + }(); + + return formatSystemError("", L"HTTP status " + numberTo(sc), statusDescr); +} + + +bool zen::isValidEmail(const std::string_view& email) +{ + //https://en.wikipedia.org/wiki/Email_address#Syntax + //https://tools.ietf.org/html/rfc3696 => note errata! https://www.rfc-editor.org/errata_search.php?rfc=3696 + //https://tools.ietf.org/html/rfc5321 + std::string_view local = beforeLast(email, '@', IfNotFoundReturn::none); + std::string_view domain = afterLast(email, '@', IfNotFoundReturn::none); + //consider: "t@st"@email.com t\@st@email.com" + + auto stripComments = [](std::string_view& part) + { + if (startsWith(part, '(')) + part = afterFirst(part, ')', IfNotFoundReturn::none); + + if (endsWith(part, ')')) + part = beforeLast(part, '(', IfNotFoundReturn::none); + }; + stripComments(local); + stripComments(domain); + + if (local .empty() || local .size() > 63 || // 64 octets -> 63 ASCII chars: https://devblogs.microsoft.com/oldnewthing/20120412-00/?p=7873 + domain.empty() || domain.size() > 253) //255 octets -> 253 ASCII chars + return false; + //--------------------------------------------------------------------- + + //we're not going to parse and validate this! + const bool quoted = (startsWith(local, '"') && endsWith(local, '"')) || + contains(local, '\\'); //e.g. "t\@st@email.com" + if (!quoted) + for (const std::string_view& comp : splitCpy(local, '.', SplitOnEmpty::allow)) + if (comp.empty() || !std::all_of(comp.begin(), comp.end(), [](const char c) + { + constexpr std::string_view printable("!#$%&'*+-/=?^_`{|}~"); + return isAsciiAlpha(c) || isDigit(c) || !isAsciiChar(c) || + contains(printable, c); + })) + return false; + //--------------------------------------------------------------------- + + //e.g. jsmith@[192.168.2.1] jsmith@[IPv6:2001:db8::1] + const bool likelyIp = startsWith(domain, '[') && endsWith(domain, ']'); + if (!likelyIp) //not interested in parsing IPs! + { + if (!contains(domain, '.')) + return false; + + for (const std::string_view& comp : splitCpy(domain, '.', SplitOnEmpty::allow)) + if (comp.empty() || comp.size() > 63 || + !std::all_of(comp.begin(), comp.end(), [](const char c) { return isAsciiAlpha(c) ||isDigit(c) || !isAsciiChar(c) || c == '-'; })) + return false; + } + + return true; +} + + +std::string zen::htmlSpecialChars(const std::string_view& str) +{ + //mirror PHP: https://github.com/php/php-src/blob/e99d5d39239c611e1e7304e79e88545c4e71a073/ext/standard/html_tables.h#L6189 + std::string output; + for (const char c : str) + switch (c) + { + case '&': output += "&" ; break; + case '"': output += """; break; + case '<': output += "<" ; break; + case '>': output += ">" ; break; + //case '\'': output += "'"; break; -> not encoded by default (needs ENT_QUOTES) + default: output += c; break; + } + return output; +} diff --git a/zen/http.h b/zen/http.h new file mode 100644 index 0000000..1943b12 --- /dev/null +++ b/zen/http.h @@ -0,0 +1,60 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef HTTP_H_879083425703425702 +#define HTTP_H_879083425703425702 + +#include "sys_error.h" +#include "serialize.h" + +namespace zen +{ +/* - Linux/macOS: init libcurl before use! + - safe to use on worker thread */ +class HttpInputStream +{ +public: + //zen/serialize.h unbuffered input stream concept: + size_t tryRead(void* buffer, size_t bytesToRead); //throw SysError; may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + + size_t getBlockSize() const; + + std::string readAll(const IoCallback& notifyUnbufferedIO /*throw X*/); //throw SysError, X + + class Impl; + HttpInputStream(std::unique_ptr&& pimpl); + HttpInputStream(HttpInputStream&&) noexcept = default; + ~HttpInputStream(); + +private: + std::unique_ptr pimpl_; +}; + + +HttpInputStream sendHttpGet(const Zstring& url, + const Zstring& userAgent, + const Zstring& caCertFilePath /*optional: enable certificate validation*/); //throw SysError + +HttpInputStream sendHttpPost(const Zstring& url, + const std::vector>& postParams, const IoCallback& notifyUnbufferedIO /*throw X*/, + const Zstring& userAgent, + const Zstring& caCertFilePath /*optional: enable certificate validation*/); //throw SysError, X + +HttpInputStream sendHttpPost(const Zstring& url, + const std::string& postBuf, const std::string& contentType, const IoCallback& notifyUnbufferedIO /*throw X*/, + const Zstring& userAgent, + const Zstring& caCertFilePath /*optional: enable certificate validation*/); //throw SysError, X + +bool internetIsAlive(); //noexcept +std::wstring formatHttpError(int httpStatus); +bool isValidEmail(const std::string_view& email); +std::string htmlSpecialChars(const std::string_view& str); + +std::string xWwwFormUrlEncode(const std::vector>& paramPairs); +std::vector> xWwwFormUrlDecode(const std::string_view str); +} + +#endif //HTTP_H_879083425703425702 diff --git a/zen/i18n.h b/zen/i18n.h new file mode 100644 index 0000000..28b8c08 --- /dev/null +++ b/zen/i18n.h @@ -0,0 +1,115 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef I18_N_H_3843489325044253425456 +#define I18_N_H_3843489325044253425456 + +#include "globals.h" +#include "string_tools.h" +#include "format_unit.h" + + +//minimal layer enabling text translation - without platform/library dependencies! + +#define ZEN_TRANS_CONCAT_SUB(X, Y) X ## Y +#define _(s) zen::translate(ZEN_TRANS_CONCAT_SUB(L, s)) +#define _P(s, p, n) zen::translate(ZEN_TRANS_CONCAT_SUB(L, s), ZEN_TRANS_CONCAT_SUB(L, p), n) +//source and translation are required to use %x as number placeholder +//for plural form, which will be substituted automatically!!! + + static_assert(WXINTL_NO_GETTEXT_MACRO, "...must be defined to deactivate wxWidgets underscore macro"); + +namespace zen +{ +//implement handler to enable program-wide localizations: +struct TranslationHandler +{ + //THREAD-SAFETY: "const" member must model thread-safe access! + TranslationHandler() {} + virtual ~TranslationHandler() {} + + //C++11: std::wstring should be thread-safe like an int + virtual std::wstring translate(const std::wstring& text) const = 0; //simple translation + virtual std::wstring translate(const std::wstring& singular, const std::wstring& plural, int64_t n) const = 0; + + virtual bool layoutIsRtl() const = 0; //right-to-left? e.g. Hebrew, Arabic + +private: + TranslationHandler (const TranslationHandler&) = delete; + TranslationHandler& operator=(const TranslationHandler&) = delete; +}; + +void setTranslator(std::unique_ptr&& newHandler); //take ownership +std::shared_ptr getTranslator(); + + + + + + + + +//######################## implementation ############################## +namespace impl +{ +//getTranslator() may be called even after static objects of this translation unit are destroyed! +inline constinit Global globalTranslationHandler; +} + +inline +std::shared_ptr getTranslator() +{ + return impl::globalTranslationHandler.get(); +} + + +inline +void setTranslator(std::unique_ptr&& newHandler) +{ + impl::globalTranslationHandler.set(std::move(newHandler)); +} + + +inline +std::wstring translate(const std::wstring& text) +{ + if (std::shared_ptr t = getTranslator()) //std::shared_ptr => temporarily take (shared) ownership while using the interface! + return t->translate(text); + return text; +} + + +//translate plural forms: "%x day" "%x days" +//returns "1 day" if n == 1; "123 days" if n == 123 for english language +template inline +std::wstring translate(const std::wstring& singular, const std::wstring& plural, T n) +{ + static_assert(sizeof(n) <= sizeof(int64_t)); + const auto n64 = static_cast(n); + + assert(contains(plural, L"%x")); + + if (std::shared_ptr t = getTranslator()) + { + std::wstring translation = t->translate(singular, plural, n64); + assert(!contains(translation, L"%x")); + return translation; + } + //fallback: + return replaceCpy(std::abs(n64) == 1 ? singular : plural, L"%x", formatNumber(n)); +} + + +inline +bool languageLayoutIsRtl() +{ + if (std::shared_ptr t = getTranslator()) + return t->layoutIsRtl(); + return false; +} +} + +#endif //I18_N_H_3843489325044253425456 diff --git a/zen/json.h b/zen/json.h new file mode 100644 index 0000000..856aa87 --- /dev/null +++ b/zen/json.h @@ -0,0 +1,616 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef JSON_H_0187348321748321758934215734 +#define JSON_H_0187348321748321758934215734 + +#include +#include + + +namespace zen +{ +//Spec: https://tools.ietf.org/html/rfc8259 +//Test: http://seriot.ch/parsing_json.php +struct JsonValue; + +class JsonObject +{ +public: + using Item = std::list>; //must NOT invalidate references used by "valuesByName_"! + //¹) careful: should be const, but fails to compile in debug => do not allow write access by the API: + + Range getItems() const { return {values_.begin(), values_.end()}; } + + bool empty() const { return values_.empty(); } + + const JsonValue* get(std::string_view name) const; + + template + void set(std::string&& name, T&& value); + + //------------------------------------------------------------- +#warning("review default operators") + JsonObject() = default; + + JsonObject(const JsonObject& other) : values_(other.values_) { initLookup(); } + + JsonObject& operator=(const JsonObject& other) { JsonObject(other).swap(*this); return *this; } + + JsonObject (JsonObject&& tmp) noexcept { swap(tmp); } + JsonObject& operator=(JsonObject&& tmp) noexcept { swap(tmp); return *this; } + +private: + void swap(JsonObject& other) noexcept { values_.swap(other.values_); valuesByName_.swap(other.valuesByName_); } + + void initLookup(); + + //"[...] most implementations of JSON libraries do not accept duplicate keys [...]" => fine! + Item values_; //in order of insertion + std::unordered_map valuesByName_; //alternate view for lookup +}; + + +struct JsonValue +{ + enum class Type + { + null, // + boolean, //primitive types + number, // + string, // + array, + object, + }; + + /**/ JsonValue() {} + explicit JsonValue(Type t) : type(t) {} + explicit JsonValue(bool b) : type(Type::boolean), primVal(b ? "true" : "false") {} + explicit JsonValue(int num) : type(Type::number), primVal(numberTo(num)) {} + explicit JsonValue(int64_t num) : type(Type::number), primVal(numberTo(num)) {} + explicit JsonValue(double num) : type(Type::number), primVal(numberTo(num)) {} + explicit JsonValue(std::string str) : type(Type::string), primVal(std::move(str)) {} //unifying assignment + explicit JsonValue(const char* str) : type(Type::string), primVal(str) {} + explicit JsonValue(const void*) = delete; //catch usage errors e.g. const int* -> JsonValue(bool) + //explicit JsonValue(std::initializer_list initList) : type(Type::array), arrayVal(initList) {} => empty list is ambiguous + explicit JsonValue(std::vector initList) : type(Type::array), arrayVal(std::move(initList)) {} //unifying assignment + + + Type type = Type::null; + std::string primVal; //for primitive types + std::vector arrayVal; + JsonObject objectVal; +}; + + +std::string serializeJson(const JsonValue& jval, + const std::string& lineBreak = "\n", + const std::string& indent = " "); //noexcept + + +struct JsonParsingError +{ + JsonParsingError(size_t rowNo, size_t colNo) : row(rowNo), col(colNo) {} + const size_t row; //beginning with 0 + const size_t col; // +}; +JsonValue parseJson(const std::string& stream); //throw JsonParsingError + + + +//---------------------- implementation ---------------------- + +//helper functions for JsonValue access: +inline +const JsonValue* getChildFromJsonObject(const JsonValue& jvalue, const std::string& name) +{ + return jvalue.type != JsonValue::Type::object ? nullptr : jvalue.objectVal.get(name); +} + + +inline +std::optional getPrimitiveFromJsonObject(const JsonValue& jvalue, const std::string& name) +{ + if (const JsonValue* childValue = getChildFromJsonObject(jvalue, name)) + if (childValue->type != JsonValue::Type::object && + childValue->type != JsonValue::Type::array) + return childValue->primVal; + return std::nullopt; +} + + +inline +void JsonObject::initLookup() +{ + assert(valuesByName_.empty()); + for (auto it = values_.begin(); it != values_.end(); ++it) + valuesByName_.emplace(it->first, it); +} + + +inline +const JsonValue* JsonObject::get(std::string_view name) const +{ + auto it = valuesByName_.find(name); + return it == valuesByName_.end() ? nullptr : &(it->second->second); +} + + +template inline +void JsonObject::set(std::string&& name, T&& value) +{ + auto it = valuesByName_.find(name); + if (it != valuesByName_.end()) + it->second->second = JsonValue(std::forward(value)); + else + { + //values_.emplace_back(std::move(name), std::forward(value)); -> not yet on macOS/clang + values_.push_back({std::move(name), JsonValue(std::forward(value))}); + valuesByName_.emplace(values_.back().first, --values_.end()); + } +} + + +namespace json_impl +{ +namespace +{ +[[nodiscard]] std::string jsonEscape(const std::string& str) +{ + std::string output; + for (const char c : str) + switch (c) + { + case '\\': output += "\\\\"; break; // + case '"': output += "\\\""; break; //escaping mandatory + + case '\b': output += "\\b"; break; // + case '\f': output += "\\f"; break; // + case '\n': output += "\\n"; break; //prefer compact escaping + case '\r': output += "\\r"; break; // + case '\t': output += "\\t"; break; // + + default: + if (static_cast(c) < 32) + { + const auto [high, low] = hexify(c); + output += "\\u00"; + output += high; + output += low; + } + else + output += c; + break; + } + return output; +} + + +[[nodiscard]] std::string jsonUnescape(const std::string& str) +{ + std::string output; + std::basic_string utf16Buf; + + auto flushUtf16 = [&] + { + if (!utf16Buf.empty()) + { + UtfDecoder decoder(utf16Buf.c_str(), utf16Buf.size()); + while (std::optional cp = decoder.getNext()) + codePointToUtf(*cp, [&](const char c) { output += c; }); + utf16Buf.clear(); + } + }; + auto writeOut = [&](const char c) + { + flushUtf16(); + output += c; + }; + + for (auto it = str.begin(); it != str.end(); ++it) + { + const char c = *it; + if (c == '\\') + { + ++it; + if (it == str.end()) //unexpected end! + { + writeOut(c); + break; + } + + const char c2 = *it; + switch (c2) + { + case '\\': + case '"': + case '/': writeOut(c2); break; + case 'b': writeOut('\b'); break; + case 'f': writeOut('\f'); break; + case 'n': writeOut('\n'); break; + case 'r': writeOut('\r'); break; + case 't': writeOut('\t'); break; + default: + if (c2 == 'u' && + str.end() - it >= 5 && + isHexDigit(it[1]) && + isHexDigit(it[2]) && + isHexDigit(it[3]) && + isHexDigit(it[4])) + { + utf16Buf += static_cast(static_cast(unhexify(it[1], it[2])) * 256 + + static_cast(unhexify(it[3], it[4]))); + it += 4; + } + else //unknown escape sequence! + { + writeOut(c); + writeOut(c2); + } + break; + } + } + else + writeOut(c); + } + flushUtf16(); + return output; +} + + +void serialize(const JsonValue& jval, std::string& stream, + const std::string& lineBreak, + const std::string& indent, + size_t indentLevel) +{ + //unlike our XML serialization the caller is repsonsible for line breaks and indentation of *first* line + auto writeIndent = [&](size_t level) + { + for (size_t i = 0; i < level; ++i) + stream += indent; + }; + + switch (jval.type) + { + case JsonValue::Type::null: + stream += "null"; + break; + + case JsonValue::Type::boolean: + case JsonValue::Type::number: + stream += jval.primVal; + break; + + case JsonValue::Type::string: + stream += '"' + jsonEscape(jval.primVal) + '"'; + break; + + case JsonValue::Type::object: + stream += '{'; + if (!jval.objectVal.empty()) + { + bool first = true; + + for (const auto& [childName, childValue] : jval.objectVal.getItems()) + { + if (!std::exchange(first, false)) + stream += ','; + + stream += lineBreak; + writeIndent(indentLevel + 1); + + stream += '"' + jsonEscape(childName) + "\":"; + + if ((childValue.type == JsonValue::Type::object && !childValue.objectVal.empty()) || + (childValue.type == JsonValue::Type::array && !childValue.arrayVal .empty())) + { + stream += lineBreak; + writeIndent(indentLevel + 1); + } + else if (!indent.empty()) + stream += ' '; + + serialize(childValue, stream, lineBreak, indent, indentLevel + 1); + } + stream += lineBreak; + writeIndent(indentLevel); + } + stream += '}'; + break; + + case JsonValue::Type::array: + stream += '['; + if (!jval.arrayVal.empty()) + { + for (auto it = jval.arrayVal.begin(); it != jval.arrayVal.end(); ++it) + { + const auto& childValue = *it; + + if (it != jval.arrayVal.begin()) + stream += ','; + + stream += lineBreak; + writeIndent(indentLevel + 1); + + serialize(childValue, stream, lineBreak, indent, indentLevel + 1); + } + stream += lineBreak; + writeIndent(indentLevel); + } + stream += ']'; + break; + } +} +} +} + + +inline +std::string serializeJson(const JsonValue& jval, + const std::string& lineBreak, + const std::string& indent) //noexcept +{ + std::string output; + json_impl::serialize(jval, output, lineBreak, indent, 0); + output += lineBreak; + return output; +} + + +namespace json_impl +{ +enum class TokenType +{ + eof, + curlyOpen, + curlyClose, + squareOpen, + squareClose, + colon, + comma, + string, // + number, //primitive types + boolean, // + null, // +}; + +struct Token +{ + Token(TokenType t) : type(t) {} + + TokenType type; + std::string primVal; //for primitive types +}; + +class Scanner +{ +public: + explicit Scanner(const std::string& stream) : stream_(stream), pos_(stream_.begin()) + { + if (zen::startsWith(stream_, BYTE_ORDER_MARK_UTF8)) + pos_ += BYTE_ORDER_MARK_UTF8.size(); + } + + Token getNextToken() //throw JsonParsingError + { + //skip whitespace + pos_ = std::find_if_not(pos_, stream_.end(), isJsonWhiteSpace); + + if (pos_ == stream_.end()) + return TokenType::eof; + + if (*pos_ == '{') return ++pos_, TokenType::curlyOpen; + if (*pos_ == '}') return ++pos_, TokenType::curlyClose; + if (*pos_ == '[') return ++pos_, TokenType::squareOpen; + if (*pos_ == ']') return ++pos_, TokenType::squareClose; + if (*pos_ == ':') return ++pos_, TokenType::colon; + if (*pos_ == ',') return ++pos_, TokenType::comma; + if (startsWith("null")) return pos_ += 4, Token(TokenType::null); + + if (startsWith("true")) + { + pos_ += 4; + Token tk(TokenType::boolean); + tk.primVal = "true"; + return tk; + } + if (startsWith("false")) + { + pos_ += 5; + Token tk(TokenType::boolean); + tk.primVal = "false"; + return tk; + } + + if (*pos_ == '"') + { + for (auto it = ++pos_; it != stream_.end(); ++it) + if (*it == '"') + { + Token tk(TokenType::string); + tk.primVal = jsonUnescape({pos_, it}); + pos_ = ++it; + return tk; + } + else if (*it == '\\') //skip next char + if (++it == stream_.end()) + break; + + throw JsonParsingError(posRow(), posCol()); + } + + //expect a number: + const auto itNumEnd = std::find_if_not(pos_, stream_.end(), isJsonNumDigit); + if (itNumEnd == pos_) + throw JsonParsingError(posRow(), posCol()); + + Token tk(TokenType::number); + tk.primVal.assign(pos_, itNumEnd); + pos_ = itNumEnd; + return tk; + } + + size_t posRow() const //current row beginning with 0 + { + const size_t crSum = std::count(stream_.begin(), pos_, '\r'); //carriage returns + const size_t nlSum = std::count(stream_.begin(), pos_, '\n'); //new lines + assert(crSum == 0 || nlSum == 0 || crSum == nlSum); + return std::max(crSum, nlSum); //be compatible with Linux/Mac/Win + } + + size_t posCol() const //current col beginning with 0 + { + //seek beginning of line + for (auto it = pos_; it != stream_.begin(); ) + { + --it; + if (isLineBreak(*it)) + return pos_ - it - 1; + } + return pos_ - stream_.begin(); + } + +private: + Scanner (const Scanner&) = delete; + Scanner& operator=(const Scanner&) = delete; + + static bool isJsonWhiteSpace(const char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } + static bool isJsonNumDigit (const char c) { return ('0' <= c && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e'|| c == 'E'; } + + bool startsWith(const std::string& prefix) const + { + return zen::startsWith(makeStringView(pos_, stream_.end()), prefix); + } + + const std::string stream_; + std::string::const_iterator pos_; +}; + + +class JsonParser +{ +public: + explicit JsonParser(const std::string& stream) : + scn_(stream), + tk_(scn_.getNextToken()) {} //throw JsonParsingError + + JsonValue parse() //throw JsonParsingError + { + JsonValue jval = parseValue(); //throw JsonParsingError + expectToken(TokenType::eof); // + return jval; + } + +private: + JsonParser (const JsonParser&) = delete; + JsonParser& operator=(const JsonParser&) = delete; + + JsonValue parseValue() //throw JsonParsingError + { + if (token().type == TokenType::curlyOpen) + { + nextToken(); //throw JsonParsingError + + JsonValue jval(JsonValue::Type::object); + + if (token().type != TokenType::curlyClose) + for (;;) + { + expectToken(TokenType::string); //throw JsonParsingError + std::string name = token().primVal; + nextToken(); //throw JsonParsingError + + consumeToken(TokenType::colon); //throw JsonParsingError + + JsonValue value = parseValue(); //throw JsonParsingError + jval.objectVal.set(std::move(name), std::move(value)); + + if (token().type != TokenType::comma) + break; + nextToken(); //throw JsonParsingError + } + + consumeToken(TokenType::curlyClose); //throw JsonParsingError + return jval; + } + else if (token().type == TokenType::squareOpen) + { + nextToken(); //throw JsonParsingError + + JsonValue jval(JsonValue::Type::array); + + if (token().type != TokenType::squareClose) + for (;;) + { + JsonValue value = parseValue(); //throw JsonParsingError + jval.arrayVal.emplace_back(std::move(value)); + + if (token().type != TokenType::comma) + break; + nextToken(); //throw JsonParsingError + } + + consumeToken(TokenType::squareClose); //throw JsonParsingError + return jval; + } + else if (token().type == TokenType::string) + { + JsonValue jval(token().primVal); + nextToken(); //throw JsonParsingError + return jval; + } + else if (token().type == TokenType::number) + { + JsonValue jval(JsonValue::Type::number); + jval.primVal = token().primVal; + nextToken(); //throw JsonParsingError + return jval; + } + else if (token().type == TokenType::boolean) + { + JsonValue jval(JsonValue::Type::boolean); + jval.primVal = token().primVal; + nextToken(); //throw JsonParsingError + return jval; + } + else if (token().type == TokenType::null) + { + nextToken(); //throw JsonParsingError + return JsonValue(); + } + else //unexpected token + throw JsonParsingError(scn_.posRow(), scn_.posCol()); + } + + const Token& token() const { return tk_; } + + void nextToken() { tk_ = scn_.getNextToken(); } //throw JsonParsingError + + void expectToken(TokenType t) //throw JsonParsingError + { + if (token().type != t) + throw JsonParsingError(scn_.posRow(), scn_.posCol()); + } + + void consumeToken(TokenType t) //throw JsonParsingError + { + expectToken(t); //throw JsonParsingError + nextToken(); // + } + + Scanner scn_; + Token tk_; +}; +} + +inline +JsonValue parseJson(const std::string& stream) //throw JsonParsingError +{ + return json_impl::JsonParser(stream).parse(); //throw JsonParsingError +} +} + +#endif //JSON_H_0187348321748321758934215734 diff --git a/zen/legacy_compiler.cpp b/zen/legacy_compiler.cpp new file mode 100644 index 0000000..6c5489d --- /dev/null +++ b/zen/legacy_compiler.cpp @@ -0,0 +1,28 @@ +// ***************************************************************************** +// * 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 "legacy_compiler.h" +#include +/* 1. including in header file blows up VC++: + - string_tools.h: "An internal error has occurred in the compiler. (compiler file 'd:\agent\_work\1\s\src\vctools\Compiler\Utc\src\p2\p2symtab.c', line 2618)" + - PCH: "fatal error C1076: compiler limit: internal heap limit reached" + => include in separate compilation unit + 2. Disable "C/C++ -> Code Generation -> Smaller Type Check" (and PCH usage!), at least for this compilation unit: https://github.com/microsoft/STL/pull/171 */ + +double zen::fromChars(const char* first, const char* last) +{ + double num = 0; + [[maybe_unused]] const std::from_chars_result rv = std::from_chars(first, last, num); + return num; +} + + +const char* zen::toChars(char* first, char* last, double num) +{ + const std::to_chars_result rv = std::to_chars(first, last, num); + return rv.ec == std::errc{} ? rv.ptr : first; +} + diff --git a/zen/legacy_compiler.h b/zen/legacy_compiler.h new file mode 100644 index 0000000..2e7ee8e --- /dev/null +++ b/zen/legacy_compiler.h @@ -0,0 +1,81 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef LEGACY_COMPILER_H_839567308565656789 +#define LEGACY_COMPILER_H_839567308565656789 + +#include //contains all __cpp_lib_ macros +#include + +/* C++ standard conformance: + https://en.cppreference.com/w/cpp/feature_test + https://en.cppreference.com/w/User:D41D8CD98F/feature_testing_macros + https://isocpp.org/std/standing-documents/sd-6-sg10-feature-test-recommendations + + MSVC https://docs.microsoft.com/en-us/cpp/overview/visual-cpp-language-conformance + + GCC https://gcc.gnu.org/projects/cxx-status.html + libstdc++ https://gcc.gnu.org/onlinedocs/libstdc++/manual/status.html + + Clang https://clang.llvm.org/cxx_status.html + Xcode https://developer.apple.com/xcode/cpp + libc++ https://libcxx.llvm.org/cxx2a_status.html */ + + +namespace std +{ + + + +//W(hy)TF is this not standard? https://stackoverflow.com/a/47735624 +template inline +basic_string operator+(basic_string&& lhs, const basic_string_view rhs) +{ return std::move(lhs.append(rhs.begin(), rhs.end())); } //the move *is* needed!!! + +//template inline +//basic_string operator+(const basic_string& lhs, const basic_string_view& rhs) { return basic_string(lhs) + rhs; } +//-> somewhat inefficient: single memory allocation should suffice!!! +} +//--------------------------------------------------------------------------------- + +//support for std::string::resize_and_overwrite() + #define ZEN_HAVE_RESIZE_AND_OVERWRITE 1 + +namespace zen +{ +//reference a sub-string for consumption by zen string_tools +//=> std::string_view seems decent, but of course fucks up in one regard: construction + +//std::string_view(first, last) is not available before C++20 (at least on clang) +template inline +auto makeStringView(Iterator first, Iterator last) +{ + using CharType = std::remove_cvref_t; + + return std::basic_string_view(first != last ? &*first : + reinterpret_cast(0x1000), /*Win32 APIs like CompareStringOrdinal() choke on nullptr!*/ + last - first); +} +//std::string_view(char*, int) fails to compile! expected size_t as second parameter +template inline +auto makeStringView(Iterator first, size_t len) { return makeStringView(first, first + len); } + + + +double fromChars(const char* first, const char* last); +const char* toChars(char* first, char* last, double num); +} + + +#if 0 //neat: supported on MSVC and GCC, but not yet on Clang +auto closure = [](this auto&& self) +{ + self(); //just call ourself until the stack overflows + //e.g. use for: deleteEmptyFolderTask, removeFolderRecursionImpl, scheduleMoreTasks, traverse +}; +#endif + +#endif //LEGACY_COMPILER_H_839567308565656789 diff --git a/zen/open_ssl.cpp b/zen/open_ssl.cpp new file mode 100644 index 0000000..536e7f5 --- /dev/null +++ b/zen/open_ssl.cpp @@ -0,0 +1,871 @@ +// ***************************************************************************** +// * 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 "open_ssl.h" +#include //std::endian (needed for macOS) +#include "base64.h" +#include "thread.h" +#include "argon2.h" +#include "serialize.h" +#include +#include +#include +#include +#include +#include +#include + + +using namespace zen; + + +namespace +{ +#ifndef OPENSSL_THREADS + #error FFS, we are royally screwed! +#endif + +/* Sign a file using SHA-256: + openssl dgst -sha256 -sign private.pem -out file.sig file.txt + + verify the signature: (caveat: public key expected to be in pkix format!) + openssl dgst -sha256 -verify public.pem -signature file.sig file.txt */ + + +std::wstring formatOpenSSLError(const char* functionName, unsigned long ec) +{ + char errorBuf[256] = {}; //== buffer size used by ERR_error_string(); err.c: it seems the message uses at most ~200 bytes + ::ERR_error_string_n(ec, errorBuf, sizeof(errorBuf)); //includes null-termination + + return formatSystemError(functionName, replaceCpy(_("Error code %x"), L"%x", numberTo(ec)), utfTo(errorBuf)); +} + + +std::wstring formatLastOpenSSLError(const char* functionName) +{ + const auto ec = ::ERR_peek_last_error(); //"returns latest error code from the thread's error queue without modifying it" - unlike ERR_get_error() + //ERR_get_error: "returns the earliest error code from the thread's error queue and removes the entry. + // This function can be called repeatedly until there are no more error codes to return." + ::ERR_clear_error(); //clean up for next OpenSSL operation on this thread + return formatOpenSSLError(functionName, ec); +} +} + + +void zen::openSslInit() +{ + //official Wiki: https://wiki.openssl.org/index.php/Library_Initialization + //see apps_shutdown(): https://github.com/openssl/openssl/blob/master/apps/openssl.c + //see Curl_ossl_cleanup(): https://github.com/curl/curl/blob/master/lib/vtls/openssl.c + + assert(runningOnMainThread()); + //explicitly init OpenSSL on main thread: seems to initialize atomically! But it still might help to avoid issues: + //https://www.openssl.org/docs/manmaster/man3/OPENSSL_init_ssl.html + if (::OPENSSL_init_ssl(OPENSSL_INIT_SSL_DEFAULT | OPENSSL_INIT_NO_LOAD_CONFIG, nullptr) != 1) + logExtraError(_("Error during process initialization.") + L"\n\n" + formatLastOpenSSLError("OPENSSL_init_ssl")); +} + + +void zen::openSslTearDown() {} +//OpenSSL 1.1.0+ deprecates all clean up functions +//=> so much the theory, in practice it leaks, of course: https://github.com/openssl/openssl/issues/6283 +namespace +{ +struct OpenSslThreadCleanUp +{ + ~OpenSslThreadCleanUp() + { + ::OPENSSL_thread_stop(); + } +}; +thread_local OpenSslThreadCleanUp tearDownOpenSslThreadData; + +//================================================================================ + +std::shared_ptr generateRsaKeyPair(int bits) //throw SysError +{ + EVP_PKEY_CTX* keyCtx = ::EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, //int id + nullptr); //ENGINE* e + if (!keyCtx) + throw SysError(formatLastOpenSSLError("EVP_PKEY_CTX_new_id")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_CTX_free(keyCtx)); + + if (::EVP_PKEY_keygen_init(keyCtx) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_keygen_init")); + + //"RSA keys set the key length during key generation rather than parameter generation" + if (::EVP_PKEY_CTX_set_rsa_keygen_bits(keyCtx, bits) <= 0) //"[...] return a positive value for success" => effectively returns "1" + throw SysError(formatLastOpenSSLError("EVP_PKEY_CTX_set_rsa_keygen_bits")); + + EVP_PKEY* keyPair = nullptr; + if (::EVP_PKEY_keygen(keyCtx, &keyPair) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_keygen")); + + return std::shared_ptr(keyPair, ::EVP_PKEY_free); +} + +//================================================================================ + +std::shared_ptr streamToKey(const std::string_view keyStream, RsaStreamType streamType, bool publicKey) //throw SysError +{ + switch (streamType) + { + case RsaStreamType::pkix: + { + BIO* bio = ::BIO_new_mem_buf(keyStream.data(), static_cast(keyStream.size())); + if (!bio) + throw SysError(formatLastOpenSSLError("BIO_new_mem_buf")); + ZEN_ON_SCOPE_EXIT(::BIO_free_all(bio)); + + if (EVP_PKEY* evp = (publicKey ? + ::PEM_read_bio_PUBKEY : + ::PEM_read_bio_PrivateKey) + (bio, //BIO* bp + nullptr, //EVP_PKEY** x + nullptr, //pem_password_cb* cb + nullptr)) //void* u + return std::shared_ptr(evp, ::EVP_PKEY_free); + throw SysError(formatLastOpenSSLError(publicKey ? "PEM_read_bio_PUBKEY" : "PEM_read_bio_PrivateKey")); + } + + case RsaStreamType::pkcs1: + { + EVP_PKEY* evp = nullptr; + auto guardEvp = makeGuard([&] { if (evp) ::EVP_PKEY_free(evp); }); + + const int selection = publicKey ? OSSL_KEYMGMT_SELECT_PUBLIC_KEY : OSSL_KEYMGMT_SELECT_PRIVATE_KEY; + + OSSL_DECODER_CTX* decCtx = ::OSSL_DECODER_CTX_new_for_pkey(&evp, //EVP_PKEY** pkey + "PEM", //const char* input_type + nullptr, //const char* input_struct + "RSA", //const char* keytype + selection, //int selection + nullptr, //OSSL_LIB_CTX* libctx + nullptr); //const char* propquery + if (!decCtx) + throw SysError(formatLastOpenSSLError("OSSL_DECODER_CTX_new_for_pkey")); + ZEN_ON_SCOPE_EXIT(::OSSL_DECODER_CTX_free(decCtx)); + +#if 0 //key stream is password-protected? => OSSL_DECODER_CTX_set_passphrase() + if (!password.empty()) + if (::OSSL_DECODER_CTX_set_passphrase(decCtx, //OSSL_DECODER_CTX *ctx + reinterpret_cast(password.c_str()), //const unsigned char* kstr + password.size()) != 1) //size_t klen + throw SysError(formatLastOpenSSLError("OSSL_DECODER_CTX_set_passphrase")); +#endif + + const unsigned char* keyBuf = reinterpret_cast(keyStream.data()); + size_t keyLen = keyStream.size(); + if (::OSSL_DECODER_from_data(decCtx, &keyBuf, &keyLen) != 1) + throw SysError(formatLastOpenSSLError("OSSL_DECODER_from_data")); + + guardEvp.dismiss(); //pass ownership + return std::shared_ptr(evp, ::EVP_PKEY_free); // + } + + case RsaStreamType::raw: + break; + } + + auto tmp = reinterpret_cast(keyStream.data()); + EVP_PKEY* evp = (publicKey ? ::d2i_PublicKey : ::d2i_PrivateKey)(EVP_PKEY_RSA, //int type + nullptr, //EVP_PKEY** a + &tmp, /*changes tmp pointer itself!*/ //const unsigned char** pp + static_cast(keyStream.size())); //long length + if (!evp) + throw SysError(formatLastOpenSSLError(publicKey ? "d2i_PublicKey" : "d2i_PrivateKey")); + return std::shared_ptr(evp, ::EVP_PKEY_free); +} + +//================================================================================ + +std::string keyToStream(const EVP_PKEY* evp, RsaStreamType streamType, bool publicKey) //throw SysError +{ + //assert(::EVP_PKEY_get_base_id(evp) == EVP_PKEY_RSA); + + switch (streamType) + { + case RsaStreamType::pkix: + { + //fix OpenSSL API inconsistencies: + auto PEM_write_bio_PrivateKey2 = [](BIO* bio, const EVP_PKEY* key) + { + return ::PEM_write_bio_PrivateKey(bio, //BIO* bp + key, //const EVP_PKEY* x + nullptr, //const EVP_CIPHER* enc + nullptr, //const unsigned char* kstr + 0, //int klen + nullptr, //pem_password_cb* cb + nullptr); //void* u + }; + + BIO* bio = ::BIO_new(BIO_s_mem()); + if (!bio) + throw SysError(formatLastOpenSSLError("BIO_new")); + ZEN_ON_SCOPE_EXIT(::BIO_free_all(bio)); + + if ((publicKey ? + ::PEM_write_bio_PUBKEY : + PEM_write_bio_PrivateKey2)(bio, evp) != 1) + throw SysError(formatLastOpenSSLError(publicKey ? "PEM_write_bio_PUBKEY" : "PEM_write_bio_PrivateKey")); + //--------------------------------------------- + const int keyLen = BIO_pending(bio); + if (keyLen < 0) + throw SysError(formatLastOpenSSLError("BIO_pending")); + if (keyLen == 0) + throw SysError(formatSystemError("BIO_pending", L"", L"No more error details.")); //no more error details + + std::string keyStream(keyLen, '\0'); + + if (::BIO_read(bio, keyStream.data(), keyLen) != keyLen) + throw SysError(formatLastOpenSSLError("BIO_read")); + return keyStream; + } + + case RsaStreamType::pkcs1: + { + const int selection = publicKey ? OSSL_KEYMGMT_SELECT_PUBLIC_KEY : OSSL_KEYMGMT_SELECT_PRIVATE_KEY; + + OSSL_ENCODER_CTX* encCtx = ::OSSL_ENCODER_CTX_new_for_pkey(evp, //const EVP_PKEY* pkey + selection, //int selection + "PEM", //const char* output_type + nullptr, //const char* output_structure + nullptr); //const char* propquery + if (!encCtx) + throw SysError(formatLastOpenSSLError("OSSL_ENCODER_CTX_new_for_pkey")); + ZEN_ON_SCOPE_EXIT(::OSSL_ENCODER_CTX_free(encCtx)); + + //password-protect stream? => OSSL_ENCODER_CTX_set_passphrase() + + unsigned char* keyBuf = nullptr; + size_t keyLen = 0; + if (::OSSL_ENCODER_to_data(encCtx, &keyBuf, &keyLen) != 1) + throw SysError(formatLastOpenSSLError("OSSL_ENCODER_to_data")); + ZEN_ON_SCOPE_EXIT(::OPENSSL_free(keyBuf)); + + return {reinterpret_cast(keyBuf), keyLen}; + } + + case RsaStreamType::raw: + break; + } + + unsigned char* buf = nullptr; + const int bufSize = (publicKey ? ::i2d_PublicKey : ::i2d_PrivateKey)(evp, &buf); + if (bufSize <= 0) + throw SysError(formatLastOpenSSLError(publicKey ? "i2d_PublicKey" : "i2d_PrivateKey")); + ZEN_ON_SCOPE_EXIT(::OPENSSL_free(buf)); //memory is only allocated for bufSize > 0 + + return {reinterpret_cast(buf), static_cast(bufSize)}; +} + +//================================================================================ + +std::string createHash(const std::string_view str, const EVP_MD* type) //throw SysError +{ + std::string output(EVP_MAX_MD_SIZE, '\0'); + unsigned int bytesWritten = 0; +#if 1 + //https://www.openssl.org/docs/manmaster/man3/EVP_Digest.html + if (::EVP_Digest(str.data(), //const void* data + str.size(), //size_t count + reinterpret_cast(output.data()), //unsigned char* md + &bytesWritten, //unsigned int* size + type, //const EVP_MD* type + nullptr) != 1) //ENGINE* impl + throw SysError(formatLastOpenSSLError("EVP_Digest")); +#else //streaming version + EVP_MD_CTX* mdctx = ::EVP_MD_CTX_new(); + if (!mdctx) + throw SysError(formatSystemError("EVP_MD_CTX_new", L"", L"No more error details.")); //no more error details + ZEN_ON_SCOPE_EXIT(::EVP_MD_CTX_free(mdctx)); + + if (::EVP_DigestInit(mdctx, //EVP_MD_CTX* ctx + type) != 1) //const EVP_MD* type + throw SysError(formatLastOpenSSLError("EVP_DigestInit")); + + if (::EVP_DigestUpdate(mdctx, //EVP_MD_CTX* ctx + str.data(), //const void* + str.size()) != 1) //size_t cnt + throw SysError(formatLastOpenSSLError("EVP_DigestUpdate")); + + if (::EVP_DigestFinal_ex(mdctx, //EVP_MD_CTX* ctx + reinterpret_cast(output.data()), //unsigned char* md + &bytesWritten) != 1) //unsigned int* s + throw SysError(formatLastOpenSSLError("EVP_DigestFinal_ex")); +#endif + output.resize(bytesWritten); + return output; +} + + +std::string createSignature(const std::string_view message, EVP_PKEY* privateKey) //throw SysError +{ + //https://www.openssl.org/docs/manmaster/man3/EVP_DigestSign.html + EVP_MD_CTX* mdctx = ::EVP_MD_CTX_new(); + if (!mdctx) + throw SysError(formatSystemError("EVP_MD_CTX_new", L"", L"No more error details.")); //no more error details + ZEN_ON_SCOPE_EXIT(::EVP_MD_CTX_free(mdctx)); + + if (::EVP_DigestSignInit(mdctx, //EVP_MD_CTX* ctx + nullptr, //EVP_PKEY_CTX** pctx + EVP_sha256(), //const EVP_MD* type + nullptr, //ENGINE* e + privateKey) != 1) //EVP_PKEY* pkey + throw SysError(formatLastOpenSSLError("EVP_DigestSignInit")); + + if (::EVP_DigestSignUpdate(mdctx, //EVP_MD_CTX* ctx + message.data(), //const void* d + message.size()) != 1) //size_t cnt + throw SysError(formatLastOpenSSLError("EVP_DigestSignUpdate")); + + size_t sigLenMax = 0; //"first call to EVP_DigestSignFinal returns the maximum buffer size required" + if (::EVP_DigestSignFinal(mdctx, //EVP_MD_CTX* ctx + nullptr, //unsigned char* sigret + &sigLenMax) != 1) //size_t* siglen + throw SysError(formatLastOpenSSLError("EVP_DigestSignFinal")); + + std::string signature(sigLenMax, '\0'); + size_t sigLen = sigLenMax; + + if (::EVP_DigestSignFinal(mdctx, //EVP_MD_CTX* ctx + reinterpret_cast(signature.data()), //unsigned char* sigret + &sigLen) != 1) //size_t* siglen + throw SysError(formatLastOpenSSLError("EVP_DigestSignFinal")); + + signature.resize(sigLen); + return signature; +} + + +void verifySignature(const std::string_view message, const std::string_view signature, EVP_PKEY* publicKey) //throw SysError +{ + //https://www.openssl.org/docs/manmaster/man3/EVP_DigestVerify.html + EVP_MD_CTX* mdctx = ::EVP_MD_CTX_new(); + if (!mdctx) + throw SysError(formatSystemError("EVP_MD_CTX_new", L"", L"No more error details.")); //no more error details + ZEN_ON_SCOPE_EXIT(::EVP_MD_CTX_free(mdctx)); + + if (::EVP_DigestVerifyInit(mdctx, //EVP_MD_CTX* ctx + nullptr, //EVP_PKEY_CTX** pctx + EVP_sha256(), //const EVP_MD* type + nullptr, //ENGINE* e + publicKey) != 1) //EVP_PKEY* pkey + throw SysError(formatLastOpenSSLError("EVP_DigestVerifyInit")); + + if (::EVP_DigestVerifyUpdate(mdctx, //EVP_MD_CTX* ctx + message.data(), //const void* d + message.size()) != 1) //size_t cnt + throw SysError(formatLastOpenSSLError("EVP_DigestVerifyUpdate")); + + if (::EVP_DigestVerifyFinal(mdctx, //EVP_MD_CTX* ctx + reinterpret_cast(signature.data()), //const unsigned char* sig + signature.size()) != 1) //size_t siglen + throw SysError(formatLastOpenSSLError("EVP_DigestVerifyFinal")); +} +} + + +std::string zen::convertRsaKey(const std::string_view keyStream, RsaStreamType typeFrom, RsaStreamType typeTo, bool publicKey) //throw SysError +{ + assert(typeFrom != typeTo); + std::shared_ptr evp = streamToKey(keyStream, typeFrom, publicKey); //throw SysError + return keyToStream(evp.get(), typeTo, publicKey); //throw SysError +} + + +void zen::verifySignature(const std::string_view message, const std::string_view signature, const std::string_view publicKeyStream, RsaStreamType streamType) //throw SysError +{ + std::shared_ptr publicKey = streamToKey(publicKeyStream, streamType, true /*publicKey*/); //throw SysError + ::verifySignature(message, signature, publicKey.get()); //throw SysError +} + + +bool zen::isPuttyKeyStream(const std::string_view keyStream) +{ + return startsWith(trimCpy(keyStream, TrimSide::left), "PuTTY-User-Key-File-"); +} + + +std::string zen::convertPuttyKeyToPkix(const std::string_view keyStream, const std::string_view passphrase) //throw SysError +{ + std::vector lines; + + split2(keyStream, isLineBreak, [&lines](const std::string_view block) + { + if (!block.empty()) //consider Windows' + lines.push_back(block); + }); + + //----------- parse PuTTY ppk structure ---------------------------------- + auto itLine = lines.begin(); + + auto lineStartsWith = [&](const char* str) + { + return itLine != lines.end() && startsWith(*itLine, str); + }; + + const int ppkFormat = [&] + { + if (lineStartsWith("PuTTY-User-Key-File-2: ")) + return 2; + else if (lineStartsWith("PuTTY-User-Key-File-3: ")) + return 3; + else + throw SysError(L"Unknown key file format"); + }(); + + const std::string_view algorithm = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + if (!lineStartsWith("Encryption: ")) + throw SysError(L"Missing key encryption"); + const std::string_view keyEncryption = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + const bool keyEncrypted = keyEncryption == "aes256-cbc"; + if (!keyEncrypted && keyEncryption != "none") + throw SysError(L"Unknown key encryption"); + + if (!lineStartsWith("Comment: ")) + throw SysError(L"Missing comment"); + const std::string_view comment = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + if (!lineStartsWith("Public-Lines: ")) + throw SysError(L"Missing public lines"); + size_t pubLineCount = stringTo(afterFirst(*itLine++, ' ', IfNotFoundReturn::none)); + + std::string publicBlob64; + while (pubLineCount-- != 0) + if (itLine != lines.end()) + publicBlob64 += *itLine++; + else + throw SysError(L"Invalid key: incomplete public lines"); + + Argon2Flavor argonFlavor = Argon2Flavor::d; + uint32_t argonMemory = 0; + uint32_t argonPasses = 0; + uint32_t argonParallelism = 0; + std::string argonSalt; + if (ppkFormat >= 3 && keyEncrypted) + { + if (!lineStartsWith("Key-Derivation: ")) + throw SysError(L"Missing Argon2 parameter: Key-Derivation"); + const std::string_view keyDerivation = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + argonFlavor = [&] + { + if (keyDerivation == "Argon2d") + return Argon2Flavor::d; + else if (keyDerivation == "Argon2i") + return Argon2Flavor::i; + else if (keyDerivation == "Argon2id") + return Argon2Flavor::id; + else + throw SysError(L"Unexpected Argon2 parameter for Key-Derivation"); + }(); + + if (!lineStartsWith("Argon2-Memory: ")) + throw SysError(L"Missing Argon2 parameter: Argon2-Memory"); + argonMemory = stringTo(afterFirst(*itLine++, ' ', IfNotFoundReturn::none)); + + if (!lineStartsWith("Argon2-Passes: ")) + throw SysError(L"Missing Argon2 parameter: Argon2-Passes"); + argonPasses = stringTo(afterFirst(*itLine++, ' ', IfNotFoundReturn::none)); + + if (!lineStartsWith("Argon2-Parallelism: ")) + throw SysError(L"Missing Argon2 parameter: Argon2-Parallelism"); + argonParallelism = stringTo(afterFirst(*itLine++, ' ', IfNotFoundReturn::none)); + + if (!lineStartsWith("Argon2-Salt: ")) + throw SysError(L"Missing Argon2 parameter: Argon2-Salt"); + const std::string_view argonSaltHex = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + if (argonSaltHex.size() % 2 != 0 || !std::all_of(argonSaltHex.begin(), argonSaltHex.end(), isHexDigit)) + throw SysError(L"Invalid Argon2 parameter: Argon2-Salt"); + + for (size_t i = 0; i < argonSaltHex.size(); i += 2) + argonSalt += unhexify(argonSaltHex[i], argonSaltHex[i + 1]); + } + + if (!lineStartsWith("Private-Lines: ")) + throw SysError(L"Missing private lines"); + size_t privLineCount = stringTo(afterFirst(*itLine++, ' ', IfNotFoundReturn::none)); + + std::string privateBlob64; + while (privLineCount-- != 0) + if (itLine != lines.end()) + privateBlob64 += *itLine++; + else + throw SysError(L"Invalid key: incomplete private lines"); + + if (!lineStartsWith("Private-MAC: ")) + throw SysError(L"MAC missing"); //apparently "Private-Hash" is/was possible here: maybe with ppk version 1!? + const std::string_view macHex = afterFirst(*itLine++, ' ', IfNotFoundReturn::none); + + //----------- unpack key file elements --------------------- + if (macHex.size() % 2 != 0 || !std::all_of(macHex.begin(), macHex.end(), isHexDigit)) + throw SysError(L"Invalid key: invalid MAC"); + + std::string mac; + for (size_t i = 0; i < macHex.size(); i += 2) + mac += unhexify(macHex[i], macHex[i + 1]); + + const std::string publicBlob = stringDecodeBase64(publicBlob64); + const std::string privateBlobEnc = stringDecodeBase64(privateBlob64); + + std::string privateBlob; + std::string macKeyFmt3; + + if (!keyEncrypted) + privateBlob = privateBlobEnc; + else + { + if (passphrase.empty()) + throw SysError(L"Passphrase required to access private key"); + + const EVP_CIPHER* const cipher = EVP_aes_256_cbc(); + std::string decryptKey; + std::string iv; + if (ppkFormat >= 3) + { + decryptKey.resize(::EVP_CIPHER_get_key_length(cipher)); + iv .resize(::EVP_CIPHER_get_iv_length (cipher)); + macKeyFmt3.resize(32); + + const std::string argonBlob = zargon2(argonFlavor, argonMemory, argonPasses, argonParallelism, + static_cast(decryptKey.size() + iv.size() + macKeyFmt3.size()), passphrase, argonSalt); + MemoryStreamIn streamIn(argonBlob); + readArray(streamIn, decryptKey.data(), decryptKey.size()); // + readArray(streamIn, iv .data(), iv .size()); //throw SysErrorUnexpectedEos + readArray(streamIn, macKeyFmt3.data(), macKeyFmt3.size()); // + } + else + { + decryptKey = createHash(std::string("\0\0\0\0", 4) + passphrase, EVP_sha1()) + //throw SysError + createHash(std::string("\0\0\0\1", 4) + passphrase, EVP_sha1()); // + decryptKey.resize(::EVP_CIPHER_get_key_length(cipher)); //PuTTYgen only uses first 32 bytes as key (== key length of EVP_aes_256_cbc) + + iv.assign(::EVP_CIPHER_get_iv_length(cipher), 0); //initialization vector is 16-byte-range of zeros (== default for EVP_aes_256_cbc) + } + + EVP_CIPHER_CTX* cipCtx = ::EVP_CIPHER_CTX_new(); + if (!cipCtx) + throw SysError(formatSystemError("EVP_CIPHER_CTX_new", L"", L"No more error details.")); //no more error details + ZEN_ON_SCOPE_EXIT(::EVP_CIPHER_CTX_free(cipCtx)); + + if (::EVP_DecryptInit(cipCtx, //EVP_CIPHER_CTX* ctx + cipher, //const EVP_CIPHER* type + reinterpret_cast(decryptKey.c_str()), //const unsigned char* key + reinterpret_cast(iv.c_str())) != 1) //const unsigned char* iv => nullptr = 16-byte zeros for EVP_aes_256_cbc + throw SysError(formatLastOpenSSLError("EVP_DecryptInit_ex")); + + if (::EVP_CIPHER_CTX_set_padding(cipCtx, 0 /*padding*/) != 1) + throw SysError(formatSystemError("EVP_CIPHER_CTX_set_padding", L"", L"No more error details.")); //no more error details + + privateBlob.resize(privateBlobEnc.size() + ::EVP_CIPHER_block_size(EVP_aes_256_cbc())); + //"EVP_DecryptUpdate() should have room for (inl + cipher_block_size) bytes" + + int decLen1 = 0; + if (::EVP_DecryptUpdate(cipCtx, //EVP_CIPHER_CTX* ctx + reinterpret_cast(privateBlob.data()), //unsigned char* out + &decLen1, //int* outl + reinterpret_cast(privateBlobEnc.c_str()), //const unsigned char* in + static_cast(privateBlobEnc.size())) != 1) //int inl + throw SysError(formatLastOpenSSLError("EVP_DecryptUpdate")); + + int decLen2 = 0; + if (::EVP_DecryptFinal(cipCtx, //EVP_CIPHER_CTX* ctx + reinterpret_cast(&privateBlob[decLen1]), //unsigned char* outm + &decLen2) != 1) //int* outl + throw SysError(formatLastOpenSSLError("EVP_DecryptFinal_ex")); + + privateBlob.resize(decLen1 + decLen2); + } + + //----------- verify key consistency --------------------- + std::string macKey; + if (ppkFormat >= 3) + macKey = macKeyFmt3; + else + macKey = createHash(std::string("putty-private-key-file-mac-key") + (keyEncrypted ? passphrase : ""), EVP_sha1()); //throw SysError + + auto numToBeString = [](size_t n) -> std::string + { + static_assert(std::endian::native == std::endian::little && sizeof(n) >= 4); + assert(n == static_cast(n)); + const char* numStr = reinterpret_cast(&n); + return {numStr[3], numStr[2], numStr[1], numStr[0]}; //big endian! + }; + + const std::string macData = numToBeString(algorithm .size()) + algorithm + + numToBeString(keyEncryption.size()) + keyEncryption + + numToBeString(comment .size()) + comment + + numToBeString(publicBlob .size()) + publicBlob + + numToBeString(privateBlob .size()) + privateBlob; + char md[EVP_MAX_MD_SIZE] = {}; + unsigned int mdLen = 0; + if (!::HMAC(ppkFormat <= 2 ? EVP_sha1() : EVP_sha256(), //const EVP_MD* evp_md + macKey.c_str(), //const void* key + static_cast(macKey.size()), //int key_len + reinterpret_cast(macData.c_str()), //const unsigned char* d + static_cast(macData.size()), //int n + reinterpret_cast(md), //unsigned char* md + &mdLen)) //unsigned int* md_len + throw SysError(formatSystemError("HMAC", L"", L"No more error details.")); //no more error details + + if (mac != std::string_view(md, mdLen)) + throw SysError(keyEncrypted ? L"Wrong passphrase (or corrupted key)" : L"Validation failed: corrupted key"); + //---------------------------------------------------------- + + auto extractString = [](auto& it, auto itEnd) + { + uint32_t byteCount = 0; + if (itEnd - it < makeSigned(sizeof(byteCount))) + throw SysError(L"String extraction failed: unexpected end of stream"); + + static_assert(std::endian::native == std::endian::little); + char* numStr = reinterpret_cast(&byteCount); + numStr[3] = *it++; // + numStr[2] = *it++; //Putty uses big endian! + numStr[1] = *it++; // + numStr[0] = *it++; // + + if (makeUnsigned(itEnd - it) < byteCount) + throw SysError(L"String extraction failed: unexpected end of stream(2)"); + + std::string str(it, it + byteCount); + it += byteCount; + return str; + }; + + struct BnFree { void operator()(BIGNUM* num) const { ::BN_free(num); } }; + auto createBigNum = [] + { + BIGNUM* bn = ::BN_new(); + if (!bn) + throw SysError(formatLastOpenSSLError("BN_new")); + return std::unique_ptr(bn); + }; + + auto extractBigNum = [&extractString](auto& it, auto itEnd) + { + const std::string bytes = extractString(it, itEnd); + + BIGNUM* bn = ::BN_bin2bn(reinterpret_cast(bytes.c_str()), static_cast(bytes.size()), nullptr); + if (!bn) + throw SysError(formatLastOpenSSLError("BN_bin2bn")); + return std::unique_ptr(bn); + }; + + auto itPub = publicBlob .begin(); + auto itPriv = privateBlob.begin(); + + auto extractStringPub = [&] { return extractString(itPub, publicBlob .end()); }; + auto extractStringPriv = [&] { return extractString(itPriv, privateBlob.end()); }; + + auto extractBigNumPub = [&] { return extractBigNum(itPub, publicBlob .end()); }; + auto extractBigNumPriv = [&] { return extractBigNum(itPriv, privateBlob.end()); }; + + //----------- parse public/private key blobs ---------------- + if (extractStringPub() != algorithm) + throw SysError(L"Invalid public key stream (header)"); + + if (algorithm == "ssh-rsa") + { + std::unique_ptr e = extractBigNumPub (); // + std::unique_ptr n = extractBigNumPub (); // + std::unique_ptr d = extractBigNumPriv(); //throw SysError + std::unique_ptr p = extractBigNumPriv(); // + std::unique_ptr q = extractBigNumPriv(); // + std::unique_ptr iqmp = extractBigNumPriv(); // + + //------ calculate missing numbers: dmp1, dmq1 ------------- + std::unique_ptr dmp1 = createBigNum(); // + std::unique_ptr dmq1 = createBigNum(); //throw SysError + std::unique_ptr tmp = createBigNum(); // + + BN_CTX* bnCtx = BN_CTX_new(); + if (!bnCtx) + throw SysError(formatLastOpenSSLError("BN_CTX_new")); + ZEN_ON_SCOPE_EXIT(::BN_CTX_free(bnCtx)); + + if (::BN_sub(tmp.get(), p.get(), BN_value_one()) != 1) + throw SysError(formatLastOpenSSLError("BN_sub")); + + if (::BN_mod(dmp1.get(), d.get(), tmp.get(), bnCtx) != 1) + throw SysError(formatLastOpenSSLError("BN_mod")); + + if (::BN_sub(tmp.get(), q.get(), BN_value_one()) != 1) + throw SysError(formatLastOpenSSLError("BN_sub")); + + if (::BN_mod(dmq1.get(), d.get(), tmp.get(), bnCtx) != 1) + throw SysError(formatLastOpenSSLError("BN_mod")); + //---------------------------------------------------------- + + OSSL_PARAM_BLD* paramBld = ::OSSL_PARAM_BLD_new(); + if (!paramBld) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_new")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_BLD_free(paramBld)); + + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_N, n.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(n)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_E, e.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(e)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_D, d.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(d)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_FACTOR1, p.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(p)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_FACTOR2, q.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(q)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_EXPONENT1, dmp1.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(dmp1)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_EXPONENT2, dmq1.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(dmq1)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_RSA_COEFFICIENT1, iqmp.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(iqmp)")); + + OSSL_PARAM* sslParams = ::OSSL_PARAM_BLD_to_param(paramBld); + if (!sslParams) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_to_param")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_free(sslParams)); + + + EVP_PKEY_CTX* evpCtx = ::EVP_PKEY_CTX_new_from_name(nullptr, "RSA", nullptr); + if (!evpCtx) + throw SysError(formatLastOpenSSLError("EVP_PKEY_CTX_new_from_name(RSA)")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_CTX_free(evpCtx)); + + if (::EVP_PKEY_fromdata_init(evpCtx) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata_init")); + + EVP_PKEY* evp = nullptr; + if (::EVP_PKEY_fromdata(evpCtx, &evp, EVP_PKEY_KEYPAIR, sslParams) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_free(evp)); + + return keyToStream(evp, RsaStreamType::pkix, false /*publicKey*/); //throw SysError + } + //---------------------------------------------------------- + else if (algorithm == "ssh-dss") + { + std::unique_ptr p = extractBigNumPub (); // + std::unique_ptr q = extractBigNumPub (); // + std::unique_ptr g = extractBigNumPub (); //throw SysError + std::unique_ptr pub = extractBigNumPub (); // + std::unique_ptr pri = extractBigNumPriv(); // + //---------------------------------------------------------- + OSSL_PARAM_BLD* paramBld = ::OSSL_PARAM_BLD_new(); + if (!paramBld) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_new")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_BLD_free(paramBld)); + + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_FFC_P, p.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(p)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_FFC_Q, q.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(q)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_FFC_G, g.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(g)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_PUB_KEY, pub.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(pub)")); + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_PRIV_KEY, pri.get()) != 1) throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(pri)")); + + OSSL_PARAM* sslParams = ::OSSL_PARAM_BLD_to_param(paramBld); + if (!sslParams) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_to_param")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_free(sslParams)); + + + EVP_PKEY_CTX* evpCtx = ::EVP_PKEY_CTX_new_from_name(nullptr, "DSA", nullptr); + if (!evpCtx) + throw SysError(formatLastOpenSSLError("EVP_PKEY_CTX_new_from_name(DSA)")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_CTX_free(evpCtx)); + + if (::EVP_PKEY_fromdata_init(evpCtx) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata_init")); + + EVP_PKEY* evp = nullptr; + if (::EVP_PKEY_fromdata(evpCtx, &evp, EVP_PKEY_KEYPAIR, sslParams) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_free(evp)); + + return keyToStream(evp, RsaStreamType::pkix, false /*publicKey*/); //throw SysError + } + //---------------------------------------------------------- + else if (algorithm == "ecdsa-sha2-nistp256" || + algorithm == "ecdsa-sha2-nistp384" || + algorithm == "ecdsa-sha2-nistp521") + { + const std::string_view algoShort = afterLast(algorithm, '-', IfNotFoundReturn::none); + if (extractStringPub() != algoShort) + throw SysError(L"Invalid public key stream (header)"); + + const std::string pointStream = extractStringPub(); + std::unique_ptr pri = extractBigNumPriv(); //throw SysError + //---------------------------------------------------------- + const char* groupName = [&] + { + if (algoShort == "nistp256") + return SN_X9_62_prime256v1; //same as SECG secp256r1 + if (algoShort == "nistp384") + return SN_secp384r1; + if (algoShort == "nistp521") + return SN_secp521r1; + throw SysError(L"Unknown elliptic curve: " + utfTo(algorithm)); + }(); + + OSSL_PARAM_BLD* paramBld = ::OSSL_PARAM_BLD_new(); + if (!paramBld) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_new")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_BLD_free(paramBld)); + + if (::OSSL_PARAM_BLD_push_utf8_string(paramBld, OSSL_PKEY_PARAM_GROUP_NAME, groupName, 0) != 1) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_utf8_string(group)")); + + if (::OSSL_PARAM_BLD_push_octet_string(paramBld, OSSL_PKEY_PARAM_PUB_KEY, pointStream.data(), pointStream.size()) != 1) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_octet_string(pub)")); + + if (::OSSL_PARAM_BLD_push_BN(paramBld, OSSL_PKEY_PARAM_PRIV_KEY, pri.get()) != 1) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_push_BN(priv)")); + + OSSL_PARAM* sslParams = ::OSSL_PARAM_BLD_to_param(paramBld); + if (!sslParams) + throw SysError(formatLastOpenSSLError("OSSL_PARAM_BLD_to_param")); + ZEN_ON_SCOPE_EXIT(::OSSL_PARAM_free(sslParams)); + + + EVP_PKEY_CTX* evpCtx = ::EVP_PKEY_CTX_new_from_name(nullptr, "EC", nullptr); + if (!evpCtx) + throw SysError(formatLastOpenSSLError("EVP_PKEY_CTX_new_from_name(EC)")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_CTX_free(evpCtx)); + + if (::EVP_PKEY_fromdata_init(evpCtx) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata_init")); + + EVP_PKEY* evp = nullptr; + if (::EVP_PKEY_fromdata(evpCtx, &evp, EVP_PKEY_KEYPAIR, sslParams) != 1) + throw SysError(formatLastOpenSSLError("EVP_PKEY_fromdata")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_free(evp)); + + return keyToStream(evp, RsaStreamType::pkix, false /*publicKey*/); //throw SysError + } + //---------------------------------------------------------- + else if (algorithm == "ssh-ed25519") + { + //const std::string pubStream = extractStringPub(); -> we don't need the public key + const std::string priStream = extractStringPriv(); + + EVP_PKEY* evpPriv = ::EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, //int type + nullptr, //ENGINE* e + reinterpret_cast(priStream.c_str()), //const unsigned char* priv + priStream.size()); //size_t len + if (!evpPriv) + throw SysError(formatLastOpenSSLError("EVP_PKEY_new_raw_private_key")); + ZEN_ON_SCOPE_EXIT(::EVP_PKEY_free(evpPriv)); + + return keyToStream(evpPriv, RsaStreamType::pkix, false /*publicKey*/); //throw SysError + } + else + throw SysError(L"Unsupported key algorithm: " + utfTo(algorithm)); + /* PuTTYgen supports many more (which are not yet supported by libssh2): + - rsa-sha2-256 + - rsa-sha2-512 + - ssh-ed448 + - ssh-dss-cert-v01@openssh.com + - ssh-rsa-cert-v01@openssh.com + - rsa-sha2-256-cert-v01@openssh.com + - rsa-sha2-512-cert-v01@openssh.com + - ssh-ed25519-cert-v01@openssh.com + - ecdsa-sha2-nistp256-cert-v01@openssh.com + - ecdsa-sha2-nistp384-cert-v01@openssh.com + - ecdsa-sha2-nistp521-cert-v01@openssh.com */ +} diff --git a/zen/open_ssl.h b/zen/open_ssl.h new file mode 100644 index 0000000..96e25ff --- /dev/null +++ b/zen/open_ssl.h @@ -0,0 +1,40 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef OPEN_SSL_H_801974580936508934568792347506 +#define OPEN_SSL_H_801974580936508934568792347506 + +#include "sys_error.h" + + +namespace zen +{ +//init OpenSSL before use! +void openSslInit(); +void openSslTearDown(); + + +enum class RsaStreamType +{ + pkix, //base-64-encoded X.509 SubjectPublicKeyInfo structure ("BEGIN PUBLIC KEY") + pkcs1, //base-64-encoded PKCS#1 RSAPublicKey: RSA number and exponent ("BEGIN RSA PUBLIC KEY") + raw //raw bytes: DER-encoded PKCS#1 +}; + +//verify signatures produced with: "openssl dgst -sha256 -sign private.pem -out file.sig file.txt" +void verifySignature(const std::string_view message, + const std::string_view signature, + const std::string_view publicKeyStream, + RsaStreamType streamType); //throw SysError + +std::string convertRsaKey(const std::string_view keyStream, RsaStreamType typeFrom, RsaStreamType typeTo, bool publicKey); //throw SysError + + +bool isPuttyKeyStream(const std::string_view keyStream); +std::string convertPuttyKeyToPkix(const std::string_view keyStream, const std::string_view passphrase); //throw SysError +} + +#endif //OPEN_SSL_H_801974580936508934568792347506 diff --git a/zen/perf.h b/zen/perf.h new file mode 100644 index 0000000..61ec0c4 --- /dev/null +++ b/zen/perf.h @@ -0,0 +1,103 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PERF_H_83947184145342652456 +#define PERF_H_83947184145342652456 + +#include +#include "string_tools.h" + + #include + + +//############# two macros for quick performance measurements ############### +#define PERF_START zen::PerfTimer perfTest; +#define PERF_STOP perfTest.showResult(); +//########################################################################### + +/* Example: Aggregated function call time: + + static zen::PerfTimer perfTest(true); //startPaused + perfTest.resume(); + ZEN_ON_SCOPE_EXIT(perfTest.pause()); */ + +namespace zen +{ +/* issue with wxStopWatch? https://freefilesync.org/forum/viewtopic.php?t=1426 + - wxStopWatch implementation uses QueryPerformanceCounter: https://github.com/wxWidgets/wxWidgets/blob/17d72a48ffd4d8ff42eed070ac48ee2de50ceabd/src/common/stopwatch.cpp + - MSDN: "How often does QPC roll over? Not less than 100 years from the most recent system boot" + https://docs.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps#general-faq-about-qpc-and-tsc + - But QPC can glitch out in VMs: https://web.archive.org/web/20190420142348/https://blogs.msdn.microsoft.com/tvoellm/2008/06/05/negative-ping-times-in-windows-vms-whats-up/ + ... or for AMD Opteron CPUs: https://web.archive.org/web/20191101122320/https://support.microsoft.com/en-us/help/938448/a-windows-server-2003-based-server-may-experience-time-stamp-counter-d + + - system clock is obviously no alternative: https://freefilesync.org/forum/viewtopic.php?t=5280 + + std::chrono::system_clock wraps ::GetSystemTimePreciseAsFileTime() + std::chrono::steady_clock wraps ::QueryPerformanceCounter() */ +class StopWatch +{ +public: + explicit StopWatch(bool startPaused = false) + { + if (startPaused) + startTime_ = {}; + } + + bool isPaused() const { return startTime_ == std::chrono::steady_clock::time_point{}; } + + void pause() + { + if (!isPaused()) + elapsedUntilPause_ += std::chrono::steady_clock::now() - std::exchange(startTime_, {}); + } + + void resume() + { + if (isPaused()) + startTime_ = std::chrono::steady_clock::now(); + } + + std::chrono::nanoseconds elapsed() const + { + auto elapsedTotal = elapsedUntilPause_; + if (!isPaused()) + elapsedTotal += std::chrono::steady_clock::now() - startTime_; + return elapsedTotal; + } + +private: + std::chrono::steady_clock::time_point startTime_ = std::chrono::steady_clock::now(); + std::chrono::nanoseconds elapsedUntilPause_{}; //std::chrono::duration is uninitialized by default! WTF! When will this stupidity end! +}; + + +class PerfTimer +{ +public: + [[deprecated]] explicit PerfTimer(bool startPaused = false) : watch_(startPaused) {} + + ~PerfTimer() { if (!resultShown_) showResult(); } + + void pause () { watch_.pause(); } + void resume() { watch_.resume(); } + + void showResult() + { + const int64_t timeMs = std::chrono::duration_cast(watch_.elapsed()).count(); + const std::string msg = numberTo(timeMs) + " ms"; + std::clog << "Perf: duration: " << msg + '\n'; + resultShown_ = true; + + watch_ = StopWatch(watch_.isPaused()); + } + +private: + StopWatch watch_; + bool resultShown_ = false; +}; +} + +#endif //PERF_H_83947184145342652456 diff --git a/zen/process_exec.cpp b/zen/process_exec.cpp new file mode 100644 index 0000000..2026a98 --- /dev/null +++ b/zen/process_exec.cpp @@ -0,0 +1,267 @@ +// ***************************************************************************** +// * 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 "process_exec.h" +#include "guid.h" +#include "file_access.h" +#include "file_io.h" + + #include //fork, pipe + #include //waitpid + #include + +using namespace zen; + + +Zstring zen::escapeCommandArg(const Zstring& arg) +{ + Zstring output; + bool addQuotes = false; + + for (const char c : arg) + { + switch (c) + { + case '"': // + case '\\': //must be escaped - no matter if string is double-quoted or not + case '`': // + case '$': // + output += '\\'; + break; + + default: + if (contains(" '&*()|;<>#~", c)) //must *either* be escaped or protected by double-quotes, never both! + addQuotes = true; + break; + } + output += c; + } + + if (addQuotes) + return '"' + output + '"'; //caveat: single-quotes not working on macOS if string contains escaped chars! no such issue on Linux + return output; +} + + + + +namespace +{ +std::pair processExecuteImpl(const Zstring& filePath, const std::vector& arguments, + std::optional timeoutMs) //throw SysError, SysErrorTimeOut +{ + const Zstring tempFilePath = appendPath(getTempFolderPath(), //throw FileError + Zstr("FFS-") + utfTo(formatAsHexString(generateGUID()))); + /* can't use popen(): does NOT return the exit code on Linux (despite the documentation!), although it works correctly on macOS + => use pipes instead: https://linux.die.net/man/2/waitpid + bonus: no need for "2>&1" to redirect STDERR to STDOUT + + What about premature exit via SysErrorTimeOut? + Linux: child process' end of the pipe *still works* even after the parent process is gone: + There does not seem to be any output buffer size limit + no observable strain on system memory or disk space! :) + macOS: child process exits if parent end of pipe is closed: fuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu.......... + + => solution: buffer output in temporary file + + Unresolved problem: premature exit via SysErrorTimeOut (=> no waitpid()) creates zombie proceses: + "As long as a zombie is not removed from the system via a wait, + it will consume a slot in the kernel process table, and if this table fills, + it will not be possible to create further processes." */ + + const int EC_CHILD_LAUNCH_FAILED = 120; //avoid 127: used by the system, e.g. failure to execute due to missing .so file + + //use O_TMPFILE? sounds nice, but support is probably crap: https://github.com/libvips/libvips/issues/1151 + const int fdTempFile = ::open(tempFilePath.c_str(), O_CREAT | O_EXCL | O_RDWR | O_CLOEXEC, + S_IRUSR | S_IWUSR); //0600 + if (fdTempFile == -1) + THROW_LAST_SYS_ERROR("open(" + utfTo(tempFilePath) + ")"); + auto guardTmpFile = makeGuard([&] { ::close(fdTempFile); }); + + //"deleting while handle is open" == FILE_FLAG_DELETE_ON_CLOSE + if (::unlink(tempFilePath.c_str()) != 0) + THROW_LAST_SYS_ERROR("unlink"); + + //-------------------------------------------------------------- + //waitpid() is a useless pile of garbage without time out => check EOF from dummy pipe instead + int pipe[2] = {}; + if (::pipe2(pipe, O_CLOEXEC) != 0) + THROW_LAST_SYS_ERROR("pipe2"); + + const int fdLifeSignR = pipe[0]; //for parent process + const int fdLifeSignW = pipe[1]; //for child process + ZEN_ON_SCOPE_EXIT(::close(fdLifeSignR)); + auto guardFdLifeSignW = makeGuard([&] { ::close(fdLifeSignW ); }); + + //-------------------------------------------------------------- + + //follow implemenation of ::system(): https://github.com/lattera/glibc/blob/master/sysdeps/posix/system.c + const pid_t pid = ::fork(); + if (pid < 0) //pids are never negative, empiric proof: https://linux.die.net/man/2/wait + THROW_LAST_SYS_ERROR("fork"); + + if (pid == 0) //child process + try + { + //first task: set STDOUT redirection in case an error needs to be reported + if (::dup2(fdTempFile, STDOUT_FILENO) != STDOUT_FILENO) //O_CLOEXEC does NOT propagate with dup2() + THROW_LAST_SYS_ERROR("dup2(STDOUT)"); + + if (::dup2(fdTempFile, STDERR_FILENO) != STDERR_FILENO) //O_CLOEXEC does NOT propagate with dup2() + THROW_LAST_SYS_ERROR("dup2(STDERR)"); + + //avoid blocking scripts waiting for user input + // => appending " < /dev/null" is not good enough! e.g. hangs for: read -p "still hanging here"; echo fuuuuu... + const int fdDevNull = ::open("/dev/null", O_RDONLY | O_CLOEXEC); + if (fdDevNull == -1) //don't check "< 0" -> docu seems to allow "-2" to be a valid file handle + THROW_LAST_SYS_ERROR("open(/dev/null)"); + ZEN_ON_SCOPE_EXIT(::close(fdDevNull)); + + if (::dup2(fdDevNull, STDIN_FILENO) != STDIN_FILENO) //O_CLOEXEC does NOT propagate with dup2() + THROW_LAST_SYS_ERROR("dup2(STDIN)"); + + //*leak* the fd and have it closed automatically on child process exit after execv() + if (::dup(fdLifeSignW) == -1) //O_CLOEXEC does NOT propagate with dup() + THROW_LAST_SYS_ERROR("dup(fdLifeSignW)"); + + std::vector argv{filePath.c_str()}; + for (const Zstring& arg : arguments) + argv.push_back(arg.c_str()); + argv.push_back(nullptr); + + /*int rv =*/::execv(argv[0], const_cast(argv.data())); //only returns if an error occurred + //safe to cast away const: https://pubs.opengroup.org/onlinepubs/9699919799/functions/exec.html + // "The statement about argv[] and envp[] being constants is included to make explicit to future + // writers of language bindings that these objects are completely constant. Due to a limitation of + // the ISO C standard, it is not possible to state that idea in standard C." + THROW_LAST_SYS_ERROR("execv"); + } + catch (const SysError& e) + { + ::puts(utfTo(e.toString()).c_str()); + ::fflush(stdout); //note: stderr is unbuffered by default + ::_exit(EC_CHILD_LAUNCH_FAILED); //[!] avoid flushing I/O buffers or doing other clean up from child process like with "exit()"! + } + //else: parent process + + + if (timeoutMs) + { + guardFdLifeSignW.dismiss(); + ::close(fdLifeSignW); //[!] make sure we get EOF when fd is closed by child! + + const int flags = ::fcntl(fdLifeSignR, F_GETFL); + if (flags == -1) + THROW_LAST_SYS_ERROR("fcntl(F_GETFL)"); + + //fcntl() success: Linux: 0 + // macOS: "Value other than -1." + if (::fcntl(fdLifeSignR, F_SETFL, flags | O_NONBLOCK) == -1) + THROW_LAST_SYS_ERROR("fcntl(F_SETFL, O_NONBLOCK)"); + + + const auto stopTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(*timeoutMs); + for (;;) //EINTR handling? => allow interruption!? + { + //read until EAGAIN + char buf[16]; + const ssize_t bytesRead = ::read(fdLifeSignR, buf, sizeof(buf)); + if (bytesRead < 0) + { + if (errno != EAGAIN) + THROW_LAST_SYS_ERROR("read"); + } + else if (bytesRead > 0) + throw SysError(formatSystemError("read", L"", L"Unexpected data.")); + else //bytesRead == 0: EOF + break; + + //wait for stream input + const auto now = std::chrono::steady_clock::now(); + if (now > stopTime) + throw SysErrorTimeOut(_P("Operation timed out after 1 second.", "Operation timed out after %x seconds.", *timeoutMs / 1000)); + + const auto waitTimeMs = std::chrono::duration_cast(stopTime - now).count(); + + timeval tv{.tv_sec = static_cast(waitTimeMs / 1000)}; + tv.tv_usec = static_cast(waitTimeMs - tv.tv_sec * 1000) * 1000; + + fd_set rfd{}; //includes FD_ZERO + FD_SET(fdLifeSignR, &rfd); + + if (const int rv = ::select(fdLifeSignR + 1, //int nfds = "highest-numbered file descriptor in any of the three sets, plus 1" + &rfd, //fd_set* readfds + nullptr, //fd_set* writefds + nullptr, //fd_set* exceptfds + &tv); //struct timeval* timeout + rv < 0) + THROW_LAST_SYS_ERROR("select"); + else if (rv == 0) + throw SysErrorTimeOut(_P("Operation timed out after 1 second.", "Operation timed out after %x seconds.", *timeoutMs / 1000)); + } + } + + //https://linux.die.net/man/2/waitpid + int statusCode = 0; + if (::waitpid(pid, //pid_t pid + &statusCode, //int* status + 0) != pid) //int options + THROW_LAST_SYS_ERROR("waitpid"); + + + if (::lseek(fdTempFile, 0, SEEK_SET) != 0) + THROW_LAST_SYS_ERROR("lseek"); + + guardTmpFile.dismiss(); + FileInputPlain streamIn(fdTempFile, tempFilePath); //pass ownership! + + std::string output = unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + return streamIn.tryRead(buffer, bytesToRead); //throw FileError; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! + }, + streamIn.getBlockSize()); //throw FileError + + if (!WIFEXITED(statusCode)) //signalled, crashed? + throw SysError(formatSystemError("waitpid", WIFSIGNALED(statusCode) ? + L"Killed by signal " + numberTo(WTERMSIG(statusCode)) : + L"Exit status " + numberTo(statusCode), + utfTo(trimCpy(output)))); + + const int exitCode = WEXITSTATUS(statusCode); //precondition: "WIFEXITED() == true" + if (exitCode == EC_CHILD_LAUNCH_FAILED || //child process should already have provided details to STDOUT + exitCode == 127) //details should have been streamed to STDERR: used by /bin/sh, e.g. failure to execute due to missing .so file + throw SysError(utfTo(trimCpy(output))); + + return {exitCode, std::move(output)}; +} +} + + +std::pair zen::consoleExecute(const Zstring& cmdLine, std::optional timeoutMs) //throw SysError, SysErrorTimeOut +{ + const auto& [exitCode, output] = processExecuteImpl("/bin/sh", {"-c", cmdLine.c_str()}, timeoutMs); //throw SysError, SysErrorTimeOut + return {exitCode, copyStringTo(output)}; +} + + +void zen::openWithDefaultApp(const Zstring& itemPath) //throw FileError +{ + try + { + std::optional timeoutMs; + const Zstring cmdTemplate = "xdg-open %x"; //*might* block! + timeoutMs = 0; //e.g. on Lubuntu if Firefox is started and not already running => no need for time out! https://freefilesync.org/forum/viewtopic.php?t=8260 + const Zstring cmdLine = replaceCpy(cmdTemplate, Zstr("%x"), escapeCommandArg(itemPath)); + + if (const auto& [exitCode, output] = consoleExecute(cmdLine, timeoutMs); //throw SysError, SysErrorTimeOut + exitCode != 0) + throw SysError(formatSystemError(utfTo(cmdTemplate), + replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + } + catch (SysErrorTimeOut&) {} //child process not failed yet => probably fine :> + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot open file %x."), L"%x", fmtPath(itemPath)), e.toString()); } +} + + diff --git a/zen/process_exec.h b/zen/process_exec.h new file mode 100644 index 0000000..1c18c3f --- /dev/null +++ b/zen/process_exec.h @@ -0,0 +1,28 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SHELL_EXECUTE_H_23482134578134134 +#define SHELL_EXECUTE_H_23482134578134134 + +#include "file_error.h" + + +namespace zen +{ +Zstring escapeCommandArg(const Zstring& arg); + + +DEFINE_NEW_SYS_ERROR(SysErrorTimeOut) +[[nodiscard]] std::pair consoleExecute(const Zstring& cmdLine, std::optional timeoutMs); //throw SysError, SysErrorTimeOut +/* Windows: - cmd.exe returns exit code 1 if file not found (instead of throwing SysError) => nodiscard! + - handles elevation when CreateProcess() would fail with ERROR_ELEVATION_REQUIRED! + - no support for UNC path and Unicode on Win7; apparently no issue on Win10! + Linux/macOS: SysErrorTimeOut leaves zombie process behind if timeoutMs is used */ + +void openWithDefaultApp(const Zstring& itemPath); //throw FileError +} + +#endif //SHELL_EXECUTE_H_23482134578134134 diff --git a/zen/process_priority.cpp b/zen/process_priority.cpp new file mode 100644 index 0000000..6bd2e3f --- /dev/null +++ b/zen/process_priority.cpp @@ -0,0 +1,124 @@ +// ***************************************************************************** +// * 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 "process_priority.h" + + #include //setpriority + +using namespace zen; + + +namespace +{ +#if 0 +//https://linux.die.net/man/2/getpriority +//CPU priority from highest to lowest range: [-NZERO, NZERO -1] usually: [-20, 19] +enum //with values from CentOS 7 +{ + CPU_PRIO_VERYHIGH = -NZERO, + CPU_PRIO_HIGH = -5, + CPU_PRIO_NORMAL = 0, + CPU_PRIO_LOW = 5, + CPU_PRIO_VERYLOW = NZERO - 1, +}; + +int getCpuPriority() //throw SysError +{ + errno = 0; + const int prio = getpriority(PRIO_PROCESS, 0 /* = the calling process */); + if (prio == -1 && errno != 0) //"can legitimately return the value -1" + THROW_LAST_SYS_ERROR("getpriority"); + return prio; +} + + +//lowering is allowed, but increasing CPU prio requires admin rights >:( +void setCpuPriority(int prio) //throw SysError +{ + if (setpriority(PRIO_PROCESS, 0 /* = the calling process */, prio) != 0) + THROW_LAST_SYS_ERROR("setpriority(" + numberTo(prio) + ')'); +} +#endif +//--------------------------------------------------------------------------------------------------- + +//- required functions ioprio_get/ioprio_set are not part of glibc: https://linux.die.net/man/2/ioprio_set +//- and probably never will: https://sourceware.org/bugzilla/show_bug.cgi?id=4464 +//https://github.com/torvalds/linux/blob/master/include/uapi/linux/ioprio.h +#define IOPRIO_CLASS_SHIFT 13 + +#define IOPRIO_PRIO_VALUE(prioclass, priolevel) \ + (((prioclass) << IOPRIO_CLASS_SHIFT) | (priolevel)) + +#define IOPRIO_NORM 4 + +enum +{ + IOPRIO_WHO_PROCESS = 1, + IOPRIO_WHO_PGRP, + IOPRIO_WHO_USER, +}; + +enum +{ + IOPRIO_CLASS_NONE = 0, + IOPRIO_CLASS_RT = 1, + IOPRIO_CLASS_BE = 2, + IOPRIO_CLASS_IDLE = 3, +}; + + +int getIoPriority() //throw SysError +{ + const int rv = ::syscall(SYS_ioprio_get, IOPRIO_WHO_PROCESS, ::getpid()); + if (rv == -1) + THROW_LAST_SYS_ERROR("ioprio_get"); + + //fix Linux kernel fuck up: bogus system default value + if (rv == IOPRIO_PRIO_VALUE(IOPRIO_CLASS_NONE, IOPRIO_NORM)) + return IOPRIO_PRIO_VALUE(IOPRIO_CLASS_BE, IOPRIO_NORM); + + return rv; +} + + +void setIoPriority(int ioPrio) //throw SysError +{ + if (::syscall(SYS_ioprio_set, IOPRIO_WHO_PROCESS, ::getpid(), ioPrio) != 0) + THROW_LAST_SYS_ERROR("ioprio_set(0x" + printNumber("%x", static_cast(ioPrio)) + ')'); +} +} + + +struct SetProcessPriority::Impl +{ + std::optional oldIoPrio; +}; + + +SetProcessPriority::SetProcessPriority(ProcessPriority prio) : //throw FileError + pimpl_(new Impl) +{ + if (prio == ProcessPriority::background) + try + { + pimpl_->oldIoPrio = getIoPriority(); //throw SysError + + setIoPriority(IOPRIO_PRIO_VALUE(IOPRIO_CLASS_BE, 6 /*0 (highest) to 7 (lowest)*/)); //throw SysError + //maybe even IOPRIO_PRIO_VALUE(IOPRIO_CLASS_IDLE, 0) ? nope: "only served when no one else is using the disk" + } + catch (const SysError& e) { throw FileError(_("Cannot change process I/O priorities."), e.toString()); } +} + + +SetProcessPriority::~SetProcessPriority() +{ + if (pimpl_->oldIoPrio) + try + { + setIoPriority(*pimpl_->oldIoPrio); //throw SysError + } + catch (const SysError& e) { logExtraError(_("Cannot change process I/O priorities.") + L"\n\n" + e.toString()); } +} diff --git a/zen/process_priority.h b/zen/process_priority.h new file mode 100644 index 0000000..216c115 --- /dev/null +++ b/zen/process_priority.h @@ -0,0 +1,36 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PROCESS_PRIORITY_H_83421759082143245 +#define PROCESS_PRIORITY_H_83421759082143245 + +#include +#include "file_error.h" + + +namespace zen +{ +enum class ProcessPriority +{ + normal, + background, //lower CPU and file I/O priorities +}; + +//- prevent operating system going into sleep state +//- set process I/O priorities +class SetProcessPriority +{ +public: + explicit SetProcessPriority(ProcessPriority prio); //throw FileError + ~SetProcessPriority(); + +private: + struct Impl; + const std::unique_ptr pimpl_; +}; +} + +#endif //PROCESS_PRIORITY_H_83421759082143245 diff --git a/zen/recycler.cpp b/zen/recycler.cpp new file mode 100644 index 0000000..7e6205b --- /dev/null +++ b/zen/recycler.cpp @@ -0,0 +1,106 @@ +// ***************************************************************************** +// * 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 "recycler.h" + + #include + #include "scope_guard.h" + +using namespace zen; + + + + +void zen::moveToRecycleBin(const Zstring& itemPath) //throw FileError, RecycleBinUnavailable +{ + GFile* file = ::g_file_new_for_path(itemPath.c_str()); //never fails according to docu + ZEN_ON_SCOPE_EXIT(g_object_unref(file);) + + GError* error = nullptr; + ZEN_ON_SCOPE_EXIT(if (error) ::g_error_free(error)); + + if (!::g_file_trash(file, nullptr, &error)) + { + /* g_file_trash() can fail with different error codes/messages when trash is unavailable: + Debian 8 (GLib 2.42): G_IO_ERROR_NOT_SUPPORTED: Unable to find or create trash directory + CentOS 7 (GLib 2.56): G_IO_ERROR_FAILED: Unable to find or create trash directory for file.txt => localized! >:( + master (GLib 2.64): G_IO_ERROR_NOT_SUPPORTED: Trashing on system internal mounts is not supported + https://gitlab.gnome.org/GNOME/glib/blob/master/gio/glocalfile.c#L2042 */ + + //*INDENT-OFF* + const bool trashUnavailable = error && error->domain == G_IO_ERROR && + (error->code == G_IO_ERROR_NOT_SUPPORTED || + + //yes, the following is a cluster fuck, but what can you do? + (error->code == G_IO_ERROR_FAILED && [&] + { + for (const char* msgLoc : //translations from https://gitlab.gnome.org/GNOME/glib/-/tree/main/po + { + "Unable to find or create trash directory for", + "No s'ha pogut trobar o crear el directori de la paperera per", + "Nelze nalézt nebo vytvořit složku koše pro", + "Kan ikke finde eller oprette papirkurvskatalog for", + "Αδύνατη η εύρεση ή δημιουργία του καταλόγου απορριμμάτων", + "Unable to find or create wastebasket directory for", + "Ne eblas trovi aŭ krei rubujan dosierujon", + "No se pudo encontrar o crear la carpeta de la papelera para", + "Prügikasti kataloogi pole võimalik leida või luua", + "zakarrontziaren direktorioa aurkitu edo sortu", + "Roskakori kansiota ei löydy tai sitä ei voi luoda", + "Impossible de trouver ou créer le répertoire de la corbeille pour", + "Non é posíbel atopar ou crear o directorio do lixo para", + "Nisam mogao promijeniti putanju u mapu", + "Nem található vagy nem hozható létre a Kuka könyvtár ehhez:", + "Tidak bisa menemukan atau membuat direktori tong sampah bagi", + "Impossibile trovare o creare la directory cestino per", + "のゴミ箱ディレクトリが存在しないか作成できません", + "휴지통 디렉터리를 찾을 수 없거나 만들 수 없습니다", + "Nepavyko rasti ar sukurti šiukšlių aplanko", + "Nevar atrast vai izveidot miskastes mapi priekš", + "Tidak boleh mencari atau mencipta direktori tong sampah untuk", + "Kan ikke finne eller opprette mappe for papirkurv for", + "फाइल सिर्जना गर्न असफल:", + "Impossible de trobar o crear lo repertòri de l'escobilhièr per", + "ਲਈ ਰੱਦੀ ਡਾਇਰੈਕਟਰੀ ਲੱਭਣ ਜਾਂ ਬਣਾਉਣ ਲਈ ਅਸਮਰੱਥ", + "Nie można odnaleźć lub utworzyć katalogu kosza dla", + "Impossível encontrar ou criar a pasta de lixo para", + "Não é possível localizar ou criar o diretório da lixeira para", + "Nu se poate găsi sau crea directorul coșului de gunoi pentru", + "Не удалось найти или создать каталог корзины для", + "Nepodarilo sa nájsť ani vytvoriť adresár Kôš pre", + "Ni mogoče najti oziroma ustvariti mape smeti za", + "Не могу да нађем или направим директоријум смећа за", + "Ne mogu da nađem ili napravim direktorijum smeća za", + "Kunde inte hitta eller skapa papperskorgskatalog för", + "için çöp dizini bulunamıyor ya da oluşturulamıyor", + "Не вдалося знайти або створити каталог смітника для", + "หาหรือสร้างไดเรกทอรีถังขยะสำหรับ", + }) + if (contains(error->message, msgLoc)) + return true; + + for (const auto& [msgLoc1, msgLoc2] : + { + std::pair{"Papierkorb-Ordner konnte für", "nicht gefunden oder angelegt werden"}, + std::pair{"Kan prullenbakmap voor", "niet vinden of aanmaken"}, + std::pair{"无法为", "找到或创建回收站目录"}, + std::pair{"無法找到或建立", "的垃圾桶目錄"}, + }) + if (contains(error->message, msgLoc1) && contains(error->message, msgLoc2)) + return true; + + return false; + }())); + //*INDENT-ON* + + if (trashUnavailable) + throw RecycleBinUnavailable(replaceCpy(_("The recycle bin is not available for %x."), L"%x", fmtPath(itemPath)), + formatGlibError("g_file_trash", error)); + + throw FileError(replaceCpy(_("Unable to move %x to the recycle bin."), L"%x", fmtPath(itemPath)), + formatGlibError("g_file_trash", error)); + } +} diff --git a/zen/recycler.h b/zen/recycler.h new file mode 100644 index 0000000..7977132 --- /dev/null +++ b/zen/recycler.h @@ -0,0 +1,34 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RECYCLER_H_18345067341545 +#define RECYCLER_H_18345067341545 + +#include +#include +#include "file_error.h" + + +namespace zen +{ +/* -------------------- + |Recycle Bin Access| + -------------------- + + Windows: -> Recycler API (IFileOperation) always available + -> COM needs to be initialized before calling any of these functions! CoInitializeEx/CoUninitialize + + Linux: Compiler flags: `pkg-config --cflags gio-2.0` + Linker flags: `pkg-config --libs gio-2.0` + + Already included in package "gtk+-2.0"! */ + + +//fails if item is not existing (anymore) +void moveToRecycleBin(const Zstring& itemPath); //throw FileError, RecycleBinUnavailable +} + +#endif //RECYCLER_H_18345067341545 diff --git a/zen/resolve_path.cpp b/zen/resolve_path.cpp new file mode 100644 index 0000000..7bf50b1 --- /dev/null +++ b/zen/resolve_path.cpp @@ -0,0 +1,231 @@ +// ***************************************************************************** +// * 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 "resolve_path.h" +#include "time.h" +#include "thread.h" +#include "file_access.h" + + #include + #include //getcwd() + +using namespace zen; + + +namespace +{ +Zstring resolveRelativePath(const Zstring& relativePath) +{ + if (relativePath.empty()) + return relativePath; + + Zstring pathTmp = relativePath; + //https://linux.die.net/man/2/path_resolution + if (!startsWith(pathTmp, FILE_NAME_SEPARATOR)) //absolute names are exactly those starting with a '/' + { + /* basic support for '~': strictly speaking this is a shell-layer feature, so "realpath()" won't handle it + https://www.gnu.org/software/bash/manual/html_node/Tilde-Expansion.html */ + if (startsWith(pathTmp, "~/") || pathTmp == "~") + { + try + { + const Zstring& homePath = getUserHome(); //throw FileError + + if (startsWith(pathTmp, "~/")) + pathTmp = appendPath(homePath, pathTmp.c_str() + 2); + else //pathTmp == "~" + pathTmp = homePath; + } + catch (FileError&) {} + //else: error! no further processing! + } + else + { + //we cannot use ::realpath() which only resolves *existing* relative paths! + if (char* dirPath = ::getcwd(nullptr, 0)) + { + ZEN_ON_SCOPE_EXIT(::free(dirPath)); + pathTmp = appendPath(dirPath, pathTmp); + } + } + } + //get rid of some cruft (just like GetFullPathName()) + replace(pathTmp, "/./", '/'); + if (endsWith(pathTmp, "/.")) + pathTmp.pop_back(); //keep the "/" => consider pathTmp == "/." + + //what about "/../"? might be relative to symlinks => preserve! + + return pathTmp; +} + + + + +//returns value if resolved +std::optional tryResolveMacro(const ZstringView macro) //macro without %-characters +{ + Zstring timeStr; + auto resolveTimePhrase = [&](const Zchar* phrase, const Zchar* format) -> bool + { + if (!equalAsciiNoCase(macro, phrase)) + return false; + + timeStr = formatTime(format); + return true; + }; + + //https://en.cppreference.com/w/cpp/chrono/c/strftime + //there exist environment variables named %TIME%, %DATE% so check for our internal macros first! + if (resolveTimePhrase(Zstr("Date"), Zstr("%Y-%m-%d"))) return timeStr; + if (resolveTimePhrase(Zstr("Time"), Zstr("%H%M%S"))) return timeStr; + if (resolveTimePhrase(Zstr("TimeStamp"), Zstr("%Y-%m-%d %H%M%S"))) return timeStr; //e.g. "2012-05-15 131513" + if (resolveTimePhrase(Zstr("Year"), Zstr("%Y"))) return timeStr; + if (resolveTimePhrase(Zstr("Month"), Zstr("%m"))) return timeStr; + if (resolveTimePhrase(Zstr("MonthName"), Zstr("%b"))) return timeStr; //e.g. "Jan" + if (resolveTimePhrase(Zstr("Day"), Zstr("%d"))) return timeStr; + if (resolveTimePhrase(Zstr("Hour"), Zstr("%H"))) return timeStr; + if (resolveTimePhrase(Zstr("Min"), Zstr("%M"))) return timeStr; + if (resolveTimePhrase(Zstr("Sec"), Zstr("%S"))) return timeStr; + if (resolveTimePhrase(Zstr("WeekDayName"), Zstr("%a"))) return timeStr; //e.g. "Mon" + if (resolveTimePhrase(Zstr("Week"), Zstr("%V"))) return timeStr; //ISO 8601 week of the year + + if (equalAsciiNoCase(macro, Zstr("WeekDay"))) + { + const int weekDayStartSunday = stringTo(formatTime(Zstr("%w"))); //[0 (Sunday), 6 (Saturday)] => not localized! + //alternative 1: use "%u": ISO 8601 weekday as number with Monday as 1 (1-7) => newer standard than %w + //alternative 2: ::mktime() + std::tm::tm_wday + + const int weekDayStartMonday = (weekDayStartSunday + 6) % 7; //+6 == -1 in Z_7 + // [0-Monday, 6-Sunday] + + const int weekDayStartLocal = ((weekDayStartMonday + 7 - static_cast(getFirstDayOfWeek())) % 7) + 1; + //[1 (local first day of week), 7 (local last day of week)] + + return numberTo(weekDayStartLocal); + } + + //try to resolve as environment variables + if (std::optional value = getEnvironmentVar(macro)) + return *value; + + return {}; +} + +const Zchar MACRO_SEP = Zstr('%'); +} + + +//returns expanded or original string +Zstring zen::expandMacros(const Zstring& text) +{ + if (contains(text, MACRO_SEP)) + { + Zstring prefix = beforeFirst(text, MACRO_SEP, IfNotFoundReturn::none); + Zstring rest = afterFirst (text, MACRO_SEP, IfNotFoundReturn::none); + if (contains(rest, MACRO_SEP)) + { + Zstring potentialMacro = beforeFirst(rest, MACRO_SEP, IfNotFoundReturn::none); + Zstring postfix = afterFirst (rest, MACRO_SEP, IfNotFoundReturn::none); //text == prefix + MACRO_SEP + potentialMacro + MACRO_SEP + postfix + + if (std::optional value = tryResolveMacro(potentialMacro)) + return prefix + *value + expandMacros(postfix); + else + return prefix + MACRO_SEP + potentialMacro + expandMacros(MACRO_SEP + postfix); + } + } + return text; +} + + +namespace +{ + + +//expand volume name if possible, return original input otherwise +Zstring tryExpandVolumeName(Zstring pathPhrase) // [volname]:\folder [volname]\folder [volname]folder -> C:\folder +{ + //we only expect the [.*] pattern at the beginning => do not touch dir names like "C:\somedir\[stuff]" + trim(pathPhrase, TrimSide::left); + + if (startsWith(pathPhrase, Zstr('['))) + { + return "/.../" + pathPhrase; + } + return pathPhrase; +} +} + + +std::vector zen::getPathPhraseAliases(const Zstring& itemPath) +{ + assert(!itemPath.empty()); + std::vector pathAliases{makePathPhrase(itemPath)}; + + { + + //environment variables: C:\Users\ -> %UserProfile% + auto substByMacro = [&](const ZstringView macroName, const Zstring& macroPath) + { + //should use a replaceCpy() that considers "local path" case-sensitivity (if only we had one...) + if (contains(itemPath, macroPath)) + pathAliases.push_back(makePathPhrase(replaceCpyAsciiNoCase(itemPath, macroPath, Zstring() + MACRO_SEP + macroName + MACRO_SEP))); + }; + + for (const ZstringView envName : + { + "HOME", //Linux: /home/ Mac: /Users/ + //"USER", -> any benefit? + }) + if (const std::optional envPath = getEnvironmentVar(envName)) + substByMacro(envName, *envPath); + + } + //removeDuplicates()? should not be needed... + + std::sort(pathAliases.begin(), pathAliases.end(), LessNaturalSort() /*even on Linux*/); + return pathAliases; +} + + +Zstring zen::makePathPhrase(const Zstring& itemPath) +{ + if (endsWith(itemPath, Zstr(' '))) //path phrase concept must survive trimming! + return itemPath + FILE_NAME_SEPARATOR; + return itemPath; +} + + +//coordinate changes with acceptsFolderPathPhraseNative()! +Zstring zen::getResolvedFilePath(const Zstring& pathPhrase) //noexcept +{ + Zstring path = pathPhrase; + + path = expandMacros(path); //expand before trimming! + + trim(path); //remove leading/trailing whitespace before allowing misinterpretation in applyLongPathPrefix() + + { + path = tryExpandVolumeName(path); //may block for slow USB sticks and idle HDDs! + + /* need to resolve relative paths: + WINDOWS: + - \\?\-prefix requires absolute names + - Volume Shadow Copy: volume name needs to be part of each file path + - file icon buffer (at least for extensions that are actually read from disk, like "exe") + WINDOWS/LINUX: + - detection of dependent directories, e.g. "\" and "C:\test" */ + path = resolveRelativePath(path); + } + + //remove trailing slash, unless volume root: + if (const std::optional pc = parsePathComponents(path)) + path = appendPath(pc->rootPath, pc->relPath); + + return path; +} + + diff --git a/zen/resolve_path.h b/zen/resolve_path.h new file mode 100644 index 0000000..bfef087 --- /dev/null +++ b/zen/resolve_path.h @@ -0,0 +1,31 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RESOLVE_PATH_H_817402834713454 +#define RESOLVE_PATH_H_817402834713454 + +#include "file_error.h" + + +namespace zen +{ +/* - expand macros + - trim whitespace + - expand volume path by name + - convert relative paths into absolute + + => may block for slow USB sticks and idle HDDs */ +Zstring getResolvedFilePath(const Zstring& pathPhrase); //noexcept + +//macro substitution only +Zstring expandMacros(const Zstring& text); + +std::vector getPathPhraseAliases(const Zstring& itemPath); +Zstring makePathPhrase(const Zstring& itemPath); + +} + +#endif //RESOLVE_PATH_H_817402834713454 diff --git a/zen/ring_buffer.h b/zen/ring_buffer.h new file mode 100644 index 0000000..f6cc3e5 --- /dev/null +++ b/zen/ring_buffer.h @@ -0,0 +1,255 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef RING_BUFFER_H_01238467085684139453534 +#define RING_BUFFER_H_01238467085684139453534 + +#include +#include "scope_guard.h" + + +namespace zen +{ +//like std::deque<> but with a non-garbage implementation: circular buffer with std::vector<>-like exponential growth! +//https://stackoverflow.com/questions/39324192/why-is-an-stl-deque-not-implemented-as-just-a-circular-vector + +template +class RingBuffer +{ +public: + RingBuffer() {} + + RingBuffer(RingBuffer&& tmp) noexcept : rawMem_(std::move(tmp.rawMem_)), capacity_(tmp.capacity_), bufStart_(tmp.bufStart_), size_(tmp.size_) + { + tmp.capacity_ = tmp.bufStart_ = tmp.size_ = 0; + } + RingBuffer& operator=(RingBuffer&& tmp) noexcept { swap(tmp); return *this; } //noexcept *required* to support move for reallocations in std::vector and std::swap!!! + + ~RingBuffer() { clear(); } + + using value_type = T; + using reference = T&; + using const_reference = const T&; + + size_t size () const { return size_; } + size_t capacity() const { return capacity_; } + bool empty () const { return size_ == 0; } + + reference front() { checkInvariants(); assert(!empty()); return getBufPtr()[bufStart_]; } + const_reference front() const { checkInvariants(); assert(!empty()); return getBufPtr()[bufStart_]; } + + reference back() { checkInvariants(); assert(!empty()); return getBufPtr()[getBufPos(size_ - 1)]; } + const_reference back() const { checkInvariants(); assert(!empty()); return getBufPtr()[getBufPos(size_ - 1)]; } + + template + void push_front(U&& value) + { + reserve(size_ + 1); //throw ? + ::new (getBufPtr() + getBufPos(capacity_ - 1)) T(std::forward(value)); //throw ? + ++size_; + bufStart_ = getBufPos(capacity_ - 1); + } + + template + void push_back(U&& value) + { + reserve(size_ + 1); //throw ? + ::new (getBufPtr() + getBufPos(size_)) T(std::forward(value)); //throw ? + ++size_; + } + + void pop_front() + { + front().~T(); + --size_; + + if (size_ == 0) + bufStart_ = 0; + else + bufStart_ = getBufPos(1); + } + + void pop_back() + { + back().~T(); + --size_; + + if (size_ == 0) + bufStart_ = 0; + } + + void clear() + { + checkInvariants(); + + const size_t frontSize = std::min(size_, capacity_ - bufStart_); + + std::destroy(getBufPtr() + bufStart_, getBufPtr() + bufStart_ + frontSize); + std::destroy(getBufPtr(), getBufPtr() + size_ - frontSize); + bufStart_ = size_ = 0; + } + + template + void insert_back(Iterator first, Iterator last) //throw ? (strong exception-safety!) + { + const size_t len = last - first; + reserve(size_ + len); //throw ? + + const size_t endPos = getBufPos(size_); + const size_t tailSize = std::min(len, capacity_ - endPos); + + std::uninitialized_copy(first, first + tailSize, getBufPtr() + endPos); //throw ? + ZEN_ON_SCOPE_FAIL(std::destroy(first, first + tailSize)); + std::uninitialized_copy(first + tailSize, last, getBufPtr()); //throw ? + + size_ += len; + } + + //contract: last - first <= size() + template + void extract_front(Iterator first, Iterator last) //throw ? strongly exception-safe! (but only basic exception safety for [first, last) range) + { + checkInvariants(); + const size_t len = last - first; + assert(size_ >= len); + + const size_t frontSize = std::min(len, capacity_ - bufStart_); + + auto itTrg = std::copy(getBufPtr() + bufStart_, getBufPtr() + bufStart_ + frontSize, first); //throw ? + /**/ std::copy(getBufPtr(), getBufPtr() + len - frontSize, itTrg); // + + std::destroy(getBufPtr() + bufStart_, getBufPtr() + bufStart_ + frontSize); + std::destroy(getBufPtr(), getBufPtr() + len - frontSize); + + size_ -= len; + + if (size_ == 0) + bufStart_ = 0; + else + bufStart_ = getBufPos(len); + } + + void swap(RingBuffer& other) + { + std::swap(rawMem_, other.rawMem_); + std::swap(capacity_, other.capacity_); + std::swap(bufStart_, other.bufStart_); + std::swap(size_, other.size_); + } + + void reserve(size_t minCapacity) //throw ? (strong exception-safety!) + { + checkInvariants(); + + if (minCapacity > capacity_) + { + const size_t newCapacity = std::max(minCapacity + minCapacity / 2, minCapacity); //no lower limit for capacity: just like std::vector<> + + RingBuffer newBuf(newCapacity); //throw ? + + T* itTrg = reinterpret_cast(newBuf.rawMem_.get()); + + const size_t frontSize = std::min(size_, capacity_ - bufStart_); + + itTrg = uninitializedMoveIfNoexcept(getBufPtr() + bufStart_, getBufPtr() + bufStart_ + frontSize, itTrg); //throw ? + newBuf.size_ = frontSize; //pass ownership + /**/ uninitializedMoveIfNoexcept(getBufPtr(), getBufPtr() + size_ - frontSize, itTrg); //throw ? + newBuf.size_ = size_; // + + newBuf.swap(*this); + } + } + + const T& operator[](size_t offset) const + { + assert(offset < size()); //design by contract! no runtime check! + return getBufPtr()[getBufPos(offset)]; + } + + T& operator[](size_t offset) { return const_cast(static_cast(this)->operator[](offset)); } + + template + class Iterator + { + public: + using iterator_category = std::random_access_iterator_tag; + using value_type = Value; + using difference_type = ptrdiff_t; + using pointer = Value*; + using reference = Value&; + + Iterator(Container& container, size_t offset) : container_(&container), offset_(offset) {} + Iterator& operator++() { ++offset_; return *this; } + Iterator& operator--() { --offset_; return *this; } + Iterator& operator+=(ptrdiff_t offset) { offset_ += offset; return *this; } + Value& operator* () const { return (*container_)[offset_]; } + Value* operator->() const { return &(*container_)[offset_]; } + inline friend Iterator operator+(const Iterator& lhs, ptrdiff_t offset) { Iterator tmp(lhs); return tmp += offset; } + inline friend ptrdiff_t operator-(const Iterator& lhs, const Iterator& rhs) { return lhs.offset_ - rhs.offset_; } + inline friend bool operator==(const Iterator& lhs, const Iterator& rhs) { assert(lhs.container_ == rhs.container_); return lhs.offset_ == rhs.offset_; } + inline friend std::strong_ordering operator<=>(const Iterator& lhs, const Iterator& rhs) { assert(lhs.container_ == rhs.container_); return lhs.offset_ <=> rhs.offset_; } + //GCC debug needs "operator<=" + private: + Container* container_ = nullptr; //iterator must be assignable + ptrdiff_t offset_ = 0; + }; + + using iterator = Iterator< RingBuffer, T>; + using const_iterator = Iterator; + + iterator begin() { return {*this, 0 }; } + iterator end () { return {*this, size_}; } + + const_iterator begin() const { return {*this, 0 }; } + const_iterator end () const { return {*this, size_}; } + + const_iterator cbegin() const { return begin(); } + const_iterator cend () const { return end (); } + +private: + RingBuffer (const RingBuffer&) = delete; //wait until there is a reason to copy a RingBuffer + RingBuffer& operator=(const RingBuffer&) = delete; // + + explicit RingBuffer(size_t capacity) : + rawMem_(static_cast(::operator new (capacity * sizeof(T)))), //throw std::bad_alloc + capacity_(capacity) {} + + /**/ T* getBufPtr() { return reinterpret_cast(rawMem_.get()); } + const T* getBufPtr() const { return reinterpret_cast(rawMem_.get()); } + + //unlike pure std::uninitialized_move, this one allows for strong exception-safety! + static T* uninitializedMoveIfNoexcept(T* first, T* last, T* firstTrg) + { + return uninitializedMoveIfNoexcept(first, last, firstTrg, std::is_nothrow_move_constructible()); + } + static T* uninitializedMoveIfNoexcept(T* first, T* last, T* firstTrg, std::true_type ) { return std::uninitialized_move(first, last, firstTrg); } + static T* uninitializedMoveIfNoexcept(T* first, T* last, T* firstTrg, std::false_type) { return std::uninitialized_copy(first, last, firstTrg); } //throw ? + + size_t getBufPos(size_t offset) const + { + //assert(offset < capacity_); -> redundant in this context + size_t bufPos = bufStart_ + offset; + if (bufPos >= capacity_) + bufPos -= capacity_; + return bufPos; + } + + void checkInvariants() const + { + assert(bufStart_ == 0 || bufStart_ < capacity_); + assert(size_ <= capacity_); + } + + struct FreeStoreDelete { void operator()(std::byte* p) const { ::operator delete (p); } }; + + std::unique_ptr rawMem_; + size_t capacity_ = 0; //as number of T + size_t bufStart_ = 0; //< capacity_ + size_t size_ = 0; //<= capacity_ +}; +} + +#endif //RING_BUFFER_H_01238467085684139453534 diff --git a/zen/scope_guard.h b/zen/scope_guard.h new file mode 100644 index 0000000..8575a07 --- /dev/null +++ b/zen/scope_guard.h @@ -0,0 +1,113 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SCOPE_GUARD_H_8971632487321434 +#define SCOPE_GUARD_H_8971632487321434 + +#include +#include "legacy_compiler.h" //std::uncaught_exceptions + +//best of Zen, Loki and C++17 + +namespace zen +{ +/* Scope Guard + + auto guardAio = zen::makeGuard([&] { ::CloseHandle(hDir); }); + ... + guardAio.dismiss(); + + Scope Exit: + ZEN_ON_SCOPE_EXIT (CleanUp()); + ZEN_ON_SCOPE_FAIL (UndoTemporaryWork()); + ZEN_ON_SCOPE_SUCCESS(NotifySuccess()); */ + +enum class ScopeGuardRunMode +{ + onExit, + onSuccess, + onFail +}; + + +//partially specialize scope guard destructor code and get rid of those pesky MSVC "4127 conditional expression is constant" +template inline +void runScopeGuardDestructor(F& fun, bool failed, std::integral_constant) +{ + if (!failed) + fun(); //throw X + else + try { fun(); } + catch (...) { assert(false); } +} + + +template inline +void runScopeGuardDestructor(F& fun, bool failed, std::integral_constant) +{ + if (!failed) + fun(); //throw X +} + + +template inline +void runScopeGuardDestructor(F& fun, bool failed, std::integral_constant) noexcept +{ + if (failed) + try { fun(); } + catch (...) { assert(false); } +} + + +template +class ScopeGuard +{ +public: + explicit ScopeGuard(const F& fun) : fun_(fun) {} + explicit ScopeGuard( F&& fun) : fun_(std::move(fun)) {} + + //ScopeGuard(ScopeGuard&& tmp) : + // fun_(std::move(tmp.fun_)), + // exeptionCount_(tmp.exeptionCount_), + // dismissed_(tmp.dismissed_) { tmp.dismissed_ = true; } + + ~ScopeGuard() noexcept(runMode == ScopeGuardRunMode::onFail) + { + if (!dismissed_) + { + const bool failed = std::uncaught_exceptions() > exeptionCount_; + runScopeGuardDestructor(fun_, failed, std::integral_constant()); + } + } + + void dismiss() { dismissed_ = true; } + +private: + ScopeGuard (const ScopeGuard&) = delete; + ScopeGuard& operator=(const ScopeGuard&) = delete; + + const F fun_; + const int exeptionCount_ = std::uncaught_exceptions(); + bool dismissed_ = false; +}; + + +template inline +auto makeGuard(F&& fun) { return ScopeGuard>(std::forward(fun)); } +} + +#define ZEN_CONCAT_SUB(X, Y) X ## Y +#define ZEN_CONCAT(X, Y) ZEN_CONCAT_SUB(X, Y) + +#define ZEN_CHECK_CASE_FOR_CONSTANT(X) case X: return ZEN_CHECK_CASE_FOR_CONSTANT_IMPL(#X) +#define ZEN_CHECK_CASE_FOR_CONSTANT_IMPL(X) L ## X + + +#define ZEN_ON_SCOPE_EXIT(X) [[maybe_unused]] auto ZEN_CONCAT(scopeGuard, __LINE__) = zen::makeGuard([&]{ X; }); +#define ZEN_ON_SCOPE_FAIL(X) [[maybe_unused]] auto ZEN_CONCAT(scopeGuard, __LINE__) = zen::makeGuard([&]{ X; }); +#define ZEN_ON_SCOPE_SUCCESS(X) [[maybe_unused]] auto ZEN_CONCAT(scopeGuard, __LINE__) = zen::makeGuard([&]{ X; }); + +#endif //SCOPE_GUARD_H_8971632487321434 diff --git a/zen/serialize.h b/zen/serialize.h new file mode 100644 index 0000000..844c27d --- /dev/null +++ b/zen/serialize.h @@ -0,0 +1,437 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SERIALIZE_H_839405783574356 +#define SERIALIZE_H_839405783574356 + +#include +#include "sys_error.h" +//keep header clean from specific stream implementations! (e.g.file_io.h)! used by abstract.h! + + +namespace zen +{ +/* high-performance unformatted serialization (avoiding wxMemoryOutputStream/wxMemoryInputStream inefficiencies) + + ---------------------------- + | Binary Container Concept | + ---------------------------- + binary container for data storage: must support "basic" std::vector interface (e.g. std::vector, std::string, Zbase) + + --------------------------------- + | Unbuffered Input Stream Concept | + --------------------------------- + size_t getBlockSize(); //throw X + size_t tryRead(void* buffer, size_t bytesToRead); //throw X; may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + + ---------------------------------- + | Unbuffered Output Stream Concept | + ---------------------------------- + size_t getBlockSize(); //throw X + size_t tryWrite(const void* buffer, size_t bytesToWrite); //throw X; may return short! CONTRACT: bytesToWrite > 0 + + =============================================================================================== + + --------------------------------- + | Buffered Input Stream Concept | + --------------------------------- + size_t read(void* buffer, size_t bytesToRead); //throw X; return "bytesToRead" bytes unless end of stream! + + ---------------------------------- + | Buffered Output Stream Concept | + ---------------------------------- + void write(const void* buffer, size_t bytesToWrite); //throw X */ + +using IoCallback = std::function; //throw X + + +template +BinContainer unbufferedLoad(Function tryRead/*(void* buffer, size_t bytesToRead) throw X; may return short; only 0 means EOF*/, + size_t blockSize); //throw X + +template +void unbufferedSave(const BinContainer& cont, Function tryWrite /*(const void* buffer, size_t bytesToWrite) throw X; may return short*/, + size_t blockSize); //throw X + +template +uint64_t /*streamSize*/ unbufferedStreamCopy(Function1 tryRead /*(void* buffer, size_t bytesToRead) throw X; may return short; only 0 means EOF*/, size_t blockSizeIn, + Function2 tryWrite /*(const void* buffer, size_t bytesToWrite) throw X; may return short*/, size_t blockSizeOut); //throw X + + +template void writeNumber (BufferedOutputStream& stream, const N& num); // +template void writeContainer(BufferedOutputStream& stream, const C& str); //noexcept +template < class BufferedOutputStream> void writeArray (BufferedOutputStream& stream, const void* buffer, size_t len); // +//---------------------------------------------------------------------- +struct SysErrorUnexpectedEos : public SysError +{ + SysErrorUnexpectedEos() : SysError(_("File content is corrupted.") + L" (unexpected end of stream)") {} +}; + +template N readNumber (BufferedInputStream& stream); //throw SysErrorUnexpectedEos (corrupted data) +template C readContainer(BufferedInputStream& stream); // +template < class BufferedInputStream> void readArray (BufferedInputStream& stream, void* buffer, size_t len); // + + +struct IOCallbackDivider +{ + IOCallbackDivider(const IoCallback& notifyUnbufferedIO, int64_t& totalBytesNotified) : + totalBytesNotified_(totalBytesNotified), + notifyUnbufferedIO_(notifyUnbufferedIO) { assert(totalBytesNotified == 0); } + + void operator()(int64_t bytesDelta) //throw X! + { + if (notifyUnbufferedIO_) notifyUnbufferedIO_((totalBytesNotified_ + bytesDelta) / 2 - totalBytesNotified_ / 2); //throw X! + totalBytesNotified_ += bytesDelta; + } + +private: + int64_t& totalBytesNotified_; + const IoCallback& notifyUnbufferedIO_; +}; + +//------------------------------------------------------------------------------------- + +//buffered input/output stream reference implementations: +struct MemoryStreamIn +{ + explicit MemoryStreamIn(const std::string_view& stream) : memRef_(stream) {} + + MemoryStreamIn(std::string&&) = delete; //careful: do NOT store reference to a temporary! + + size_t read(void* buffer, size_t bytesToRead) //return "bytesToRead" bytes unless end of stream! + { + const size_t junkSize = std::min(bytesToRead, memRef_.size() - pos_); + std::memcpy(buffer, memRef_.data() + pos_, junkSize); + pos_ += junkSize; + return junkSize; + } + + size_t pos() const { return pos_; } + +private: + //MemoryStreamIn (const MemoryStreamIn&) = delete; -> why not allow copying? + MemoryStreamIn& operator=(const MemoryStreamIn&) = delete; + + const std::string_view memRef_; + size_t pos_ = 0; +}; + + +struct MemoryStreamOut +{ + MemoryStreamOut() = default; + + void write(const void* buffer, size_t bytesToWrite) + { + memBuf_.append(static_cast(buffer), bytesToWrite); + } + + const std::string& ref() const { return memBuf_; } + /**/ std::string& ref() { return memBuf_; } + +private: + MemoryStreamOut (const MemoryStreamOut&) = delete; + MemoryStreamOut& operator=(const MemoryStreamOut&) = delete; + + std::string memBuf_; +}; + +//------------------------------------------------------------------------------------- + +template +struct BufferedInputStream +{ + BufferedInputStream(Function tryRead /*(void* buffer, size_t bytesToRead) throw X; may return short; only 0 means EOF*/, + size_t blockSize) : + tryRead_(tryRead), blockSize_(blockSize) {} + + size_t read(void* buffer, size_t bytesToRead) //throw X; return "bytesToRead" bytes unless end of stream! + { + assert(memBuf_.size() >= blockSize_); + assert(bufPos_ <= bufPosEnd_ && bufPosEnd_ <= memBuf_.size()); + const auto bufStart = buffer; + for (;;) + { + const size_t junkSize = std::min(bytesToRead, bufPosEnd_ - bufPos_); + std::memcpy(buffer, memBuf_.data() + bufPos_ /*caveat: vector debug checks*/, junkSize); + bufPos_ += junkSize; + buffer = static_cast(buffer) + junkSize; + bytesToRead -= junkSize; + + if (bytesToRead == 0) + break; + //-------------------------------------------------------------------- + const size_t bytesRead = tryRead_(memBuf_.data(), blockSize_); //throw X; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0 + bufPos_ = 0; + bufPosEnd_ = bytesRead; + + if (bytesRead == 0) //end of file + break; + } + return static_cast(buffer) - + static_cast(bufStart); + } + +private: + BufferedInputStream (const BufferedInputStream&) = delete; + BufferedInputStream& operator=(const BufferedInputStream&) = delete; + + Function tryRead_; + const size_t blockSize_; + + size_t bufPos_ = 0; + size_t bufPosEnd_= 0; + std::vector memBuf_{blockSize_}; +}; + + +template +struct BufferedOutputStream +{ + BufferedOutputStream(Function tryWrite /*(const void* buffer, size_t bytesToWrite) throw X; may return short*/, + size_t blockSize) : + tryWrite_(tryWrite), blockSize_(blockSize) {} + + ~BufferedOutputStream() + { + } + + void write(const void* buffer, size_t bytesToWrite) //throw X + { + assert(memBuf_.size() >= blockSize_); + assert(bufPos_ <= bufPosEnd_ && bufPosEnd_ <= memBuf_.size()); + + for (;;) + { + const size_t junkSize = std::min(bytesToWrite, blockSize_ - (bufPosEnd_ - bufPos_)); + std::memcpy(memBuf_.data() + bufPosEnd_, buffer, junkSize); + bufPosEnd_ += junkSize; + buffer = static_cast(buffer) + junkSize; + bytesToWrite -= junkSize; + + if (bytesToWrite == 0) + return; + //-------------------------------------------------------------------- + bufPos_ += tryWrite_(memBuf_.data() + bufPos_, blockSize_); //throw X; may return short + + if (memBuf_.size() - bufPos_ < blockSize_ || //support memBuf_.size() > blockSize to avoid memmove()s + bufPos_ == bufPosEnd_) + { + std::memmove(memBuf_.data(), memBuf_.data() + bufPos_, bufPosEnd_ - bufPos_); + bufPosEnd_ -= bufPos_; + bufPos_ = 0; + } + } + } + + void flushBuffer() //throw X + { + assert(bufPosEnd_ - bufPos_ <= blockSize_); + assert(bufPos_ <= bufPosEnd_ && bufPosEnd_ <= memBuf_.size()); + while (bufPos_ != bufPosEnd_) + bufPos_ += tryWrite_(memBuf_.data() + bufPos_, bufPosEnd_ - bufPos_); //throw X + } + +private: + BufferedOutputStream (const BufferedOutputStream&) = delete; + BufferedOutputStream& operator=(const BufferedOutputStream&) = delete; + + Function tryWrite_; + const size_t blockSize_; + + size_t bufPos_ = 0; + size_t bufPosEnd_ = 0; + std::vector memBuf_{2 * /*=> mitigate memmove()*/ blockSize_}; //throw FileError +}; + +//------------------------------------------------------------------------------------- + +template inline +BinContainer unbufferedLoad(Function tryRead /*(void* buffer, size_t bytesToRead) throw X; may return short; only 0 means EOF*/, + size_t blockSize) //throw X +{ + static_assert(sizeof(typename BinContainer::value_type) == 1); //expect: bytes + if (blockSize == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + BinContainer buf; + for (;;) + { +#ifndef ZEN_HAVE_RESIZE_AND_OVERWRITE +#error include legacy_compiler.h! +#endif +#if ZEN_HAVE_RESIZE_AND_OVERWRITE //permature(?) perf optimization; avoid needless zero-initialization: + size_t bytesRead = 0; + buf.resize_and_overwrite(buf.size() + blockSize, [&, bufSizeOld = buf.size()](char* rawBuf, size_t /*rawBufSize: caveat: may be larger than what's requested*/) + //permature(?) perf optimization; avoid needless zero-initialization: + { + bytesRead = tryRead(rawBuf + bufSizeOld, blockSize); //throw X; may return short; only 0 means EOF + return bufSizeOld + bytesRead; + }); +#else + buf.resize(buf.size() + blockSize); //needless zero-initialization! + const size_t bytesRead = tryRead(buf.data() + buf.size() - blockSize, blockSize); //throw X; may return short; only 0 means EOF + buf.resize(buf.size() - blockSize + bytesRead); //caveat: unsigned arithmetics +#endif + if (bytesRead == 0) //end of file + { + //caveat: memory consumption of returned string! + if (buf.capacity() > buf.size() * 3 / 2) //reference: in worst case, std::vector with growth factor 1.5 "wastes" 50% of its size as unused capacity + buf.shrink_to_fit(); //=> shrink if buffer is wasting more than that! + + return buf; + } + } +} + + +template inline +void unbufferedSave(const BinContainer& cont, + Function tryWrite /*(const void* buffer, size_t bytesToWrite) throw X; may return short*/, + size_t blockSize) //throw X +{ + static_assert(sizeof(typename BinContainer::value_type) == 1); //expect: bytes + if (blockSize == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + const size_t bufPosEnd = cont.size(); + size_t bufPos = 0; + + while (bufPos < bufPosEnd) + bufPos += tryWrite(cont.data() + bufPos, std::min(bufPosEnd - bufPos, blockSize)); //throw X +} + + +template inline +uint64_t /*streamSize*/ unbufferedStreamCopy(Function1 tryRead /*(void* buffer, size_t bytesToRead) throw X; may return short; only 0 means EOF*/, + size_t blockSizeIn, + Function2 tryWrite /*(const void* buffer, size_t bytesToWrite) throw X; may return short*/, + size_t blockSizeOut) //throw X +{ + /* caveat: buffer block sizes might not be a power of 2: + - f_iosize for network share on macOS + - libssh2 uses weird packet sizes like MAX_SFTP_OUTGOING_SIZE (30000), and will send incomplete packages if block size is not an exact multiple :( + - MTP uses file size as blocksize if under 256 kB (=> can be as small as 1 byte! https://freefilesync.org/forum/viewtopic.php?t=9823) + => that's a problem because we want input/output sizes to be multiples of each other to help avoid the std::memmove() below */ +#if 0 + blockSizeIn = std::bit_ceil(blockSizeIn); + blockSizeOut = std::bit_ceil(blockSizeOut); +#endif + if (blockSizeIn == 0 || blockSizeOut == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + const size_t bufCapacity = blockSizeOut - 1 + blockSizeIn; + const size_t alignment = ::sysconf(_SC_PAGESIZE); //-1 on error => posix_memalign() will fail + assert(alignment >= sizeof(void*) && std::has_single_bit(alignment)); //required by posix_memalign() + std::byte* buf = nullptr; + errno = ::posix_memalign(reinterpret_cast(&buf), alignment, bufCapacity); + ZEN_ON_SCOPE_EXIT(::free(buf)); + + uint64_t streamSize = 0; + size_t bufPosEnd = 0; + for (;;) + { + const size_t bytesRead = tryRead(buf + bufPosEnd, blockSizeIn); //throw X; may return short; only 0 means EOF + + if (bytesRead == 0) //end of file + { + size_t bufPos = 0; + while (bufPos < bufPosEnd) + bufPos += tryWrite(buf + bufPos, bufPosEnd - bufPos); //throw X; may return short + return streamSize; + } + else + { + streamSize += bytesRead; + bufPosEnd += bytesRead; + + size_t bufPos = 0; + while (bufPosEnd - bufPos >= blockSizeOut) + bufPos += tryWrite(buf + bufPos, blockSizeOut); //throw X; may return short + + if (bufPos > 0) + { + bufPosEnd -= bufPos; + std::memmove(buf, buf + bufPos, bufPosEnd); + } + } + } +} + +//------------------------------------------------------------------------------------- + +template inline +void writeArray(BufferedOutputStream& stream, const void* buffer, size_t len) +{ + stream.write(buffer, len); +} + + +template inline +void writeNumber(BufferedOutputStream& stream, const N& num) +{ + static_assert(isArithmetic || std::is_same_v || std::is_enum_v); + writeArray(stream, &num, sizeof(N)); +} + + +template inline +void writeContainer(BufferedOutputStream& stream, const C& cont) //don't even consider UTF8 conversions here, we're handling arbitrary binary data! +{ + const auto size = cont.size(); + + assert(size <= INT32_MAX); + writeNumber(stream, static_cast(size)); //use *signed* integer to help catch data corruption + + if (size > 0) + writeArray(stream, &cont[0], sizeof(typename C::value_type) * size); //don't use c_str(), but access uniformly via STL interface +} + + +template inline +void readArray(BufferedInputStream& stream, void* buffer, size_t len) //throw SysErrorUnexpectedEos +{ + const size_t bytesRead = stream.read(buffer, len); + assert(bytesRead <= len); //buffer overflow otherwise not always detected! + if (bytesRead < len) + throw SysErrorUnexpectedEos(); +} + + +template inline +N readNumber(BufferedInputStream& stream) //throw SysErrorUnexpectedEos +{ + static_assert(isArithmetic || std::is_same_v || std::is_enum_v); + N num; //uninitialized + readArray(stream, &num, sizeof(N)); //throw SysErrorUnexpectedEos + return num; +} + + +template inline +C readContainer(BufferedInputStream& stream) //throw SysErrorUnexpectedEos +{ + const auto size = readNumber(stream); //throw SysErrorUnexpectedEos + if (size < 0) //most likely due to data corruption! + throw SysErrorUnexpectedEos(); + + C cont; + if (size > 0) + { + try + { + cont.resize(size); //throw std::length_error, std::bad_alloc + } + catch (std::length_error&) { throw SysErrorUnexpectedEos(); } //most likely due to data corruption! + catch ( std::bad_alloc&) { throw SysErrorUnexpectedEos(); } // + + readArray(stream, &cont[0], sizeof(typename C::value_type) * size); //throw SysErrorUnexpectedEos + } + return cont; +} +} + +#endif //SERIALIZE_H_839405783574356 diff --git a/zen/shutdown.cpp b/zen/shutdown.cpp new file mode 100644 index 0000000..ee68b46 --- /dev/null +++ b/zen/shutdown.cpp @@ -0,0 +1,105 @@ +// ***************************************************************************** +// * 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 "shutdown.h" +#include "thread.h" + #include + + +using namespace zen; + + + + +void zen::shutdownSystem() //throw FileError +{ + assert(runningOnMainThread()); + if (runningOnMainThread()) + onSystemShutdownRunTasks(); + try + { + //https://linux.die.net/man/2/reboot => needs admin rights! + //"systemctl" should work without admin rights: + auto [exitCode, output] = consoleExecute("systemctl poweroff", std::nullopt /*timeoutMs*/); //throw SysError, (SysErrorTimeOut) + trim(output); + if (!output.empty()) //see comment in suspendSystem() + throw SysError(utfTo(output)); + + } + catch (const SysError& e) { throw FileError(_("Unable to shut down the system."), e.toString()); } +} + + +void zen::suspendSystem() //throw FileError +{ + try + { + //"systemctl" should work without admin rights: + auto [exitCode, output] = consoleExecute("systemctl suspend", std::nullopt /*timeoutMs*/); //throw SysError, (SysErrorTimeOut) + trim(output); + //why does "systemctl suspend" return exit code 1 despite apparent success!?? + if (!output.empty()) //at least we can assume "no output" on success + throw SysError(utfTo(output)); + + } + catch (const SysError& e) { throw FileError(_("Unable to shut down the system."), e.toString()); } +} + + +void zen::terminateProcess(int exitCode) +{ + std::quick_exit(exitCode); //[[noreturn]]; "Causes normal program termination to occur without completely cleaning the resources." => perfect + + + for (;;) //why still here?? => crash deliberately! + *reinterpret_cast(0) = 0; //crude but at least we'll get crash dumps *if* it ever happens +} + + +//Command line alternatives: + //Shut down: systemctl poweroff //alternative requiring admin: sudo shutdown -h 1 + //Sleep: systemctl suspend //alternative requiring admin: sudo pm-suspend + //Log off: gnome-session-quit --no-prompt + // alternative requiring admin: sudo killall Xorg + // alternative without admin: dbus-send --session --print-reply --dest=org.gnome.SessionManager /org/gnome/SessionManager org.gnome.SessionManager.Logout uint32:1 + + + +namespace +{ +using ShutdownTaskList = std::vector>>; +constinit Global globalShutdownTasks; +GLOBAL_RUN_ONCE(globalShutdownTasks.set(std::make_unique())); +} + + +void zen::onSystemShutdownRegister(const SharedRef>& task) +{ + assert(runningOnMainThread()); + + const auto& tasks = globalShutdownTasks.get(); + assert(tasks); + if (tasks) + tasks->push_back(task.ptr()); +} + + +void zen::onSystemShutdownRunTasks() +{ + assert(runningOnMainThread()); //no multithreading! else: after taskWeak.lock() task() references may go out of scope! (e.g. "this") + + const auto& tasks = globalShutdownTasks.get(); + assert(tasks); + if (tasks) + for (const std::weak_ptr>& taskWeak : *tasks) + if (const std::shared_ptr>& task = taskWeak.lock(); + task) + try + { (*task)(); } + catch (...) { assert(false); } + + globalShutdownTasks.set(nullptr); //trigger assert in onSystemShutdownRegister(), just in case... +} diff --git a/zen/shutdown.h b/zen/shutdown.h new file mode 100644 index 0000000..b4d51f6 --- /dev/null +++ b/zen/shutdown.h @@ -0,0 +1,26 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SHUTDOWN_H_3423847870238407783265 +#define SHUTDOWN_H_3423847870238407783265 + +#include +#include "file_error.h" + + +namespace zen +{ +void shutdownSystem(); //throw FileError +void suspendSystem(); // +[[noreturn]] void terminateProcess(int exitCode); + +void onSystemShutdownRegister(const SharedRef>& task /*noexcept*/); //save important/user data! +void onSystemShutdownRegister( SharedRef>&& task) = delete; //no temporaries! shared_ptr should manage life time! +void onSystemShutdownRunTasks(); //call at appropriate time, e.g. when receiving wxEVT_QUERY_END_SESSION/wxEVT_END_SESSION +//+ also called by shutdownSystem() +} + +#endif //SHUTDOWN_H_3423847870238407783265 diff --git a/zen/socket.h b/zen/socket.h new file mode 100644 index 0000000..29b871c --- /dev/null +++ b/zen/socket.h @@ -0,0 +1,286 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SOCKET_H_23498325972583947678456437 +#define SOCKET_H_23498325972583947678456437 + +#include "sys_error.h" + #include + #include //close + #include + #include //TCP_NODELAY + #include //getaddrinfo + + +namespace zen +{ +#define THROW_LAST_SYS_ERROR_WSA(functionName) \ + do { const ErrorCode ecInternal = getLastError(); throw SysError(formatSystemError(functionName, ecInternal)); } while (false) + + +#define THROW_LAST_SYS_ERROR_GAI(rcGai) \ + do { \ + if (rcGai == EAI_SYSTEM) /*"check errno for details"*/ \ + THROW_LAST_SYS_ERROR("getaddrinfo"); \ + \ + throw SysError(formatSystemError("getaddrinfo", formatGaiErrorCode(rcGai), utfTo(::gai_strerror(rcGai)))); \ + } while (false) + +inline +std::wstring formatGaiErrorCode(int ec) +{ + switch (ec) //codes used on both Linux and macOS + { + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_ADDRFAMILY); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_AGAIN); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_BADFLAGS); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_FAIL); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_FAMILY); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_MEMORY); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_NODATA); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_NONAME); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_SERVICE); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_SOCKTYPE); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_SYSTEM); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_OVERFLOW); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_INPROGRESS); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_CANCELED); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_NOTCANCELED); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_ALLDONE); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_INTR); + ZEN_CHECK_CASE_FOR_CONSTANT(EAI_IDN_ENCODE); + default: + return replaceCpy(_("Error code %x"), L"%x", numberTo(ec)); + } +} + +//patch up socket portability: +using SocketType = int; +const SocketType invalidSocket = -1; +inline void closeSocket(SocketType s) { ::close(s); } + +void setNonBlocking(SocketType socket, bool value); //throw SysError + + +//Winsock needs to be initialized before calling any of these functions! (WSAStartup/WSACleanup) + + + +class Socket //throw SysError +{ +public: + Socket(const Zstring& server, const Zstring& serviceName, int timeoutSec) //throw SysError + { + //GetAddrInfo(): "If the pNodeName parameter contains an empty string, all registered addresses on the local computer are returned." + // "If the pNodeName parameter points to a string equal to "localhost", all loopback addresses on the local computer are returned." + if (trimCpy(server).empty()) + throw SysError(_("Server name must not be empty.")); + + Zstring nodeName = server; //macOS supports IDN out of the box, it seems :) - unlike Linux: + if (!isAsciiString(server)) + { + char* punyEncoded = nullptr; + int rc = idn2_lookup_ul(server.c_str(), &punyEncoded, IDN2_NFC_INPUT | IDN2_NONTRANSITIONAL); //follow libcurl/src/lib/idn.c + if (rc != IDN2_OK) + rc = idn2_lookup_ul(server.c_str(), &punyEncoded, IDN2_TRANSITIONAL); //fallback to TR46 Transitional mode for better IDNA2003 compatibility + if (rc != IDN2_OK) + throw SysError(formatSystemError("idn2_lookup_ul", replaceCpy(_("Error code %x"), L"%x", numberTo(rc)), L"")); + ZEN_ON_SCOPE_EXIT(idn2_free(punyEncoded)); + + nodeName = punyEncoded; + } + const addrinfo hints + { + .ai_flags = AI_ADDRCONFIG, //return IPv4 address iff system supports it; dito for IPv6 + .ai_family = AF_UNSPEC, //don't care if AF_INET or AF_INET6 + .ai_socktype = SOCK_STREAM, //we *do* care about this one! + }; + + addrinfo* servinfo = nullptr; + ZEN_ON_SCOPE_EXIT(if (servinfo) ::freeaddrinfo(servinfo)); + + const int rcGai = ::getaddrinfo(nodeName.c_str(), serviceName.c_str(), &hints, &servinfo); + if (rcGai != 0) + THROW_LAST_SYS_ERROR_GAI(rcGai); + if (!servinfo) + throw SysError(formatSystemError("getaddrinfo", L"", L"Empty server info.")); + + const auto getConnectedSocket = [timeoutSec](const auto& /*addrinfo*/ ai) + { + SocketType testSocket = ::socket(ai.ai_family, //int socket_family + SOCK_CLOEXEC | SOCK_NONBLOCK | + ai.ai_socktype, //int socket_type + ai.ai_protocol); //int protocol + if (testSocket == invalidSocket) + THROW_LAST_SYS_ERROR_WSA("socket"); + ZEN_ON_SCOPE_FAIL(closeSocket(testSocket)); + + if (::connect(testSocket, ai.ai_addr, static_cast(ai.ai_addrlen)) != 0) //0 or SOCKET_ERROR(-1) + { + if (errno != EINPROGRESS) + THROW_LAST_SYS_ERROR_WSA("connect"); + + fd_set writefds{}; + fd_set exceptfds{}; //mostly only relevant for connect() + FD_SET(testSocket, &writefds); + FD_SET(testSocket, &exceptfds); + + /*const*/ timeval tv{.tv_sec = timeoutSec}; + + const int rv = ::select( + testSocket + 1, //int nfds = "highest-numbered file descriptor in any of the three sets, plus 1" + nullptr, //fd_set* readfds + &writefds, //fd_set* writefds + &exceptfds, //fd_set* exceptfds + &tv); //const timeval* timeout + if (rv < 0) + THROW_LAST_SYS_ERROR_WSA("select"); + + if (rv == 0) //time-out! + throw SysError(formatSystemError("select, " + utfTo(_P("1 sec", "%x sec", timeoutSec)), ETIMEDOUT)); + int error = 0; + socklen_t optLen = sizeof(error); + if (::getsockopt(testSocket, //[in] SOCKET s + SOL_SOCKET, //[in] int level + SO_ERROR, //[in] int optname + reinterpret_cast(&error), //[out] char* optval + &optLen) //[in, out] socklen_t* optlen + != 0) + THROW_LAST_SYS_ERROR_WSA("getsockopt(SO_ERROR)"); + + if (error != 0) + throw SysError(formatSystemError("connect, SO_ERROR", static_cast(error))/*== system error code, apparently!?*/); + } + + setNonBlocking(testSocket, false); //throw SysError + + return testSocket; + }; + + /* getAddrInfo() often returns only one ai_family == AF_INET address, but more items are possible: + facebook.com: 1 x AF_INET6, 3 x AF_INET + microsoft.com: 5 x AF_INET => server not allowing connection: hanging for 5x timeoutSec :( */ + std::optional firstError; + for (const auto* /*::addrinfo*/ si = servinfo; si; si = si->ai_next) + try + { + socket_ = getConnectedSocket(*si); //throw SysError; pass ownership + firstError = std::nullopt; + break; + } + catch (const SysError& e) { if (!firstError) firstError = e; } + + if (firstError) + throw* firstError; + assert(socket_ != invalidSocket); //list was non-empty, so there's either an error, or a valid socket + ZEN_ON_SCOPE_FAIL(closeSocket(socket_)); + //----------------------------------------------------------- + //configure *after* selecting appropriate socket: cfg-failure should not discard otherwise fine connection! + + int noDelay = 1; //disable Nagle algorithm: https://brooker.co.za/blog/2024/05/09/nagle.html + //e.g. test case "website sync": 23% shorter comparison time! + if (::setsockopt(socket_, //_In_ SOCKET s + IPPROTO_TCP, //_In_ int level + TCP_NODELAY, //_In_ int optname + reinterpret_cast(&noDelay), //_In_ const char* optval + sizeof(noDelay)) != 0) //_In_ int optlen + THROW_LAST_SYS_ERROR_WSA("setsockopt(TCP_NODELAY)"); + } + + ~Socket() { closeSocket(socket_); } + + SocketType get() const { return socket_; } + +private: + Socket (const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + + SocketType socket_ = invalidSocket; +}; + + +//more socket helper functions: +namespace +{ +size_t tryReadSocket(SocketType socket, void* buffer, size_t bytesToRead) //throw SysError; may return short, only 0 means EOF! +{ + if (bytesToRead == 0) //"read() with a count of 0 returns zero" => indistinguishable from end of file! => check! + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + int bytesReceived = 0; + for (;;) + { + bytesReceived = ::recv(socket, //_In_ SOCKET s + static_cast(buffer), //_Out_ char* buf + static_cast(bytesToRead), //_In_ int len + 0); //_In_ int flags + if (bytesReceived >= 0 || errno != EINTR) + break; + } + if (bytesReceived < 0) + THROW_LAST_SYS_ERROR_WSA("recv"); + + ASSERT_SYSERROR(makeUnsigned(bytesReceived) <= bytesToRead); //better safe than sorry + + return bytesReceived; //"zero indicates end of file" +} + + +size_t tryWriteSocket(SocketType socket, const void* buffer, size_t bytesToWrite) //throw SysError; may return short! CONTRACT: bytesToWrite > 0 +{ + if (bytesToWrite == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + int bytesWritten = 0; + for (;;) + { + bytesWritten = ::send(socket, //_In_ SOCKET s + static_cast(buffer), //_In_ const char* buf + static_cast(bytesToWrite), //_In_ int len + 0); //_In_ int flags + if (bytesWritten >= 0 || errno != EINTR) + break; + } + if (bytesWritten < 0) + THROW_LAST_SYS_ERROR_WSA("send"); + + if (bytesWritten == 0) + throw SysError(formatSystemError("send", L"", L"Zero bytes processed.")); + + ASSERT_SYSERROR(makeUnsigned(bytesWritten) <= bytesToWrite); //better safe than sorry + + return bytesWritten; +} +} + + +//initiate termination of connection by sending TCP FIN package +inline +void shutdownSocketSend(SocketType socket) //throw SysError +{ + if (::shutdown(socket, SHUT_WR) != 0) + THROW_LAST_SYS_ERROR_WSA("shutdown"); +} + + +inline +void setNonBlocking(SocketType socket, bool nonBlocking) //throw SysError +{ + int flags = ::fcntl(socket, F_GETFL); + if (flags == -1) + THROW_LAST_SYS_ERROR("fcntl(F_GETFL)"); + + if (nonBlocking) + flags |= O_NONBLOCK; + else + flags &= ~O_NONBLOCK; + + if (::fcntl(socket, F_SETFL, flags) != 0) + THROW_LAST_SYS_ERROR(nonBlocking ? "fcntl(F_SETFL, O_NONBLOCK)" : "fcntl(F_SETFL, ~O_NONBLOCK)"); +} +} + +#endif //SOCKET_H_23498325972583947678456437 diff --git a/zen/stl_tools.h b/zen/stl_tools.h new file mode 100644 index 0000000..8f1f5a2 --- /dev/null +++ b/zen/stl_tools.h @@ -0,0 +1,397 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STL_TOOLS_H_84567184321434 +#define STL_TOOLS_H_84567184321434 + +#include +#include +#include +#include +#include +#include +#include +#include +#include "type_traits.h" + + +//enhancements for +namespace zen +{ +//unfortunately std::erase_if is useless garbage on GCC 12 (requires non-modifying predicate) +template +void eraseIf(std::vector& v, Predicate p); + +template +void eraseIf(std::set& s, Predicate p); + +template +void eraseIf(std::map& m, Predicate p); + +//append STL containers +template +void append(std::vector& v, const C& c); + +template +void append(std::set& s, const C& c); + +template +void append(std::map& m, const C& c); + +template +void removeDuplicates(std::vector& v); + +template +void removeDuplicates(std::vector& v, CompLess less); + +template +void removeDuplicatesStable(std::vector& v, CompLess less); + +template +void removeDuplicatesStable(std::vector& v); + +//searching STL containers +template +BidirectionalIterator findLast(BidirectionalIterator first, BidirectionalIterator last, const T& value); + +template inline +RandomAccessIterator1 searchFirst(const RandomAccessIterator1 first, const RandomAccessIterator1 last, + const RandomAccessIterator2 needleFirst, const RandomAccessIterator2 needleLast); + +template inline +RandomAccessIterator1 searchFirst(const RandomAccessIterator1 first, const RandomAccessIterator1 last, + const RandomAccessIterator2 needleFirst, const RandomAccessIterator2 needleLast, IsEq isEqual); + +//replacement for std::find_end taking advantage of bidirectional iterators (and giving the algorithm a reasonable name) +template +RandomAccessIterator1 searchLast(RandomAccessIterator1 first, RandomAccessIterator1 last, + RandomAccessIterator2 needleFirst, RandomAccessIterator2 needleLast); + +//binary search returning an iterator +template +RandomAccessIterator binarySearch(RandomAccessIterator first, RandomAccessIterator last, const T& value, CompLess less); + +//read-only variant of std::merge; input: two sorted ranges +template +void mergeTraversal(Iterator first1, Iterator last1, + Iterator first2, Iterator last2, + FunctionLeftOnly lo, FunctionBoth bo, FunctionRightOnly ro, Compare compare); + +//why, oh why is there no std::optional::get()??? +template inline T* get( std::optional& opt) { return opt ? &*opt : nullptr; } +template inline const T* get(const std::optional& opt) { return opt ? &*opt : nullptr; } + + +//=========================================================================== +template +class SharedRef //why is there no std::shared_ref??? +{ +public: + SharedRef() = delete; //no surprise memory allocations! + + explicit SharedRef(const std::shared_ptr& ptr) : ptr_ (ptr) { assert(ptr_); } + explicit SharedRef( std::shared_ptr&& ptr) : ptr_(std::move(ptr)) { assert(ptr_); } + + template SharedRef(const SharedRef& other) : ptr_ (other.ptr_) {} + template SharedRef( SharedRef&& other) : ptr_(std::move(other.ptr_)) {} + + /**/ T& ref() { return *ptr_; }; + const T& ref() const { return *ptr_; }; + + const std::shared_ptr< T>& ptr() { return ptr_; }; + /**/ std::shared_ptr ptr() const { return ptr_; }; //careful: return value has different type => creates temporary! + +private: + template friend class SharedRef; + + std::shared_ptr ptr_; //always bound +}; + +template inline +SharedRef makeSharedRef(Args&& ... args) { return SharedRef(std::make_shared(std::forward(args)...)); } + + + +//hide SharedRef as an implementation detail +template //target value type +class DerefIter +{ +public: + using iterator_category = std::bidirectional_iterator_tag; + using value_type = T; + using difference_type = ptrdiff_t; + using pointer = T*; + using reference = T&; + + DerefIter() {} + DerefIter(IterImpl it) : it_(std::move(it)) {} + //DerefIter(const DerefIter& other) : it_(other.it_) {} + DerefIter& operator++() { ++it_; return *this; } + DerefIter& operator--() { --it_; return *this; } + inline friend DerefIter operator++(DerefIter& it, int) { return it++; } + inline friend DerefIter operator--(DerefIter& it, int) { return it--; } + inline friend ptrdiff_t operator-(const DerefIter& lhs, const DerefIter& rhs) { return lhs.it_ - rhs.it_; } + bool operator==(const DerefIter&) const = default; + T& operator* () const { return it_->ref(); } + T* operator->() const { return &it_->ref(); } +private: + IterImpl it_{}; +}; + + +template +class Range +{ +public: + Range(Iterator first, Iterator last) : first_(std::move(first)), last_(std::move(last)) {} + Iterator begin() const { return first_; } + Iterator end () const { return last_; } + + bool empty() const { return first_ == last_; } + size_t size() const { return last_ - first_; } + +private: + Iterator first_; + Iterator last_; +}; + +//######################## implementation ######################## + +template inline +void eraseIf(std::vector& v, Predicate p) +{ + v.erase(std::remove_if(v.begin(), v.end(), p), v.end()); +} + + +namespace impl +{ +template inline +void setOrMapEraseIf(S& s, Predicate p) +{ + for (auto it = s.begin(); it != s.end();) + if (p(*it)) + s.erase(it++); + else + ++it; +} +} + + +template inline +void eraseIf(std::set& s, Predicate p) { impl::setOrMapEraseIf(s, p); } //don't make this any more generic! e.g. must not compile for std::vector!!! + + +template inline +void eraseIf(std::map& m, Predicate p) { impl::setOrMapEraseIf(m, p); } + + +template inline +void eraseIf(std::unordered_set& s, Predicate p) { impl::setOrMapEraseIf(s, p); } + + +template inline +void eraseIf(std::unordered_map& m, Predicate p) { impl::setOrMapEraseIf(m, p); } + + +template inline +void append(std::vector& v, const C& c) { v.insert(v.end(), c.begin(), c.end()); } + + +template inline +void append(std::set& s, const C& c) { s.insert(c.begin(), c.end()); } + + +template inline +void append(std::map& m, const C& c) { m.insert(c.begin(), c.end()); } + + +template inline +void removeDuplicates(std::vector& v, CompLess less, CompEqual eq) +{ + std::sort(v.begin(), v.end(), less); + v.erase(std::unique(v.begin(), v.end(), eq), v.end()); +} + + +template inline +void removeDuplicates(std::vector& v, CompLess less) +{ + removeDuplicates(v, less, [&](const auto& lhs, const auto& rhs) { return !less(lhs, rhs) && !less(rhs, lhs); }); +} + + +template inline +void removeDuplicates(std::vector& v) +{ + removeDuplicates(v, std::less{}, std::equal_to{}); +} + + +template inline +void removeDuplicatesStable(std::vector& v, CompLess less) +{ + std::set usedItems(less); + v.erase(std::remove_if(v.begin(), v.end(), + /**/[&usedItems](const T& e) { return !usedItems.insert(e).second; }), v.end()); +} + + +template inline +void removeDuplicatesStable(std::vector& v) +{ + removeDuplicatesStable(v, std::less{}); +} + + +template inline +RandomAccessIterator binarySearch(RandomAccessIterator first, RandomAccessIterator last, const T& value, CompLess less) +{ + static_assert(std::is_same_v::iterator_category, std::random_access_iterator_tag>); + + first = std::lower_bound(first, last, value, less); //alternative: std::partition_point + if (first != last && !less(value, *first)) + return first; + else + return last; +} + + +template inline +BidirectionalIterator findLast(const BidirectionalIterator first, const BidirectionalIterator last, const T& value) +{ + for (BidirectionalIterator it = last; it != first;) //reverse iteration: 1. check 2. decrement 3. evaluate + { + --it; // + + if (*it == value) + return it; + } + return last; +} + + +template inline +RandomAccessIterator1 searchFirst(const RandomAccessIterator1 first, const RandomAccessIterator1 last, + const RandomAccessIterator2 needleFirst, const RandomAccessIterator2 needleLast, IsEq isEqual) +{ + if (needleLast - needleFirst == 1) //don't use expensive std::search unless required! + return std::find_if(first, last, [needleFirst, isEqual](const auto c) { return isEqual(*needleFirst, c); }); + //"*needleFirst" could be improved with value rather than iterator access, at least for built-in types like "char" + + return std::search(first, last, + needleFirst, needleLast, isEqual); +} + + +template inline +RandomAccessIterator1 searchFirst(const RandomAccessIterator1 first, const RandomAccessIterator1 last, + const RandomAccessIterator2 needleFirst, const RandomAccessIterator2 needleLast) +{ + return searchFirst(first, last, needleFirst, needleLast, std::equal_to{}); +} + + + +template inline +RandomAccessIterator1 searchLast(const RandomAccessIterator1 first, RandomAccessIterator1 last, + const RandomAccessIterator2 needleFirst, const RandomAccessIterator2 needleLast) +{ + if (needleLast - needleFirst == 1) //fast-path + return findLast(first, last, *needleFirst); + + const RandomAccessIterator1 itNotFound = last; + + //reverse iteration: 1. check 2. decrement 3. evaluate + for (;;) + { + RandomAccessIterator1 it1 = last; + RandomAccessIterator2 it2 = needleLast; + + for (;;) + { + if (it2 == needleFirst) return it1; + if (it1 == first) return itNotFound; + + --it1; + --it2; + + if (*it1 != *it2) break; + } + --last; + } +} + +//--------------------------------------------------------------------------------------- + +//read-only variant of std::merge; input: two sorted ranges +template inline +void mergeTraversal(Iterator firstL, Iterator lastL, + Iterator firstR, Iterator lastR, + FunctionLeftOnly lo, FunctionBoth bo, FunctionRightOnly ro, Compare compare) +{ + auto itL = firstL; + auto itR = firstR; + + auto finishLeft = [&] { std::for_each(itL, lastL, lo); }; + auto finishRight = [&] { std::for_each(itR, lastR, ro); }; + + if (itL == lastL) return finishRight(); + if (itR == lastR) return finishLeft (); + + for (;;) + if (const std::weak_ordering cmp = compare(*itL, *itR); + cmp < 0) + { + lo(*itL); + if (++itL == lastL) + return finishRight(); + } + else if (cmp > 0) + { + ro(*itR); + if (++itR == lastR) + return finishLeft(); + } + else + { + bo(*itL, *itR); + ++itL; // + ++itR; //increment BOTH before checking for end of range! + if (itL == lastL) return finishRight(); + if (itR == lastR) return finishLeft (); + //simplify loop by placing both EOB checks at the beginning? => slightly slower + } +} + + +template +class FNV1aHash //FNV-1a: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function +{ +public: + FNV1aHash() {} + explicit FNV1aHash(Num startVal) : hashVal_(startVal) { assert(startVal != 0); /*yes, might be a real hash, but most likely bad init value*/} + + void add(Num n) + { + hashVal_ ^= n; + hashVal_ *= prime_; + } + + Num get() const { return hashVal_; } + +private: + static_assert(isUnsignedInt); + static_assert(sizeof(Num) == 4 || sizeof(Num) == 8); + static constexpr Num base_ = sizeof(Num) == 4 ? 2166136261U : 14695981039346656037ULL; + static constexpr Num prime_ = sizeof(Num) == 4 ? 16777619U : 1099511628211ULL; + + Num hashVal_ = base_; +}; +} + +#endif //STL_TOOLS_H_84567184321434 diff --git a/zen/stream_buffer.h b/zen/stream_buffer.h new file mode 100644 index 0000000..752c9b7 --- /dev/null +++ b/zen/stream_buffer.h @@ -0,0 +1,207 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STREAM_BUFFER_H_08492572089560298 +#define STREAM_BUFFER_H_08492572089560298 + +#include +#include "ring_buffer.h" +#include "string_tools.h" + + +namespace zen +{ +/* implement streaming API on top of libcurl's icky callback-based design + + curl uses READBUFFER_SIZE download buffer size, but returns via a retarded sendf.c::chop_write() writing in small junks of CURL_MAX_WRITE_SIZE (16 kB) + => support copying arbitrarily-large files: https://freefilesync.org/forum/viewtopic.php?t=4471 + => maximum performance through async processing (prefetching + output buffer!) + => cost per worker thread creation ~ 1/20 ms */ +class AsyncStreamBuffer +{ +public: + explicit AsyncStreamBuffer(size_t capacity) { ringBuf_.reserve(capacity); } + + //context of input thread, blocking + size_t read(void* buffer, size_t bytesToRead) //throw ; return "bytesToRead" bytes unless end of stream! + { + std::unique_lock dummy(lockStream_); + const auto bufStart = buffer; + + while (bytesToRead > 0) + { + const size_t bytesRead = tryReadImpl(dummy, buffer, bytesToRead); //throw + if (bytesRead == 0) //end of file + break; + conditionBytesRead_.notify_all(); + buffer = static_cast(buffer) + bytesRead; + bytesToRead -= bytesRead; + } + return static_cast(buffer) - + static_cast(bufStart); + } + + //context of input thread, blocking + size_t tryRead(void* buffer, size_t bytesToRead) //throw ; may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + { + size_t bytesRead = 0; + { + std::unique_lock dummy(lockStream_); + bytesRead = tryReadImpl(dummy, buffer, bytesToRead); + } + if (bytesRead > 0) + conditionBytesRead_.notify_all(); //...*outside* the lock + return bytesRead; + } + + //context of output thread, blocking + void write(const void* buffer, size_t bytesToWrite) //throw + { + std::unique_lock dummy(lockStream_); + while (bytesToWrite > 0) + { + const size_t bytesWritten = tryWriteWhileImpl(dummy, buffer, bytesToWrite); //throw + conditionBytesWritten_.notify_all(); + buffer = static_cast(buffer) + bytesWritten; + bytesToWrite -= bytesWritten; + } + } + + //context of output thread, blocking + size_t tryWrite(const void* buffer, size_t bytesToWrite) //throw ; may return short! CONTRACT: bytesToWrite > 0 + { + size_t bytesWritten = 0; + { + std::unique_lock dummy(lockStream_); + bytesWritten = tryWriteWhileImpl(dummy, buffer, bytesToWrite); + } + conditionBytesWritten_.notify_all(); //...*outside* the lock + return bytesWritten; + } + + //context of output thread + void closeStream() + { + { + std::lock_guard dummy(lockStream_); + assert(!eof_ && !errorWrite_); + eof_ = true; + } + conditionBytesWritten_.notify_all(); + } + + //context of input thread + void setReadError(const std::exception_ptr& error) + { + { + std::lock_guard dummy(lockStream_); + assert(error && !errorRead_); + if (!errorRead_) + errorRead_ = error; + } + conditionBytesRead_.notify_all(); + } + + //context of output thread + void setWriteError(const std::exception_ptr& error) + { + { + std::lock_guard dummy(lockStream_); + assert(error && !errorWrite_); + if (!errorWrite_) + errorWrite_ = error; + } + conditionBytesWritten_.notify_all(); + } + +#if 0 + //function not needed: after file upload completed successfully, no further error can occur! + // => caveat: writing is NOT done (yet) when closeStream() is called! + //context of *output* thread + void checkReadErrors() //throw + { + std::lock_guard dummy(lockStream_); + if (errorRead_) + std::rethrow_exception(errorRead_); //throw + } + + //function not needed: when EOF is reached (without errors), reading is done => no further error can occur! + //context of *input* thread + void checkWriteErrors() //throw + { + std::lock_guard dummy(lockStream_); + if (errorWrite_) + std::rethrow_exception(errorWrite_); //throw + } +#endif + + uint64_t getTotalBytesWritten() const { return totalBytesWritten_; } + uint64_t getTotalBytesRead () const { return totalBytesRead_; } + +private: + AsyncStreamBuffer (const AsyncStreamBuffer&) = delete; + AsyncStreamBuffer& operator=(const AsyncStreamBuffer&) = delete; + + //context of input thread, blocking + size_t tryReadImpl(std::unique_lock& ul, void* buffer, size_t bytesToRead) //throw ; may return short; only 0 means EOF! CONTRACT: bytesToRead > 0! + { + if (bytesToRead == 0) //"read() with a count of 0 returns zero" => indistinguishable from end of file! => check! + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + assert(isLocked(lockStream_)); + assert(!errorRead_); + + conditionBytesWritten_.wait(ul, [this] { return errorWrite_ || !ringBuf_.empty() || eof_; }); + + if (errorWrite_) + std::rethrow_exception(errorWrite_); //throw + + const size_t junkSize = std::min(bytesToRead, ringBuf_.size()); + ringBuf_.extract_front(static_cast(buffer), + static_cast(buffer)+ junkSize); + totalBytesRead_ += junkSize; + return junkSize; + } + + //context of output thread, blocking + size_t tryWriteWhileImpl(std::unique_lock& ul, const void* buffer, size_t bytesToWrite) //throw ; may return short! CONTRACT: bytesToWrite > 0 + { + if (bytesToWrite == 0) + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + assert(isLocked(lockStream_)); + assert(!eof_ && !errorWrite_); + /* => can't use InterruptibleThread's interruptibleWait() :( + -> AsyncStreamBuffer is used for input and output streaming + => both AsyncStreamBuffer::write()/read() would have to implement interruptibleWait() + => one of these usually called from main thread + => but interruptibleWait() cannot be called from main thread! */ + conditionBytesRead_.wait(ul, [this] { return errorRead_ || ringBuf_.size() < ringBuf_.capacity(); }); + + if (errorRead_) + std::rethrow_exception(errorRead_); //throw + + const size_t junkSize = std::min(bytesToWrite, ringBuf_.capacity() - ringBuf_.size()); + + ringBuf_.insert_back(static_cast(buffer), + static_cast(buffer) + junkSize); + totalBytesWritten_ += junkSize; + return junkSize; + } + + std::mutex lockStream_; + RingBuffer ringBuf_; //prefetch/output buffer + bool eof_ = false; + std::exception_ptr errorWrite_; + std::exception_ptr errorRead_; + std::condition_variable conditionBytesWritten_; + std::condition_variable conditionBytesRead_; + + std::atomic totalBytesWritten_{0}; //std:atomic is uninitialized by default! + std::atomic totalBytesRead_ {0}; // +}; +} + +#endif //STREAM_BUFFER_H_08492572089560298 diff --git a/zen/string_base.h b/zen/string_base.h new file mode 100644 index 0000000..b19b485 --- /dev/null +++ b/zen/string_base.h @@ -0,0 +1,682 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STRING_BASE_H_083217454562342526 +#define STRING_BASE_H_083217454562342526 + +#include +#include //std::exchange +#include "string_tools.h" + + +//Zbase - a policy based string class optimizing performance and flexibility +namespace zen +{ +/* Allocator Policy: + ----------------- + void* allocate(size_t size) //throw std::bad_alloc + void deallocate(void* ptr) + size_t calcCapacity(size_t length) */ +class AllocatorOptimalSpeed //exponential growth + min size +{ +protected: + //::operator new/delete show same performance characterisics like malloc()/free()! + static void* allocate(size_t size) { return ::operator new (size); } //throw std::bad_alloc + static void deallocate(void* ptr) { ::operator delete (ptr); } + static size_t calcCapacity(size_t length) { return std::max(16, std::max(length + length / 2, length)); } + //- size_t might overflow! => better catch here than return a too small size covering up the real error: a way too large length! + //- any growth rate should not exceed golden ratio: 1.618033989 +}; + + +class AllocatorOptimalMemory //no wasted memory, but more reallocations required when manipulating string +{ +protected: + static void* allocate(size_t size) { return ::operator new (size); } //throw std::bad_alloc + static void deallocate(void* ptr) { ::operator delete (ptr); } + static size_t calcCapacity(size_t length) { return length; } +}; + +/* Storage Policy: + --------------- + template //Allocator Policy + + Char* create(size_t size) + Char* create(size_t size, size_t minCapacity) + Char* clone(Char* ptr) + void destroy(Char* ptr) //must handle "destroy(nullptr)"! + bool canWrite(const Char* ptr, size_t minCapacity) //needs to be checked before writing to "ptr" + size_t length(const Char* ptr) + void setLength(Char* ptr, size_t newLength) */ + +template //Allocator Policy +class StorageDeepCopy : public AP +{ +protected: + ~StorageDeepCopy() {} + + Char* create(size_t size) { return create(size, size); } + Char* create(size_t size, size_t minCapacity) + { + assert(size <= minCapacity); + const size_t newCapacity = AP::calcCapacity(minCapacity); + assert(newCapacity >= minCapacity); + + Descriptor* const newDescr = static_cast(this->allocate(sizeof(Descriptor) + (newCapacity + 1) * sizeof(Char))); //throw std::bad_alloc + new (newDescr) Descriptor(size, newCapacity); + + return reinterpret_cast(newDescr + 1); //alignment note: "newDescr + 1" is Descriptor-aligned, which is larger than alignment for Char-array! => no problem! + } + + Char* clone(Char* ptr) + { + const size_t len = length(ptr); + Char* newData = create(len); //throw std::bad_alloc + std::copy(ptr, ptr + len + 1, newData); + return newData; + } + + void destroy(Char* ptr) + { + if (!ptr) return; //support "destroy(nullptr)" + + Descriptor* const d = descr(ptr); + d->~Descriptor(); + this->deallocate(d); + } + + //this needs to be checked before writing to "ptr" + static bool canWrite(const Char* ptr, size_t minCapacity) { return minCapacity <= descr(ptr)->capacity; } + static size_t size(const Char* ptr) { return descr(ptr)->length; } + + static void setLength(Char* ptr, size_t newLength) + { + assert(canWrite(ptr, newLength)); + descr(ptr)->length = newLength; + } + +private: + struct Descriptor + { + Descriptor(size_t len, size_t cap) : + length (static_cast(len)), + capacity(static_cast(cap)) {} + + uint32_t length; + const uint32_t capacity; //allocated size without null-termination + }; + + static Descriptor* descr( Char* ptr) { return reinterpret_cast< Descriptor*>(ptr) - 1; } + static const Descriptor* descr(const Char* ptr) { return reinterpret_cast(ptr) - 1; } +}; + + +template //Allocator Policy +class StorageRefCountThreadSafe : public AP +{ +protected: + ~StorageRefCountThreadSafe() {} + + Char* create(size_t size) { return create(size, size); } + Char* create(size_t size, size_t minCapacity) + { + assert(size <= minCapacity); + + if (minCapacity == 0) //perf: avoid memory allocation for empty string + { + ++globalEmptyString.descr.refCount; + return &globalEmptyString.nullTerm; + } + + const size_t newCapacity = AP::calcCapacity(minCapacity); + assert(newCapacity >= minCapacity); + + Descriptor* const newDescr = static_cast(this->allocate(sizeof(Descriptor) + (newCapacity + 1) * sizeof(Char))); //throw std::bad_alloc + new (newDescr) Descriptor(size, newCapacity); + + return reinterpret_cast(newDescr + 1); + } + + static Char* clone(Char* ptr) + { + ++descr(ptr)->refCount; + return ptr; + } + + void destroy(Char* ptr) + { + assert(ptr != reinterpret_cast(0x1)); //detect double-deletion + + if (!ptr) //support "destroy(nullptr)" + { + return; + } + + Descriptor* const d = descr(ptr); + + if (--(d->refCount) == 0) //operator--() is overloaded to decrement and evaluate in a single atomic operation! + { + d->~Descriptor(); + this->deallocate(d); + } + } + + static bool canWrite(const Char* ptr, size_t minCapacity) //needs to be checked before writing to "ptr" + { + const Descriptor* const d = descr(ptr); + assert(d->refCount > 0); + return d->refCount == 1 && minCapacity <= d->capacity; + } + + static size_t size(const Char* ptr) { return descr(ptr)->length; } + + static void setLength(Char* ptr, size_t newLength) + { + assert(canWrite(ptr, newLength)); + descr(ptr)->length = static_cast(newLength); + } + +private: + struct Descriptor + { + constexpr Descriptor(size_t len, size_t cap) : + length (static_cast(len)), + capacity(static_cast(cap)) + { + static_assert(decltype(refCount)::is_always_lock_free); + } + + std::atomic refCount{1}; //std:atomic is uninitialized by default! + uint32_t length; + const uint32_t capacity; //allocated size without null-termination + }; + + static Descriptor* descr( Char* ptr) { return reinterpret_cast< Descriptor*>(ptr) - 1; } + static const Descriptor* descr(const Char* ptr) { return reinterpret_cast(ptr) - 1; } + + struct GlobalEmptyString + { + Descriptor descr{0 /*length*/, 0 /*capacity*/}; + Char nullTerm = 0; + }; + static_assert(offsetof(GlobalEmptyString, nullTerm) - offsetof(GlobalEmptyString, descr) == sizeof(Descriptor), "no gap!"); + static_assert(std::is_trivially_destructible_v, "this memory needs to live forever"); + + inline static constinit GlobalEmptyString globalEmptyString; //constinit: dodge static initialization order fiasco! +}; + + +template +using DefaultStoragePolicy = StorageRefCountThreadSafe; + + +//################################################################################################################################################################ + +//perf note: interestingly StorageDeepCopy and StorageRefCountThreadSafe show same performance in FFS comparison + +template class SP = DefaultStoragePolicy> //Storage Policy +class Zbase : public SP +{ +public: + Zbase(); + Zbase(const Char* str) : Zbase(str, str + strLength(str)) {} //implicit conversion from a C-string! + Zbase(const Char* str, size_t len) : Zbase(str, str + len) {} + explicit Zbase(const std::basic_string_view view) : Zbase(view.begin(), view.end()) {} + Zbase(size_t count, Char fillChar); + template + Zbase(RandomAccessIterator first, RandomAccessIterator last); + Zbase(const Zbase& str); + Zbase(Zbase&& tmp) noexcept; + //explicit Zbase(Char ch); //dangerous if implicit: Char buffer[]; return buffer[0]; ups... forgot &, but not a compiler error! //-> non-standard extension!!! + + ~Zbase(); + + //operator const Char* () const; //NO implicit conversion to a C-string!! Many problems... one of them: if we forget to provide operator overloads, it'll just work with a Char*... + + operator std::basic_string_view() const& noexcept { return {data(), size()}; } + //operator std::basic_string_view() const&& = delete; //=> probably a bug! + + //STL accessors + using iterator = Char*; + using const_iterator = const Char*; + using reference = Char&; + using const_reference = const Char&; + using value_type = Char; + + iterator begin(); + iterator end (); + + const_iterator begin () const { return rawStr_; } + const_iterator end () const { return rawStr_ + size(); } + + const_iterator cbegin() const { return begin(); } + const_iterator cend () const { return end (); } + + //std::string functions + size_t length() const { return size(); } + size_t size () const; + const Char* c_str() const { return rawStr_; } //C-string format with 0-termination + const Char* data() const { return &*begin(); } + /**/ Char* data() { return &*begin(); } + const Char& operator[](size_t pos) const; + /**/ Char& operator[](size_t pos); + bool empty() const { return size() == 0; } + void clear(); +#if 0 //avoid redundant std::string API bloat! + size_t find (const Zbase& str, size_t pos = 0) const; // + size_t find (const Char* str, size_t pos = 0) const; // + size_t find (Char ch, size_t pos = 0) const; //returns "npos" if not found + size_t rfind(Char ch, size_t pos = npos) const; // + size_t rfind(const Char* str, size_t pos = npos) const; // +#endif + //Zbase& replace(size_t pos1, size_t n1, const Zbase& str); + void reserve(size_t minCapacity); + Zbase& assign(const Char* str, size_t len) { return assign(str, str + len); } + Zbase& append(const Char* str, size_t len) { return append(str, str + len); } + + template Zbase& assign(RandomAccessIterator first, RandomAccessIterator last); + template Zbase& append(RandomAccessIterator first, RandomAccessIterator last); + + void resize(size_t newSize, Char fillChar = 0); + void swap(Zbase& str) { std::swap(rawStr_, str.rawStr_); } + void push_back(Char val) { operator+=(val); } //STL access + void pop_back(); + + Zbase& operator=(Zbase&& tmp) noexcept; + Zbase& operator=(const Zbase& str); + Zbase& operator=(const Char* str) { return assign(str, strLength(str)); } + Zbase& operator=(Char ch) { return assign(&ch, 1); } + Zbase& operator+=(const Zbase& str) { return append(str.c_str(), str.size()); } + Zbase& operator+=(const Char* str) { return append(str, strLength(str)); } + Zbase& operator+=(Char ch) { return append(&ch, 1); } + Zbase& operator+=(const std::basic_string_view str) { return append(str.begin(), str.end()); } + + static const size_t npos = static_cast(-1); + + inline friend Zbase operator+( const Char* lhs, const Zbase& rhs) { return Zbase(lhs, strLength(lhs), rhs.c_str(), rhs.size()); } + inline friend Zbase operator+( Char lhs, const Zbase& rhs) { return Zbase(&lhs, 1, rhs.c_str(), rhs.size()); } + inline friend Zbase operator+(const std::basic_string_view lhs, const Zbase& rhs) { return Zbase(lhs.data(), lhs.size(), rhs.c_str(), rhs.size()); } + +private: + Zbase (int) = delete; // + Zbase(size_t count, int) = delete; // + Zbase& operator= (int) = delete; //detect usage errors by creating an intentional ambiguity with "Char" + Zbase& operator+= (int) = delete; // + void push_back (int) = delete; // + + Zbase (std::nullptr_t) = delete; + Zbase(size_t count, std::nullptr_t) = delete; + Zbase& operator= (std::nullptr_t) = delete; + Zbase& operator+= (std::nullptr_t) = delete; + void push_back (std::nullptr_t) = delete; + + //not part of std::string API => private: + Zbase(const Char* str1, size_t len1, const Char* str2, size_t len2); + //alternative: Zbase() + reserve() + 2 x append() + + Char* rawStr_; +}; + + + +template class SP> bool operator==(const Zbase& lhs, const Zbase& rhs); +template class SP> bool operator==(const Zbase& lhs, const Char* rhs); +template class SP> inline bool operator==(const Char* lhs, const Zbase& rhs) { return operator==(rhs, lhs); } + +//follow convention + compare by unsigned char; alternative: std::lexicographical_compare_three_way + reinterpret_cast*>() +template class SP> std::strong_ordering operator<=>(const Zbase& lhs, const Zbase& rhs) { return compareString(lhs, rhs); } +template class SP> std::strong_ordering operator<=>(const Zbase& lhs, const Char* rhs) { return compareString(lhs, rhs); } +template class SP> std::strong_ordering operator<=>(const Char* lhs, const Zbase& rhs) { return compareString(lhs, rhs); } + +template class SP> inline Zbase operator+(const Zbase& lhs, const Zbase& rhs) { return Zbase(lhs) += rhs; } +template class SP> inline Zbase operator+(const Zbase& lhs, const Char* rhs) { return Zbase(lhs) += rhs; } +template class SP> inline Zbase operator+(const Zbase& lhs, Char rhs) { return Zbase(lhs) += rhs; } +template class SP> inline Zbase operator+(const Zbase& lhs, const std::basic_string_view rhs) { return Zbase(lhs) += rhs; } + +//don't use unified first argument but save one move-construction in the r-value case instead! +template class SP> inline Zbase operator+(Zbase&& lhs, const Zbase& rhs) { return std::move(lhs += rhs); } //the move *is* needed!!! +template class SP> inline Zbase operator+(Zbase&& lhs, const Char* rhs) { return std::move(lhs += rhs); } //lhs, is an l-value parameter... +template class SP> inline Zbase operator+(Zbase&& lhs, Char rhs) { return std::move(lhs += rhs); } //and not a local variable => no copy elision +template class SP> inline Zbase operator+(Zbase&& lhs, const std::basic_string_view rhs) { return std::move(lhs += rhs); } + +template class SP> inline Zbase operator+(const Zbase&, int) = delete; //detect usage errors +template class SP> inline Zbase operator+(int, const Zbase&) = delete; // + + + + + + + + + + +//################################# implementation ######################################## +template class SP> inline +Zbase::Zbase() +{ + rawStr_ = this->create(0); + rawStr_[0] = 0; +} + + +template class SP> +template inline +Zbase::Zbase(RandomAccessIterator first, RandomAccessIterator last) +{ + rawStr_ = this->create(last - first); + *std::copy(first, last, rawStr_) = 0; +} + + +template class SP> inline +Zbase::Zbase(size_t count, Char fillChar) +{ + rawStr_ = this->create(count); + std::fill(rawStr_, rawStr_ + count, fillChar); + rawStr_[count] = 0; +} + + +template class SP> inline +Zbase::Zbase(const Zbase& str) +{ + rawStr_ = this->clone(str.rawStr_); +} + + +template class SP> inline +Zbase::Zbase(Zbase&& tmp) noexcept +{ + rawStr_ = std::exchange(tmp.rawStr_, nullptr); + //usually nullptr would violate the class invarants, but it is good enough for the destructor! + //caveat: do not increment ref-count of an unshared string! We'd lose optimization opportunity of reusing its memory! +} + + +template class SP> inline +Zbase::Zbase(const Char* str1, size_t len1, const Char* str2, size_t len2) +{ + rawStr_ = this->create(len1 + len2); + std::copy (str1, str1 + len1, rawStr_); + *std::copy(str2, str2 + len2, rawStr_ + len1) = 0; +} + + +template class SP> inline +Zbase::~Zbase() +{ + static_assert(noexcept(this->~Zbase())); //has exception spec of compiler-generated destructor by default + + this->destroy(rawStr_); //rawStr_ may be nullptr; see move constructor! +} + + +#if 0 //avoid redundant std::string API bloat! +template class SP> inline +size_t Zbase::find(const Zbase& str, size_t pos) const //returns "npos" if not found +{ + assert(pos <= size()); + const size_t len = size(); + const Char* thisEnd = begin() + len; //respect embedded 0 + const Char* it = searchFirst(begin() + std::min(pos, len), thisEnd, + str.begin(), str.end()); + return it == thisEnd ? npos : it - begin(); +} + + +template class SP> inline +size_t Zbase::find(const Char* str, size_t pos) const //returns "npos" if not found +{ + assert(pos <= size()); + const size_t len = size(); + const Char* thisEnd = begin() + len; //respect embedded 0 + const Char* it = searchFirst(begin() + std::min(pos, len), thisEnd, + str, str + strLength(str)); + return it == thisEnd ? npos : it - begin(); +} + + +template class SP> inline +size_t Zbase::find(Char ch, size_t pos) const //returns "npos" if not found +{ + assert(pos <= size()); + const size_t len = size(); + const Char* thisEnd = begin() + len; //respect embedded 0 + const Char* it = std::find(begin() + std::min(pos, len), thisEnd, ch); + return it == thisEnd ? npos : it - begin(); +} + + +template class SP> inline +size_t Zbase::rfind(Char ch, size_t pos) const //returns "npos" if not found +{ + assert(pos == npos || pos <= size()); + const size_t len = size(); + const Char* currEnd = begin() + (pos == npos ? len : std::min(pos + 1, len)); + const Char* it = findLast(begin(), currEnd, ch); + return it == currEnd ? npos : it - begin(); +} + + +template class SP> inline +size_t Zbase::rfind(const Char* str, size_t pos) const //returns "npos" if not found +{ + assert(pos == npos || pos <= size()); + const size_t strLen = strLength(str); + const size_t len = size(); + const Char* currEnd = begin() + (pos == npos ? len : std::min(pos + strLen, len)); + const Char* it = searchLast(begin(), currEnd, + str, str + strLen); + return it == currEnd ? npos : it - begin(); +} +#endif + + +template class SP> inline +void Zbase::resize(size_t newSize, Char fillChar) +{ + const size_t oldSize = size(); + if (this->canWrite(rawStr_, newSize)) + { + if (oldSize < newSize) + std::fill(rawStr_ + oldSize, rawStr_ + newSize, fillChar); + rawStr_[newSize] = 0; + this->setLength(rawStr_, newSize); + } + else + { + Char* newStr = this->create(newSize); + if (oldSize < newSize) + { + std::copy(rawStr_, rawStr_ + oldSize, newStr); + std::fill(newStr + oldSize, newStr + newSize, fillChar); + } + else + std::copy(rawStr_, rawStr_ + newSize, newStr); + newStr[newSize] = 0; + + this->destroy(rawStr_); + rawStr_ = newStr; + } +} + + +template class SP> inline +bool operator==(const Zbase& lhs, const Zbase& rhs) +{ + return lhs.size() == rhs.size() && std::equal(lhs.begin(), lhs.end(), rhs.begin()); //respect embedded 0 +} + + +template class SP> inline +bool operator==(const Zbase& lhs, const Char* rhs) +{ + return lhs.size() == strLength(rhs) && std::equal(lhs.begin(), lhs.end(), rhs); //respect embedded 0 +} + + +template class SP> inline +size_t Zbase::size() const +{ + return SP::size(rawStr_); +} + + +template class SP> inline +const Char& Zbase::operator[](size_t pos) const +{ + assert(pos < size()); //design by contract! no runtime check! + return rawStr_[pos]; +} + + +template class SP> inline +Char& Zbase::operator[](size_t pos) +{ + reserve(size()); //make unshared! + assert(pos < size()); //design by contract! no runtime check! + return rawStr_[pos]; +} + + +template class SP> inline +auto Zbase::begin() -> iterator +{ + reserve(size()); //make unshared! + return rawStr_; +} + + +template class SP> inline +auto Zbase::end() -> iterator +{ + return begin() + size(); +} + + +template class SP> inline +void Zbase::clear() +{ + if (!empty()) + { + if (this->canWrite(rawStr_, 0)) + { + rawStr_[0] = 0; //keep allocated memory + this->setLength(rawStr_, 0); // + } + else + *this = Zbase(); + } +} + + +template class SP> inline +void Zbase::reserve(size_t minCapacity) //make unshared and check capacity +{ + if (!this->canWrite(rawStr_, minCapacity)) + { + //allocate a new string + const size_t len = size(); + Char* newStr = this->create(len, std::max(len, minCapacity)); //reserve() must NEVER shrink the string: logical const! + *std::copy(rawStr_, rawStr_ + len, newStr) = 0; + + this->destroy(rawStr_); + rawStr_ = newStr; + } +} + + +template class SP> +template inline +Zbase& Zbase::assign(RandomAccessIterator first, RandomAccessIterator last) +{ + const size_t len = last - first; + if (this->canWrite(rawStr_, len)) + { + *std::copy(first, last, rawStr_) = 0; + this->setLength(rawStr_, len); + } + else + *this = Zbase(first, last); + + return *this; +} + + +template class SP> +template inline +Zbase& Zbase::append(RandomAccessIterator first, RandomAccessIterator last) +{ + const size_t len = last - first; //std::distance(first, last); + if (len > 0) //avoid making this string unshared for no reason + { + const size_t thisLen = size(); + reserve(thisLen + len); //make unshared and check capacity + + *std::copy(first, last, rawStr_ + thisLen) = 0; + this->setLength(rawStr_, thisLen + len); + } + return *this; +} + + +//don't use unifying assignment but save one move-construction in the r-value case instead! +template class SP> inline +Zbase& Zbase::operator=(const Zbase& str) +{ + Zbase(str).swap(*this); + return *this; +} + + +template class SP> inline +Zbase& Zbase::operator=(Zbase&& tmp) noexcept +{ + //don't swap() but end rawStr_ life time immediately + this->destroy(rawStr_); + + rawStr_ = std::exchange(tmp.rawStr_, nullptr); + return *this; +} + + +template class SP> inline +void Zbase::pop_back() +{ + const size_t len = size(); + assert(len > 0); + if (len > 0) + resize(len - 1); +} +} + + +//std::hash specialization in global namespace +template class SP> +struct std::hash> +{ + using is_transparent = int; //allow heterogenous lookup! + + template + size_t operator()(const String& str) const { return zen::hashString(str); } +}; + + +template class SP> +struct std::equal_to> +{ + using is_transparent = int; //enable heterogenous lookup! + + template + bool operator()(const String1& lhs, const String2& rhs) const { return zen::equalString(lhs, rhs); } +}; + +#endif //STRING_BASE_H_083217454562342526 diff --git a/zen/string_tools.h b/zen/string_tools.h new file mode 100644 index 0000000..f937170 --- /dev/null +++ b/zen/string_tools.h @@ -0,0 +1,1019 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STRING_TOOLS_H_213458973046 +#define STRING_TOOLS_H_213458973046 + +#include //sprintf +#include //swprintf +#include "stl_tools.h" +#include "string_traits.h" +#include "legacy_compiler.h" // but without the compiler crashes :> + + +//enhance *any* string class with useful non-member functions: +namespace zen +{ +template bool isWhiteSpace(Char c); +template bool isLineBreak (Char c); +template bool isDigit (Char c); //not exactly the same as "std::isdigit" -> we consider '0'-'9' only! +template bool isHexDigit (Char c); +template bool isAsciiChar (Char c); +template bool isAsciiAlpha(Char c); +template bool isAsciiString(const S& str); +template Char asciiToLower(Char c); +template Char asciiToUpper(Char c); + +//both S and T can be strings or char/wchar_t arrays or single char/wchar_t +template /*Astyle hates tripe >*/ >> bool contains(const S& str, const T& term); + +template bool startsWith (const S& str, const T& prefix); +template bool startsWithAsciiNoCase(const S& str, const T& prefix); + +template bool endsWith (const S& str, const T& postfix); +template bool endsWithAsciiNoCase(const S& str, const T& postfix); + +template bool equalString (const S& lhs, const T& rhs); +template bool equalAsciiNoCase(const S& lhs, const T& rhs); + +template std::strong_ordering compareString(const S& lhs, const T& rhs); +template std::weak_ordering compareAsciiNoCase(const S& lhs, const T& rhs); //basic case-insensitive comparison (considering A-Z only!) + +//STL container predicates for std::map, std::unordered_set/map +struct StringHash; +struct StringEqual; + +struct LessAsciiNoCase; +struct StringHashAsciiNoCase; +struct StringEqualAsciiNoCase; + +template Num hashString(const S& str); + +enum class IfNotFoundReturn +{ + all, + none +}; +template S afterLast (const S& str, const T& term, IfNotFoundReturn infr); +template S beforeLast (const S& str, const T& term, IfNotFoundReturn infr); +template S afterFirst (const S& str, const T& term, IfNotFoundReturn infr); +template S beforeFirst(const S& str, const T& term, IfNotFoundReturn infr); + +enum class SplitOnEmpty +{ + allow, + skip +}; +template void split(const S& str, Char delimiter, Function onStringPart); +template void split2(const S& str, Function1 isDelimiter, Function2 onStringPart); +template [[nodiscard]] std::vector splitCpy(const S& str, Char delimiter, SplitOnEmpty soe); + +enum class TrimSide +{ + both, + left, + right, +}; +template [[nodiscard]] S trimCpy(const S& str, TrimSide side = TrimSide::both); +template void trim(S& str, TrimSide side = TrimSide::both); +template void trim(S& str, TrimSide side, Function trimThisChar); + + +template [[nodiscard]] S replaceCpy(S str, const T& oldTerm, const U& newTerm); +template void replace (S& str, const T& oldTerm, const U& newTerm); + +template [[nodiscard]] S replaceCpyAsciiNoCase(S str, const T& oldTerm, const U& newTerm); +template void replaceAsciiNoCase (S& str, const T& oldTerm, const U& newTerm); + +//high-performance conversion between numbers and strings +template S numberTo(const Num& number); +template Num stringTo(const S& str); + +std::pair hexify (unsigned char c, bool upperCase = true); +char unhexify(char high, char low); +std::string formatAsHexString(const std::string_view& blob); //bytes -> (human-readable) hex string + +template S printNumber(const T& format, const Num& number); //format a single number using std::snprintf() + +//string to string conversion: converts string-like type into char-compatible target string class +template T copyStringTo(S&& str); + + + + + + + + + + + + + + + +//---------------------- implementation ---------------------- +template inline +bool isWhiteSpace(Char c) +{ + static_assert(std::is_same_v || std::is_same_v); + assert(c != 0); //std C++ does not consider 0 as white space + return c == static_cast(' ') || (static_cast('\t') <= c && c <= static_cast('\r')); + //following std::isspace() for default locale but without the interface insanity: + // - std::isspace() takes an int, but expects an unsigned char + // - some parts of UTF-8 chars are erroneously seen as whitespace, e.g. the a0 from "\xec\x8b\xa0" (MSVC) +} + +template inline +bool isLineBreak(Char c) +{ + static_assert(std::is_same_v || std::is_same_v); + return c == static_cast('\r') || c == static_cast('\n'); +} + + +template inline +bool isDigit(Char c) //similar to implementation of std::isdigit()! +{ + static_assert(std::is_same_v || std::is_same_v); + return static_cast('0') <= c && c <= static_cast('9'); +} + + +template inline +bool isHexDigit(Char c) +{ + static_assert(std::is_same_v || std::is_same_v); + return (static_cast('0') <= c && c <= static_cast('9')) || + (static_cast('A') <= c && c <= static_cast('F')) || + (static_cast('a') <= c && c <= static_cast('f')); +} + + +template inline +bool isAsciiChar(Char c) +{ + return makeUnsigned(c) < 128; +} + + +template inline +bool isAsciiAlpha(Char c) +{ + static_assert(std::is_same_v || std::is_same_v); + return (static_cast('A') <= c && c <= static_cast('Z')) || + (static_cast('a') <= c && c <= static_cast('z')); +} + + +template inline +bool isAsciiString(const S& str) +{ + const auto* const first = strBegin(str); + return std::all_of(first, first + strLength(str), [](auto c) { return isAsciiChar(c); }); +} + + +template inline +Char asciiToLower(Char c) +{ + if (static_cast('A') <= c && c <= static_cast('Z')) + return static_cast(c - static_cast('A') + static_cast('a')); + return c; +} + + +template inline +Char asciiToUpper(Char c) +{ + if (static_cast('a') <= c && c <= static_cast('z')) + return static_cast(c - static_cast('a') + static_cast('A')); + return c; +} + + +namespace impl +{ +template inline +bool equalSubstring(const Char* lhs, const Char* rhs, size_t len) +{ + //support embedded 0, unlike strncmp/wcsncmp: + return std::equal(lhs, lhs + len, rhs); +} + + +template inline +std::weak_ordering strcmpAsciiNoCase(const Char1* lhs, const Char2* rhs, size_t len) +{ + while (len-- > 0) + { + const Char1 charL = asciiToLower(*lhs++); //ordering: lower-case chars have higher code points than uppper-case + const Char2 charR = asciiToLower(*rhs++); // + if (charL != charR) + return makeUnsigned(charL) <=> makeUnsigned(charR); //unsigned char-comparison is the convention! + } + return std::weak_ordering::equivalent; +} +} + + +template inline +bool startsWith(const S& str, const T& prefix) +{ + const size_t pfLen = strLength(prefix); + return strLength(str) >= pfLen && impl::equalSubstring(strBegin(str), strBegin(prefix), pfLen); +} + + +template inline +bool startsWithAsciiNoCase(const S& str, const T& prefix) +{ + assert(isAsciiString(str) || isAsciiString(prefix)); + const size_t pfLen = strLength(prefix); + return strLength(str) >= pfLen && impl::strcmpAsciiNoCase(strBegin(str), strBegin(prefix), pfLen) == std::weak_ordering::equivalent; +} + + +template inline +bool endsWith(const S& str, const T& postfix) +{ + const size_t strLen = strLength(str); + const size_t pfLen = strLength(postfix); + return strLen >= pfLen && impl::equalSubstring(strBegin(str) + strLen - pfLen, strBegin(postfix), pfLen); +} + + +template inline +bool endsWithAsciiNoCase(const S& str, const T& postfix) +{ + const size_t strLen = strLength(str); + const size_t pfLen = strLength(postfix); + return strLen >= pfLen && impl::strcmpAsciiNoCase(strBegin(str) + strLen - pfLen, strBegin(postfix), pfLen) == std::weak_ordering::equivalent; +} + + +template inline +bool equalString(const S& lhs, const T& rhs) +{ + const size_t lhsLen = strLength(lhs); + return lhsLen == strLength(rhs) && impl::equalSubstring(strBegin(lhs), strBegin(rhs), lhsLen); +} + + +template inline +bool equalAsciiNoCase(const S& lhs, const T& rhs) +{ + //assert(isAsciiString(lhs) || isAsciiString(rhs)); -> no, too strict (e.g. comparing file extensions ASCII-CI) + const size_t lhsLen = strLength(lhs); + return lhsLen == strLength(rhs) && impl::strcmpAsciiNoCase(strBegin(lhs), strBegin(rhs), lhsLen) == std::weak_ordering::equivalent; +} + + +namespace impl +{ +//support embedded 0 (unlike strncmp/wcsncmp) + compare unsigned[!] char +inline std::strong_ordering strcmpWithNulls(const char* ptr1, const char* ptr2, size_t num) { return std:: memcmp(ptr1, ptr2, num) <=> 0; } +inline std::strong_ordering strcmpWithNulls(const wchar_t* ptr1, const wchar_t* ptr2, size_t num) { return std::wmemcmp(ptr1, ptr2, num) <=> 0; } +} + +template inline +std::strong_ordering compareString(const S& lhs, const T& rhs) +{ + const size_t lhsLen = strLength(lhs); + const size_t rhsLen = strLength(rhs); + + //length check *after* strcmpWithNulls(): we DO care about natural ordering + if (const std::strong_ordering cmp = impl::strcmpWithNulls(strBegin(lhs), strBegin(rhs), std::min(lhsLen, rhsLen)); + cmp != std::strong_ordering::equal) + return cmp; + return lhsLen <=> rhsLen; +} + + +template inline +std::weak_ordering compareAsciiNoCase(const S& lhs, const T& rhs) +{ + const size_t lhsLen = strLength(lhs); + const size_t rhsLen = strLength(rhs); + + if (const std::weak_ordering cmp = impl::strcmpAsciiNoCase(strBegin(lhs), strBegin(rhs), std::min(lhsLen, rhsLen)); + cmp != std::weak_ordering::equivalent) + return cmp; + return lhsLen <=> rhsLen; +} + + +template inline +bool contains(const S& str, const T& term) +{ + static_assert(std::is_same_v, GetCharTypeT>); + const size_t strLen = strLength(str); + const size_t termLen = strLength(term); + if (strLen < termLen) + return false; + + const auto* const strFirst = strBegin(str); + const auto* const strLast = strFirst + strLen; + const auto* const termFirst = strBegin(term); + + return searchFirst(strFirst, strLast, + termFirst, termFirst + termLen) != strLast; +} + + +template inline +S afterLast(const S& str, const T& term, IfNotFoundReturn infr) +{ + static_assert(std::is_same_v, GetCharTypeT>); + const size_t termLen = strLength(term); + assert(termLen > 0); + + const auto* const strFirst = strBegin(str); + const auto* const strLast = strFirst + strLength(str); + const auto* const termFirst = strBegin(term); + + const auto* it = searchLast(strFirst, strLast, + termFirst, termFirst + termLen); + if (it == strLast) + return infr == IfNotFoundReturn::all ? str : S(); + + it += termLen; + return S(it, strLast - it); +} + + +template inline +S beforeLast(const S& str, const T& term, IfNotFoundReturn infr) +{ + static_assert(std::is_same_v, GetCharTypeT>); + const size_t termLen = strLength(term); + assert(termLen > 0); + + const auto* const strFirst = strBegin(str); + const auto* const strLast = strFirst + strLength(str); + const auto* const termFirst = strBegin(term); + + const auto* it = searchLast(strFirst, strLast, + termFirst, termFirst + termLen); + if (it == strLast) + return infr == IfNotFoundReturn::all ? str : S(); + + return S(strFirst, it - strFirst); +} + + +template inline +S afterFirst(const S& str, const T& term, IfNotFoundReturn infr) +{ + static_assert(std::is_same_v, GetCharTypeT>); + const size_t termLen = strLength(term); + assert(termLen > 0); + + const auto* const strFirst = strBegin(str); + const auto* const strLast = strFirst + strLength(str); + const auto* const termFirst = strBegin(term); + + const auto* it = searchFirst(strFirst, strLast, + termFirst, termFirst + termLen); + if (it == strLast) + return infr == IfNotFoundReturn::all ? str : S(); + + it += termLen; + return S(it, strLast - it); +} + + +template inline +S beforeFirst(const S& str, const T& term, IfNotFoundReturn infr) +{ + static_assert(std::is_same_v, GetCharTypeT>); + const size_t termLen = strLength(term); + assert(termLen > 0); + + const auto* const strFirst = strBegin(str); + const auto* const strLast = strFirst + strLength(str); + const auto* const termFirst = strBegin(term); + + auto it = searchFirst(strFirst, strLast, + termFirst, termFirst + termLen); + if (it == strLast) + return infr == IfNotFoundReturn::all ? str : S(); + + return S(strFirst, it - strFirst); +} + + +template inline +void split2(const S& str, Function1 isDelimiter, Function2 onStringPart) +{ + const auto* blockFirst = strBegin(str); + const auto* const strEnd = blockFirst + strLength(str); + + for (;;) + { + const auto* const blockLast = std::find_if(blockFirst, strEnd, isDelimiter); + onStringPart(makeStringView(blockFirst, blockLast)); + + if (blockLast == strEnd) + return; + + blockFirst = blockLast + 1; + } +} + + +template inline +void split(const S& str, Char delimiter, Function onStringPart) +{ + static_assert(std::is_same_v, Char>); + split2(str, [delimiter](const Char c) { return c == delimiter; }, onStringPart); +} + + +template inline +std::vector splitCpy(const S& str, Char delimiter, SplitOnEmpty soe) +{ + static_assert(std::is_same_v, Char>); + std::vector output; + + split2(str, [delimiter](const Char c) { return c == delimiter; }, [&, soe](std::basic_string_view block) + { + if (!block.empty() || soe == SplitOnEmpty::allow) + output.emplace_back(block.data(), block.size()); + }); + return output; +} + + +namespace impl +{ +ZEN_INIT_DETECT_MEMBER(append) + +//either call operator+=(S(str, len)) or append(str, len) +template >> inline +void stringAppend(S& str, InputIterator first, InputIterator last) { str.append(first, last); } + +//inefficient append: keep disabled until really needed +//template >> inline +//void stringAppend(S& str, InputIterator first, InputIterator last) { str += S(first, last); } + + +template inline +void replace(S& str, const T& oldTerm, const U& newTerm, CharEq charEqual) +{ + static_assert(std::is_same_v, GetCharTypeT>); + static_assert(std::is_same_v, GetCharTypeT>); + const size_t oldLen = strLength(oldTerm); + const size_t newLen = strLength(newTerm); + //assert(oldLen != 0); -> reasonable check, but challenged by unit-test + if (oldLen == 0) + return; + + const auto* const oldBegin = strBegin(oldTerm); + const auto* const oldEnd = oldBegin + oldLen; + + const auto* const newBegin = strBegin(newTerm); + const auto* const newEnd = newBegin + newLen; + + using CharType = GetCharTypeT; + if (oldLen == 1 && newLen == 1) //don't use expensive std::search unless required! + return std::replace_if(str.begin(), str.end(), [charEqual, charOld = *oldBegin](CharType c) { return charEqual(c, charOld); }, *newBegin); + + auto* it = strBegin(str); //don't use str.begin() or wxString will return this wxUni* nonsense! + auto* const strEnd = it + strLength(str); + + auto itFound = searchFirst(it, strEnd, + oldBegin, oldEnd, charEqual); + if (itFound == strEnd) + return; //optimize "oldTerm not found" + + S output(it, itFound); + do + { + impl::stringAppend(output, newBegin, newEnd); + it = itFound + oldLen; +#if 0 + if (!replaceAll) + itFound = strEnd; + else +#endif + itFound = searchFirst(it, strEnd, + oldBegin, oldEnd, charEqual); + + impl::stringAppend(output, it, itFound); + } + while (itFound != strEnd); + + str = std::move(output); +} +} + + +template inline +void replace(S& str, const T& oldTerm, const U& newTerm) +{ impl::replace(str, oldTerm, newTerm, std::equal_to()); } + + +template inline +S replaceCpy(S str, const T& oldTerm, const U& newTerm) +{ + replace(str, oldTerm, newTerm); + return str; +} + + +template inline +void replaceAsciiNoCase(S& str, const T& oldTerm, const U& newTerm) +{ + using CharType = GetCharTypeT; + impl::replace(str, oldTerm, newTerm, + [](CharType charL, CharType charR) { return asciiToLower(charL) == asciiToLower(charR); }); +} + + +template inline +S replaceCpyAsciiNoCase(S str, const T& oldTerm, const U& newTerm) +{ + replaceAsciiNoCase(str, oldTerm, newTerm); + return str; +} + + +template +[[nodiscard]] inline +std::pair trimCpy2(Char* first, Char* last, TrimSide side, Function trimThisChar) +{ + if (side == TrimSide::right || side == TrimSide::both) + while (first != last && trimThisChar(last[-1])) + --last; + + if (side == TrimSide::left || side == TrimSide::both) + while (first != last && trimThisChar(*first)) + ++first; + + return {first, last}; +} + + +template inline +void trim(S& str, TrimSide side, Function trimThisChar) +{ + const auto* const oldBegin = strBegin(str); + const auto [newBegin, newEnd] = trimCpy2(oldBegin, oldBegin + strLength(str), side, trimThisChar); + + if (newBegin != oldBegin) + str = S(newBegin, newEnd); //minor inefficiency: in case "str" is not shared, we could save an allocation and do a memory move only + else + str.resize(newEnd - newBegin); +} + + +template inline +void trim(S& str, TrimSide side) +{ + using CharType = GetCharTypeT; + trim(str, side, [](CharType c) { return isWhiteSpace(c); }); +} + + +template inline +S trimCpy(const S& str, TrimSide side) +{ + using CharType = GetCharTypeT; + const auto* const oldBegin = strBegin(str); + const auto* const oldEnd = oldBegin + strLength(str); + + const auto [newBegin, newEnd] = trimCpy2(oldBegin, oldEnd, side, [](CharType c) { return isWhiteSpace(c); }); + + if (newBegin == oldBegin && newEnd == oldEnd) + return str; + else + return S(newBegin, newEnd - newBegin); +} + + +namespace impl +{ +template +struct CopyStringToString +{ + T copy(const S& src) const + { + static_assert(!std::is_same_v, std::decay_t>); + return {strBegin(src), strLength(src)}; + } +}; + +template +struct CopyStringToString //perf: we don't need a deep copy if string types match +{ + template + T copy(S&& str) const { return std::forward(str); } +}; +} + +template inline +T copyStringTo(S&& str) { return impl::CopyStringToString, T>().copy(std::forward(str)); } + + +namespace impl +{ +template inline +int saferPrintf(char* buffer, size_t bufferSize, const char* format, const Num& number) //there is no such thing as a "safe" printf ;) +{ + return std::snprintf(buffer, bufferSize, format, number); //C99: returns number of chars written if successful, < 0 or >= bufferSize on error +} + +template inline +int saferPrintf(wchar_t* buffer, size_t bufferSize, const wchar_t* format, const Num& number) +{ + return std::swprintf(buffer, bufferSize, format, number); //C99: returns number of chars written if successful, < 0 on error (including buffer too small) +} +} + +template inline +S printNumber(const T& format, const Num& number) //format a single number using ::sprintf +{ + static_assert(std::is_same_v, GetCharTypeT>); + assert(strBegin(format)[strLength(format)] == 0); //format must be null-terminated! + + S buf(128, static_cast>('0')); + const int charsWritten = impl::saferPrintf(buf.data(), buf.size(), strBegin(format), number); + + if (charsWritten < 0 || makeUnsigned(charsWritten) > buf.size()) + { + assert(false); + return S(); + } + + buf.resize(charsWritten); + return buf; +} + + +namespace impl +{ +enum class NumberType +{ + signedInt, + unsignedInt, + floatingPoint, + other, +}; + + +template S numberTo(const Num& number, std::integral_constant) = delete; +#if 0 //default number to string conversion using streams: convenient, but SLOW, SLOW, SLOW!!!! (~ factor of 20) +template inline +S numberTo(const Num& number, std::integral_constant) +{ + std::basic_ostringstream> ss; + ss << number; + return copyStringTo(ss.str()); +} +#endif + + +template inline +S numberTo(const Num& number, std::integral_constant) +{ + //don't use sprintf("%g"): way SLOWWWWWWER than std::to_chars() + + char buffer[128]; //zero-initialize? + //let's give some leeway, but 24 chars should suffice: https://www.reddit.com/r/cpp/comments/dgj89g/cppcon_2019_stephan_t_lavavej_floatingpoint/f3j7d3q/ + const char* strEnd = toChars(std::begin(buffer), std::end(buffer), number); + + S output; + + for (const char c : makeStringView(static_cast(buffer), strEnd)) + output += static_cast>(c); + + return output; +} + + +/* +perf: integer to string: (executed 10 mio. times) + std::stringstream - 14796 ms + std::sprintf - 3086 ms + formatInteger - 778 ms +*/ + +template inline +void formatNegativeInteger(Num n, OutputIterator& it) +{ + assert(n < 0); + using CharType = typename std::iterator_traits::value_type; + do + { + const Num tmp = n / 10; + *--it = static_cast('0' + (tmp * 10 - n)); //8% faster than using modulus operator! + n = tmp; + } + while (n != 0); + + *--it = static_cast('-'); +} + +template inline +void formatPositiveInteger(Num n, OutputIterator& it) +{ + assert(n >= 0); + using CharType = typename std::iterator_traits::value_type; + do + { + const Num tmp = n / 10; + *--it = static_cast('0' + (n - tmp * 10)); //8% faster than using modulus operator! + n = tmp; + } + while (n != 0); +} + + +template inline +S numberTo(const Num& number, std::integral_constant) +{ + GetCharTypeT buffer[2 + sizeof(Num) * 241 / 100]; //zero-initialize? + //it's generally faster to use a buffer than to rely on String::operator+=() (in)efficiency + //required chars (+ sign char): 1 + ceil(ln_10(256^sizeof(n) / 2 + 1)) -> divide by 2 for signed half-range; second +1 since one half starts with 1! + // <= 1 + ceil(ln_10(256^sizeof(n))) =~ 1 + ceil(sizeof(n) * 2.4082) <= 2 + floor(sizeof(n) * 2.41) + + //caveat: consider INT_MIN: technically -INT_MIN == INT_MIN + auto it = std::end(buffer); + if (number < 0) + formatNegativeInteger(number, it); + else + formatPositiveInteger(number, it); + assert(it >= std::begin(buffer)); + + return S(&*it, std::end(buffer) - it); +} + + +template inline +S numberTo(const Num& number, std::integral_constant) +{ + GetCharTypeT buffer[1 + sizeof(Num) * 241 / 100]; //zero-initialize? + //required chars: ceil(ln_10(256^sizeof(n))) =~ ceil(sizeof(n) * 2.4082) <= 1 + floor(sizeof(n) * 2.41) + + auto it = std::end(buffer); + formatPositiveInteger(number, it); + assert(it >= std::begin(buffer)); + + return S(&*it, std::end(buffer) - it); +} + +//-------------------------------------------------------------------------------- + +template Num stringTo(const S& str, std::integral_constant) = delete; +#if 0 //default string to number conversion using streams: convenient, but SLOW +template inline +Num stringTo(const S& str, std::integral_constant) +{ + using CharType = GetCharTypeT; + Num number = 0; + std::basic_istringstream(copyStringTo>(str)) >> number; + return number; +} +#endif + + +inline +double stringToFloat(const char* first, const char* last) +{ + //don't use std::strtod(): 1. requires null-terminated string 2. SLOWER than std::from_chars() + return fromChars(first, last); +} + + +inline +double stringToFloat(const wchar_t* first, const wchar_t* last) +{ + std::string buf; //let's rely on SSO + + for (const wchar_t c : makeStringView(first, last)) + buf += static_cast(c); + + return fromChars(buf.c_str(), buf.c_str() + buf.size()); +} + + +template inline +Num stringTo(const S& str, std::integral_constant) +{ + const auto* const first = strBegin(str); + const auto* const last = first + strLength(str); + return static_cast(stringToFloat(first, last)); +} + + +template +Num extractInteger(const S& str, bool& hasMinusSign) //very fast conversion to integers: slightly faster than std::atoi, but more importantly: generic +{ + using CharType = GetCharTypeT; + + const CharType* first = strBegin(str); + const CharType* last = first + strLength(str); + + while (first != last && isWhiteSpace(*first)) //skip leading whitespace + ++first; + + hasMinusSign = false; + if (first != last) + { + if (*first == static_cast('-')) + { + hasMinusSign = true; + ++first; + } + else if (*first == static_cast('+')) + ++first; + } + + Num number = 0; + + for (const CharType c : makeStringView(first, last)) + if (static_cast('0') <= c && c <= static_cast('9')) + { + number *= 10; + number += c - static_cast('0'); + } + else //rest of string should contain whitespace only, it's NOT a bug if there is something else! + break; //assert(std::all_of(it, last, isWhiteSpace)); -> this is NO assert situation + + return number; +} + + +template inline +Num stringTo(const S& str, std::integral_constant) +{ + bool hasMinusSign = false; //handle minus sign + const Num number = extractInteger(str, hasMinusSign); + return hasMinusSign ? -number : number; +} + + +template inline +Num stringTo(const S& str, std::integral_constant) //very fast conversion to integers: slightly faster than std::atoi, but more importantly: generic +{ + bool hasMinusSign = false; //handle minus sign + const Num number = extractInteger(str, hasMinusSign); + if (hasMinusSign) + { + assert(false); + return -makeSigned(number); //at least make some noise + } + return number; +} +} + + +template inline +S numberTo(const Num& number) +{ + using TypeTag = std::integral_constant ? impl::NumberType::signedInt : + isUnsignedInt ? impl::NumberType::unsignedInt : + isFloat ? impl::NumberType::floatingPoint : + impl::NumberType::other>; + + return impl::numberTo(number, TypeTag()); +} + + +template inline +Num stringTo(const S& str) +{ + using TypeTag = std::integral_constant ? impl::NumberType::signedInt : + isUnsignedInt ? impl::NumberType::unsignedInt : + isFloat ? impl::NumberType::floatingPoint : + impl::NumberType::other>; + + return impl::stringTo(str, TypeTag()); +} + + +inline //hexify beats "printNumber("%02X", c)" by a nice factor of 3! +std::pair hexify(unsigned char c, bool upperCase) +{ + auto hexifyDigit = [upperCase](int num) -> char //input [0, 15], output 0-9, A-F + { + assert(0 <= num&& num <= 15); //guaranteed by design below! + if (num <= 9) + return static_cast('0' + num); //no signed/unsigned char problem here! + + if (upperCase) + return static_cast('A' + (num - 10)); + else + return static_cast('a' + (num - 10)); + }; + return {hexifyDigit(c / 16), hexifyDigit(c % 16)}; +} + + +inline //unhexify beats "::sscanf(&it[3], "%02X", &tmp)" by a factor of 3000 for ~250000 calls!!! +char unhexify(char high, char low) +{ + auto unhexifyDigit = [](const char hex) -> int //input 0-9, a-f, A-F; output range: [0, 15] + { + if ('0' <= hex && hex <= '9') //no signed/unsigned char problem here! + return hex - '0'; + else if ('A' <= hex && hex <= 'F') + return (hex - 'A') + 10; + else if ('a' <= hex && hex <= 'f') + return (hex - 'a') + 10; + assert(false); + return 0; + }; + return static_cast(16 * unhexifyDigit(high) + unhexifyDigit(low)); //[!] convert to unsigned char first, then to char (which may be signed) +} + + +inline +std::string formatAsHexString(const std::string_view& blob) +{ + std::string output; + for (const char c : blob) + { + const auto [high, low] = hexify(c, false /*upperCase*/); + output += high; + output += low; + } + return output; +} + + + + +template inline +Num hashString(const S& str) +{ + using CharType = GetCharTypeT; + const auto* const strFirst = strBegin(str); + + FNV1aHash hash; + std::for_each(strFirst, strFirst + strLength(str), [&hash](CharType c) { hash.add(c); }); + return hash.get(); +} + + +struct StringHash +{ + using is_transparent = int; //enable heterogenous lookup! + + template + size_t operator()(const String& str) const { return hashString(str); } +}; + + +struct StringEqual +{ + using is_transparent = int; //enable heterogenous lookup! + + template + bool operator()(const String1& lhs, const String2& rhs) const { return equalString(lhs, rhs); } +}; + + +struct LessAsciiNoCase +{ + template + bool operator()(const String& lhs, const String& rhs) const { return compareAsciiNoCase(lhs, rhs) < 0; } +}; + + +struct StringHashAsciiNoCase +{ + using is_transparent = int; //allow heterogenous lookup! + + template + size_t operator()(const String& str) const + { + using CharType = GetCharTypeT; + const auto* const strFirst = strBegin(str); + + FNV1aHash hash; + std::for_each(strFirst, strFirst + strLength(str), [&hash](CharType c) { hash.add(asciiToLower(c)); }); + return hash.get(); + } +}; + + +struct StringEqualAsciiNoCase +{ + using is_transparent = int; //allow heterogenous lookup! + + template + bool operator()(const String1& lhs, const String2& rhs) const + { + return equalAsciiNoCase(lhs, rhs); + } +}; +} + +#endif //STRING_TOOLS_H_213458973046 diff --git a/zen/string_traits.h b/zen/string_traits.h new file mode 100644 index 0000000..576ea2b --- /dev/null +++ b/zen/string_traits.h @@ -0,0 +1,186 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef STRING_TRAITS_H_813274321443234 +#define STRING_TRAITS_H_813274321443234 + +#include //strlen +#include +#include "type_traits.h" + + +//uniform access to string-like types, both classes and character arrays +namespace zen +{ +/* isStringLike<>: + isStringLike //equals "true" + isStringLike //equals "false" + + GetCharTypeT<>: + GetCharTypeT //equals wchar_t + GetCharTypeT //equals wchar_t + + strLength(): + strLength(str); //equals str.length() + strLength(array); //equals cStringLength(array) + + strBegin(): -> not null-terminated! -> may be nullptr if length is 0! + std::wstring str(L"dummy"); + char array[] = "dummy"; + strBegin(str); //returns str.c_str() + strBegin(array); //returns array */ + + + +//---------------------- implementation ---------------------- +namespace impl +{ +template //test if result of S::c_str() can convert to const Char* +class HasConversion +{ + using Yes = char[1]; + using No = char[2]; + + static Yes& hasConversion(const Char*); + static No& hasConversion(...); + +public: + enum { value = sizeof(hasConversion(std::declval().c_str())) == sizeof(Yes) }; +}; + + +template struct GetCharTypeImpl { using Type = void; }; + +template +struct GetCharTypeImpl +{ + using Type = std::conditional_t::value, wchar_t, + /**/ std::conditional_t::value, char, void>>; + + //using Type = typename S::value_type; + /*DON'T use S::value_type: + 1. support Glib::ustring: value_type is "unsigned int" but c_str() returns "const char*" + 2. wxString, wxWidgets v2.9, has some questionable string design: wxString::c_str() returns a proxy (wxCStrData) which + is implicitly convertible to *both* "const char*" and "const wchar_t*" while wxString::value_type is a wrapper around an unsigned int + */ +}; + +template <> struct GetCharTypeImpl { using Type = char; }; +template <> struct GetCharTypeImpl { using Type = wchar_t; }; + +template <> struct GetCharTypeImpl, false> { using Type = char; }; +template <> struct GetCharTypeImpl, false> { using Type = wchar_t; }; +template <> struct GetCharTypeImpl, false> { using Type = char; }; +template <> struct GetCharTypeImpl, false> { using Type = wchar_t; }; + + +ZEN_INIT_DETECT_MEMBER_TYPE(value_type) +ZEN_INIT_DETECT_MEMBER(c_str) //we don't know the exact declaration of the member attribute and it may be in a base class! +ZEN_INIT_DETECT_MEMBER(length) // + +template +class StringTraits +{ + using CleanType = std::remove_cvref_t; + using NonArrayType = std::remove_extent_t ; + using NonPtrType = std::remove_pointer_t; + using UndecoratedType = std::remove_cv_t ; //handle "const char* const" + +public: + enum + { + isStringClass = hasMemberType_value_type&& + hasMember_c_str && + hasMember_length + }; + + using CharType = typename GetCharTypeImpl::Type; + + enum + { + isStringLike = std::is_same_v || + std::is_same_v + }; +}; +} + + +template +constexpr bool isStringLike = impl::StringTraits::isStringLike; + +template +using GetCharTypeT = typename impl::StringTraits::CharType; + + +namespace impl +{ +//strlen/wcslen are vectorized since VS14 CTP3 +inline size_t cStringLength(const char* str) { return std::strlen(str); } +inline size_t cStringLength(const wchar_t* str) { return std::wcslen(str); } + +#if 0 //no significant perf difference for "comparison" test case between cStringLength/wcslen: +template inline +size_t cStringLength(const C* str) +{ + static_assert(std::is_same_v || std::is_same_v); + size_t len = 0; + while (*str++ != 0) + ++len; + return len; +} +#endif + +template ::isStringClass>> inline +const GetCharTypeT* strBegin(const S& str) //SFINAE: T must be a "string" +{ + return str.c_str(); +} + +inline const char* strBegin(const char* str) { return str; } +inline const wchar_t* strBegin(const wchar_t* str) { return str; } +inline const char* strBegin(const char& ch) { return &ch; } +inline const wchar_t* strBegin(const wchar_t& ch) { return &ch; } + +inline const char* strBegin(const std::basic_string_view& ref) { return ref.data(); } +inline const wchar_t* strBegin(const std::basic_string_view& ref) { return ref.data(); } +inline const char* strBegin(const std::basic_string_view& ref) { return ref.data(); } +inline const wchar_t* strBegin(const std::basic_string_view& ref) { return ref.data(); } + +template ::isStringClass>> inline +size_t strLength(const S& str) //SFINAE: T must be a "string" +{ + return str.length(); +} + +inline size_t strLength(const char* str) { return cStringLength(str); } +inline size_t strLength(const wchar_t* str) { return cStringLength(str); } +inline size_t strLength(char) { return 1; } +inline size_t strLength(wchar_t) { return 1; } + +inline size_t strLength(const std::basic_string_view& ref) { return ref.length(); } +inline size_t strLength(const std::basic_string_view& ref) { return ref.length(); } +inline size_t strLength(const std::basic_string_view& ref) { return ref.length(); } +inline size_t strLength(const std::basic_string_view& ref) { return ref.length(); } +} + + +template inline +auto strBegin(S&& str) +{ + static_assert(isStringLike); + return impl::strBegin(std::forward(str)); +} + + +template inline +size_t strLength(S&& str) +{ + static_assert(isStringLike); + return impl::strLength(std::forward(str)); +} +} + +#endif //STRING_TRAITS_H_813274321443234 diff --git a/zen/symlink_target.h b/zen/symlink_target.h new file mode 100644 index 0000000..97bba69 --- /dev/null +++ b/zen/symlink_target.h @@ -0,0 +1,97 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYMLINK_TARGET_H_801783470198357483 +#define SYMLINK_TARGET_H_801783470198357483 + +#include "file_error.h" +#include "file_path.h" + + #include + #include //realpath + + +namespace zen +{ + +struct SymlinkRawContent +{ + Zstring targetPath; +}; +SymlinkRawContent getSymlinkRawContent(const Zstring& linkPath); //throw FileError + +Zstring getSymlinkResolvedPath(const Zstring& linkPath); //throw FileError +} + + + + + + + + + +//################################ implementation ################################ + + +namespace zen +{ +namespace +{ +//retrieve raw target data of symlink or junction +SymlinkRawContent getSymlinkRawContent_impl(const Zstring& linkPath) //throw SysError +{ + const size_t bufSize = 10000; + std::vector buf(bufSize); + + const ssize_t bytesWritten = ::readlink(linkPath.c_str(), buf.data(), bufSize); + if (bytesWritten < 0) + THROW_LAST_SYS_ERROR("readlink"); + + ASSERT_SYSERROR(makeUnsigned(bytesWritten) <= bufSize); //better safe than sorry + + if (makeUnsigned(bytesWritten) == bufSize) //detect truncation; not an error for readlink! + throw SysError(formatSystemError("readlink", L"", L"Buffer truncated.")); + + return {.targetPath = Zstring(buf.data(), bytesWritten)}; //readlink does not append 0-termination! +} + + +Zstring getSymlinkResolvedPath_impl(const Zstring& linkPath) //throw SysError +{ + char* targetPath = ::realpath(linkPath.c_str(), nullptr /*resolved_path*/); + if (!targetPath) + THROW_LAST_SYS_ERROR("realpath"); + ZEN_ON_SCOPE_EXIT(::free(targetPath)); + return targetPath; +} +} + + +inline +SymlinkRawContent getSymlinkRawContent(const Zstring& linkPath) +{ + try + { + return getSymlinkRawContent_impl(linkPath); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot resolve symbolic link %x."), L"%x", fmtPath(linkPath)), e.toString()); } +} + + +inline +Zstring getSymlinkResolvedPath(const Zstring& linkPath) +{ + try + { + return getSymlinkResolvedPath_impl(linkPath); //throw SysError + } + catch (const SysError& e) { throw FileError(replaceCpy(_("Cannot determine final path for %x."), L"%x", fmtPath(linkPath)), e.toString()); } +} + +} + +#endif //SYMLINK_TARGET_H_801783470198357483 diff --git a/zen/sys_error.cpp b/zen/sys_error.cpp new file mode 100644 index 0000000..90d9ee2 --- /dev/null +++ b/zen/sys_error.cpp @@ -0,0 +1,281 @@ +// ***************************************************************************** +// * 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 "sys_error.h" + #include + +using namespace zen; + + +namespace +{ +std::wstring formatSystemErrorCode(ErrorCode ec) +{ + switch (ec) //pretty much all codes currently used on CentOS 7 and macOS 10.15 + { + ZEN_CHECK_CASE_FOR_CONSTANT(EPERM); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOENT); + ZEN_CHECK_CASE_FOR_CONSTANT(ESRCH); + ZEN_CHECK_CASE_FOR_CONSTANT(EINTR); + ZEN_CHECK_CASE_FOR_CONSTANT(EIO); + ZEN_CHECK_CASE_FOR_CONSTANT(ENXIO); + ZEN_CHECK_CASE_FOR_CONSTANT(E2BIG); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOEXEC); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADF); + ZEN_CHECK_CASE_FOR_CONSTANT(ECHILD); + ZEN_CHECK_CASE_FOR_CONSTANT(EAGAIN); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOMEM); + ZEN_CHECK_CASE_FOR_CONSTANT(EACCES); + ZEN_CHECK_CASE_FOR_CONSTANT(EFAULT); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTBLK); + ZEN_CHECK_CASE_FOR_CONSTANT(EBUSY); + ZEN_CHECK_CASE_FOR_CONSTANT(EEXIST); + ZEN_CHECK_CASE_FOR_CONSTANT(EXDEV); + ZEN_CHECK_CASE_FOR_CONSTANT(ENODEV); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTDIR); + ZEN_CHECK_CASE_FOR_CONSTANT(EISDIR); + ZEN_CHECK_CASE_FOR_CONSTANT(EINVAL); + ZEN_CHECK_CASE_FOR_CONSTANT(ENFILE); + ZEN_CHECK_CASE_FOR_CONSTANT(EMFILE); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTTY); + ZEN_CHECK_CASE_FOR_CONSTANT(ETXTBSY); + ZEN_CHECK_CASE_FOR_CONSTANT(EFBIG); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOSPC); + ZEN_CHECK_CASE_FOR_CONSTANT(ESPIPE); + ZEN_CHECK_CASE_FOR_CONSTANT(EROFS); + ZEN_CHECK_CASE_FOR_CONSTANT(EMLINK); + ZEN_CHECK_CASE_FOR_CONSTANT(EPIPE); + ZEN_CHECK_CASE_FOR_CONSTANT(EDOM); + ZEN_CHECK_CASE_FOR_CONSTANT(ERANGE); + ZEN_CHECK_CASE_FOR_CONSTANT(EDEADLK); + ZEN_CHECK_CASE_FOR_CONSTANT(ENAMETOOLONG); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOLCK); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOSYS); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTEMPTY); + ZEN_CHECK_CASE_FOR_CONSTANT(ELOOP); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOMSG); + ZEN_CHECK_CASE_FOR_CONSTANT(EIDRM); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOSTR); + ZEN_CHECK_CASE_FOR_CONSTANT(ENODATA); + ZEN_CHECK_CASE_FOR_CONSTANT(ETIME); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOSR); + ZEN_CHECK_CASE_FOR_CONSTANT(EREMOTE); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOLINK); + ZEN_CHECK_CASE_FOR_CONSTANT(EPROTO); + ZEN_CHECK_CASE_FOR_CONSTANT(EMULTIHOP); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADMSG); + ZEN_CHECK_CASE_FOR_CONSTANT(EOVERFLOW); + ZEN_CHECK_CASE_FOR_CONSTANT(EILSEQ); + ZEN_CHECK_CASE_FOR_CONSTANT(EUSERS); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTSOCK); + ZEN_CHECK_CASE_FOR_CONSTANT(EDESTADDRREQ); + ZEN_CHECK_CASE_FOR_CONSTANT(EMSGSIZE); + ZEN_CHECK_CASE_FOR_CONSTANT(EPROTOTYPE); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOPROTOOPT); + ZEN_CHECK_CASE_FOR_CONSTANT(EPROTONOSUPPORT); + ZEN_CHECK_CASE_FOR_CONSTANT(ESOCKTNOSUPPORT); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTSUP); + ZEN_CHECK_CASE_FOR_CONSTANT(EPFNOSUPPORT); + ZEN_CHECK_CASE_FOR_CONSTANT(EAFNOSUPPORT); + ZEN_CHECK_CASE_FOR_CONSTANT(EADDRINUSE); + ZEN_CHECK_CASE_FOR_CONSTANT(EADDRNOTAVAIL); + ZEN_CHECK_CASE_FOR_CONSTANT(ENETDOWN); + ZEN_CHECK_CASE_FOR_CONSTANT(ENETUNREACH); + ZEN_CHECK_CASE_FOR_CONSTANT(ENETRESET); + ZEN_CHECK_CASE_FOR_CONSTANT(ECONNABORTED); + ZEN_CHECK_CASE_FOR_CONSTANT(ECONNRESET); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOBUFS); + ZEN_CHECK_CASE_FOR_CONSTANT(EISCONN); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTCONN); + ZEN_CHECK_CASE_FOR_CONSTANT(ESHUTDOWN); + ZEN_CHECK_CASE_FOR_CONSTANT(ETOOMANYREFS); + ZEN_CHECK_CASE_FOR_CONSTANT(ETIMEDOUT); + ZEN_CHECK_CASE_FOR_CONSTANT(ECONNREFUSED); + ZEN_CHECK_CASE_FOR_CONSTANT(EHOSTDOWN); + ZEN_CHECK_CASE_FOR_CONSTANT(EHOSTUNREACH); + ZEN_CHECK_CASE_FOR_CONSTANT(EALREADY); + ZEN_CHECK_CASE_FOR_CONSTANT(EINPROGRESS); + ZEN_CHECK_CASE_FOR_CONSTANT(ESTALE); + ZEN_CHECK_CASE_FOR_CONSTANT(EDQUOT); + ZEN_CHECK_CASE_FOR_CONSTANT(ECANCELED); + ZEN_CHECK_CASE_FOR_CONSTANT(EOWNERDEAD); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTRECOVERABLE); + + ZEN_CHECK_CASE_FOR_CONSTANT(ECHRNG); + ZEN_CHECK_CASE_FOR_CONSTANT(EL2NSYNC); + ZEN_CHECK_CASE_FOR_CONSTANT(EL3HLT); + ZEN_CHECK_CASE_FOR_CONSTANT(EL3RST); + ZEN_CHECK_CASE_FOR_CONSTANT(ELNRNG); + ZEN_CHECK_CASE_FOR_CONSTANT(EUNATCH); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOCSI); + ZEN_CHECK_CASE_FOR_CONSTANT(EL2HLT); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADE); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADR); + ZEN_CHECK_CASE_FOR_CONSTANT(EXFULL); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOANO); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADRQC); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADSLT); + ZEN_CHECK_CASE_FOR_CONSTANT(EBFONT); + ZEN_CHECK_CASE_FOR_CONSTANT(ENONET); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOPKG); + ZEN_CHECK_CASE_FOR_CONSTANT(EADV); + ZEN_CHECK_CASE_FOR_CONSTANT(ESRMNT); + ZEN_CHECK_CASE_FOR_CONSTANT(ECOMM); + ZEN_CHECK_CASE_FOR_CONSTANT(EDOTDOT); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTUNIQ); + ZEN_CHECK_CASE_FOR_CONSTANT(EBADFD); + ZEN_CHECK_CASE_FOR_CONSTANT(EREMCHG); + ZEN_CHECK_CASE_FOR_CONSTANT(ELIBACC); + ZEN_CHECK_CASE_FOR_CONSTANT(ELIBBAD); + ZEN_CHECK_CASE_FOR_CONSTANT(ELIBSCN); + ZEN_CHECK_CASE_FOR_CONSTANT(ELIBMAX); + ZEN_CHECK_CASE_FOR_CONSTANT(ELIBEXEC); + ZEN_CHECK_CASE_FOR_CONSTANT(ERESTART); + ZEN_CHECK_CASE_FOR_CONSTANT(ESTRPIPE); + ZEN_CHECK_CASE_FOR_CONSTANT(EUCLEAN); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOTNAM); + ZEN_CHECK_CASE_FOR_CONSTANT(ENAVAIL); + ZEN_CHECK_CASE_FOR_CONSTANT(EISNAM); + ZEN_CHECK_CASE_FOR_CONSTANT(EREMOTEIO); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOMEDIUM); + ZEN_CHECK_CASE_FOR_CONSTANT(EMEDIUMTYPE); + ZEN_CHECK_CASE_FOR_CONSTANT(ENOKEY); + ZEN_CHECK_CASE_FOR_CONSTANT(EKEYEXPIRED); + ZEN_CHECK_CASE_FOR_CONSTANT(EKEYREVOKED); + ZEN_CHECK_CASE_FOR_CONSTANT(EKEYREJECTED); + ZEN_CHECK_CASE_FOR_CONSTANT(ERFKILL); + ZEN_CHECK_CASE_FOR_CONSTANT(EHWPOISON); + default: + return replaceCpy(_("Error code %x"), L"%x", numberTo(ec)); + } +} +} + + +std::wstring zen::formatGlibError(const std::string& functionName, GError* error) +{ + if (!error) + return formatSystemError(functionName, L"", _("Error description not available.") + L" null GError"); + + if (error->domain == G_FILE_ERROR) //"values corresponding to errno codes" + return formatSystemError(functionName, error->code); + + std::wstring errorCode; + if (error->domain == G_IO_ERROR) + errorCode = [&]() -> std::wstring + { + switch (error->code) //GIOErrorEnum: https://gitlab.gnome.org/GNOME/glib/-/blob/master/gio/gioenums.h#L530 + { + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_FOUND); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_EXISTS); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_IS_DIRECTORY); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_DIRECTORY); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_EMPTY); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_REGULAR_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_SYMBOLIC_LINK); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_MOUNTABLE_FILE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_FILENAME_TOO_LONG); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_INVALID_FILENAME); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_TOO_MANY_LINKS); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NO_SPACE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_INVALID_ARGUMENT); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PERMISSION_DENIED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_SUPPORTED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_MOUNTED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_ALREADY_MOUNTED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_CLOSED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_CANCELLED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PENDING); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_READ_ONLY); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_CANT_CREATE_BACKUP); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_WRONG_ETAG); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_TIMED_OUT); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_WOULD_RECURSE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_BUSY); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_WOULD_BLOCK); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_HOST_NOT_FOUND); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_WOULD_MERGE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_FAILED_HANDLED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_TOO_MANY_OPEN_FILES); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_INITIALIZED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_ADDRESS_IN_USE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PARTIAL_INPUT); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_INVALID_DATA); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_DBUS_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_HOST_UNREACHABLE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NETWORK_UNREACHABLE); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_CONNECTION_REFUSED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PROXY_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PROXY_AUTH_FAILED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PROXY_NEED_AUTH); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_PROXY_NOT_ALLOWED); +#ifndef GLIB_CHECK_VERSION //e.g Debian 8 (GLib 2.42) CentOS 7 (GLib 2.56) +#error Where is GLib? +#endif +#if GLIB_CHECK_VERSION(2, 44, 0) + static_assert(G_IO_ERROR_BROKEN_PIPE == G_IO_ERROR_CONNECTION_CLOSED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_CONNECTION_CLOSED); + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_NOT_CONNECTED); +#endif +#if GLIB_CHECK_VERSION(2, 48, 0) + ZEN_CHECK_CASE_FOR_CONSTANT(G_IO_ERROR_MESSAGE_TOO_LARGE); +#endif + default: + return replaceCpy(L"GIO error %x", L"%x", numberTo(error->code)); + } + }(); + else + { + //g-file-error-quark => g-file-error + //g-io-error-quark => g-io-error + std::wstring domain = utfTo(::g_quark_to_string(error->domain)); //e.g. "g-io-error-quark" + if (endsWith(domain, L"-quark")) + domain = beforeLast(domain, L"-", IfNotFoundReturn::none); + + errorCode = domain + L' ' + numberTo(error->code); //e.g. "g-io-error 15" + } + + const std::wstring errorMsg = utfTo(error->message); //e.g. "Unable to find or create trash directory for file.txt" + + return formatSystemError(functionName, errorCode, errorMsg); +} + + + +std::wstring zen::getSystemErrorDescription(ErrorCode ec) //return empty string on error +{ + const ErrorCode ecCurrent = getLastError(); //not necessarily == ec + ZEN_ON_SCOPE_EXIT(errno = ecCurrent); + + std::wstring errorMsg = utfTo(::g_strerror(ec)); //... vs strerror(): "marginally improves thread safety, and marginally improves consistency" + + trim(errorMsg); //Windows messages seem to end with a space... + return errorMsg; +} + + +std::wstring zen::formatSystemError(const std::string& functionName, ErrorCode ec) +{ + return formatSystemError(functionName, formatSystemErrorCode(ec), getSystemErrorDescription(ec)); +} + + +std::wstring zen::formatSystemError(const std::string& functionName, const std::wstring& errorCode, const std::wstring& errorMsg) +{ + std::wstring output = trimCpy(errorCode); + + const std::wstring errorMsgFmt = trimCpy(errorMsg); + if (!output.empty() && !errorMsgFmt.empty()) + output += L": "; + + output += errorMsgFmt; + + if (!functionName.empty()) + output += L" [" + utfTo(functionName) + L']'; + + return trimCpy(output); +} diff --git a/zen/sys_error.h b/zen/sys_error.h new file mode 100644 index 0000000..53cd284 --- /dev/null +++ b/zen/sys_error.h @@ -0,0 +1,86 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYS_ERROR_H_3284791347018951324534 +#define SYS_ERROR_H_3284791347018951324534 + +#include "scope_guard.h" // +#include "i18n.h" //not used by this header, but the "rest of the world" needs it! +#include "zstring.h" // +#include "extra_log.h" // + + #include + #include + + +namespace zen +{ +//evaluate GetLastError()/errno and assemble specific error message + using ErrorCode = int; + +ErrorCode getLastError(); + +std::wstring formatSystemError(const std::string& functionName, const std::wstring& errorCode, const std::wstring& errorMsg); +std::wstring formatSystemError(const std::string& functionName, ErrorCode ec); + std::wstring formatGlibError(const std::string& functionName, GError* error); + + +//A low-level exception class giving (non-translated) detail information only - same conceptional level like "GetLastError()"! +class SysError +{ +public: + explicit SysError(const std::wstring& msg) : msg_(msg) {} + const std::wstring& toString() const { return msg_; } + +private: + std::wstring msg_; +}; + +#define DEFINE_NEW_SYS_ERROR(X) struct X : public zen::SysError { X(const std::wstring& msg) : SysError(msg) {} }; + + + +//better leave it as a macro (see comment in file_error.h) +#define THROW_LAST_SYS_ERROR(functionName) \ + do { const ErrorCode ecInternal = getLastError(); throw zen::SysError(formatSystemError(functionName, ecInternal)); } while (false) + + +/* Example: ASSERT_SYSERROR(expr); + + Equivalent to: + if (!expr) + throw zen::SysError(L"Assertion failed: \"expr\""); */ +#define ASSERT_SYSERROR(expr) ASSERT_SYSERROR_IMPL(expr, #expr) //throw SysError + + + +//######################## implementation ######################## +inline +ErrorCode getLastError() +{ + return errno; //don't use "::" prefix, errno is a macro! +} + + +std::wstring getSystemErrorDescription(ErrorCode ec); //return empty string on error +//intentional overload ambiguity to catch usage errors with HRESULT: +std::wstring getSystemErrorDescription(long long) = delete; + + + + +namespace impl +{ +inline bool validateBool(bool b) { return b; } +inline bool validateBool(void* b) { return b; } +bool validateBool(int) = delete; //catch unintended bool conversions, e.g. HRESULT +} +#define ASSERT_SYSERROR_IMPL(expr, exprStr) \ + { if (!zen::impl::validateBool(expr)) \ + throw zen::SysError(L"Assertion failed: \"" L ## exprStr L"\""); } +} + +#endif //SYS_ERROR_H_3284791347018951324534 diff --git a/zen/sys_info.cpp b/zen/sys_info.cpp new file mode 100644 index 0000000..00b7cd2 --- /dev/null +++ b/zen/sys_info.cpp @@ -0,0 +1,296 @@ +// ***************************************************************************** +// * 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 "sys_info.h" +#include "crc.h" +#include "file_access.h" +#include "sys_version.h" + + #include "symlink_target.h" + #include "file_io.h" + #include + #include //IFF_LOOPBACK + #include //sockaddr_ll + + + #include "process_exec.h" + #include //getuid() + #include //getpwuid_r() + +using namespace zen; + + +Zstring zen::getLoginUser() //throw FileError +{ + auto tryGetNonRootUser = [](const char* varName) -> std::optional + { + if (const std::optional username = getEnvironmentVar(varName)) + if (!username->empty() && *username != "root") + return *username; + return {}; + }; + + if (const uid_t userIdNo = ::getuid(); //never fails + userIdNo != 0) //nofail; non-root + { + //ugh, the world's stupidest API: + std::vector buf(std::max(10000, ::sysconf(_SC_GETPW_R_SIZE_MAX))); //::sysconf may return long(-1) or even a too small size!! WTF! + passwd buf2 = {}; + passwd* pwEntry = nullptr; + if (const int rv = ::getpwuid_r(userIdNo, //uid_t uid + &buf2, //struct passwd* pwd + buf.data(), //char* buf + buf.size(), //size_t buflen + &pwEntry); //struct passwd** result + rv != 0 || !pwEntry) + { + //"If an error occurs, errno is set appropriately" => why the fuck, then, also return errno as return value!? + errno = rv != 0 ? rv : ENOENT; + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "getpwuid_r(" + numberTo(userIdNo) + ')'); + } + + return pwEntry->pw_name; + } + //else: root(0) => consider as request for elevation, NOT impersonation! + + //getlogin() is smarter than simply evaluating $LOGNAME! even in contexts without + //$LOGNAME, e.g. "sudo su" on Ubuntu, it returns the correct non-root user! + if (const char* loginUser = ::getlogin()) //https://linux.die.net/man/3/getlogin + if (strLength(loginUser) > 0 && !equalString(loginUser, "root")) + return loginUser; + //BUT: getlogin() can fail with ENOENT on Linux Mint: https://freefilesync.org/forum/viewtopic.php?t=8181 + + //getting a little desperate: variables used by installer.sh + if (const std::optional username = tryGetNonRootUser("USER")) return *username; + if (const std::optional username = tryGetNonRootUser("SUDO_USER")) return *username; + if (const std::optional username = tryGetNonRootUser("LOGNAME")) return *username; + + + //apparently the current user really IS root: https://freefilesync.org/forum/viewtopic.php?t=8405 + assert(getuid() == 0); + return "root"; +} + + +Zstring zen::getUserDescription() //throw FileError +{ + const Zstring username = getLoginUser(); //throw FileError + const Zstring computerName = []() -> Zstring //throw FileError + { + std::vector buf(10000); + if (::gethostname(buf.data(), buf.size()) != 0) + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "gethostname"); + + Zstring hostName = buf.data(); + if (endsWithAsciiNoCase(hostName, ".local")) //strip fluff (macOS) => apparently not added on Linux? + hostName = beforeLast(hostName, '.', IfNotFoundReturn::none); + + return hostName; + }(); + + if (contains(getUpperCase(computerName), getUpperCase(username))) + return username; //no need for text duplication! e.g. "Zenju (Zenju-PC)" + + return username + Zstr(" (") + computerName + Zstr(')'); //e.g. "Admin (Zenju-PC)" +} + + +namespace +{ +} + + +ComputerModel zen::getComputerModel() //throw FileError +{ + ComputerModel cm; + try + { + auto tryGetInfo = [](const Zstring& filePath) + { + try + { + const std::string stream = getFileContent(filePath, nullptr /*notifyUnbufferedIO*/); //throw FileError + return utfTo(stream); + } + catch (FileError&) + { + if (!itemExists(filePath)) //throw FileError + return std::wstring(); + + throw; + } + }; + cm.model = tryGetInfo("/sys/devices/virtual/dmi/id/product_name"); //throw FileError + cm.vendor = tryGetInfo("/sys/devices/virtual/dmi/id/sys_vendor"); // + + //clean up: + cm.model = beforeFirst(cm.model, L'\u00ff', IfNotFoundReturn::all); //fix broken BIOS entries: + cm.vendor = beforeFirst(cm.vendor, L'\u00ff', IfNotFoundReturn::all); //0xff can be considered 0 + + replace(cm.model, L'_', L' '); //e.g. "CBX3___", "SYSTEM_MANUFACTURER", or just "_" + replace(cm.vendor, L'_', L' '); //e.g. "DELL__", "Exertis_CapTech", or just "_" + + trim(cm.model); + trim(cm.vendor); + + for (const char* dummyModel : + { + "Please change product name", + "System Product Name", + "To Be Filled By O.E.M.", + "Default string", + "$(DEFAULT STRING)", + "", + "Product Name", + "Undefined", + "INVALID", + "Unknow", + "empty", + "O.E.M.", + "O.E.M", + "OEM", + "NA", + ".", + }) + if (equalAsciiNoCase(cm.model, dummyModel)) + { + cm.model.clear(); + break; + } + + for (const char* dummyVendor : + { + "OEM Manufacturer", + "System manufacturer", + "System Manufacter", + "To Be Filled By O.E.M.", + "Default string", + "$(DEFAULT STRING)", + "Undefined", + "Unknow", + "empty", + "O.E.M.", + "O.E.M", + "OEM", + "NA", + ".", + }) + if (equalAsciiNoCase(cm.vendor, dummyVendor)) + { + cm.vendor.clear(); + break; + } + + return cm; + } + catch (const SysError& e) { throw FileError(_("Cannot get process information."), e.toString()); } +} + + + + + +std::wstring zen::getOsDescription() //throw FileError +{ + try + { + const OsVersionDetail verDetail = getOsVersionDetail(); //throw SysError + return trimCpy(verDetail.osName + L" (" + verDetail.osVersionRaw) + L')'; //e.g. "CentOS (7.8.2003)" + + } + catch (const SysError& e) { throw FileError(_("Cannot get process information."), e.toString()); } +} + + + + +Zstring zen::getProcessPath() //throw FileError +{ + try + { + return getSymlinkRawContent_impl("/proc/self/exe").targetPath; //throw SysError + //path does not contain symlinks => no need for ::realpath() + + } + catch (const SysError& e) { throw FileError(_("Cannot get process information."), e.toString()); } +} + + +Zstring zen::getUserHome() //throw FileError +{ + if (::getuid() != 0) //nofail; non-root + /* https://linux.die.net/man/3/getpwuid: An application that wants to determine its user's home directory + should inspect the value of HOME (rather than the value getpwuid(getuid())->pw_dir) since this allows + the user to modify their notion of "the home directory" during a login session. */ + if (const std::optional homeDirPath = getEnvironmentVar("HOME")) + return *homeDirPath; + + //root(0) => consider as request for elevation, NOT impersonation! + //=> "HOME=/root" :( + + const Zstring loginUser = getLoginUser(); //throw FileError + + //ugh, the world's stupidest API: + std::vector buf(std::max(10000, ::sysconf(_SC_GETPW_R_SIZE_MAX))); //::sysconf may return long(-1) or even a too small size!! WTF! + passwd buf2 = {}; + passwd* pwEntry = nullptr; + if (const int rv = ::getpwnam_r(loginUser.c_str(), //const char *name + &buf2, //struct passwd* pwd + buf.data(), //char* buf + buf.size(), //size_t buflen + &pwEntry); //struct passwd** result + rv != 0 || !pwEntry) + { + //"If an error occurs, errno is set appropriately" => why the fuck, then also return errno as return value!? + errno = rv != 0 ? rv : ENOENT; + THROW_LAST_FILE_ERROR(_("Cannot get process information."), "getpwnam_r(" + utfTo(loginUser) + ')'); + } + + return pwEntry->pw_dir; //home directory +} + + +Zstring zen::getUserDataPath() //throw FileError +{ + if (::getuid() != 0) //nofail; non-root + if (const std::optional xdgCfgPath = getEnvironmentVar("XDG_CONFIG_HOME"); + xdgCfgPath&& !xdgCfgPath->empty()) + return *xdgCfgPath; + //root(0) => consider as request for elevation, NOT impersonation + + return appendPath(getUserHome(), ".config"); //throw FileError +} + + +Zstring zen::getUserDownloadsPath() //throw FileError +{ + try + { + if (::getuid() != 0) //nofail; non-root + if (const auto& [exitCode, output] = consoleExecute("xdg-user-dir DOWNLOAD", std::nullopt /*timeoutMs*/); //throw SysError + exitCode == 0) + { + const Zstring& downloadsPath = trimCpy(output); + ASSERT_SYSERROR(!downloadsPath.empty()); + return downloadsPath; + } + //root(0) => consider as request for elevation, NOT impersonation + + //fallback: probably correct 99.9% of the time anyway... + return appendPath(getUserHome(), "Downloads"); //throw FileError + } + catch (const SysError& e) { throw FileError(_("Cannot get process information."), e.toString()); } +} + + +bool zen::runningElevated() //throw FileError +{ + if (::geteuid() != 0) //nofail; non-root + return false; + + return getLoginUser() != "root"; //throw FileError + //consider "root login" like "UAC disabled" on Windows +} diff --git a/zen/sys_info.h b/zen/sys_info.h new file mode 100644 index 0000000..ed5dc1d --- /dev/null +++ b/zen/sys_info.h @@ -0,0 +1,43 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYSTEM_H_4189731847832147508915 +#define SYSTEM_H_4189731847832147508915 + +#include "file_error.h" + + +namespace zen +{ +//COM needs to be initialized before calling any of these functions! CoInitializeEx/CoUninitialize + +Zstring getLoginUser(); //throw FileError +Zstring getUserDescription();//throw FileError + + +struct ComputerModel +{ + std::wstring model; //best-effort: empty if not available + std::wstring vendor; // +}; +ComputerModel getComputerModel(); //throw FileError + + + +std::wstring getOsDescription(); //throw FileError + + +Zstring getProcessPath(); //throw FileError + +Zstring getUserDownloadsPath(); //throw FileError +Zstring getUserDataPath(); //throw FileError + +Zstring getUserHome(); //throw FileError + +bool runningElevated(); //throw FileError +} + +#endif //SYSTEM_H_4189731847832147508915 diff --git a/zen/sys_version.cpp b/zen/sys_version.cpp new file mode 100644 index 0000000..58785a0 --- /dev/null +++ b/zen/sys_version.cpp @@ -0,0 +1,101 @@ +// ***************************************************************************** +// * 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 "sys_version.h" + #include + #include "file_io.h" + #include "process_exec.h" + +using namespace zen; + + +OsVersionDetail zen::getOsVersionDetail() //throw SysError +{ + /* prefer lsb_release: lsb_release Distributor ID: Debian + 1. terser OS name Release: 8.11 + 2. detailed version number + /etc/os-release NAME="Debian GNU/Linux" + VERSION_ID="8" */ + std::wstring osName; + std::wstring osVersion; + try + { + if (const auto [exitCode, output] = consoleExecute("lsb_release --id -s", std::nullopt); //throw SysError + exitCode != 0) + throw SysError(formatSystemError("lsb_release --id", + replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + else + osName = utfTo(trimCpy(output)); + + if (const auto [exitCode, output] = consoleExecute("lsb_release --release -s", std::nullopt); //throw SysError + exitCode != 0) + throw SysError(formatSystemError("lsb_release --release", + replaceCpy(_("Exit code %x"), L"%x", numberTo(exitCode)), utfTo(output))); + else + osVersion = utfTo(trimCpy(output)); + } + //lsb_release not available on some systems: https://freefilesync.org/forum/viewtopic.php?t=7191 + catch (SysError&) // => fall back to /etc/os-release: https://www.freedesktop.org/software/systemd/man/os-release.html + { + std::string releaseInfo; + try + { + releaseInfo = getFileContent("/etc/os-release", nullptr /*notifyUnbufferedIO*/); //throw FileError + } + catch (const FileError& e) { throw SysError(replaceCpy(e.toString(), L"\n\n", L'\n')); } //errors should be further enriched by context info => SysError + + split(releaseInfo, '\n', [&](const std::string_view line) + { + if (startsWith(line, "NAME=")) + osName = utfTo(afterFirst(line, '=', IfNotFoundReturn::none)); + else if (startsWith(line, "VERSION_ID=")) + osVersion = utfTo(afterFirst(line, '=', IfNotFoundReturn::none)); + //PRETTY_NAME? too wordy! e.g. "Fedora 17 (Beefy Miracle)" + }); + trim(osName, TrimSide::both, [](const char c) { return c == L'"' || c == L'\''; }); + trim(osVersion, TrimSide::both, [](const char c) { return c == L'"' || c == L'\''; }); + } + + if (osName.empty()) + throw SysError(L"Operating system release could not be determined."); //should never happen! + //osVersion is usually available, except for Arch Linux: https://freefilesync.org/forum/viewtopic.php?t=7276 + // lsb_release Release is "rolling" + // /etc/os-release: VERSION_ID is missing, but there is BUILD_ID=rolling instead + + std::vector verDigits = splitCpy(osVersion, L'.', SplitOnEmpty::allow); //e.g. "7.7.1908" + verDigits.resize(2); + + return + { + .version + { + stringTo(verDigits[0]), + stringTo(verDigits[1]) + }, + .osVersionRaw = osVersion, + .osName = osName, + }; +} + + +OsVersion zen::getOsVersion() +{ + static const OsVersionDetail verDetail = [] + { + try + { + return getOsVersionDetail(); //throw SysError + } + catch (const SysError& e) + { + logExtraError(_("Cannot get process information.") + L"\n\n" + e.toString()); + return OsVersionDetail{}; //arrgh, it's a jungle out there: https://freefilesync.org/forum/viewtopic.php?t=7276 + } + }(); + return verDetail.version; +} + + diff --git a/zen/sys_version.h b/zen/sys_version.h new file mode 100644 index 0000000..018c353 --- /dev/null +++ b/zen/sys_version.h @@ -0,0 +1,37 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef SYS_VER_H_238470348254325 +#define SYS_VER_H_238470348254325 + +#include "file_error.h" + + +namespace zen +{ +struct OsVersion //keep it a POD, so that the global version constants can be used during static initialization +{ + int major = 0; + int minor = 0; + + std::strong_ordering operator<=>(const OsVersion&) const = default; +}; + + +struct OsVersionDetail +{ + OsVersion version; + std::wstring osVersionRaw; + std::wstring osName; +}; +OsVersionDetail getOsVersionDetail(); //throw SysError + +OsVersion getOsVersion(); + + +} + +#endif //SYS_VER_H_238470348254325 diff --git a/zen/thread.cpp b/zen/thread.cpp new file mode 100644 index 0000000..e14afac --- /dev/null +++ b/zen/thread.cpp @@ -0,0 +1,37 @@ +// ***************************************************************************** +// * 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 "thread.h" + #include + +using namespace zen; + + + + +void zen::setCurrentThreadName(const Zstring& threadName) +{ + ::prctl(PR_SET_NAME, threadName.c_str(), 0, 0, 0); + +} + + +namespace +{ +//don't make this a function-scope static (avoid code-gen for "magic static") +const std::thread::id globalMainThreadId = std::this_thread::get_id(); +} + + +bool zen::runningOnMainThread() +{ + if (globalMainThreadId == std::thread::id()) //if called during static initialization! + return true; + + return std::this_thread::get_id() == globalMainThreadId; +} + + diff --git a/zen/thread.h b/zen/thread.h new file mode 100644 index 0000000..64a3518 --- /dev/null +++ b/zen/thread.h @@ -0,0 +1,528 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef THREAD_H_7896323423432235246427 +#define THREAD_H_7896323423432235246427 + +#include +#include +#include +#include "ring_buffer.h" +#include "zstring.h" + + +namespace zen +{ +class InterruptionStatus; + +//migrate towards https://en.cppreference.com/w/cpp/thread/jthread +class InterruptibleThread +{ +public: + InterruptibleThread() {} + InterruptibleThread (InterruptibleThread&& ) noexcept = default; + InterruptibleThread& operator=(InterruptibleThread&& tmp) noexcept //don't use swap() but end stdThread_ life time immediately + { + if (joinable()) + { + requestStop(); + join(); + } + stdThread_ = std::move(tmp.stdThread_); + intStatus_ = std::move(tmp.intStatus_); + return *this; + } + + template + explicit InterruptibleThread(Function&& f); + + ~InterruptibleThread() + { + if (joinable()) + { + requestStop(); + join(); + } + } + + bool joinable () const { return stdThread_.joinable(); } + void requestStop(); + void join () { stdThread_.join(); } + void detach () { stdThread_.detach(); } + +private: + std::thread stdThread_; + std::shared_ptr intStatus_ = std::make_shared(); +}; + + +class ThreadStopRequest {}; + +//context of worker thread: +void interruptionPoint(); //throw ThreadStopRequest + +template +void interruptibleWait(std::condition_variable& cv, std::unique_lock& lock, Predicate pred); //throw ThreadStopRequest + +template +void interruptibleSleep(const std::chrono::duration& relTime); //throw ThreadStopRequest + +void setCurrentThreadName(const Zstring& threadName); + + +bool runningOnMainThread(); + +//------------------------------------------------------------------------------------------ + +/* std::async replacement without crappy semantics: + 1. guaranteed to run asynchronously + 2. does not follow C++11 [futures.async], Paragraph 5, where std::future waits for thread in destructor + + Example: + Zstring dirPath = ... + auto ft = zen::runAsync([=]{ return zen::dirExists(dirPath); }); + if (ft.wait_for(std::chrono::milliseconds(200)) == std::future_status::ready && ft.get()) + //dir existing */ +template +auto runAsync(Function&& fun); + +//wait for all with a time limit: return true if *all* results are available! +//TODO: use std::when_all when available +template +bool waitForAllTimed(InputIterator first, InputIterator last, const Duration& wait_duration); + +template inline +bool isReady(const std::future& f) { assert(f.valid()); return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; } +//------------------------------------------------------------------------------------------ + +//wait until first job is successful or all failed +//TODO: use std::when_any when available +template +class AsyncFirstResult +{ +public: + AsyncFirstResult(); + + template + void addJob(Fun&& f); //f must return a std::optional containing a value if successful + + template + bool timedWait(const Duration& duration) const; //true: "get()" is ready, false: time elapsed + + //return first value or none if all jobs failed; blocks until result is ready! + std::optional get() const; //may be called only once! + +private: + class AsyncResult; + std::shared_ptr asyncResult_; + size_t jobsTotal_ = 0; +}; + +//------------------------------------------------------------------------------------------ + +//value associated with mutex and guaranteed protected access: +//TODO: use std::synchronized_value when available https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-mutex +template +class Protected +{ +public: + Protected() {} + explicit Protected(T& value) : value_(value) {} + //Protected(T&& tmp ) : value_(std::move(tmp)) {} <- wait until needed + + template + auto access(Function fun) //-> decltype(fun(std::declval())) + { + std::lock_guard dummy(lockValue_); + return fun(value_); + } + +private: + Protected (const Protected&) = delete; + Protected& operator=(const Protected&) = delete; + + std::mutex lockValue_; + T value_{}; +}; + +//------------------------------------------------------------------------------------------ + +template +class ThreadGroup +{ +public: + ThreadGroup(size_t threadCountMax, const Zstring& groupName) : threadCountMax_(threadCountMax), groupName_(groupName) + { if (threadCountMax == 0) throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); } + + ThreadGroup (ThreadGroup&& tmp) noexcept = default; //noexcept *required* to support move for reallocations in std::vector and std::swap!!! + ThreadGroup& operator=(ThreadGroup&& tmp) noexcept = default; //don't use swap() but end worker_ life time immediately + + ~ThreadGroup() + { + for (InterruptibleThread& w : worker_) + w.requestStop(); //similar, but not the same as ~InterruptibleThread: stop *all* at the same time before join! + + if (detach_) //detach() without requestStop() doesn't make sense + for (InterruptibleThread& w : worker_) + w.detach(); + } + + //context of controlling OR worker thread, non-blocking: + void run(Function&& wi /*should throw ThreadStopRequest when needed*/, bool insertFront = false) + { + { + std::lock_guard dummy(workLoad_.ref().lock); + + if (insertFront) + workLoad_.ref().tasks.push_front(std::move(wi)); + else + workLoad_.ref().tasks.push_back(std::move(wi)); + const size_t tasksPending = ++(workLoad_.ref().tasksPending); + + if (worker_.size() < std::min(tasksPending, threadCountMax_)) + addWorkerThread(); + } + workLoad_.ref().conditionNewTask.notify_all(); + } + + //context of controlling thread, blocking: + void wait() + { + //perf: no difference in xBRZ test case compared to std::condition_variable-based implementation + auto promDone = std::make_shared>(); // + std::future futDone = promDone->get_future(); + + notifyWhenDone([promDone] { promDone->set_value(); }); //std::function doesn't support construction involving move-only types! + //use reference? => potential lifetime issue, e.g. promise object theoretically might be accessed inside set_value() after future gets signalled + + futDone.get(); + } + + //non-blocking wait()-alternative: context of controlling thread: + void notifyWhenDone(const std::function& onCompletion /*noexcept! runs on worker thread!*/) + { + std::unique_lock dummy(workLoad_.ref().lock); + + if (workLoad_.ref().tasksPending == 0) + { + dummy.unlock(); + onCompletion(); + } + else + workLoad_.ref().onCompletionCallbacks.push_back(onCompletion); + } + + //context of controlling thread: + void detach() { detach_ = true; } //not expected to also interrupt! + +private: + ThreadGroup (const ThreadGroup&) = delete; + ThreadGroup& operator=(const ThreadGroup&) = delete; + + void addWorkerThread() + { + Zstring threadName = groupName_ + Zstr('[') + numberTo(worker_.size() + 1) + Zstr('/') + numberTo(threadCountMax_) + Zstr(']'); + + worker_.emplace_back([workLoad_ /*clang bug*/= workLoad_ /*share ownership!*/, threadName = std::move(threadName)]() mutable //don't capture "this"! consider detach() and move operations + { + setCurrentThreadName(threadName); + WorkLoad& workLoad = workLoad_.ref(); + + std::unique_lock dummy(workLoad.lock); + for (;;) + { + interruptibleWait(workLoad.conditionNewTask, dummy, [&tasks = workLoad.tasks] { return !tasks.empty(); }); //throw ThreadStopRequest + + Function task = std::move(workLoad.tasks. front()); //noexcept thanks to move + /**/ workLoad.tasks.pop_front(); // + + dummy.unlock(); + task(); //throw ThreadStopRequest? + dummy.lock(); + + if (--(workLoad.tasksPending) == 0) + if (!workLoad.onCompletionCallbacks.empty()) + { + std::vector> callbacks = std::exchange(workLoad.onCompletionCallbacks, {}); + + dummy.unlock(); + for (const auto& cb : callbacks) + cb(); //noexcept! + dummy.lock(); + } + } + }); + } + + struct WorkLoad + { + std::mutex lock; + RingBuffer tasks; //FIFO! :) + size_t tasksPending = 0; + std::condition_variable conditionNewTask; + std::vector> onCompletionCallbacks; + }; + + std::vector worker_; + SharedRef workLoad_ = makeSharedRef(); + bool detach_ = false; + size_t threadCountMax_; + Zstring groupName_; +}; + + + + + + + + +//###################### implementation ###################### + +namespace impl +{ +template inline +auto runAsync(Function&& fun, std::true_type /*copy-constructible*/) +{ + using ResultType = decltype(fun()); + + //note: std::packaged_task does NOT support move-only function objects! + std::packaged_task pt(std::forward(fun)); + auto fut = pt.get_future(); + std::thread(std::move(pt)).detach(); //we have to explicitly detach since C++11: [thread.thread.destr] ~thread() calls std::terminate() if joinable()!!! + return fut; +} + + +template inline +auto runAsync(Function&& fun, std::false_type /*copy-constructible*/) +{ + //support move-only function objects! + auto sharedFun = std::make_shared(std::forward(fun)); + return runAsync([sharedFun] { return (*sharedFun)(); }, std::true_type()); +} +} + + +template inline +auto runAsync(Function&& fun) +{ + return impl::runAsync(std::forward(fun), std::is_copy_constructible()); +} + + +template inline +bool waitForAllTimed(InputIterator first, InputIterator last, const Duration& duration) +{ + const std::chrono::steady_clock::time_point stopTime = std::chrono::steady_clock::now() + duration; + for (; first != last; ++first) + if (first->wait_until(stopTime) == std::future_status::timeout) + return false; + return true; +} + + +template +class AsyncFirstResult::AsyncResult +{ +public: + //context: worker threads + void reportFinished(std::optional&& result) + { + { + std::lock_guard dummy(lockResult_); + ++jobsFinished_; + if (!result_) + result_ = std::move(result); + } + conditionJobDone_.notify_all(); //better notify all, considering bugs like: https://svn.boost.org/trac/boost/ticket/7796 + } + + //context: main thread + template + bool waitForResult(size_t jobsTotal, const Duration& duration) + { + std::unique_lock dummy(lockResult_); + return conditionJobDone_.wait_for(dummy, duration, [&] { return this->jobDone(jobsTotal); }); + } + + std::optional getResult(size_t jobsTotal) + { + std::unique_lock dummy(lockResult_); + conditionJobDone_.wait(dummy, [&] { return this->jobDone(jobsTotal); }); + + return std::move(result_); + } + +private: + bool jobDone(size_t jobsTotal) const { return result_ || (jobsFinished_ >= jobsTotal); } //call while locked! + + std::mutex lockResult_; + size_t jobsFinished_ = 0; // + std::optional result_; //our condition is: "have result" or "jobsFinished_ == jobsTotal" + std::condition_variable conditionJobDone_; +}; + + + +template inline +AsyncFirstResult::AsyncFirstResult() : asyncResult_(std::make_shared()) {} + + +template +template inline +void AsyncFirstResult::addJob(Fun&& f) //f must return a std::optional containing a value on success +{ + std::thread t([asyncResult = this->asyncResult_, f = std::forward(f)] { asyncResult->reportFinished(f()); }); + ++jobsTotal_; + t.detach(); //we have to be explicit since C++11: [thread.thread.destr] ~thread() calls std::terminate() if joinable()!!! +} + + +template +template inline +bool AsyncFirstResult::timedWait(const Duration& duration) const { return asyncResult_->waitForResult(jobsTotal_, duration); } + + +template inline +std::optional AsyncFirstResult::get() const { return asyncResult_->getResult(jobsTotal_); } + +//------------------------------------------------------------------------------------------ + +class InterruptionStatus +{ +public: + //context of InterruptibleThread instance: + void requestStop() + { + stopRequested_ = true; + + { + std::lock_guard dummy(lockSleep_); //needed! makes sure the following signal is not lost! + //usually we'd make "interrupted" non-atomic, but this is already given due to interruptibleWait() handling + } + conditionSleepInterruption_.notify_all(); + + std::lock_guard dummy(lockConditionPtr_); + if (activeCondition_) + activeCondition_->notify_all(); //signal may get lost! + //alternative design locking the cv's mutex here could be dangerous: potential for dead lock! + } + + //context of worker thread: + void throwIfStopped() //throw ThreadStopRequest + { + if (stopRequested_) + throw ThreadStopRequest(); + } + + //context of worker thread: + template + void interruptibleWait(std::condition_variable& cv, std::unique_lock& lock, Predicate pred) //throw ThreadStopRequest + { + setConditionVar(&cv); + ZEN_ON_SCOPE_EXIT(setConditionVar(nullptr)); + + //"stopRequested_" is not protected by cv's mutex => signal may get lost!!! e.g. after condition was checked but before the wait begins + //=> add artifical time out to mitigate! CPU: 0.25% vs 0% for longer time out! + while (!cv.wait_for(lock, std::chrono::milliseconds(1), [&] { return this->stopRequested_ || pred(); })) + ; + + throwIfStopped(); //throw ThreadStopRequest + } + + //context of worker thread: + template + void interruptibleSleep(const std::chrono::duration& relTime) //throw ThreadStopRequest + { + std::unique_lock lock(lockSleep_); + if (conditionSleepInterruption_.wait_for(lock, relTime, [this] { return static_cast(this->stopRequested_); })) + throw ThreadStopRequest(); + } + +private: + void setConditionVar(std::condition_variable* cv) + { + std::lock_guard dummy(lockConditionPtr_); + activeCondition_ = cv; + } + + std::atomic stopRequested_{false}; //std::atomic is uninitialized by default!!! + //"The default constructor is trivial: no initialization takes place other than zero initialization of static and thread-local objects." + + std::condition_variable* activeCondition_ = nullptr; + std::mutex lockConditionPtr_; //serialize pointer access (only!) + + std::condition_variable conditionSleepInterruption_; + std::mutex lockSleep_; +}; + + +namespace impl +{ +//thread_local with non-POD seems to cause memory leaks on VS 14 => pointer only is fine: +inline thread_local InterruptionStatus* threadLocalInterruptionStatus = nullptr; +} + + +//context of worker thread: +inline +void interruptionPoint() //throw ThreadStopRequest +{ + assert(impl::threadLocalInterruptionStatus); + if (impl::threadLocalInterruptionStatus) + impl::threadLocalInterruptionStatus->throwIfStopped(); //throw ThreadStopRequest +} + + +//context of worker thread: +template inline +void interruptibleWait(std::condition_variable& cv, std::unique_lock& lock, Predicate pred) //throw ThreadStopRequest +{ + assert(impl::threadLocalInterruptionStatus); + if (impl::threadLocalInterruptionStatus) + impl::threadLocalInterruptionStatus->interruptibleWait(cv, lock, pred); + else + cv.wait(lock, pred); +} + + +//context of worker thread: +template inline +void interruptibleSleep(const std::chrono::duration& relTime) //throw ThreadStopRequest +{ + assert(impl::threadLocalInterruptionStatus); + if (impl::threadLocalInterruptionStatus) + impl::threadLocalInterruptionStatus->interruptibleSleep(relTime); + else + std::this_thread::sleep_for(relTime); +} + + +template inline +InterruptibleThread::InterruptibleThread(Function&& f) +{ + stdThread_ = std::thread([f = std::forward(f), + intStatus = this->intStatus_]() mutable + { + assert(!impl::threadLocalInterruptionStatus); + impl::threadLocalInterruptionStatus = intStatus.get(); + ZEN_ON_SCOPE_EXIT(impl::threadLocalInterruptionStatus = nullptr); + + try + { + f(); //throw ThreadStopRequest + } + catch (ThreadStopRequest&) {} + }); +} + + +inline +void InterruptibleThread::requestStop() { intStatus_->requestStop(); } +} + +#endif //THREAD_H_7896323423432235246427 diff --git a/zen/time.h b/zen/time.h new file mode 100644 index 0000000..11bfeb1 --- /dev/null +++ b/zen/time.h @@ -0,0 +1,421 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TIME_H_8457092814324342453627 +#define TIME_H_8457092814324342453627 + +#include +#include "basic_math.h" +#include "zstring.h" + + +namespace zen +{ +struct TimeComp //replaces std::tm and SYSTEMTIME +{ + int year = 0; // - + int month = 0; //1-12 + int day = 0; //1-31 + int hour = 0; //0-23 + int minute = 0; //0-59 + int second = 0; //0-60 (including leap second) + + bool operator==(const TimeComp&) const = default; +}; + +TimeComp getUtcTime(time_t utc); //convert time_t (UTC) to UTC time components, returns TimeComp() on error +TimeComp getUtcTime(); //utc = std::time() +std::pair utcToTimeT(const TimeComp& tc); //convert UTC time components to time_t (UTC) + +TimeComp getLocalTime(time_t utc); //convert time_t (UTC) to local time components, returns TimeComp() on error +TimeComp getLocalTime(); //utc = std::time() +std::pair localToTimeT(const TimeComp& tc); //convert local time components to time_t (UTC) + +TimeComp getCompileTime(); //returns TimeComp() on error + +//---------------------------------------------------------------------------------------------------------------------------------- +/* format (current) date and time; example: + formatTime(Zstr("%Y|%m|%d")); -> "2011|10|29" + formatTime(formatDateTag); -> "2011-10-29" + formatTime(formatTimeTag); -> "17:55:34" */ +Zstring formatTime(const Zchar* format, const TimeComp& tc = getLocalTime()); //format as specified by "std::strftime", returns empty string on error + +//the "format" parameter of formatTime() is partially specialized with the following type tags: +const Zchar* const formatDateTag = Zstr("%x"); //locale-dependent date representation: e.g. 8/23/2001 +const Zchar* const formatTimeTag = Zstr("%X"); //locale-dependent time representation: e.g. 2:55:02 PM +const Zchar* const formatDateTimeTag = Zstr("%c"); //locale-dependent date and time: e.g. 8/23/2001 2:55:02 PM + +const Zchar* const formatIsoDateTag = Zstr("%Y-%m-%d"); //e.g. 2001-08-23 +const Zchar* const formatIsoTimeTag = Zstr("%H:%M:%S"); //e.g. 14:55:02 +const Zchar* const formatIsoDateTimeTag = Zstr("%Y-%m-%d %H:%M:%S"); //e.g. 2001-08-23 14:55:02 + +//---------------------------------------------------------------------------------------------------------------------------------- +//example: parseTime("%Y-%m-%d %H:%M:%S", "2001-08-23 14:55:02"); +// parseTime(formatIsoDateTimeTag, "2001-08-23 14:55:02"); +template +TimeComp parseTime(const String& format, const String2& str); //similar to ::strptime() +//---------------------------------------------------------------------------------------------------------------------------------- + +//format: [-][[d.]HH:]MM:SS e.g. -1.23:45:67 +Zstring formatTimeSpan(int64_t timeInSec, bool hourRequired = true); + + + + + + + + + + + +//############################ implementation ############################## +namespace impl +{ +inline +std::tm toClibTimeComponents(const TimeComp& tc) +{ + assert(1 <= tc.month && tc.month <= 12 && + 1 <= tc.day && tc.day <= 31 && + 0 <= tc.hour && tc.hour <= 23 && + 0 <= tc.minute && tc.minute <= 59 && + 0 <= tc.second && tc.second <= 61); + + return + { + .tm_sec = tc.second, //0-60 (including leap second) + .tm_min = tc.minute, //0-59 + .tm_hour = tc.hour, //0-23 + .tm_mday = tc.day, //1-31 + .tm_mon = tc.month - 1, //0-11 + .tm_year = tc.year - 1900, //years since 1900 + .tm_isdst = -1, //> 0 if DST is active, == 0 if DST is not active, < 0 if the information is not available + //.tm_wday + //.tm_yday + }; +} + +inline +TimeComp toZenTimeComponents(const std::tm& ctc) +{ + return + { + .year = ctc.tm_year + 1900, + .month = ctc.tm_mon + 1, + .day = ctc.tm_mday, + .hour = ctc.tm_hour, + .minute = ctc.tm_min, + .second = ctc.tm_sec, + }; +} + + +/* +inline +bool isValid(const std::tm& t) +{ + -> not enough! MSCRT has different limits than the C standard which even seem to change with different versions: + _VALIDATE_RETURN((( timeptr->tm_sec >=0 ) && ( timeptr->tm_sec <= 59 ) ), EINVAL, FALSE) + _VALIDATE_RETURN(( timeptr->tm_year >= -1900 ) && ( timeptr->tm_year <= 8099 ), EINVAL, FALSE) + -> also std::mktime does *not* help here at all! + + auto inRange = [](int value, int minVal, int maxVal) { return minVal <= value && value <= maxVal; }; + + //https://www.cplusplus.com/reference/clibrary/ctime/tm/ + return inRange(t.tm_sec, 0, 61) && + inRange(t.tm_min, 0, 59) && + inRange(t.tm_hour, 0, 23) && + inRange(t.tm_mday, 1, 31) && + inRange(t.tm_mon, 0, 11) && + //tm_year + inRange(t.tm_wday, 0, 6) && + inRange(t.tm_yday, 0, 365); + //tm_isdst +}; +*/ +} + + +constexpr auto daysPer400Years = 100 * (4 * 365 /*usual days per year*/ + 1 /*including leap day*/) - 3 /*no leap days for centuries, except if divisible by 400 */; +constexpr auto secsPer400Years = 3600LL * 24 * daysPer400Years; + + +inline +TimeComp getUtcTime(time_t utc) +{ + //Windows: gmtime_s() only works for years [1970, 3001] + //=> map into working 400-year range [1970, 2370) + // bonus: avoid asking for bugs for time_t(-1) + const int cycles400 = static_cast(numeric::intDivFloor(utc, secsPer400Years)); + utc -= secsPer400Years * cycles400; + + std::tm ctc = {}; + if (::gmtime_r(&utc, &ctc) == nullptr) //Linux, macOS: apparently NO limits (tested years 0 to 10.000!) + return TimeComp(); + + ctc.tm_year += 400 * cycles400; + + return impl::toZenTimeComponents(ctc); +} + + +inline +TimeComp getUtcTime() +{ + const time_t utc = std::time(nullptr); //returns -1 on error + if (utc == -1) + return TimeComp(); + + return getUtcTime(utc); +} + + +inline +TimeComp getLocalTime(time_t utc) +{ + const int cycles400 = static_cast(numeric::intDivFloor(utc, secsPer400Years)); + utc -= secsPer400Years * cycles400; + + std::tm ctc = {}; + if (::localtime_r(&utc, &ctc) == nullptr) + return TimeComp(); + + ctc.tm_year += 400 * cycles400; + + return impl::toZenTimeComponents(ctc); +} + + +inline +TimeComp getLocalTime() +{ + const time_t utc = std::time(nullptr); //returns -1 on error + if (utc == -1) + return TimeComp(); + + return getLocalTime(utc); +} + + +inline +std::pair utcToTimeT(const TimeComp& tc) +{ + if (tc == TimeComp()) + return {}; + + std::tm ctc = impl::toClibTimeComponents(tc); + ctc.tm_isdst = 0; //"Zero (0) to indicate that standard time is in effect" => unused by _mkgmtime, but take no chances + + /* Windows: _mkgmtime() only works for years [1970, 3001] + macOS: timegm() requires tm_year >= 1900; apparently no upper limit (tested until year 10.000!) + Linux, 64-bit: apparently NO limits (tested years 0 to 10.000!) + 32-bit: timegm() only works for years [1902, 2038] => sucks to be on 32-bit! :> + + => map into working 400-year range [1970, 2370) + bonus: disambiguate -1 error code from time_t(-1) */ + const int cycles400 = numeric::intDivFloor(ctc.tm_year + 1900 - 1970, 400); + ctc.tm_year -= 400 * cycles400; + + const time_t utc = ::timegm(&ctc); + if (utc == -1) + return {}; + + assert(utc >= 0); + return {utc + secsPer400Years * cycles400, true}; +} + + +inline +std::pair localToTimeT(const TimeComp& tc) //convert local time components to time_t (UTC) +{ + if (tc == TimeComp()) + return {}; + + std::tm ctc = impl::toClibTimeComponents(tc); + + const int cycles400 = numeric::intDivFloor(ctc.tm_year + 1900 - 1971/*[!]*/, 400); //see utcToTimeT() + //1971: ensures resulting time_t >= 0 after time zone, DST adaption, or std::mktime will fail on Windows! + ctc.tm_year -= 400 * cycles400; + + const time_t locTime = std::mktime(&ctc); + if (locTime == -1) + return {}; + + assert(locTime > 0); + return {locTime + secsPer400Years * cycles400, true}; +} + + +inline +TimeComp getCompileTime() +{ + //https://gcc.gnu.org/onlinedocs/cpp/Standard-Predefined-Macros.html + char compileTime[] = __DATE__ " " __TIME__; //e.g. "Aug 1 2017 01:32:26" + if (compileTime[4] == ' ') //day is space-padded, but %d expects zero-padding + compileTime[4] = '0'; + + return parseTime("%b %d %Y %H:%M:%S", compileTime); +} + + + + +inline +Zstring formatTime(const Zchar* format, const TimeComp& tc) +{ + if (tc == TimeComp()) //failure code from getLocalTime() + return Zstring(); + + std::tm ctc = impl::toClibTimeComponents(tc); + std::mktime(&ctc); //unfortunately std::strftime() needs all elements of "struct tm" filled, e.g. tm_wday, tm_yday + //note: although std::mktime() explicitly expects "local time", calculating weekday and day of year *should* be time-zone and DST independent + + Zstring buf(256, Zstr('\0')); + //strftime() craziness on invalid input: + // VS 2010: CRASH unless "_invalid_parameter_handler" is set: https://docs.microsoft.com/en-us/cpp/c-runtime-library/parameter-validation + // GCC: returns 0, apparently no crash. Still, considering some clib maintainer's comments, we should expect the worst! + // Windows: avoid char-based strftime() which uses ANSI encoding! (e.g. Greek letters for AM/PM) + const size_t charsWritten = std::strftime(buf.data(), buf.size(), format, &ctc); + buf.resize(charsWritten); + return buf; +} + + +template +TimeComp parseTime(const String& format, const String2& str) +{ + using CharType = GetCharTypeT; + static_assert(std::is_same_v>); + + const CharType* itStr = strBegin(str); + const CharType* const strLast = itStr + strLength(str); + + auto extractNumber = [&](int& result, size_t digitCount) + { + if (strLast - itStr < makeSigned(digitCount)) + return false; + + if (!std::all_of(itStr, itStr + digitCount, isDigit)) + return false; + + result = zen::stringTo(makeStringView(itStr, digitCount)); + itStr += digitCount; + return true; + }; + + TimeComp output; + + const CharType* itFmt = strBegin(format); + const CharType* const fmtLast = itFmt + strLength(format); + + for (; itFmt != fmtLast; ++itFmt) + { + const CharType fmt = *itFmt; + + if (fmt == '%') + { + ++itFmt; + if (itFmt == fmtLast) + return TimeComp(); + + switch (*itFmt) + { + case 'Y': + if (!extractNumber(output.year, 4)) + return TimeComp(); + break; + case 'm': + if (!extractNumber(output.month, 2)) + return TimeComp(); + break; + case 'b': //abbreviated month name: Jan-Dec + { + if (strLast - itStr < 3) + return TimeComp(); + + const char* months[] = {"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"}; + auto itMonth = std::find_if(std::begin(months), std::end(months), [&](const char* month) + { + return equalAsciiNoCase(makeStringView(itStr, 3), month); + }); + if (itMonth == std::end(months)) + return TimeComp(); + + output.month = 1 + static_cast(itMonth - std::begin(months)); + itStr += 3; + } + break; + case 'd': + if (!extractNumber(output.day, 2)) + return TimeComp(); + break; + case 'H': + if (!extractNumber(output.hour, 2)) + return TimeComp(); + break; + case 'M': + if (!extractNumber(output.minute, 2)) + return TimeComp(); + break; + case 'S': + if (!extractNumber(output.second, 2)) + return TimeComp(); + break; + default: + return TimeComp(); + } + } + else if (isWhiteSpace(fmt)) //single whitespace in format => skip 0..n whitespace chars + { + while (itStr != strLast && isWhiteSpace(*itStr)) + ++itStr; + } + else + { + if (itStr == strLast || *itStr != fmt) + return TimeComp(); + ++itStr; + } + } + + if (itStr != strLast) + return TimeComp(); + + return output; +} + + +inline +Zstring formatTimeSpan(int64_t timeInSec, bool hourRequired) +{ + Zstring timespanStr; + + if (timeInSec < 0) + { + timeInSec = -timeInSec; //need to fix LLONG_MIN? + timespanStr = Zstr('-'); + } + + //check *before* subtracting days! + const Zchar* timeSpanFmt = timeInSec < 3600 && !hourRequired ? Zstr("%M:%S") : formatIsoTimeTag; + + const int secsPerDay = 24 * 3600; + const int64_t days = numeric::intDivFloor(timeInSec, secsPerDay); + if (days > 0) + { + timeInSec -= days * secsPerDay; + timespanStr += numberTo(days) + Zstr("."); //don't need zen::formatNumber(), do we? + } + + //format time span as if absolute UTC time + const TimeComp& tc = getUtcTime(timeInSec); //returns TimeComp() on error + timespanStr += formatTime(timeSpanFmt, tc); //returns empty string on error + + return timespanStr; +} +} + +#endif //TIME_H_8457092814324342453627 diff --git a/zen/type_traits.h b/zen/type_traits.h new file mode 100644 index 0000000..e373ba0 --- /dev/null +++ b/zen/type_traits.h @@ -0,0 +1,198 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef TYPE_TRAITS_H_3425628658765467 +#define TYPE_TRAITS_H_3425628658765467 + +#include +#include + +//https://en.cppreference.com/w/cpp/header/type_traits + +namespace zen +{ +template +struct GetFirstOf +{ + using Type = T; +}; +template using GetFirstOfT = typename GetFirstOf::Type; + + +template +class FunctionReturnType +{ + template static R dummyFun(R(*)(Args...)); +public: + using Type = decltype(dummyFun(F())); +}; +template using FunctionReturnTypeT = typename FunctionReturnType::Type; +//yes, there's std::invoke_result_t, but it requires to specify function argument types for no good reason + +//============================================================================= + +template +constexpr uint32_t arrayHash(T (&arr)[N]) //don't bother making FNV1aHash constexpr instead +{ + uint32_t hashVal = 2166136261U; //FNV-1a base + + std::for_each(&arr[0], &arr[N], [&hashVal](T n) + { + //static_assert(isInteger || std::is_same_v || std::is_same_v); + static_assert(sizeof(T) <= sizeof(hashVal)); + hashVal ^= static_cast(n); + hashVal *= 16777619U; //prime + }); + return hashVal; +} + +//Herb Sutter's signedness conversion helpers: https://herbsutter.com/2013/06/13/gotw-93-solution-auto-variables-part-2/ +template inline auto makeSigned (T t) { return static_cast>(t); } +template inline auto makeUnsigned(T t) { return static_cast>(t); } + +//################# Built-in Types ######################## +//unfortunate standardized nonsense: std::is_integral<> includes bool, char, wchar_t! => roll our own: +template constexpr bool isUnsignedInt = std::is_same_v, unsigned char> || + std::is_same_v, unsigned short int> || + std::is_same_v, unsigned int> || + std::is_same_v, unsigned long int> || + std::is_same_v, unsigned long long int>; + +template constexpr bool isSignedInt = std::is_same_v, signed char> || + std::is_same_v, short int> || + std::is_same_v, int> || + std::is_same_v, long int> || + std::is_same_v, long long int>; + +template constexpr bool isInteger = isUnsignedInt || isSignedInt; +template constexpr bool isFloat = std::is_floating_point_v; +template constexpr bool isArithmetic = isInteger || isFloat; + +//################# Class Members ######################## + +/* Detect data or function members of a class by name: ZEN_INIT_DETECT_MEMBER + hasMember_ + Example: 1. ZEN_INIT_DETECT_MEMBER(c_str); + 2. hasMember_c_str -> use boolean + + + Detect data or function members of a class by name *and* type: ZEN_INIT_DETECT_MEMBER2 + HasMember_ + + Example: 1. ZEN_INIT_DETECT_MEMBER2(size, size_t (T::*)() const); + 2. hasMember_size::value -> use as boolean + + + Detect member type of a class: ZEN_INIT_DETECT_MEMBER_TYPE + hasMemberType_ + + Example: 1. ZEN_INIT_DETECT_MEMBER_TYPE(value_type); + 2. hasMemberType_value_type -> use as boolean */ + +//########## Sorting ############################## +/* +Generate a descending binary predicate at compile time! + +Usage: + static const bool ascending = ... + makeSortDirection(old binary predicate, std::bool_constant()) -> new binary predicate +*/ + +template +struct LessDescending +{ + LessDescending(Predicate lessThan) : lessThan_(std::move(lessThan)) {} + template bool operator()(const T& lhs, const T& rhs) const { return lessThan_(rhs, lhs); } +private: + Predicate lessThan_; +}; + +template inline +/**/ Predicate makeSortDirection(Predicate pred, std::true_type) { return pred; } + +template inline +LessDescending makeSortDirection(Predicate pred, std::false_type) { return pred; } + + + + + + + +//################ implementation ###################### +#define ZEN_INIT_DETECT_MEMBER(NAME) \ + \ + template \ + struct HasMemberImpl_##NAME \ + { \ + private: \ + using Yes = char[1]; \ + using No = char[2]; \ + \ + template \ + class Helper {}; \ + \ + struct Fallback { int NAME; }; \ + \ + template \ + struct Helper2 : public U, public Fallback {}; /*this works only for class types!!!*/ \ + \ + template static No& hasMember(Helper::NAME>*); \ + template static Yes& hasMember(...); \ + public: \ + enum { value = sizeof(hasMember(nullptr)) == sizeof(Yes) }; \ + }; \ + \ + template \ + struct HasMemberImpl_##NAME : std::false_type {}; \ + \ + template constexpr bool hasMember_##NAME = HasMemberImpl_##NAME, T>::value; + +//#################################################################### + +#define ZEN_INIT_DETECT_MEMBER2(NAME, TYPE) \ + \ + template \ + class HasMember_##NAME \ + { \ + using Yes = char[1]; \ + using No = char[2]; \ + \ + template class Helper {}; \ + \ + template static Yes& hasMember(Helper*); \ + template static No& hasMember(...); \ + public: \ + enum { value = sizeof(hasMember(nullptr)) == sizeof(Yes) }; \ + }; \ + \ + template constexpr bool hasMember_##NAME = HasMember_##NAME::value; + +//#################################################################### + +#define ZEN_INIT_DETECT_MEMBER_TYPE(TYPENAME) \ + \ + template \ + class HasMemberType_##TYPENAME \ + { \ + using Yes = char[1]; \ + using No = char[2]; \ + \ + template class Helper {}; \ + \ + template static Yes& hasMemberType(Helper*); \ + template static No& hasMemberType(...); \ + public: \ + enum { value = sizeof(hasMemberType(nullptr)) == sizeof(Yes) }; \ + }; \ + \ + template constexpr bool hasMemberType_##TYPENAME = HasMemberType_##TYPENAME::value; +} + + +//--------------------------------------------------------------------------- +//ZEN macro consistency checks: => place in most-used header! + + + +#endif //TYPE_TRAITS_H_3425628658765467 diff --git a/zen/utf.h b/zen/utf.h new file mode 100644 index 0000000..0872fc8 --- /dev/null +++ b/zen/utf.h @@ -0,0 +1,369 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef UTF_H_01832479146991573473545 +#define UTF_H_01832479146991573473545 + +#include "string_tools.h" //copyStringTo + + +namespace zen +{ +//convert all(!) char- and wchar_t-based "string-like" objects applying UTF conversions (but only if necessary!) +template +TargetString utfTo(const SourceString& str); + +constexpr std::string_view BYTE_ORDER_MARK_UTF8 = "\xEF\xBB\xBF"; + +template +bool isValidUtf(const UtfString& str); //check for UTF-8 encoding errors + +//access unicode characters in UTF-encoded string (char- or wchar_t-based) +template +size_t unicodeLength(const UtfString& str); //return number of code points for UTF-encoded string + +template +UtfStringOut getUnicodeSubstring(const UtfStringIn& str, size_t uniPosFirst, size_t uniPosLast); + + + + + + + + + +//----------------------- implementation ---------------------------------- +namespace impl +{ +using CodePoint = uint32_t; +using Char16 = uint16_t; +using Char8 = uint8_t; + +const CodePoint LEAD_SURROGATE = 0xd800; //1101 1000 0000 0000 LEAD_SURROGATE_MAX = TRAIL_SURROGATE - 1 +const CodePoint TRAIL_SURROGATE = 0xdc00; //1101 1100 0000 0000 +const CodePoint TRAIL_SURROGATE_MAX = 0xdfff; + +const CodePoint REPLACEMENT_CHAR = 0xfffd; +const CodePoint CODE_POINT_MAX = 0x10ffff; + +static_assert(LEAD_SURROGATE + TRAIL_SURROGATE + TRAIL_SURROGATE_MAX + REPLACEMENT_CHAR + CODE_POINT_MAX == 1348603); + + +template inline +void codePointToUtf16(CodePoint cp, Function writeOutput) //"writeOutput" is a unary function taking a Char16 +{ + //https://en.wikipedia.org/wiki/UTF-16 + if (cp < LEAD_SURROGATE) + writeOutput(static_cast(cp)); + else if (cp <= TRAIL_SURROGATE_MAX) //invalid code point + writeOutput(static_cast(REPLACEMENT_CHAR)); + else if (cp <= 0xffff) + writeOutput(static_cast(cp)); + else if (cp <= CODE_POINT_MAX) + { + cp -= 0x10000; + writeOutput(static_cast( LEAD_SURROGATE + (cp >> 10))); + writeOutput(static_cast(TRAIL_SURROGATE + (cp & 0b11'1111'1111))); + } + else //invalid code point + writeOutput(static_cast(REPLACEMENT_CHAR)); +} + + +class Utf16Decoder +{ +public: + Utf16Decoder(const Char16* str, size_t len) : it_(str), last_(str + len) {} + + std::optional getNext() + { + if (it_ == last_) + return {}; + + const Char16 ch = *it_++; + CodePoint cp = ch; + + if (ch < LEAD_SURROGATE || ch > TRAIL_SURROGATE_MAX) //single Char16, no surrogates + ; + else if (ch < TRAIL_SURROGATE) //two Char16: lead and trail surrogates + decodeTrail(cp); //no range check needed: cp is inside [U+010000, U+10FFFF] by construction + else //unexpected trail surrogate + cp = REPLACEMENT_CHAR; + + return cp; + } + +private: + void decodeTrail(CodePoint& cp) + { + if (it_ != last_) //trail surrogate expected! + { + const Char16 ch = *it_; + if (TRAIL_SURROGATE <= ch && ch <= TRAIL_SURROGATE_MAX) //trail surrogate expected! + { + cp = ((cp - LEAD_SURROGATE) << 10) + (ch - TRAIL_SURROGATE) + 0x10000; + ++it_; + return; + } + } + cp = REPLACEMENT_CHAR; + } + + const Char16* it_; + const Char16* const last_; +}; + +//---------------------------------------------------------------------------------------------------------------- + +template inline +void codePointToUtf8(CodePoint cp, Function writeOutput) //"writeOutput" is a unary function taking a Char8 +{ + /* https://en.wikipedia.org/wiki/UTF-8 + "high and low surrogate halves used by UTF-16 (U+D800 through U+DFFF) and + code points not encodable by UTF-16 (those after U+10FFFF) [...] must be treated as an invalid byte sequence" */ + + if (cp <= 0b111'1111) + writeOutput(static_cast(cp)); + else if (cp <= 0b0111'1111'1111) + { + writeOutput(static_cast((cp >> 6) | 0b1100'0000)); //110x xxxx + writeOutput(static_cast((cp & 0b11'1111) | 0b1000'0000)); //10xx xxxx + } + else if (cp <= 0b1111'1111'1111'1111) + { + if (LEAD_SURROGATE <= cp && cp <= TRAIL_SURROGATE_MAX) //[0xd800, 0xdfff] + codePointToUtf8(REPLACEMENT_CHAR, writeOutput); + else + { + writeOutput(static_cast( (cp >> 12) | 0b1110'0000)); //1110 xxxx + writeOutput(static_cast(((cp >> 6) & 0b11'1111) | 0b1000'0000)); //10xx xxxx + writeOutput(static_cast( (cp & 0b11'1111) | 0b1000'0000)); //10xx xxxx + } + } + else if (cp <= CODE_POINT_MAX) + { + writeOutput(static_cast( (cp >> 18) | 0b1111'0000)); //1111 0xxx + writeOutput(static_cast(((cp >> 12) & 0b11'1111) | 0b1000'0000)); //10xx xxxx + writeOutput(static_cast(((cp >> 6) & 0b11'1111) | 0b1000'0000)); //10xx xxxx + writeOutput(static_cast( (cp & 0b11'1111) | 0b1000'0000)); //10xx xxxx + } + else //invalid code point + codePointToUtf8(REPLACEMENT_CHAR, writeOutput); //resolves to 3-byte UTF8 +} + + +class Utf8Decoder +{ +public: + Utf8Decoder(const Char8* str, size_t len) : it_(str), last_(str + len) {} + + std::optional getNext() + { + if (it_ == last_) + return std::nullopt; + + const Char8 ch = *it_++; + CodePoint cp = ch; + + if (ch < 0x80) //1 byte + ; + else if (ch >> 5 == 0b110) //2 bytes + { + cp &= 0b1'1111; + if (decodeTrail(cp)) + if (cp <= 0b111'1111) //overlong encoding: "correct encoding of a code point uses only the minimum number of bytes required" + cp = REPLACEMENT_CHAR; + } + else if (ch >> 4 == 0b1110) //3 bytes + { + cp &= 0b1111; + if (decodeTrail(cp) && decodeTrail(cp)) + if (cp <= 0b0111'1111'1111 || + (LEAD_SURROGATE <= cp && cp <= TRAIL_SURROGATE_MAX)) //[0xd800, 0xdfff] are invalid code points + cp = REPLACEMENT_CHAR; + } + else if (ch >> 3 == 0b11110) //4 bytes + { + cp &= 0b111; + if (decodeTrail(cp) && decodeTrail(cp) && decodeTrail(cp)) + if (cp <= 0b1111'1111'1111'1111 || cp > CODE_POINT_MAX) + cp = REPLACEMENT_CHAR; + } + else //invalid begin of UTF8 encoding + cp = REPLACEMENT_CHAR; + + return cp; + } + +private: + bool decodeTrail(CodePoint& cp) + { + if (it_ != last_) //trail surrogate expected! + { + const Char8 ch = *it_; + if (ch >> 6 == 0b10) //trail surrogate expected! + { + cp = (cp << 6) + (ch & 0b11'1111); + ++it_; + return true; + } + } + cp = REPLACEMENT_CHAR; + return false; + } + + const Char8* it_; + const Char8* const last_; +}; + +//---------------------------------------------------------------------------------------------------------------- + +template inline void codePointToUtfImpl(CodePoint cp, Function writeOutput, std::integral_constant) { codePointToUtf8 (cp, writeOutput); } //UTF8-char +template inline void codePointToUtfImpl(CodePoint cp, Function writeOutput, std::integral_constant) { codePointToUtf16(cp, writeOutput); } //Windows: UTF16-wchar_t +template inline void codePointToUtfImpl(CodePoint cp, Function writeOutput, std::integral_constant) { writeOutput(cp); } //other OS: UTF32-wchar_t + +//---------------------------------------------------------------------------------------------------------------- + +template +class UtfDecoderImpl; + + +template +class UtfDecoderImpl //UTF8-char +{ +public: + UtfDecoderImpl(const CharType* str, size_t len) : decoder_(reinterpret_cast(str), len) {} + std::optional getNext() { return decoder_.getNext(); } +private: + Utf8Decoder decoder_; +}; + + +template +class UtfDecoderImpl //Windows: UTF16-wchar_t +{ +public: + UtfDecoderImpl(const CharType* str, size_t len) : decoder_(reinterpret_cast(str), len) {} + std::optional getNext() { return decoder_.getNext(); } +private: + Utf16Decoder decoder_; +}; + + +template +class UtfDecoderImpl //other OS: UTF32-wchar_t +{ +public: + UtfDecoderImpl(const CharType* str, size_t len) : it_(reinterpret_cast(str)), last_(it_ + len) {} + std::optional getNext() + { + if (it_ == last_) + return {}; + return *it_++; + } +private: + const CodePoint* it_; + const CodePoint* last_; +}; +} + + +template +using UtfDecoder = impl::UtfDecoderImpl; + + +template inline +void codePointToUtf(impl::CodePoint cp, Function writeOutput) //"writeOutput" is a unary function taking a CharType +{ + return impl::codePointToUtfImpl(cp, writeOutput, std::integral_constant()); +} + + +//------------------------------------------------------------------------------------------- + +template inline +bool isValidUtf(const UtfString& str) +{ + using namespace impl; + + UtfDecoder> decoder(strBegin(str), strLength(str)); + while (const std::optional cp = decoder.getNext()) + if (*cp == REPLACEMENT_CHAR) + return false; + + return true; +} + + +template inline +size_t unicodeLength(const UtfString& str) //return number of code points (+ correctly handle broken UTF encoding) +{ + size_t uniLen = 0; + UtfDecoder> decoder(strBegin(str), strLength(str)); + while (decoder.getNext()) + ++uniLen; + return uniLen; +} + + +template inline +UtfStringOut getUnicodeSubstring(const UtfStringIn& str, size_t uniPosFirst, size_t uniPosLast) //return position of unicode char in UTF-encoded string +{ + assert(uniPosFirst <= uniPosLast && uniPosLast <= unicodeLength(str)); + using namespace impl; + using CharType = GetCharTypeT; + + UtfStringOut output; + assert(uniPosFirst <= uniPosLast); + if (uniPosFirst >= uniPosLast) //optimize for empty range + return output; + + UtfDecoder decoder(strBegin(str), strLength(str)); + for (size_t uniPos = 0; std::optional cp = decoder.getNext(); ++uniPos) //[!] declaration in condition part of the for-loop + if (uniPos >= uniPosFirst) + { + if (uniPos >= uniPosLast) + break; + codePointToUtf(*cp, [&](CharType c) { output += c; }); + } + return output; +} + +//------------------------------------------------------------------------------------------- + +namespace impl +{ +template inline +TargetString utfTo(const SourceString& str, std::true_type) { return copyStringTo(str); } + + +template inline +TargetString utfTo(const SourceString& str, std::false_type) +{ + using CharSrc = GetCharTypeT; + using CharTrg = GetCharTypeT; + static_assert(sizeof(CharSrc) != sizeof(CharTrg)); + + TargetString output; + + UtfDecoder decoder(strBegin(str), strLength(str)); + while (const std::optional cp = decoder.getNext()) + codePointToUtf(*cp, [&](CharTrg c) { output += c; }); + + return output; +} +} + + +template inline +TargetString utfTo(const SourceString& str) +{ + return impl::utfTo(str, std::bool_constant) == sizeof(GetCharTypeT)>()); +} +} + +#endif //UTF_H_01832479146991573473545 diff --git a/zen/zlib_wrap.cpp b/zen/zlib_wrap.cpp new file mode 100644 index 0000000..5810ef5 --- /dev/null +++ b/zen/zlib_wrap.cpp @@ -0,0 +1,245 @@ +// ***************************************************************************** +// * 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 "zlib_wrap.h" +//Windows: use the SAME zlib version that wxWidgets is linking against! //C:\Data\Projects\wxWidgets\Source\src\zlib\zlib.h +//Linux/macOS: use zlib system header for wxWidgets, libcurl (HTTP), libssh2 (SFTP) +// => don't compile wxWidgets with: --with-zlib=builtin +#include +#include "scope_guard.h" +#include "serialize.h" + +using namespace zen; + + +namespace +{ +std::wstring getZlibErrorLiteral(int sc) +{ + switch (sc) + { + ZEN_CHECK_CASE_FOR_CONSTANT(Z_NEED_DICT); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_STREAM_END); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_OK); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_ERRNO); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_STREAM_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_DATA_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_MEM_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_BUF_ERROR); + ZEN_CHECK_CASE_FOR_CONSTANT(Z_VERSION_ERROR); + + default: + return replaceCpy(L"zlib error %x", L"%x", numberTo(sc)); + } +} + + +size_t zlib_compressBound(size_t len) +{ + return ::compressBound(static_cast(len)); //upper limit for buffer size, larger than input size!!! +} + + +size_t zlib_compress(const void* src, size_t srcLen, void* trg, size_t trgLen, int level) //throw SysError +{ + uLongf bufSize = static_cast(trgLen); + const int rv = ::compress2(static_cast(trg), //Bytef* dest + &bufSize, //uLongf* destLen + static_cast(src), //const Bytef* source + static_cast(srcLen), //uLong sourceLen + level); //int level + // Z_OK: success + // Z_MEM_ERROR: not enough memory + // Z_BUF_ERROR: not enough room in the output buffer + if (rv != Z_OK || bufSize > trgLen) + throw SysError(formatSystemError("zlib compress2", getZlibErrorLiteral(rv), L"")); + + return bufSize; +} + + +size_t zlib_decompress(const void* src, size_t srcLen, void* trg, size_t trgLen) //throw SysError +{ + uLongf bufSize = static_cast(trgLen); + const int rv = ::uncompress(static_cast(trg), //Bytef* dest + &bufSize, //uLongf* destLen + static_cast(src), //const Bytef* source + static_cast(srcLen)); //uLong sourceLen + // Z_OK: success + // Z_MEM_ERROR: not enough memory + // Z_BUF_ERROR: not enough room in the output buffer + // Z_DATA_ERROR: input data was corrupted or incomplete + if (rv != Z_OK || bufSize > trgLen) + throw SysError(formatSystemError("zlib uncompress", getZlibErrorLiteral(rv), L"")); + + return bufSize; +} +} + + +#undef compress //mitigate zlib macro shit... + +std::string zen::compress(const std::string_view& stream, int level) //throw SysError +{ + std::string output; + if (!stream.empty()) //don't dereference iterator into empty container! + { + //save uncompressed stream size for decompression + const uint64_t uncompressedSize = stream.size(); //use portable number type! + output.resize(sizeof(uncompressedSize)); + std::memcpy(output.data(), &uncompressedSize, sizeof(uncompressedSize)); + + const size_t bufferEstimate = zlib_compressBound(stream.size()); //upper limit for buffer size, larger than input size!!! + + output.resize(output.size() + bufferEstimate); + + const size_t bytesWritten = zlib_compress(stream.data(), + stream.size(), + output.data() + output.size() - bufferEstimate, + bufferEstimate, + level); //throw SysError + if (bytesWritten < bufferEstimate) + output.resize(output.size() - bufferEstimate + bytesWritten); //caveat: unsigned arithmetics + //caveat: physical memory consumption still *unchanged*! + } + return output; +} + + +std::string zen::decompress(const std::string_view& stream) //throw SysError +{ + std::string output; + if (!stream.empty()) //don't dereference iterator into empty container! + { + //retrieve size of uncompressed data + uint64_t uncompressedSize = 0; //use portable number type! + if (stream.size() < sizeof(uncompressedSize)) + throw SysError(L"zlib error: stream size < 8"); + + std::memcpy(&uncompressedSize, stream.data(), sizeof(uncompressedSize)); + + //attention: output MUST NOT be empty! Else it will pass a nullptr to zlib_decompress() => Z_STREAM_ERROR although "uncompressedSize == 0"!!! + if (uncompressedSize == 0) //cannot be 0: compress() directly maps empty -> empty container skipping zlib! + throw SysError(L"zlib error: uncompressed size == 0"); + + try + { + output.resize(static_cast(uncompressedSize)); //throw std::bad_alloc + } + //most likely this is due to data corruption: + catch (const std::length_error& e) { throw SysError(L"zlib error: " + _("Out of memory.") + L' ' + utfTo(e.what())); } + catch (const std::bad_alloc& e) { throw SysError(L"zlib error: " + _("Out of memory.") + L' ' + utfTo(e.what())); } + + const size_t bytesWritten = zlib_decompress(stream.data() + sizeof(uncompressedSize), + stream.size() - sizeof(uncompressedSize), + output.data(), + static_cast(uncompressedSize)); //throw SysError + if (bytesWritten != static_cast(uncompressedSize)) + throw SysError(formatSystemError("zlib_decompress", L"", L"bytes written != uncompressed size.")); + } + return output; +} + + +class InputStreamAsGzip::Impl +{ +public: + Impl(const std::function& tryReadBlock /*throw X; may return short, only 0 means EOF!*/, + size_t blockSize) : //throw SysError + tryReadBlock_(tryReadBlock), + blockSize_(blockSize) + { + const int windowBits = MAX_WBITS + 16; //"add 16 to windowBits to write a simple gzip header" + + //"memLevel=1 uses minimum memory but is slow and reduces compression ratio; memLevel=9 uses maximum memory for optimal speed. + const int memLevel = 9; //test; 280 MB installer file: level 9 shrinks runtime by ~8% compared to level 8 (==DEF_MEM_LEVEL) at the cost of 128 KB extra memory + static_assert(memLevel <= MAX_MEM_LEVEL); + + const int rv = ::deflateInit2(&gzipStream_, //z_streamp strm + 3 /*see db_file.cpp*/, //int level + Z_DEFLATED, //int method + windowBits, //int windowBits + memLevel, //int memLevel + Z_DEFAULT_STRATEGY); //int strategy + if (rv != Z_OK) + throw SysError(formatSystemError("zlib deflateInit2", getZlibErrorLiteral(rv), L"")); + } + + ~Impl() + { + [[maybe_unused]] const int rv = ::deflateEnd(&gzipStream_); + assert(rv == Z_OK); + } + + size_t read(void* buffer, size_t bytesToRead) //throw SysError, X; return "bytesToRead" bytes unless end of stream! + { + if (bytesToRead == 0) //"read() with a count of 0 returns zero" => indistinguishable from end of file! => check! + throw std::logic_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Contract violation!"); + + gzipStream_.next_out = static_cast(buffer); + gzipStream_.avail_out = static_cast(bytesToRead); + + for (;;) + { + //refill input buffer once avail_in == 0: https://www.zlib.net/manual.html + if (gzipStream_.avail_in == 0 && !eof_) + { + const size_t bytesRead = tryReadBlock_(bufIn_.data(), blockSize_); //throw X; may return short, only 0 means EOF! + gzipStream_.next_in = reinterpret_cast(bufIn_.data()); + gzipStream_.avail_in = static_cast(bytesRead); + if (bytesRead == 0) + eof_ = true; + } + + const int rv = ::deflate(&gzipStream_, eof_ ? Z_FINISH : Z_NO_FLUSH); + if (eof_ && rv == Z_STREAM_END) + return bytesToRead - gzipStream_.avail_out; + if (rv != Z_OK) + throw SysError(formatSystemError("zlib deflate", getZlibErrorLiteral(rv), L"")); + + if (gzipStream_.avail_out == 0) + return bytesToRead; + } + } + + size_t getBlockSize() const { return blockSize_; } //returning input blockSize_ makes sense for low compression ratio + +private: + const std::function tryReadBlock_; //throw X + const size_t blockSize_; + bool eof_ = false; + std::vector bufIn_{blockSize_}; + z_stream gzipStream_ = {}; +}; + + +InputStreamAsGzip::InputStreamAsGzip(const std::function& tryReadBlock /*throw X*/, size_t blockSize) : + pimpl_(std::make_unique(tryReadBlock, blockSize)) {} //throw SysError + +InputStreamAsGzip::~InputStreamAsGzip() {} + +size_t InputStreamAsGzip::getBlockSize() const { return pimpl_->getBlockSize(); } + +size_t InputStreamAsGzip::read(void* buffer, size_t bytesToRead) { return pimpl_->read(buffer, bytesToRead); } //throw SysError, X + + +std::string zen::compressAsGzip(const std::string_view& stream) //throw SysError +{ + MemoryStreamIn memStream(stream); + + auto tryReadBlock = [&](void* buffer, size_t bytesToRead) //may return short, only 0 means EOF! + { + return memStream.read(buffer, bytesToRead); //return "bytesToRead" bytes unless end of stream! + }; + + InputStreamAsGzip gzipStream(tryReadBlock, 1024 * 1024 /*blockSize*/); //throw SysError + + return unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + return gzipStream.read(buffer, bytesToRead); //throw SysError; return "bytesToRead" bytes unless end of stream! + }, + gzipStream.getBlockSize()); //throw SysError +} diff --git a/zen/zlib_wrap.h b/zen/zlib_wrap.h new file mode 100644 index 0000000..d672707 --- /dev/null +++ b/zen/zlib_wrap.h @@ -0,0 +1,44 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ZLIB_WRAP_H_428597064566 +#define ZLIB_WRAP_H_428597064566 + +#include +#include "sys_error.h" + + +namespace zen +{ +// compression level must be between 0 and 9: +// 0: no compression +// 9: best compression +std::string compress(const std::string_view& stream, int level); //throw SysError +//caveat: output stream is physically larger than input! => strip additional reserved space if needed: "BinContainer(output.begin(), output.end())" + +std::string decompress(const std::string_view& stream); //throw SysError + + +class InputStreamAsGzip //convert input stream into gzip on the fly +{ +public: + explicit InputStreamAsGzip(const std::function& tryReadBlock /*throw X; may return short, only 0 means EOF!*/, + size_t blockSize); //throw SysError + ~InputStreamAsGzip(); + + size_t getBlockSize() const; + + size_t read(void* buffer, size_t bytesToRead); //throw SysError, X; return "bytesToRead" bytes unless end of stream! + +private: + class Impl; + const std::unique_ptr pimpl_; +}; + +std::string compressAsGzip(const std::string_view& stream); //throw SysError +} + +#endif //ZLIB_WRAP_H_428597064566 diff --git a/zen/zstring.cpp b/zen/zstring.cpp new file mode 100644 index 0000000..a70228e --- /dev/null +++ b/zen/zstring.cpp @@ -0,0 +1,315 @@ +// ***************************************************************************** +// * 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 "zstring.h" + //#include + #include "sys_error.h" + +using namespace zen; + + +namespace +{ +Zstring getUnicodeNormalForm_NonAsciiValidUtf(const Zstring& str, UnicodeNormalForm form) +{ + //Example: const char* decomposed = "\x6f\xcc\x81"; //ó + // const char* precomposed = "\xc3\xb3"; //ó + assert(!isAsciiString(str)); //includes "not-empty" check + assert(!contains(str, Zchar('\0'))); //don't expect embedded nulls! + + try + { + gchar* strNorm = ::g_utf8_normalize(str.c_str(), str.length(), form == UnicodeNormalForm::nfc ? G_NORMALIZE_NFC : G_NORMALIZE_NFD); + if (!strNorm) + throw SysError(formatSystemError("g_utf8_normalize", L"", L"Conversion failed.")); + ZEN_ON_SCOPE_EXIT(::g_free(strNorm)); + + const std::string_view strNormView(strNorm, strLength(strNorm)); + + if (equalString(str, strNormView)) //avoid extra memory allocation + return str; + + return Zstring(strNormView); + + } + catch (const SysError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Error normalizing string:" + '\n' + + utfTo(str) + "\n\n" + utfTo(e.toString())); + } +} + + +Zstring getValidUtf(const Zstring& str) +{ + /* 1. do NOT fail on broken UTF encoding, instead normalize using REPLACEMENT_CHAR! + 2. NormalizeString() haateeez them Unicode non-characters: ERROR_NO_UNICODE_TRANSLATION! http://www.unicode.org/faq/private_use.html#nonchar1 + - No such issue on Linux/macOS with g_utf8_normalize(), and CFStringGetFileSystemRepresentation() + -> still, probably good idea to "normalize" Unicode non-characters cross-platform + - consistency for compareNoCase(): let's *unconditionally* check before other normalization operations, not just in error case! */ + using impl::CodePoint; + auto isUnicodeNonCharacter = [](CodePoint cp) { assert(cp <= impl::CODE_POINT_MAX); return (0xfdd0 <= cp && cp <= 0xfdef) || cp % 0x10'000 >= 0xfffe; }; + + const bool invalidUtf = [&] //pre-check: avoid memory allocation if valid UTF + { + UtfDecoder decoder(str.c_str(), str.size()); + while (const std::optional cp = decoder.getNext()) + if (*cp == impl::REPLACEMENT_CHAR || //marks broken UTF encoding + isUnicodeNonCharacter(*cp)) + return true; + return false; + }(); + + if (invalidUtf) //band-aid broken UTF encoding with REPLACEMENT_CHAR + { + Zstring validStr; //don't want extra memory allocations in the standard case (valid UTF) + UtfDecoder decoder(str.c_str(), str.size()); + while (std::optional cp = decoder.getNext()) + { + if (isUnicodeNonCharacter(*cp)) // + *cp = impl::REPLACEMENT_CHAR; //"normalize" Unicode non-characters + + codePointToUtf(*cp, [&](Zchar ch) { validStr += ch; }); + } + return validStr; + } + else + return str; +} + + +Zstring getUpperCaseAscii(const Zstring& str) +{ + assert(isAsciiString(str)); + + Zstring output = str; + for (Zchar& c : output) //identical to LCMapStringEx(), g_unichar_toupper(), CFStringUppercase() [verified!] + c = asciiToUpper(c); // + return output; +} + + +Zstring getUpperCaseNonAscii(const Zstring& str) +{ + const Zstring& strValidUtf = getValidUtf(str); + try + { + const Zstring strNorm = getUnicodeNormalForm_NonAsciiValidUtf(strValidUtf, UnicodeNormalForm::native); + + Zstring output; + output.reserve(strNorm.size()); + + UtfDecoder decoder(strNorm.c_str(), strNorm.size()); + while (const std::optional cp = decoder.getNext()) + codePointToUtf(::g_unichar_toupper(*cp), [&](const char c) { output += c; }); //don't use std::towupper: *incomplete* and locale-dependent! + + static_assert(sizeof(impl::CodePoint) == sizeof(gunichar)); + return output; + + } + catch (const SysError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Error converting string to upper case:" + '\n' + + utfTo(str) + "\n\n" + utfTo(e.toString())); + } +} +} + + +Zstring getUnicodeNormalForm(const Zstring& str, UnicodeNormalForm form) +{ + static_assert(std::is_same_v&>, "god bless our ref-counting! => save needless memory allocation!"); + + if (isAsciiString(str)) //fast path: in the range of 3.5ns + return str; + + return getUnicodeNormalForm_NonAsciiValidUtf(getValidUtf(str), form); //slow path +} + + +Zstring getUpperCase(const Zstring& str) +{ + return isAsciiString(str) ? //fast path: in the range of 3.5ns + getUpperCaseAscii(str) : + getUpperCaseNonAscii(str); //slow path +} + + +namespace +{ +std::weak_ordering compareNoCaseUtf8(const char* lhs, size_t lhsLen, const char* rhs, size_t rhsLen) +{ + //expect Unicode normalized strings! + assert(Zstring(lhs, lhsLen) == getUnicodeNormalForm(Zstring(lhs, lhsLen), UnicodeNormalForm::nfd)); + assert(Zstring(rhs, rhsLen) == getUnicodeNormalForm(Zstring(rhs, rhsLen), UnicodeNormalForm::nfd)); + + //- strncasecmp implements ASCII CI-comparsion only! => signature is broken for UTF8-input; toupper() similarly doesn't support Unicode + //- wcsncasecmp: https://opensource.apple.com/source/Libc/Libc-763.12/string/wcsncasecmp-fbsd.c + // => re-implement comparison based on g_unichar_tolower() to avoid memory allocations + + UtfDecoder decL(lhs, lhsLen); + UtfDecoder decR(rhs, rhsLen); + for (;;) + { + const std::optional cpL = decL.getNext(); + const std::optional cpR = decR.getNext(); + if (!cpL || !cpR) + return !cpR <=> !cpL; + + static_assert(sizeof(gunichar) == sizeof(impl::CodePoint)); + static_assert(std::is_unsigned_v, "unsigned char-comparison is the convention!"); + + //ordering: "to lower" converts to higher code points than "to upper" + const gunichar charL = ::g_unichar_toupper(*cpL); //note: tolower can be ambiguous, so don't use: + const gunichar charR = ::g_unichar_toupper(*cpR); //e.g. "Σ" (upper case) can be lower-case "ς" in the end of the word or "σ" in the middle. + if (charL != charR) + return charL <=> charR; + } +} +} + + +std::weak_ordering compareNatural(const Zstring& lhs, const Zstring& rhs) +{ + try + { + /* Unicode Normalization Forms: + Windows: CompareString() ignores NFD/NFC differences and converts to NFD + Linux: g_unichar_toupper() can't ignore differences + macOS: CFStringCompare() considers differences */ + const Zstring& lhsNorm = getUnicodeNormalForm(lhs, UnicodeNormalForm::nfd); //normalize: - broken UTF encoding + const Zstring& rhsNorm = getUnicodeNormalForm(rhs, UnicodeNormalForm::nfd); // - Unicode non-characters + + const char* strL = lhsNorm.c_str(); + const char* strR = rhsNorm.c_str(); + + const char* const strEndL = strL + lhsNorm.size(); + const char* const strEndR = strR + rhsNorm.size(); + /* - compare strings after conceptually creating blocks of whitespace/numbers/text + - implement strict weak ordering! + - don't follow broken "strnatcasecmp": https://github.com/php/php-src/blob/master/ext/standard/strnatcmp.c + 1. incorrect non-ASCII CI-comparison + 2. incorrect bounds checks + 3. incorrect trimming of *all* whitespace + 4. arbitrary handling of leading 0 only at string begin + 5. incorrect handling of whitespace following a number + 6. code is a mess */ + for (;;) + { + if (strL == strEndL || strR == strEndR) + return (strL != strEndL) <=> (strR != strEndR); //"nothing" before "something" + //note: "something" never would have been condensed to "nothing" further below => can finish evaluation here + + const bool wsL = isWhiteSpace(*strL); + const bool wsR = isWhiteSpace(*strR); + if (wsL != wsR) + return !wsL <=> !wsR; //whitespace before non-ws! + if (wsL) + { + ++strL, ++strR; + while (strL != strEndL && isWhiteSpace(*strL)) ++strL; + while (strR != strEndR && isWhiteSpace(*strR)) ++strR; + continue; + } + + const bool digitL = isDigit(*strL); + const bool digitR = isDigit(*strR); + if (digitL != digitR) + return !digitL <=> !digitR; //numbers before chars! + if (digitL) + { + while (strL != strEndL && *strL == '0') ++strL; + while (strR != strEndR && *strR == '0') ++strR; + + int rv = 0; + for (;; ++strL, ++strR) + { + const bool endL = strL == strEndL || !isDigit(*strL); + const bool endR = strR == strEndR || !isDigit(*strR); + if (endL != endR) + return !endL <=> !endR; //more digits means bigger number + if (endL) + break; //same number of digits + + if (rv == 0 && *strL != *strR) + rv = *strL - *strR; //found first digit difference comparing from left + } + if (rv != 0) + return rv <=> 0; + continue; + } + + //compare full junks of text: consider Unicode encoding! + const char* textBeginL = strL++; + const char* textBeginR = strR++; //current char is neither white space nor digit at this point! + while (strL != strEndL && !isWhiteSpace(*strL) && !isDigit(*strL)) ++strL; + while (strR != strEndR && !isWhiteSpace(*strR) && !isDigit(*strR)) ++strR; + + if (const std::weak_ordering cmp = compareNoCaseUtf8(textBeginL, strL - textBeginL, textBeginR, strR - textBeginR); + cmp != std::weak_ordering::equivalent) + return cmp; + } + + } + catch (const SysError& e) + { + throw std::runtime_error(std::string(__FILE__) + '[' + numberTo(__LINE__) + "] Error comparing strings:" + '\n' + + utfTo(lhs) + '\n' + utfTo(rhs) + "\n\n" + utfTo(e.toString())); + } +} + + +std::weak_ordering compareNoCase(const Zstring& lhs, const Zstring& rhs) +{ + const bool isAsciiL = isAsciiString(lhs); + const bool isAsciiR = isAsciiString(rhs); + + //fast path: no memory allocations => ~ 6x speedup + if (isAsciiL && isAsciiR) + { + const size_t minSize = std::min(lhs.size(), rhs.size()); + for (size_t i = 0; i < minSize; ++i) + { + //ordering: do NOT call compareAsciiNoCase(), which uses asciiToLower()! + const Zchar lUp = asciiToUpper(lhs[i]); // + const Zchar rUp = asciiToUpper(rhs[i]); //no surprises: emulate getUpperCase() [verified!] + if (lUp != rUp) // + return lUp <=> rUp; // + } + return lhs.size() <=> rhs.size(); + } + //-------------------------------------- + + //can't we instead skip isAsciiString() and compare chars as long as isAsciiChar()? + // => NOPE! e.g. decomposed Unicode! A seemingly single isAsciiChar() might be followed by a combining character!!! + + return (isAsciiL ? getUpperCaseAscii(lhs) : getUpperCaseNonAscii(lhs)) <=> + (isAsciiR ? getUpperCaseAscii(rhs) : getUpperCaseNonAscii(rhs)); +} + + +bool equalNoCase(const Zstring& lhs, const Zstring& rhs) +{ + const bool isAsciiL = isAsciiString(lhs); + const bool isAsciiR = isAsciiString(rhs); + + //fast-path: no extra memory allocations + //caveat: ASCII-char and non-ASCII Unicode *can* compare case-insensitive equal!!! e.g. i and ı https://freefilesync.org/forum/viewtopic.php?t=9718 + if (isAsciiL && isAsciiR) + { + if (lhs.size() != rhs.size()) + return false; + + for (size_t i = 0; i < lhs.size(); ++i) + if (asciiToUpper(lhs[i]) != + asciiToUpper(rhs[i])) + return false; + return true; + } + + return (isAsciiL ? getUpperCaseAscii(lhs) : getUpperCaseNonAscii(lhs)) == + (isAsciiR ? getUpperCaseAscii(rhs) : getUpperCaseNonAscii(rhs)); +} diff --git a/zen/zstring.h b/zen/zstring.h new file mode 100644 index 0000000..0d49331 --- /dev/null +++ b/zen/zstring.h @@ -0,0 +1,110 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef ZSTRING_H_73425873425789 +#define ZSTRING_H_73425873425789 + +#include //not used by this header, but the "rest of the world" needs it! +#include "utf.h" // +#include "string_base.h" + + + using Zchar = char; + #define Zstr(x) x + + +//"The reason for all the fuss above" - Loki/SmartPtr +//a high-performance string for interfacing with native OS APIs in multithreaded contexts +using Zstring = zen::Zbase; + +using ZstringView = std::basic_string_view; + +//for special UI-contexts: guaranteed exponential growth + ref-counting + COW + no SSO overhead +using Zstringc = zen::Zbase; +//using Zstringw = zen::Zbase; + + +enum class UnicodeNormalForm +{ + nfc, //precomposed + nfd, //decomposed + native = nfc, +}; +Zstring getUnicodeNormalForm(const Zstring& str, UnicodeNormalForm form = UnicodeNormalForm::native); +/* "In fact, Unicode declares that there is an equivalence relationship between decomposed and composed sequences, + and conformant software should not treat canonically equivalent sequences, whether composed or decomposed or something in between, as different." + https://www.win.tue.nl/~aeb/linux/uc/nfc_vs_nfd.html */ + +/* Caveat: don't expect input/output string sizes to match: + - different UTF-8 encoding length of upper-case chars + - different number of upper case chars (e.g. ß => "SS" on macOS) + - output is Unicode-normalized */ +Zstring getUpperCase(const Zstring& str); + +//------------------------------------------------------------------------------------------ +struct ZstringNorm //use as STL container key: better than repeated Unicode normalizations during std::map<>::find() +{ + /*explicit*/ ZstringNorm(const Zstring& str) : normStr(getUnicodeNormalForm(str)) {} + Zstring normStr; + + std::strong_ordering operator<=>(const ZstringNorm&) const = default; +}; +template<> struct std::hash { size_t operator()(const ZstringNorm& str) const { return std::hash()(str.normStr); } }; + +//struct LessUnicodeNormal { bool operator()(const Zstring& lhs, const Zstring& rhs) const { return getUnicodeNormalForm(lhs) < getUnicodeNormalForm(rhs); } }; + +//------------------------------------------------------------------------------------------ +struct ZstringNoCase //use as STL container key: better than repeated upper-case conversions during std::map<>::find() +{ + /*explicit*/ ZstringNoCase(const Zstring& str) : upperCase(getUpperCase(str)) {} + Zstring upperCase; + + std::strong_ordering operator<=>(const ZstringNoCase&) const = default; +}; +template<> struct std::hash { size_t operator()(const ZstringNoCase& str) const { return std::hash()(str.upperCase); } }; + + +std::weak_ordering compareNoCase(const Zstring& lhs, const Zstring& rhs); + +bool equalNoCase(const Zstring& lhs, const Zstring& rhs); + +//------------------------------------------------------------------------------------------ +std::weak_ordering compareNatural(const Zstring& lhs, const Zstring& rhs); + +struct LessNaturalSort { bool operator()(const Zstring& lhs, const Zstring& rhs) const { return compareNatural(lhs, rhs) < 0; } }; + + +//------------------------------------------------------------------------------------------ +//common Unicode characters +const wchar_t EN_DASH = L'\u2013'; //– +const wchar_t EM_DASH = L'\u2014'; //— + const wchar_t* const SPACED_DASH = L" \u2014 "; //using 'EM DASH' +const wchar_t* const ELLIPSIS = L"\u2026"; //… +const wchar_t MULT_SIGN = L'\u00D7'; //× +const wchar_t NOBREAK_SPACE = L'\u00A0'; +const wchar_t ZERO_WIDTH_SPACE = L'\u200B'; + +const wchar_t EN_SPACE = L'\u2002'; + +const wchar_t LTR_MARK = L'\u200E'; //UTF-8: E2 80 8E +const wchar_t RTL_MARK = L'\u200F'; //UTF-8: E2 80 8F https://www.w3.org/International/questions/qa-bidi-unicode-controls +//const wchar_t BIDI_DIR_ISOLATE_RTL = L'\u2067'; //=> not working on Win 10 +//const wchar_t BIDI_POP_DIR_ISOLATE = L'\u2069'; //=> not working on Win 10 +//const wchar_t BIDI_DIR_EMBEDDING_RTL = L'\u202B'; //=> not working on Win 10 +//const wchar_t BIDI_POP_DIR_FORMATTING = L'\u202C'; //=> not working on Win 10 + +const wchar_t RIGHT_ARROW_CURV_DOWN = L'\u2935'; //Right Arrow Curving Down: ⤵ +//Windows bug: rendered differently depending on presence of e.g. LTR_MARK! +//there is no "Left Arrow Curving Down" => WTF => better than nothing: +const wchar_t LEFT_ARROW_ANTICLOCK = L'\u2B8F'; //Anticlockwise Triangle-Headed Top U-Shaped Arrow: ⮏ + +const wchar_t* const TAB_SPACE = L" "; //4: the only sensible space count for tabs + +const wchar_t LINE_SEPARATOR = L'\u2028'; //WTF: visually indistinguishable from new line! +const wchar_t PARAGRAPH_SEPARATOR = L'\u2029'; + + +#endif //ZSTRING_H_73425873425789 diff --git a/zenXml/zenxml/cvrt_struc.h b/zenXml/zenxml/cvrt_struc.h new file mode 100644 index 0000000..57a5d09 --- /dev/null +++ b/zenXml/zenxml/cvrt_struc.h @@ -0,0 +1,202 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CVRT_STRUC_H_018727409908342709743 +#define CVRT_STRUC_H_018727409908342709743 + +#include "dom.h" + + +namespace zen +{ +/** +\file +\brief Handle conversion of arbitrary types to and from XML elements. +See comments in cvrt_text.h +*/ + +///Convert XML element to structured user data +/** + \param input The input XML element. + \param value Conversion target value. + \return "true" if value was read successfully. +*/ +template bool readStruc(const XmlElement& input, T& value); +///Convert structured user data into an XML element +/** + \param value The value to be converted. + \param output The output XML element. +*/ +template void writeStruc(const T& value, XmlElement& output); + + + + + + + + + + + + +//------------------------------ implementation ------------------------------------- +namespace impl_2384343 +{ +ZEN_INIT_DETECT_MEMBER_TYPE(value_type) +ZEN_INIT_DETECT_MEMBER_TYPE(iterator) +ZEN_INIT_DETECT_MEMBER_TYPE(const_iterator) + +ZEN_INIT_DETECT_MEMBER(begin) // +ZEN_INIT_DETECT_MEMBER(end) //we don't know the exact declaration of the member attribute: may be in a base class! +ZEN_INIT_DETECT_MEMBER(insert) // +} + +template +using IsStlContainer = std::bool_constant< + impl_2384343::hasMemberType_value_type && + impl_2384343::hasMemberType_iterator && + impl_2384343::hasMemberType_const_iterator&& + impl_2384343::hasMember_begin && + impl_2384343::hasMember_end && + impl_2384343::hasMember_insert >; + + +template +struct IsStlPair +{ +private: + using Yes = char[1]; + using No = char[2]; + + template + static Yes& isPair(const std::pair&); + static No& isPair(...); +public: + enum { value = sizeof(isPair(std::declval())) == sizeof(Yes) }; +}; + +//###################################################################################### + +//Conversion from arbitrary types to an XML element +enum class ValueType +{ + stlContainer, + stlPair, + other, +}; + +template +using GetValueType = std::integral_constant::value != TextType::other ? ValueType::other : //some string classes are also STL containers, so check this first + IsStlContainer::value ? ValueType::stlContainer : + IsStlPair ::value ? ValueType::stlPair : + ValueType::other>; + + +template +struct ConvertElement; +/* -> expected interface +{ + void writeStruc(const T& value, XmlElement& output) const; + bool readStruc(const XmlElement& input, T& value) const; +}; +*/ + + +//partial specialization: handle conversion for all STL-container types! +template +struct ConvertElement +{ + void writeStruc(const T& value, XmlElement& output) const + { + for (const typename T::value_type& childVal : value) + { + XmlElement& newChild = output.addChild("Item"); + zen::writeStruc(childVal, newChild); + } + } + bool readStruc(const XmlElement& input, T& value) const + { + value.clear(); + + bool success = true; + for (const XmlElement& xmlChild : input.getChildren()) + { + typename T::value_type childVal; + if (zen::readStruc(xmlChild, childVal)) + value.insert(value.end(), std::move(childVal)); + else + success = false; + //should we support insertion of partially-loaded struct?? + } + return success; + } +}; + + +//partial specialization: handle conversion for std::pair +template +struct ConvertElement +{ + void writeStruc(const T& value, XmlElement& output) const + { + XmlElement& child1 = output.addChild("one"); //don't use "1st/2nd", this will confuse a few pedantic XML parsers + zen::writeStruc(value.first, child1); + + XmlElement& child2 = output.addChild("two"); + zen::writeStruc(value.second, child2); + } + bool readStruc(const XmlElement& input, T& value) const + { + bool success = true; + const XmlElement* child1 = input.getChild("one"); + if (!child1 || !zen::readStruc(*child1, value.first)) + success = false; + + const XmlElement* child2 = input.getChild("two"); + if (!child2 || !zen::readStruc(*child2, value.second)) + success = false; + + return success; + } +}; + + +//partial specialization: not a pure structured type, try text conversion (thereby respect user specializations of writeText()/readText()) +template +struct ConvertElement +{ + void writeStruc(const T& value, XmlElement& output) const + { + std::string tmp; + writeText(value, tmp); + output.setValue(std::move(tmp)); + } + bool readStruc(const XmlElement& input, T& value) const + { + std::string rawStr; + input.getValue(rawStr); + return readText(rawStr, value); + } +}; + + +template inline +void writeStruc(const T& value, XmlElement& output) +{ + ConvertElement::value>().writeStruc(value, output); +} + + +template inline +bool readStruc(const XmlElement& input, T& value) +{ + return ConvertElement::value>().readStruc(input, value); +} +} + +#endif //CVRT_STRUC_H_018727409908342709743 diff --git a/zenXml/zenxml/cvrt_text.h b/zenXml/zenxml/cvrt_text.h new file mode 100644 index 0000000..4fa4ec8 --- /dev/null +++ b/zenXml/zenxml/cvrt_text.h @@ -0,0 +1,251 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef CVRT_TEXT_H_018727339083427097434 +#define CVRT_TEXT_H_018727339083427097434 + +#include +#include + + +namespace zen +{ +/** +\file +\brief Handle conversion of string-convertible types to and from std::string. + +It is \b not required to call these functions directly. They are implicitly used by zen::XmlElement::getValue(), +zen::XmlElement::setValue(), zen::XmlElement::getAttribute() and zen::XmlElement::setAttribute(). +\n\n +Conversions for the following user types are supported by default: + - strings - std::string, std::wstring, char*, wchar_t*, char, wchar_t, etc..., all STL-compatible-string-classes + - numbers - int, double, float, bool, long, etc..., all built-in numbers + - STL containers - std::map, std::set, std::vector, std::list, etc..., all STL-compatible-containers + - std::pair + +You can add support for additional types via template specialization. \n\n +Specialize zen::readStruc() and zen::writeStruc() to enable conversion from structured user types to XML elements. +Specialize zen::readText() and zen::writeText() to enable conversion from string-convertible user types to std::string. +Prefer latter if possible since it does not only enable conversions from XML elements to user data, but also from and to XML attributes. +\n\n + Example: type "bool" +\code +namespace zen +{ +template <> inline +void writeText(const bool& value, std::string& output) +{ + output = value ? "true" : "false"; +} + +template <> inline +bool readText(const std::string& input, bool& value) +{ + const std::string tmp = trimCpy(input); + if (tmp == "true") + value = true; + else if (tmp == "false") + value = false; + else + return false; + return true; +} +} +\endcode +*/ + + +///Convert text to user data - used by XML elements and attributes +/** + \param input Input text. + \param value Conversion target value. + \return "true" if value was read successfully. +*/ +template bool readText(const std::string& input, T& value); +///Convert user data into text - used by XML elements and attributes +/** + \param value The value to be converted. + \param output Output text. +*/ +template void writeText(const T& value, std::string& output); + + +/* Different classes of data types: + +----------------------------- +| structured | readStruc/writeStruc - e.g. string-convertible types, STL containers, std::pair, structured user types +| ------------------------- | +| | to-string-convertible | | readText/writeText - e.g. string-like types, all built-in arithmetic numbers, bool +| | --------------- | | +| | | string-like | | | utfTo - e.g. std::string, wchar_t*, char[], wchar_t, wxString, MyStringClass, ... +| | --------------- | | +| ------------------------- | +----------------------------- +*/ + + + + + + + + + + + + + + + +//------------------------------ implementation ------------------------------------- +template +struct IsChronoDuration +{ +private: + using Yes = char[1]; + using No = char[2]; + + template + static Yes& isDuration(std::chrono::duration); + static No& isDuration(...); +public: + enum { value = sizeof(isDuration(std::declval())) == sizeof(Yes) }; +}; + + +//Conversion from arbitrary types to text (for use with XML elements and attributes) +enum class TextType +{ + boolean, + number, + chrono, + string, + other, +}; + +template +struct GetTextType : std::integral_constant ? TextType::boolean : + isStringLike ? TextType::string : //string before number to correctly handle char/wchar_t -> this was an issue with Loki only! + isArithmetic ? TextType::number : // + IsChronoDuration::value ? TextType::chrono : + TextType::other> {}; + +//###################################################################################### + +template +struct ConvertText; +/* -> expected interface +{ + void writeText(const T& value, std::string& output) const; + bool readText(const std::string& input, T& value) const; +}; +*/ + +//partial specialization: type bool +template +struct ConvertText +{ + void writeText(bool value, std::string& output) const + { + output = value ? "true" : "false"; + } + bool readText(const std::string& input, bool& value) const + { + const std::string tmp = trimCpy(input); + if (tmp == "true") + value = true; + else if (tmp == "false") + value = false; + else + return false; + return true; + } +}; + +//partial specialization: handle conversion for all built-in arithmetic types! +template +struct ConvertText +{ + void writeText(const T& value, std::string& output) const + { + output = numberTo(value); + } + bool readText(const std::string& input, T& value) const + { + value = stringTo(input); + return true; + } +}; + +template +struct ConvertText +{ + void writeText(const T& value, std::string& output) const + { + output = numberTo(value.count()); + } + bool readText(const std::string& input, T& value) const + { + value = T(stringTo(input)); + return true; + } +}; + +//partial specialization: handle conversion for all string-like types! +template +struct ConvertText +{ + void writeText(const T& value, std::string& output) const + { + output = utfTo(value); + } + bool readText(const std::string& input, T& value) const + { + value = utfTo(input); + return true; + } +}; + + +//partial specialization: unknown type +template +struct ConvertText +{ + //########################################################################################################################################### + static_assert(sizeof(T) == -1); + /* + ATTENTION: The data type T is yet unknown to the zen::Xml framework! + + Please provide a specialization for T of the following two functions in order to handle conversions to XML elements and attributes + + template <> void zen::writeText(const T& value, std::string& output) + template <> bool zen::readText(const std::string& input, T& value) + + If T is structured and cannot be converted to a text representation specialize these two functions to allow at least for conversions to XML elements: + + template <> void zen::writeStruc(const T& value, XmlElement& output) + template <> bool zen::readStruc(const XmlElement& input, T& value) + */ + //########################################################################################################################################### +}; + + +template inline +void writeText(const T& value, std::string& output) +{ + ConvertText::value>().writeText(value, output); +} + + +template inline +bool readText(const std::string& input, T& value) +{ + return ConvertText::value>().readText(input, value); +} +} + +#endif //CVRT_TEXT_H_018727339083427097434 diff --git a/zenXml/zenxml/dom.h b/zenXml/zenxml/dom.h new file mode 100644 index 0000000..19a07ff --- /dev/null +++ b/zenXml/zenxml/dom.h @@ -0,0 +1,270 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef DOM_H_82085720723894567204564256 +#define DOM_H_82085720723894567204564256 + +#include +#include +#include +#include "cvrt_text.h" //"readText/writeText" + + +namespace zen +{ +class XmlDoc; + +/// An XML element +class XmlElement +{ +public: + XmlElement() {} + + //Construct an empty XML element + explicit XmlElement(std::string name, XmlElement* parent = nullptr) : name_(std::move(name)), parent_(parent) {} + + ///Retrieve the name of this XML element. + /** + \returns Name of the XML element. + */ + const std::string& getName() const { return name_; } + + ///Get the value of this element as a user type. + /** + \tparam T Arbitrary user data type: e.g. any string class, all built-in arithmetic numbers, STL container, ... + \returns "true" if Xml element was successfully converted to value, cannot fail for string-like types + */ + template + bool getValue(T& value) const { return readStruc(*this, value); } + + ///Set the value of this element. + /** + \tparam T Arbitrary user data type: e.g. any string-like type, all built-in arithmetic numbers, STL container, ... + */ + template + void setValue(const T& value) { writeStruc(value, *this); } + + void setValue(std::string&& value) { value_ = std::move(value); } //perf + + ///Retrieve an attribute by name. + /** + \tparam T String-convertible user data type: e.g. any string class, all built-in arithmetic numbers + \param name The name of the attribute to retrieve. + \param value The value of the attribute converted to T. + \return "true" if value was retrieved successfully. + */ + template + bool getAttribute(std::string_view name, T& value) const + { + auto it = attributesByName_.find(name); + return it == attributesByName_.end() ? false : readText(it->second->value, value); + } + + bool hasAttribute(std::string_view name) const { return attributesByName_.contains(name); } + + ///Create or update an XML attribute. + /** + \tparam T String-convertible user data type: e.g. any string-like type, all built-in arithmetic numbers + \param name The name of the attribute to create or update. + \param value The value to set. + */ + template + void setAttribute(std::string&& name, const T& value) + { + std::string attrValue; + writeText(value, attrValue); + + auto it = attributesByName_.find(name); + if (it != attributesByName_.end()) + it->second->value = std::move(attrValue); + else + { + //attributes_.emplace_back(name, std::move(attrValue)); -> not yet on macOS/clang + attributes_.push_back({std::move(name), std::move(attrValue)}); + attributesByName_.emplace(attributes_.back().name, --attributes_.end()); + } + static_assert(std::is_same_v>); //must NOT invalidate references used in "attributesByName_"! + } + + ///Remove the attribute with the given name. + void removeAttribute(std::string_view name) + { + auto it = attributesByName_.find(name); + if (it != attributesByName_.end()) + { + attributes_.erase(it->second); + attributesByName_.erase(it); + } + else assert(false); + } + + ///Create a new child element and return a reference to it. + /** + \param name The name of the child element to be created. + */ + XmlElement& addChild(std::string name) + { + childElements_.emplace_back(name, this); + XmlElement& newElement = childElements_.back(); + childElementByName_.emplace(std::move(name), --childElements_.end()); + + static_assert(std::is_same_v>); //must NOT invalidate references used in "childElementByName_"! + return newElement; + } + + ///Retrieve a child element with the given name. + /** + \param name The name of the child element to be retrieved. + \return A pointer to the child element or nullptr if none was found. + */ + const XmlElement* getChild(const std::string& name) const + { + auto it = childElementByName_.find(name); + return it == childElementByName_.end() ? nullptr : &*(it->second); + } + + ///\sa getChild + XmlElement* getChild(const std::string& name) + { + return const_cast(static_cast(this)->getChild(name)); + } + + ///Access all child elements sequentially + /** + \code + for (const XmlElement& child : elem.getChildren()) + { ... } + \endcode + \return A range object supporting begin/end functions to access all child elements sequentially. */ + Range::const_iterator> getChildren() const { return {childElements_.begin(), childElements_.end()}; } + + ///\sa getChildren + Range::iterator> getChildren() { return {childElements_.begin(), childElements_.end()}; } + + ///Get parent XML element, may be nullptr for root element + XmlElement* parent() { return parent_; } + ///Get parent XML element, may be nullptr for root element + const XmlElement* parent() const { return parent_; } + + struct Attribute + { + const std::string name; + /**/ std::string value; + }; + using AttrIter = std::list::const_iterator; + + /* -> disabled documentation extraction + \brief Get all attributes associated with the element. + \code + auto itPair = elem.getAttributes(); + for (auto it = itPair.first; it != itPair.second; ++it) + std::cout << std::string("name: ") + it->name + " value: " + it->value + '\n'; + \endcode + \return A pair of STL begin/end iterators to access all attributes sequentially as a list of name/value pairs of std::string. */ + std::pair getAttributes() const { return {attributes_.begin(), attributes_.end()}; } + + //swap two elements while keeping references to parent. -> disabled documentation extraction + void swapSubtree(XmlElement& other) noexcept + { + name_ .swap(other.name_); + value_ .swap(other.value_); + attributes_ .swap(other.attributes_); + attributesByName_ .swap(other.attributesByName_); + childElements_ .swap(other.childElements_); + childElementByName_.swap(other.childElementByName_); + + for (XmlElement& child : childElements_) + child.parent_ = this; + for (XmlElement& child : other.childElements_) + child.parent_ = &other; + } + +private: + XmlElement (const XmlElement&) = delete; + XmlElement& operator=(const XmlElement&) = delete; + + std::string name_; + std::string value_; + + std::list attributes_; //attributes in order of insertion + std::unordered_map::iterator> attributesByName_; //alternate view for lookup + + std::list childElements_; //child elements in order of insertion + std::unordered_map::iterator> childElementByName_; //alternate view for lookup of (*first*) child by name + + XmlElement* parent_ = nullptr; //currently unused: YAGNI? +}; + + +//XmlElement::setValue() calls zen::writeStruc() which calls XmlElement::setValue() ... => these two specializations end the circle +template <> inline +void XmlElement::setValue(const std::string& value) { value_ = value; } + +template <> inline +bool XmlElement::getValue(std::string& value) const { value = value_; return true; } + + +///The complete XML document +class XmlDoc +{ +public: + ///Default constructor setting up an empty XML document with a standard declaration: + XmlDoc() {} + + XmlDoc(XmlDoc&& tmp) noexcept { swap(tmp); } + XmlDoc& operator=(XmlDoc&& tmp) noexcept { swap(tmp); return *this; } + + //Setup an empty XML document + /** + \param rootName The name of the XML document's root element. + */ + explicit XmlDoc(std::string rootName) : root_(std::move(rootName)) {} + + ///Get a const reference to the document's root element. + const XmlElement& root() const { return root_; } + ///Get a reference to the document's root element. + XmlElement& root() { return root_; } + + ///Get the version used in the XML declaration. + const std::string& getVersion() const { return version_; } + + ///Set the version used in the XML declaration. + void setVersion(const std::string& version) { version_ = version; } + + ///Get the encoding used in the XML declaration. + const std::string& getEncoding() const { return encoding_; } + + ///Set the encoding used in the XML declaration. + void setEncoding(const std::string& encoding) { encoding_ = encoding; } + + ///Get the standalone string used in the XML declaration. + const std::string& getStandalone() const { return standalone_; } + + ///Set the standalone string used in the XML declaration. + void setStandalone(const std::string& standalone) { standalone_ = standalone; } + + //Transactionally swap two elements. -> disabled documentation extraction + void swap(XmlDoc& other) noexcept + { + version_ .swap(other.version_); + encoding_ .swap(other.encoding_); + standalone_.swap(other.standalone_); + root_.swapSubtree(other.root_); + } + +private: + XmlDoc (const XmlDoc&) = delete; //not implemented, thanks to XmlElement::parent_ + XmlDoc& operator=(const XmlDoc&) = delete; + + std::string version_ {"1.0"}; //non-optional for valid XML + std::string encoding_{"utf-8"}; + std::string standalone_; + + XmlElement root_{"Root"}; +}; +} + +#endif //DOM_H_82085720723894567204564256 diff --git a/zenXml/zenxml/parser.h b/zenXml/zenxml/parser.h new file mode 100644 index 0000000..ed87fe0 --- /dev/null +++ b/zenXml/zenxml/parser.h @@ -0,0 +1,576 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef PARSER_H_81248670213764583021432 +#define PARSER_H_81248670213764583021432 + +#include //ptrdiff_t; req. on Linux +#include +#include "dom.h" + + +namespace zen +{ +/** +\file +\brief Convert an XML document object model (class XmlDoc) to and from a byte stream representation. +*/ + +///Save XML document as a byte stream +/** +\param doc Input XML document +\param lineBreak Line break, default: carriage return + new line +\param indent Indentation, default: four space characters +\return Output byte stream +*/ +std::string serializeXml(const XmlDoc& doc, + const std::string& lineBreak = "\r\n", + const std::string& indent = " "); //noexcept + + +///Exception thrown due to an XML parsing error +struct XmlParsingError +{ + XmlParsingError(size_t rowNo, size_t colNo) : row(rowNo), col(colNo) {} + ///Input file row where the parsing error occured (zero-based) + const size_t row; //beginning with 0 + ///Input file column where the parsing error occured (zero-based) + const size_t col; // +}; + +///Load XML document from a byte stream +/** +\param stream Input byte stream +\returns Output XML document +\throw XmlParsingError +*/ +XmlDoc parseXml(const std::string& stream); //throw XmlParsingError + + + + + + + + + + + + + + + + + + + + +//---------------------------- implementation ---------------------------- +//see: https://www.w3.org/TR/xml/ + +namespace xml_impl +{ +template inline +std::string normalize(const std::string_view& str, Predicate pred) //pred: unary function taking a char, return true if value shall be encoded as hex +{ + std::string output; + for (const char c : str) + switch (c) + { + case '&': output += "&"; break; // + case '<': output += "<"; break; //normalization mandatory: https://www.w3.org/TR/xml/#syntax + case '>': output += ">"; break; // + default: + if (pred(c)) + { + if (c == '\'') output += "'"; + else if (c == '"') output += """; + else + { + output += "&#x"; + const auto [high, low] = hexify(c); + output += high; + output += low; + output += ';'; + } + } + else + output += c; + break; + } + return output; +} + +inline +std::string normalizeName(const std::string& str) +{ + /*const*/ std::string nameFmt = normalize(str, [](const char c) { return isWhiteSpace(c) || c == '=' || c == '/' || c == '\'' || c == '"'; }); + assert(!nameFmt.empty()); + return nameFmt; +} + +inline +std::string normalizeElementValue(const std::string& str) +{ + return normalize(str, [](const char c) { return static_cast(c) < 32; }); +} + +inline +std::string normalizeAttribValue(const std::string& str) +{ + return normalize(str, [](const char c) { return static_cast(c) < 32 || c == '\'' || c == '"'; }); +} + + +template inline +bool checkEntity(CharIterator& first, CharIterator last, const char (&placeholder)[N]) +{ + assert(placeholder[N - 1] == 0); + const ptrdiff_t strLen = N - 1; //don't count null-terminator + if (last - first >= strLen && std::equal(first, first + strLen, placeholder)) + { + first += strLen - 1; + return true; + } + return false; +} + + +namespace +{ +std::string denormalize(const std::string_view& str) +{ + std::string output; + for (auto it = str.begin(); it != str.end(); ++it) + { + const char c = *it; + + if (c == '&') + { + if (checkEntity(it, str.end(), "&")) output += '&'; + else if (checkEntity(it, str.end(), "<")) output += '<'; + else if (checkEntity(it, str.end(), ">")) output += '>'; + else if (checkEntity(it, str.end(), "'")) output += '\''; + else if (checkEntity(it, str.end(), """)) output += '"'; + else if (str.end() - it >= 6 && + it[1] == '#' && + it[2] == 'x' && + isHexDigit(it[3]) && + isHexDigit(it[4]) && + it[5] == ';') + { + output += unhexify(it[3], it[4]); + it += 5; + } + else + output += c; //unexpected char! + } + else if (c == '\r') //map all end-of-line characters to \n https://www.w3.org/TR/xml/#sec-line-ends + { + auto itNext = it + 1; + if (itNext != str.end() && *itNext == '\n') + ++it; + output += '\n'; + } + else + output += c; + } + return output; +} + + +void serialize(const XmlElement& element, std::string& stream, + const std::string& lineBreak, + const std::string& indent, + size_t indentLevel) +{ + const std::string& nameFmt = normalizeName(element.getName()); + + for (size_t i = 0; i < indentLevel; ++i) + stream += indent; + + stream += '<' + nameFmt; + + auto attr = element.getAttributes(); + for (auto it = attr.first; it != attr.second; ++it) + stream += ' ' + normalizeName(it->name) + "=\"" + normalizeAttribValue(it->value) + '"'; + + const auto& children = element.getChildren(); + if (!children.empty()) //structured element + { + //no support for mixed-mode content + stream += '>' + lineBreak; + + for (const XmlElement& el : children) + serialize(el, stream, lineBreak, indent, indentLevel + 1); + + for (size_t i = 0; i < indentLevel; ++i) + stream += indent; + stream += "' + lineBreak; + } + else + { + std::string value; + element.getValue(value); + + if (!value.empty()) //value element + stream += '>' + normalizeElementValue(value) + "' + lineBreak; + else //empty element + stream += "/>" + lineBreak; + } +} +} +} + +inline +std::string serializeXml(const XmlDoc& doc, + const std::string& lineBreak, + const std::string& indent) +{ + std::string output = "" + lineBreak; + + xml_impl::serialize(doc.root(), output, lineBreak, indent, 0 /*indentLevel*/); + return output; +} + +/* +Grammar for XML parser +------------------------------- +document-expression: + + element-expression: + +element-expression: + + pm-expression + +element-list-expression: + + element-expression element-list-expression + +attributes-expression: + + string="string" attributes-expression + +pm-expression: + string + element-list-expression +*/ + +namespace xml_impl +{ +struct Token +{ + enum Type + { + TK_LESS, + TK_GREATER, + TK_LESS_SLASH, + TK_SLASH_GREATER, + TK_EQUAL, + TK_QUOTE, + TK_DECL_BEGIN, + TK_DECL_END, + TK_NAME, + TK_END + }; + + Token(Type t) : type(t) {} + Token(const std::string& txt) : type(TK_NAME), name(txt) {} + Token( std::string&& txt) : type(TK_NAME), name(std::move(txt)) {} + + Type type; + std::string name; //filled if type == TK_NAME +}; + +class Scanner +{ +public: + explicit Scanner(const std::string& stream) : stream_(stream), pos_(stream_.begin()) + { + if (zen::startsWith(stream_, BYTE_ORDER_MARK_UTF8)) + pos_ += BYTE_ORDER_MARK_UTF8.size(); + } + + Token getNextToken() //throw XmlParsingError + { + //skip whitespace + pos_ = std::find_if_not(pos_, stream_.end(), isWhiteSpace); + + if (pos_ == stream_.end()) + return Token::TK_END; + + //skip XML comments + if (startsWith(xmlCommentBegin_)) + { + auto it = std::search(pos_ + xmlCommentBegin_.size(), stream_.end(), xmlCommentEnd_.begin(), xmlCommentEnd_.end()); + if (it != stream_.end()) + { + pos_ = it + xmlCommentEnd_.size(); + return getNextToken(); //throw XmlParsingError + } + } + + for (auto it = tokens_.begin(); it != tokens_.end(); ++it) + if (startsWith(it->first)) + { + pos_ += it->first.size(); + return it->second; + } + + const auto itNameEnd = std::find_if(pos_, stream_.end(), [](const char c) + { + return c == '<' || + c == '>' || + c == '=' || + c == '/' || + c == '\'' || + c == '"' || + isWhiteSpace(c); + }); + + if (itNameEnd != pos_) + { + const std::string_view name = makeStringView(pos_, itNameEnd); + pos_ = itNameEnd; + return denormalize(name); + } + + //unknown token + throw XmlParsingError(posRow(), posCol()); + } + + std::string extractElementValue() + { + auto it = std::find_if(pos_, stream_.end(), [](const char c) + { + return c == '<' || + c == '>'; + }); + const std::string_view output = makeStringView(pos_, it); + pos_ = it; + return denormalize(output); + } + + std::string extractAttributeValue() + { + auto it = std::find_if(pos_, stream_.end(), [](const char c) + { + return c == '<' || + c == '>' || + c == '\'' || + c == '"'; + }); + const std::string_view output = makeStringView(pos_, it); + pos_ = it; + return denormalize(output); + } + + size_t posRow() const //current row beginning with 0 + { + const size_t crSum = std::count(stream_.begin(), pos_, '\r'); //carriage returns + const size_t nlSum = std::count(stream_.begin(), pos_, '\n'); //new lines + assert(crSum == 0 || nlSum == 0 || crSum == nlSum); + return std::max(crSum, nlSum); //be compatible with Linux/Mac/Win + } + + size_t posCol() const //current col beginning with 0 + { + //seek beginning of line + for (auto it = pos_; it != stream_.begin(); ) + { + --it; + if (isLineBreak(*it)) + return pos_ - it - 1; + } + return pos_ - stream_.begin(); + } + +private: + Scanner (const Scanner&) = delete; + Scanner& operator=(const Scanner&) = delete; + + bool startsWith(const std::string& prefix) const + { + return zen::startsWith(makeStringView(pos_, stream_.end()), prefix); + } + + using TokenList = std::vector>; + const TokenList tokens_ + { + {"", Token::TK_DECL_END }, + {"", Token::TK_SLASH_GREATER}, + {"<", Token::TK_LESS }, //evaluate after TK_DECL_BEGIN! + {">", Token::TK_GREATER }, + {"=", Token::TK_EQUAL }, + {"\"", Token::TK_QUOTE }, + {"\'", Token::TK_QUOTE }, + }; + + const std::string xmlCommentBegin_ = ""; + + const std::string stream_; + std::string::const_iterator pos_; +}; + + +class XmlParser +{ +public: + explicit XmlParser(const std::string& stream) : + scn_(stream), + tk_(scn_.getNextToken()) {} //throw XmlParsingError + + XmlDoc parse() //throw XmlParsingError + { + XmlDoc doc; + + //declaration (optional) + if (token().type == Token::TK_DECL_BEGIN) + { + nextToken(); //throw XmlParsingError + + while (token().type == Token::TK_NAME) + { + std::string attribName = token().name; + nextToken(); //throw XmlParsingError + + consumeToken(Token::TK_EQUAL); //throw XmlParsingError + expectToken (Token::TK_QUOTE); // + std::string attribValue = scn_.extractAttributeValue(); + nextToken(); //throw XmlParsingError + + consumeToken(Token::TK_QUOTE); //throw XmlParsingError + + if (attribName == "version") + doc.setVersion(attribValue); + else if (attribName == "encoding") + doc.setEncoding(attribValue); + else if (attribName == "standalone") + doc.setStandalone(attribValue); + } + consumeToken(Token::TK_DECL_END); //throw XmlParsingError + } + + XmlElement dummy; + parseChildElements(dummy); + + const auto& children = dummy.getChildren(); + if (!children.empty()) + doc.root().swapSubtree(*children.begin()); + + expectToken(Token::TK_END); //throw XmlParsingError + return doc; + } + +private: + XmlParser (const XmlParser&) = delete; + XmlParser& operator=(const XmlParser&) = delete; + + void parseChildElements(XmlElement& parent) + { + while (token().type == Token::TK_LESS) + { + nextToken(); //throw XmlParsingError + + expectToken(Token::TK_NAME); //throw XmlParsingError + const std::string elementName = token().name; + nextToken(); //throw XmlParsingError + + XmlElement& newElement = parent.addChild(elementName); + + parseAttributes(newElement); + + if (token().type == Token::TK_SLASH_GREATER) //empty element + { + nextToken(); //throw XmlParsingError + continue; + } + + expectToken(Token::TK_GREATER); //throw XmlParsingError + std::string elementValue = scn_.extractElementValue(); + nextToken(); //throw XmlParsingError + + //no support for mixed-mode content + if (token().type == Token::TK_LESS) //structure-element + parseChildElements(newElement); + else //value-element + newElement.setValue(std::move(elementValue)); + + consumeToken(Token::TK_LESS_SLASH); //throw XmlParsingError + + expectToken(Token::TK_NAME); //throw XmlParsingError + if (token().name != elementName) + throw XmlParsingError(scn_.posRow(), scn_.posCol()); + nextToken(); //throw XmlParsingError + + consumeToken(Token::TK_GREATER); //throw XmlParsingError + } + } + + void parseAttributes(XmlElement& element) + { + while (token().type == Token::TK_NAME) + { + std::string attribName = token().name; + nextToken(); //throw XmlParsingError + + consumeToken(Token::TK_EQUAL); //throw XmlParsingError + expectToken (Token::TK_QUOTE); // + std::string attribValue = scn_.extractAttributeValue(); + nextToken(); //throw XmlParsingError + + consumeToken(Token::TK_QUOTE); //throw XmlParsingError + element.setAttribute(std::move(attribName), attribValue); + } + } + + const Token& token() const { return tk_; } + + void nextToken() { tk_ = scn_.getNextToken(); } //throw XmlParsingError + + void expectToken(Token::Type t) //throw XmlParsingError + { + if (token().type != t) + throw XmlParsingError(scn_.posRow(), scn_.posCol()); + } + + void consumeToken(Token::Type t) //throw XmlParsingError + { + expectToken(t); //throw XmlParsingError + nextToken(); // + } + + Scanner scn_; + Token tk_; +}; +} + +inline +XmlDoc parseXml(const std::string& stream) //throw XmlParsingError +{ + return xml_impl::XmlParser(stream).parse(); //throw XmlParsingError +} +} + +#endif //PARSER_H_81248670213764583021432 diff --git a/zenXml/zenxml/xml.h b/zenXml/zenxml/xml.h new file mode 100644 index 0000000..03974fa --- /dev/null +++ b/zenXml/zenxml/xml.h @@ -0,0 +1,405 @@ +// ***************************************************************************** +// * 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 * +// ***************************************************************************** + +#ifndef XML_H_349578228034572457454554 +#define XML_H_349578228034572457454554 + +#include +#include +#include "cvrt_struc.h" +#include "parser.h" + + +/// The zen::Xml namespace +namespace zen +{ +/** +\file +\brief Save and load byte streams from files +*/ + +///Load XML document from a file +/** +Load and parse XML byte stream. Quick-exit if (potentially large) input file is not an XML. + +\param filePath Input file path +\returns The loaded XML document +\throw FileError +*/ +namespace +{ +XmlDoc loadXml(const Zstring& filePath) //throw FileError +{ + FileInputPlain fileIn(filePath); //throw FileError, ErrorFileLocked + std::string headBuf; + const size_t headSizeMin = BYTE_ORDER_MARK_UTF8.size() + strLength(""); + + const std::string buf = unbufferedLoad([&](void* buffer, size_t bytesToRead) + { + const size_t bytesRead = fileIn.tryRead(buffer, bytesToRead); //throw FileError; may return short, only 0 means EOF! => CONTRACT: bytesToRead > 0! + + //quick test whether input is an XML: avoid loading large binary files up front! + if (headBuf.size() < headSizeMin) + { + headBuf.append(static_cast(buffer), std::min(headSizeMin - headBuf.size(), bytesRead)); + + if (headBuf.size() == headSizeMin) + { + std::string_view header = headBuf; + if (startsWith(header, BYTE_ORDER_MARK_UTF8)) + header.remove_prefix(BYTE_ORDER_MARK_UTF8.size()); //keep headBuf.size()! + + if (!startsWith(header, "")) + throw FileError(replaceCpy(_("File %x does not contain a valid configuration."), L"%x", fmtPath(filePath))); + } + } + return bytesRead; + }, + fileIn.getBlockSize()); //throw FileError + + try + { + return parseXml(buf); //throw XmlParsingError + } + catch (const XmlParsingError& e) + { + throw FileError( + replaceCpy(replaceCpy(replaceCpy(_("Error parsing file %x, row %y, column %z."), + L"%x", fmtPath(filePath)), + L"%y", formatNumber(e.row + 1)), + L"%z", formatNumber(e.col + 1))); + } +} +} + + +///Save XML document to a file +/** +Serialize XML to byte stream and save to file. + +\param doc The XML document to save +\param filePath Output file path +\throw FileError +*/ +inline +void saveXml(const XmlDoc& doc, const Zstring& filePath) //throw FileError +{ + const std::string stream = serializeXml(doc); //noexcept + + try //only update XML file if there are changes + { + if (getFileSize(filePath) == stream.size()) //throw FileError + if (getFileContent(filePath, nullptr /*notifyUnbufferedIO*/) == stream) //throw FileError + return; + } + catch (FileError&) {} + + setFileContent(filePath, stream, nullptr /*notifyUnbufferedIO*/); //throw FileError +} + + +///Proxy class to conveniently convert user data into XML structure +class XmlOut +{ +public: + ///Construct an output proxy for an XML document + /** + \code + zen::XmlDoc doc; + + zen::XmlOut out(doc); + out["elem1"]( 1); // + out["elem2"]( 2); //write data into XML elements + out["elem3"](-3); // + + saveXml(doc, "out.xml"); //throw FileError + \endcode + Output: + \verbatim + + + 1 + 2 + -3 + + \endverbatim + */ + explicit XmlOut(XmlDoc& doc) : ref_(doc.root()) {} + + ///Retrieve a handle to an XML child element for writing + /** + The child element will be created if it is not yet existing. + \param name The name of the child element + */ + XmlOut operator[](std::string name) const + { + XmlElement* child = ref_.getChild(name); + return XmlOut(child ? *child : ref_.addChild(std::move(name))); + } + + ///Retrieve a handle to an XML child element for writing + /** + The child element will be added, allowing for multiple elements with the same name. + \tparam String Arbitrary string-like type: e.g. std::string, wchar_t*, char[], wchar_t, wxString, MyStringClass, ... + \param name The name of the child element + */ + XmlOut addChild(std::string name) const + { + return XmlOut(ref_.addChild(std::move(name))); + } + + ///Write user data to the underlying XML element + /** + This conversion requires a specialization of zen::writeText() or zen::writeStruc() for type T. + \tparam T User type that is converted into an XML element value. + */ + template + void operator()(const T& value) { writeStruc(value, ref_); } + + ///Write user data to an XML attribute + /** + This conversion requires a specialization of zen::writeText() for type T. + \code + zen::XmlDoc doc; + + zen::XmlOut out(doc); + out["elem"].attribute("attr1", 1); // + out["elem"].attribute("attr2", 2); //write data into XML attributes + out["elem"].attribute("attr3", -3); // + + saveXml(doc, "out.xml"); //throw FileError + \endcode + Output: + \verbatim + + + + + \endverbatim + + \tparam T String-convertible user data type: e.g. any string-like type, all built-in arithmetic numbers + \sa XmlElement::setAttribute() + */ + template + void attribute(std::string name, const T& value) { ref_.setAttribute(std::move(name), value); } + +private: + ///Construct an output proxy for a single XML element + /** + \sa XmlOut(XmlDoc& doc) + */ + explicit XmlOut(XmlElement& element) : ref_(element) {} + + XmlElement& ref_; +}; + + +///Proxy class to conveniently convert XML structure to user data +class XmlIn +{ + struct ErrorLog; + +public: + ///Construct an input proxy for an XML document + /** + \code + zen::XmlDoc doc; + ... //load document + zen::XmlIn in(doc); + in["elem1"](value1); // + in["elem2"](value2); //read data from XML elements into variables "value1", "value2", "value3" + in["elem3"](value3); // + \endcode + */ + explicit XmlIn(const XmlDoc& doc) : XmlIn(&doc.root(), '<' + doc.root().getName() + '>', makeSharedRef()) {} + + ///Retrieve a handle to an XML child element for reading + /** + It is \b not an error if the child element does not exist, but only later if a conversion to user data is attempted. + \param name The name of the child element + */ + XmlIn operator[](const std::string& name) const + { + return XmlIn(elem_ ? elem_->getChild(name) : nullptr, elementNameFmt_ + " <" + name + '>', log_); + } + + ///Iterate over XML child elements + /** + Example: Loop over all XML child elements + \verbatim + + + 1 + 3 + 5 + + \endverbatim + + \code + zen::XmlIn in(doc); + ... + in.visitChildren([&](const XmlIn& inChild) + { + ... + }); + \endcode + */ + template + void visitChildren(Function fun) + { + if (!elem_) + logMissingElement(); + else if (std::string value; elem_->getValue(value) && !value.empty()) + logConversionError(); //have XML value element, not container! + else + { + size_t childIdx = 0; + for (const XmlElement& child : elem_->getChildren()) + fun(XmlIn(&child, elementNameFmt_ + " <" + child.getName() + ">[" + numberTo(++childIdx) + ']', log_)); + } + } + + ///Test whether the underlying XML element exists + /** + \code + XmlIn in(doc); + XmlIn child = in["elem1"]; + if (child) + ... + \endcode + Use member pointer as implicit conversion to bool (C++ Templates - Vandevoorde/Josuttis; chapter 20) + */ + explicit operator bool() const { return elem_; } + + ///Read user data from the underlying XML element + /** + This conversion requires a specialization of zen::readText() or zen::readStruc() for type T. + \tparam T User type that receives the data + \return "true" if data was read successfully + */ + template + bool operator()(T& value) const + { + if (elem_) + { + if (readStruc(*elem_, value)) + return true; + + logConversionError(); + } + else + logMissingElement(); + + return false; + } + + bool hasAttribute(const std::string& name) const + { + return elem_ && elem_->hasAttribute(name); + } + + ///Read user data from an XML attribute + /** + This conversion requires a specialization of zen::readText() for type T. + + \code + zen::XmlDoc doc; + ... //load document + zen::XmlIn in(doc); + in["elem"].attribute("attr1", value1); // + in["elem"].attribute("attr2", value2); //read data from XML attributes into variables "value1", "value2", "value3" + in["elem"].attribute("attr3", value3); // + \endcode + + \tparam String Arbitrary string-like type: e.g. std::string, wchar_t*, char[], wchar_t, wxString, MyStringClass, ... + \tparam T String-convertible user data type: e.g. any string-like type, all built-in arithmetic numbers + \returns "true" if the attribute was found and the conversion to the output value was successful. + \sa XmlElement::getAttribute() + */ + template + bool attribute(const std::string& name, T& value) const + { + if (elem_) + { + if (elem_->getAttribute(name, value)) + return true; + + logMissingAttribute(name); + } + else + logMissingElement(); + + return false; + } + + ///Notifies errors while mapping the XML to user data + /** + Error logging is shared by each hiearchy of XmlIn proxy instances that are created from each other. Consequently it doesn't matter which instance you query for errors: + \code + XmlIn in(doc); + XmlIn inItem = in["item1"]; + + int value = 0; + inItem(value); //let's assume this conversion failed + + assert(in.getErrors() == inItem.getErrors()); + \endcode + + Note that error logging is \b NOT global, but owned by all instances of a hierarchy of XmlIn proxies. + Therefore it's safe to use unrelated XmlIn proxies in different threads. + */ + + ///Get a list of XML element and attribute names which failed to convert to user data. + /** + \returns A list of XML element and attribute names, empty if no errors occured. + */ + const std::wstring& getErrors() const { return log_.ref().failedElements; } + + ///Retrieve the name of this XML element. + /** + \returns Name of the XML element. + */ + const std::string* getName() const + { + if (elem_) + return &elem_->getName(); + return nullptr; + } + +private: + XmlIn(const XmlElement* elem, + const std::string& elementNameFmt, + const SharedRef& sharedlog) : log_(sharedlog), elem_(elem), elementNameFmt_(elementNameFmt) {} + + struct ErrorLog + { + std::wstring failedElements; //unique list of failed elements + std::unordered_set usedElements; + }; + + void logElementError(const std::string& elementName) const + { + if (const auto [it, inserted] = log_.ref().usedElements.insert(elementName); + inserted) + { + if (!log_.ref().failedElements.empty()) + log_.ref().failedElements += L'\n'; + log_.ref().failedElements += utfTo(elementName); + } + } + + void logConversionError() const { logElementError(elementNameFmt_); } + void logMissingElement() const { logElementError(elementNameFmt_); } + void logMissingAttribute(const std::string& attribName) const { logElementError(elementNameFmt_ + " @" + attribName); } + + mutable SharedRef log_; + const XmlElement* elem_; + std::string elementNameFmt_; //e.g. " [1]" +}; +} + +#endif //XML_H_349578228034572457454554

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