From d8bbc508e959d8d38c54a6db6cfc868bd64697f5 Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Mon, 26 Feb 2024 23:18:15 +0100 Subject: Disallow referring to OV audio unless channel counts match (#2780). --- src/lib/dcp_content.cc | 8 ++++++++ src/wx/audio_panel.cc | 1 + 2 files changed, 9 insertions(+) diff --git a/src/lib/dcp_content.cc b/src/lib/dcp_content.cc index 6185b3a19..bdd5e0e09 100644 --- a/src/lib/dcp_content.cc +++ b/src/lib/dcp_content.cc @@ -720,6 +720,14 @@ DCPContent::can_reference_audio (shared_ptr film, string& why_not) c return false; } + if (audio && audio->stream()) { + auto const channels = audio->stream()->channels(); + if (channels != film->audio_channels()) { + why_not = String::compose(_("it has a different number of audio channels than the project; set the project to have %1 channels."), channels); + return false; + } + } + /// TRANSLATORS: this string will follow "Cannot reference this DCP: " return can_reference( film, [](shared_ptr c) { diff --git a/src/wx/audio_panel.cc b/src/wx/audio_panel.cc index f0863431a..fcadd0c68 100644 --- a/src/wx/audio_panel.cc +++ b/src/wx/audio_panel.cc @@ -208,6 +208,7 @@ AudioPanel::film_changed (FilmProperty property) case FilmProperty::AUDIO_PROCESSOR: _mapping->set_output_channels (_parent->film()->audio_output_names ()); setup_peak (); + setup_sensitivity(); break; case FilmProperty::VIDEO_FRAME_RATE: setup_description (); -- cgit v1.2.3 From efc6f0d2148441f3a30e2a66349d66461bb986f9 Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Tue, 27 Feb 2024 00:57:51 +0100 Subject: Add Ctrl+scrollwheel zoom to the timeline (#2781). --- src/wx/timeline.cc | 38 ++++++++++++++++++++++++++++++++++++++ src/wx/timeline.h | 3 +++ 2 files changed, 41 insertions(+) diff --git a/src/wx/timeline.cc b/src/wx/timeline.cc index 4683769d4..f67bf52dc 100644 --- a/src/wx/timeline.cc +++ b/src/wx/timeline.cc @@ -109,6 +109,7 @@ Timeline::Timeline(wxWindow* parent, ContentPanel* cp, shared_ptr film, Fi _main_canvas->Bind (wxEVT_RIGHT_DOWN, boost::bind (&Timeline::right_down, this, _1)); _main_canvas->Bind (wxEVT_MOTION, boost::bind (&Timeline::mouse_moved, this, _1)); _main_canvas->Bind (wxEVT_SIZE, boost::bind (&Timeline::resized, this)); + _main_canvas->Bind (wxEVT_MOUSEWHEEL, boost::bind(&Timeline::mouse_wheel_turned, this, _1)); _main_canvas->Bind (wxEVT_SCROLLWIN_TOP, boost::bind (&Timeline::scrolled, this, _1)); _main_canvas->Bind (wxEVT_SCROLLWIN_BOTTOM, boost::bind (&Timeline::scrolled, this, _1)); _main_canvas->Bind (wxEVT_SCROLLWIN_LINEUP, boost::bind (&Timeline::scrolled, this, _1)); @@ -132,6 +133,43 @@ Timeline::Timeline(wxWindow* parent, ContentPanel* cp, shared_ptr film, Fi } +void +Timeline::mouse_wheel_turned(wxMouseEvent& event) +{ + if (event.ControlDown()) { + auto const rotation = event.GetWheelRotation(); + /* On my mouse one click of the scroll wheel is 120, and it's -ve when + * scrolling the wheel towards me. + */ + auto const scale = rotation > 0 ? + (1.0 / (rotation / 90.0)) : + (-rotation / 90.0); + + int before_start_x; + int before_start_y; + _main_canvas->GetViewStart(&before_start_x, &before_start_y); + + auto const before_pps = _pixels_per_second.get_value_or(1); + auto const before_pos = _last_mouse_wheel_x && *_last_mouse_wheel_x == event.GetX() ? + *_last_mouse_wheel_time : + (before_start_x * _x_scroll_rate + event.GetX()) / before_pps; + + set_pixels_per_second(before_pps * scale); + setup_scrollbars(); + + auto after_left = std::max(0.0, before_pos * _pixels_per_second.get_value_or(1) - event.GetX()); + _main_canvas->Scroll(after_left / _x_scroll_rate, before_start_y); + _labels_canvas->Scroll(0, before_start_y); + Refresh(); + + if (!_last_mouse_wheel_x || *_last_mouse_wheel_x != event.GetX()) { + _last_mouse_wheel_x = event.GetX(); + _last_mouse_wheel_time = before_pos; + } + } +} + + void Timeline::update_playhead () { diff --git a/src/wx/timeline.h b/src/wx/timeline.h index 2485e835f..621609fa7 100644 --- a/src/wx/timeline.h +++ b/src/wx/timeline.h @@ -110,6 +110,7 @@ private: void set_pixels_per_track (int h); void zoom_all (); void update_playhead (); + void mouse_wheel_turned(wxMouseEvent& event); std::shared_ptr event_to_view (wxMouseEvent &); TimelineContentViewList selected_views () const; @@ -143,6 +144,8 @@ private: int _pixels_per_track; bool _first_resize; wxTimer _timer; + boost::optional _last_mouse_wheel_x; + boost::optional _last_mouse_wheel_time; static double const _minimum_pixels_per_second; static int const _minimum_pixels_per_track; -- cgit v1.2.3 From 5f62647ae8896b62439fa6c29b66fd640072838a Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Tue, 27 Feb 2024 01:11:52 +0100 Subject: Add Shift+scrollwheel to move left/right in the timeline. --- src/wx/timeline.cc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wx/timeline.cc b/src/wx/timeline.cc index f67bf52dc..38e9de4ee 100644 --- a/src/wx/timeline.cc +++ b/src/wx/timeline.cc @@ -136,8 +136,9 @@ Timeline::Timeline(wxWindow* parent, ContentPanel* cp, shared_ptr film, Fi void Timeline::mouse_wheel_turned(wxMouseEvent& event) { + auto const rotation = event.GetWheelRotation(); + if (event.ControlDown()) { - auto const rotation = event.GetWheelRotation(); /* On my mouse one click of the scroll wheel is 120, and it's -ve when * scrolling the wheel towards me. */ @@ -166,6 +167,12 @@ Timeline::mouse_wheel_turned(wxMouseEvent& event) _last_mouse_wheel_x = event.GetX(); _last_mouse_wheel_time = before_pos; } + } else if (event.ShiftDown()) { + int before_start_x; + int before_start_y; + _main_canvas->GetViewStart(&before_start_x, &before_start_y); + auto const width = _main_canvas->GetSize().GetWidth(); + _main_canvas->Scroll(std::max(0.0, before_start_x - rotation * 100.0 / width), before_start_y); } } -- cgit v1.2.3 From 54010eb460cb1038ded4177154fa63947f766a05 Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Fri, 1 Mar 2024 13:42:50 +0100 Subject: Build for Ubuntu 24.04. --- cscript | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/cscript b/cscript index 222a911b5..84b6076e6 100644 --- a/cscript +++ b/cscript @@ -38,6 +38,9 @@ for v in ['22.04']: for v in ['23.04', '23.10']: deb_build_depends[v] = copy.deepcopy(deb_build_depends_base) deb_build_depends[v].extend(['libssh-dev', 'python3.11']) +for v in ['24.04']: + deb_build_depends[v] = copy.deepcopy(deb_build_depends_base) + deb_build_depends[v].extend(['libssh-dev', 'python3.12']) for v in ['9', '10']: deb_build_depends[v] = copy.deepcopy(deb_build_depends_base) deb_build_depends[v].extend(['libssh-gcrypt-dev', 'python']) @@ -185,6 +188,30 @@ deb_depends['23.10'].extend(['libboost-filesystem1.74.0', 'libwxgtk3.2-1', 'libwxgtk-gl3.2-1']) +def debs(boost, icu, x264): + output = copy.deepcopy(deb_depends_base) + output.extend(['libboost-filesystem' + boost, + 'libboost-thread' + boost, + 'libboost-regex' + boost, + 'libboost-date-time' + boost, + 'libcairomm-1.0-1v5', + 'libpangomm-1.4-1v5', + 'libxml++2.6-2v5', + 'libzip4', + 'libicu' + icu, + 'libnettle8', + 'libssh-4', + 'libx264-' + x264, + 'libcurl4', + 'libpulse0', + 'libxerces-c3.2', + 'libnanomsg5', + 'libwxgtk3.2-1', + 'libwxgtk-gl3.2-1']) + return output + +deb_depends['24.04'] = debs(boost='1.83.0', icu='74', x264='164') + deb_depends['9'] = copy.deepcopy(deb_depends_base) deb_depends['9'].extend(['libboost-filesystem1.62.0', 'libboost-thread1.62.0', @@ -728,7 +755,7 @@ def package_debian(target, cpu, version, options): target.set('CDIST_CONFIGURE', '"' + configure_options(target, options, for_package=True) + '"') target.set('CDIST_PACKAGE', f'dcpomatic{suffix}') - target.set('CDIST_WX_VERSION', "3.2" if target.version in ("23.04", "23.10") else "3.1") + target.set('CDIST_WX_VERSION', "3.2" if target.version in ("23.04", "23.10", "24.04") else "3.1") if not target.debug: target.set('CDIST_DEBUG_PACKAGE_FLAG', '--no-ddebs') -- cgit v1.2.3 From 08eb6cc9958c200ae90d376cb521bde930144c09 Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Mon, 4 Mar 2024 22:34:52 +0100 Subject: Add a couple more Dolby certificate location / filename pairs. --- src/wx/dolby_doremi_certificate_panel.cc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wx/dolby_doremi_certificate_panel.cc b/src/wx/dolby_doremi_certificate_panel.cc index d290535c2..4d8845402 100644 --- a/src/wx/dolby_doremi_certificate_panel.cc +++ b/src/wx/dolby_doremi_certificate_panel.cc @@ -116,11 +116,21 @@ try_imb(vector& locations, string prefix, string serial) static void try_ims(vector& locations, string prefix, string serial) { + locations.push_back({ + String::compose("%1%2xxx/Dolby-IMS1000-%3.dcicerts.zip", prefix, serial.substr(0, 3), serial), + String::compose("Dolby-IMS1000-%1.cert.sha256.pem", serial) + }); + locations.push_back({ String::compose("%1%2xxx/Dolby-IMS2000-%3.dcicerts.zip", prefix, serial.substr(0, 3), serial), String::compose("Dolby-IMS2000-%1.cert.sha256.pem", serial) }); + locations.push_back({ + String::compose("%1%2xxx/cert_Dolby-IMS3000-%3-SMPTE.zip", prefix, serial.substr(0, 3), serial), + String::compose("cert_Dolby-IMS3000-%1-SMPTE.pem", serial) + }); + locations.push_back({ String::compose("%1%2xxx/ims-%3.dcicerts.zip", prefix, serial.substr(0, 3), serial), String::compose("ims-%1.cert.sha256.pem", serial) -- cgit v1.2.3 From f610a8708f11d6052a995f31cd506bc93faafa1c Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Thu, 7 Mar 2024 16:12:22 +0100 Subject: Rename Emailer -> Email. --- src/lib/email.cc | 300 +++++++++++++++++++++++++++++++++ src/lib/email.h | 73 ++++++++ src/lib/emailer.cc | 300 --------------------------------- src/lib/emailer.h | 73 -------- src/lib/kdm_cli.cc | 4 +- src/lib/kdm_with_metadata.cc | 6 +- src/lib/send_notification_email_job.cc | 4 +- src/lib/send_problem_report_job.cc | 6 +- src/lib/wscript | 2 +- src/tools/dcpomatic.cc | 6 +- src/wx/full_config_dialog.cc | 6 +- 11 files changed, 390 insertions(+), 390 deletions(-) create mode 100644 src/lib/email.cc create mode 100644 src/lib/email.h delete mode 100644 src/lib/emailer.cc delete mode 100644 src/lib/emailer.h diff --git a/src/lib/email.cc b/src/lib/email.cc new file mode 100644 index 000000000..5017f4d28 --- /dev/null +++ b/src/lib/email.cc @@ -0,0 +1,300 @@ +/* + Copyright (C) 2015-2021 Carl Hetherington + + This file is part of DCP-o-matic. + + DCP-o-matic is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + DCP-o-matic 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DCP-o-matic. If not, see . + +*/ + + +#include "compose.hpp" +#include "config.h" +#include "email.h" +#include "exceptions.h" +#include +#include +#include + +#include "i18n.h" + + +using std::cout; +using std::min; +using std::pair; +using std::shared_ptr; +using std::string; +using std::vector; +using dcp::ArrayData; + + +Email::Email(string from, vector to, string subject, string body) + : _from (from) + , _to (to) + , _subject (subject) + , _body (fix (body)) + , _offset (0) +{ + +} + + +string +Email::fix(string s) const +{ + boost::algorithm::replace_all (s, "\n", "\r\n"); + boost::algorithm::replace_all (s, "\0", " "); + return s; +} + + +void +Email::add_cc(string cc) +{ + _cc.push_back (cc); +} + + +void +Email::add_bcc(string bcc) +{ + _bcc.push_back (bcc); +} + + +void +Email::add_attachment(boost::filesystem::path file, string name, string mime_type) +{ + Attachment a; + a.file = file; + a.name = name; + a.mime_type = mime_type; + _attachments.push_back (a); +} + + +static size_t +curl_data_shim (void* ptr, size_t size, size_t nmemb, void* userp) +{ + return reinterpret_cast(userp)->get_data (ptr, size, nmemb); +} + + +static int +curl_debug_shim (CURL* curl, curl_infotype type, char* data, size_t size, void* userp) +{ + return reinterpret_cast(userp)->debug (curl, type, data, size); +} + + +size_t +Email::get_data(void* ptr, size_t size, size_t nmemb) +{ + size_t const t = min (_email.length() - _offset, size * nmemb); + memcpy (ptr, _email.substr (_offset, t).c_str(), t); + _offset += t; + return t; +} + + +void +Email::send(string server, int port, EmailProtocol protocol, string user, string password) +{ + char date_buffer[128]; + time_t now = time (0); + strftime (date_buffer, sizeof(date_buffer), "%a, %d %b %Y %H:%M:%S ", localtime(&now)); + + auto const utc_now = boost::posix_time::second_clock::universal_time (); + auto const local_now = boost::date_time::c_local_adjustor::utc_to_local (utc_now); + auto offset = local_now - utc_now; + sprintf (date_buffer + strlen(date_buffer), "%s%02d%02d", (offset.hours() >= 0 ? "+" : "-"), int(abs(offset.hours())), int(offset.minutes())); + + _email = "Date: " + string(date_buffer) + "\r\n" + "To: " + address_list (_to) + "\r\n" + "From: " + _from + "\r\n"; + + if (!_cc.empty()) { + _email += "Cc: " + address_list(_cc) + "\r\n"; + } + + if (!_bcc.empty()) { + _email += "Bcc: " + address_list(_bcc) + "\r\n"; + } + + string const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; + string boundary; + for (int i = 0; i < 32; ++i) { + boundary += chars[rand() % chars.length()]; + } + + if (!_attachments.empty ()) { + _email += "MIME-Version: 1.0\r\n" + "Content-Type: multipart/mixed; boundary=" + boundary + "\r\n"; + } + + _email += "Subject: " + encode_rfc1342(_subject) + "\r\n" + "User-Agent: DCP-o-matic\r\n" + "\r\n"; + + if (!_attachments.empty ()) { + _email += "--" + boundary + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n"; + } + + _email += _body; + + for (auto i: _attachments) { + _email += "\r\n\r\n--" + boundary + "\r\n" + "Content-Type: " + i.mime_type + "; name=" + encode_rfc1342(i.name) + "\r\n" + "Content-Transfer-Encoding: Base64\r\n" + "Content-Disposition: attachment; filename=" + encode_rfc1342(i.name) + "\r\n\r\n"; + + auto b64 = BIO_new (BIO_f_base64()); + if (!b64) { + throw std::bad_alloc(); + } + + auto bio = BIO_new (BIO_s_mem()); + if (!bio) { + throw std::bad_alloc(); + } + bio = BIO_push (b64, bio); + + ArrayData data (i.file); + BIO_write (bio, data.data(), data.size()); + (void) BIO_flush (bio); + + char* out; + long int bytes = BIO_get_mem_data (bio, &out); + _email += fix (string (out, bytes)); + + BIO_free_all (b64); + } + + if (!_attachments.empty ()) { + _email += "\r\n--" + boundary + "--\r\n"; + } + + curl_global_init (CURL_GLOBAL_DEFAULT); + + auto curl = curl_easy_init (); + if (!curl) { + throw NetworkError ("Could not initialise libcurl"); + } + + if ((protocol == EmailProtocol::AUTO && port == 465) || protocol == EmailProtocol::SSL) { + /* "SSL" or "Implicit TLS"; I think curl wants us to use smtps here */ + curl_easy_setopt (curl, CURLOPT_URL, String::compose("smtps://%1:%2", server, port).c_str()); + } else { + curl_easy_setopt (curl, CURLOPT_URL, String::compose("smtp://%1:%2", server, port).c_str()); + } + + if (!user.empty ()) { + curl_easy_setopt (curl, CURLOPT_USERNAME, user.c_str ()); + } + if (!password.empty ()) { + curl_easy_setopt (curl, CURLOPT_PASSWORD, password.c_str()); + } + + curl_easy_setopt (curl, CURLOPT_MAIL_FROM, _from.c_str()); + + struct curl_slist* recipients = nullptr; + for (auto i: _to) { + recipients = curl_slist_append (recipients, i.c_str()); + } + for (auto i: _cc) { + recipients = curl_slist_append (recipients, i.c_str()); + } + for (auto i: _bcc) { + recipients = curl_slist_append (recipients, i.c_str()); + } + + curl_easy_setopt (curl, CURLOPT_MAIL_RCPT, recipients); + + curl_easy_setopt (curl, CURLOPT_READFUNCTION, curl_data_shim); + curl_easy_setopt (curl, CURLOPT_READDATA, this); + curl_easy_setopt (curl, CURLOPT_UPLOAD, 1L); + + if (protocol == EmailProtocol::AUTO || protocol == EmailProtocol::STARTTLS) { + curl_easy_setopt (curl, CURLOPT_USE_SSL, (long) CURLUSESSL_TRY); + } + curl_easy_setopt (curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt (curl, CURLOPT_SSL_VERIFYHOST, 0L); + curl_easy_setopt (curl, CURLOPT_VERBOSE, 1L); + curl_easy_setopt (curl, CURLOPT_DEBUGFUNCTION, curl_debug_shim); + curl_easy_setopt (curl, CURLOPT_DEBUGDATA, this); + + auto const r = curl_easy_perform (curl); + if (r != CURLE_OK) { + throw NetworkError (_("Failed to send email"), string(curl_easy_strerror(r))); + } + + curl_slist_free_all (recipients); + curl_easy_cleanup (curl); + curl_global_cleanup (); +} + + +string +Email::address_list(vector addresses) +{ + string o; + for (auto i: addresses) { + o += i + ", "; + } + + return o.substr (0, o.length() - 2); +} + + +int +Email::debug(CURL *, curl_infotype type, char* data, size_t size) +{ + if (type == CURLINFO_TEXT) { + _notes += string (data, size); + } else if (type == CURLINFO_HEADER_IN) { + _notes += "<- " + string (data, size); + } else if (type == CURLINFO_HEADER_OUT) { + _notes += "-> " + string (data, size); + } + return 0; +} + + +string +Email::encode_rfc1342(string subject) +{ + auto b64 = BIO_new(BIO_f_base64()); + if (!b64) { + throw std::bad_alloc(); + } + + auto bio = BIO_new(BIO_s_mem()); + if (!bio) { + throw std::bad_alloc(); + } + + bio = BIO_push(b64, bio); + BIO_write(bio, subject.c_str(), subject.length()); + (void) BIO_flush(bio); + + char* out; + long int bytes = BIO_get_mem_data(bio, &out); + string base64_subject(out, bytes); + BIO_free_all(b64); + + boost::algorithm::replace_all(base64_subject, "\n", ""); + return "=?utf-8?B?" + base64_subject + "?="; +} + diff --git a/src/lib/email.h b/src/lib/email.h new file mode 100644 index 000000000..3bcb8af4a --- /dev/null +++ b/src/lib/email.h @@ -0,0 +1,73 @@ +/* + Copyright (C) 2015-2021 Carl Hetherington + + This file is part of DCP-o-matic. + + DCP-o-matic is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + DCP-o-matic 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DCP-o-matic. If not, see . + +*/ + + +#include +#include + + +class Email +{ +public: + Email(std::string from, std::vector to, std::string subject, std::string body); + + void add_cc (std::string cc); + void add_bcc (std::string bcc); + void add_attachment (boost::filesystem::path file, std::string name, std::string mime_type); + + void send (std::string server, int port, EmailProtocol protocol, std::string user = "", std::string password = ""); + + std::string notes () const { + return _notes; + } + + size_t get_data (void* ptr, size_t size, size_t nmemb); + int debug (CURL* curl, curl_infotype type, char* data, size_t size); + + /** @return full email, after send() has been called */ + std::string email () const { + return _email; + } + + static std::string address_list(std::vector addresses); + +private: + + std::string fix (std::string s) const; + static std::string encode_rfc1342 (std::string subject); + + std::string _from; + std::vector _to; + std::string _subject; + std::string _body; + std::vector _cc; + std::vector _bcc; + + struct Attachment { + boost::filesystem::path file; + std::string name; + std::string mime_type; + }; + + std::vector _attachments; + std::string _email; + size_t _offset; + std::string _notes; +}; diff --git a/src/lib/emailer.cc b/src/lib/emailer.cc deleted file mode 100644 index f580e3c56..000000000 --- a/src/lib/emailer.cc +++ /dev/null @@ -1,300 +0,0 @@ -/* - Copyright (C) 2015-2021 Carl Hetherington - - This file is part of DCP-o-matic. - - DCP-o-matic is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - DCP-o-matic 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 General Public License for more details. - - You should have received a copy of the GNU General Public License - along with DCP-o-matic. If not, see . - -*/ - - -#include "compose.hpp" -#include "config.h" -#include "emailer.h" -#include "exceptions.h" -#include -#include -#include - -#include "i18n.h" - - -using std::cout; -using std::min; -using std::pair; -using std::shared_ptr; -using std::string; -using std::vector; -using dcp::ArrayData; - - -Emailer::Emailer(string from, vector to, string subject, string body) - : _from (from) - , _to (to) - , _subject (subject) - , _body (fix (body)) - , _offset (0) -{ - -} - - -string -Emailer::fix (string s) const -{ - boost::algorithm::replace_all (s, "\n", "\r\n"); - boost::algorithm::replace_all (s, "\0", " "); - return s; -} - - -void -Emailer::add_cc (string cc) -{ - _cc.push_back (cc); -} - - -void -Emailer::add_bcc (string bcc) -{ - _bcc.push_back (bcc); -} - - -void -Emailer::add_attachment (boost::filesystem::path file, string name, string mime_type) -{ - Attachment a; - a.file = file; - a.name = name; - a.mime_type = mime_type; - _attachments.push_back (a); -} - - -static size_t -curl_data_shim (void* ptr, size_t size, size_t nmemb, void* userp) -{ - return reinterpret_cast(userp)->get_data (ptr, size, nmemb); -} - - -static int -curl_debug_shim (CURL* curl, curl_infotype type, char* data, size_t size, void* userp) -{ - return reinterpret_cast(userp)->debug (curl, type, data, size); -} - - -size_t -Emailer::get_data (void* ptr, size_t size, size_t nmemb) -{ - size_t const t = min (_email.length() - _offset, size * nmemb); - memcpy (ptr, _email.substr (_offset, t).c_str(), t); - _offset += t; - return t; -} - - -void -Emailer::send (string server, int port, EmailProtocol protocol, string user, string password) -{ - char date_buffer[128]; - time_t now = time (0); - strftime (date_buffer, sizeof(date_buffer), "%a, %d %b %Y %H:%M:%S ", localtime(&now)); - - auto const utc_now = boost::posix_time::second_clock::universal_time (); - auto const local_now = boost::date_time::c_local_adjustor::utc_to_local (utc_now); - auto offset = local_now - utc_now; - sprintf (date_buffer + strlen(date_buffer), "%s%02d%02d", (offset.hours() >= 0 ? "+" : "-"), int(abs(offset.hours())), int(offset.minutes())); - - _email = "Date: " + string(date_buffer) + "\r\n" - "To: " + address_list (_to) + "\r\n" - "From: " + _from + "\r\n"; - - if (!_cc.empty()) { - _email += "Cc: " + address_list(_cc) + "\r\n"; - } - - if (!_bcc.empty()) { - _email += "Bcc: " + address_list(_bcc) + "\r\n"; - } - - string const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; - string boundary; - for (int i = 0; i < 32; ++i) { - boundary += chars[rand() % chars.length()]; - } - - if (!_attachments.empty ()) { - _email += "MIME-Version: 1.0\r\n" - "Content-Type: multipart/mixed; boundary=" + boundary + "\r\n"; - } - - _email += "Subject: " + encode_rfc1342(_subject) + "\r\n" - "User-Agent: DCP-o-matic\r\n" - "\r\n"; - - if (!_attachments.empty ()) { - _email += "--" + boundary + "\r\n" - + "Content-Type: text/plain; charset=utf-8\r\n\r\n"; - } - - _email += _body; - - for (auto i: _attachments) { - _email += "\r\n\r\n--" + boundary + "\r\n" - "Content-Type: " + i.mime_type + "; name=" + encode_rfc1342(i.name) + "\r\n" - "Content-Transfer-Encoding: Base64\r\n" - "Content-Disposition: attachment; filename=" + encode_rfc1342(i.name) + "\r\n\r\n"; - - auto b64 = BIO_new (BIO_f_base64()); - if (!b64) { - throw std::bad_alloc(); - } - - auto bio = BIO_new (BIO_s_mem()); - if (!bio) { - throw std::bad_alloc(); - } - bio = BIO_push (b64, bio); - - ArrayData data (i.file); - BIO_write (bio, data.data(), data.size()); - (void) BIO_flush (bio); - - char* out; - long int bytes = BIO_get_mem_data (bio, &out); - _email += fix (string (out, bytes)); - - BIO_free_all (b64); - } - - if (!_attachments.empty ()) { - _email += "\r\n--" + boundary + "--\r\n"; - } - - curl_global_init (CURL_GLOBAL_DEFAULT); - - auto curl = curl_easy_init (); - if (!curl) { - throw NetworkError ("Could not initialise libcurl"); - } - - if ((protocol == EmailProtocol::AUTO && port == 465) || protocol == EmailProtocol::SSL) { - /* "SSL" or "Implicit TLS"; I think curl wants us to use smtps here */ - curl_easy_setopt (curl, CURLOPT_URL, String::compose("smtps://%1:%2", server, port).c_str()); - } else { - curl_easy_setopt (curl, CURLOPT_URL, String::compose("smtp://%1:%2", server, port).c_str()); - } - - if (!user.empty ()) { - curl_easy_setopt (curl, CURLOPT_USERNAME, user.c_str ()); - } - if (!password.empty ()) { - curl_easy_setopt (curl, CURLOPT_PASSWORD, password.c_str()); - } - - curl_easy_setopt (curl, CURLOPT_MAIL_FROM, _from.c_str()); - - struct curl_slist* recipients = nullptr; - for (auto i: _to) { - recipients = curl_slist_append (recipients, i.c_str()); - } - for (auto i: _cc) { - recipients = curl_slist_append (recipients, i.c_str()); - } - for (auto i: _bcc) { - recipients = curl_slist_append (recipients, i.c_str()); - } - - curl_easy_setopt (curl, CURLOPT_MAIL_RCPT, recipients); - - curl_easy_setopt (curl, CURLOPT_READFUNCTION, curl_data_shim); - curl_easy_setopt (curl, CURLOPT_READDATA, this); - curl_easy_setopt (curl, CURLOPT_UPLOAD, 1L); - - if (protocol == EmailProtocol::AUTO || protocol == EmailProtocol::STARTTLS) { - curl_easy_setopt (curl, CURLOPT_USE_SSL, (long) CURLUSESSL_TRY); - } - curl_easy_setopt (curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt (curl, CURLOPT_SSL_VERIFYHOST, 0L); - curl_easy_setopt (curl, CURLOPT_VERBOSE, 1L); - curl_easy_setopt (curl, CURLOPT_DEBUGFUNCTION, curl_debug_shim); - curl_easy_setopt (curl, CURLOPT_DEBUGDATA, this); - - auto const r = curl_easy_perform (curl); - if (r != CURLE_OK) { - throw NetworkError (_("Failed to send email"), string(curl_easy_strerror(r))); - } - - curl_slist_free_all (recipients); - curl_easy_cleanup (curl); - curl_global_cleanup (); -} - - -string -Emailer::address_list(vector addresses) -{ - string o; - for (auto i: addresses) { - o += i + ", "; - } - - return o.substr (0, o.length() - 2); -} - - -int -Emailer::debug (CURL *, curl_infotype type, char* data, size_t size) -{ - if (type == CURLINFO_TEXT) { - _notes += string (data, size); - } else if (type == CURLINFO_HEADER_IN) { - _notes += "<- " + string (data, size); - } else if (type == CURLINFO_HEADER_OUT) { - _notes += "-> " + string (data, size); - } - return 0; -} - - -string -Emailer::encode_rfc1342 (string subject) -{ - auto b64 = BIO_new(BIO_f_base64()); - if (!b64) { - throw std::bad_alloc(); - } - - auto bio = BIO_new(BIO_s_mem()); - if (!bio) { - throw std::bad_alloc(); - } - - bio = BIO_push(b64, bio); - BIO_write(bio, subject.c_str(), subject.length()); - (void) BIO_flush(bio); - - char* out; - long int bytes = BIO_get_mem_data(bio, &out); - string base64_subject(out, bytes); - BIO_free_all(b64); - - boost::algorithm::replace_all(base64_subject, "\n", ""); - return "=?utf-8?B?" + base64_subject + "?="; -} - diff --git a/src/lib/emailer.h b/src/lib/emailer.h deleted file mode 100644 index 78942ad1e..000000000 --- a/src/lib/emailer.h +++ /dev/null @@ -1,73 +0,0 @@ -/* - Copyright (C) 2015-2021 Carl Hetherington - - This file is part of DCP-o-matic. - - DCP-o-matic is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - DCP-o-matic 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 General Public License for more details. - - You should have received a copy of the GNU General Public License - along with DCP-o-matic. If not, see . - -*/ - - -#include -#include - - -class Emailer -{ -public: - Emailer(std::string from, std::vector to, std::string subject, std::string body); - - void add_cc (std::string cc); - void add_bcc (std::string bcc); - void add_attachment (boost::filesystem::path file, std::string name, std::string mime_type); - - void send (std::string server, int port, EmailProtocol protocol, std::string user = "", std::string password = ""); - - std::string notes () const { - return _notes; - } - - size_t get_data (void* ptr, size_t size, size_t nmemb); - int debug (CURL* curl, curl_infotype type, char* data, size_t size); - - /** @return full email, after send() has been called */ - std::string email () const { - return _email; - } - - static std::string address_list(std::vector addresses); - -private: - - std::string fix (std::string s) const; - static std::string encode_rfc1342 (std::string subject); - - std::string _from; - std::vector _to; - std::string _subject; - std::string _body; - std::vector _cc; - std::vector _bcc; - - struct Attachment { - boost::filesystem::path file; - std::string name; - std::string mime_type; - }; - - std::vector _attachments; - std::string _email; - size_t _offset; - std::string _notes; -}; diff --git a/src/lib/kdm_cli.cc b/src/lib/kdm_cli.cc index ddc77e771..2d3a021b5 100644 --- a/src/lib/kdm_cli.cc +++ b/src/lib/kdm_cli.cc @@ -27,7 +27,7 @@ #include "cinema.h" #include "config.h" #include "dkdm_wrapper.h" -#include "emailer.h" +#include "email.h" #include "exceptions.h" #include "film.h" #include "kdm_with_metadata.h" @@ -610,7 +610,7 @@ try if (list_cinemas) { auto cinemas = Config::instance()->cinemas (); for (auto i: cinemas) { - out (String::compose("%1 (%2)", i->name, Emailer::address_list (i->emails))); + out (String::compose("%1 (%2)", i->name, Email::address_list(i->emails))); } return {}; } diff --git a/src/lib/kdm_with_metadata.cc b/src/lib/kdm_with_metadata.cc index 10054f01e..e7d9aa31c 100644 --- a/src/lib/kdm_with_metadata.cc +++ b/src/lib/kdm_with_metadata.cc @@ -23,7 +23,7 @@ #include "config.h" #include "cross.h" #include "dcpomatic_log.h" -#include "emailer.h" +#include "email.h" #include "kdm_with_metadata.h" #include "screen.h" #include "util.h" @@ -253,7 +253,7 @@ send_emails ( continue; } - Emailer email (config->kdm_from(), { emails.front() }, subject, body); + Email email(config->kdm_from(), { emails.front() }, subject, body); /* Use CC for the second and subsequent email addresses, so we seem less spammy (#2310) */ for (auto cc = std::next(emails.begin()); cc != emails.end(); ++cc) { @@ -269,7 +269,7 @@ send_emails ( email.add_attachment (zip_file, container_name_format.get(first->name_values(), ".zip"), "application/zip"); - auto log_details = [](Emailer& email) { + auto log_details = [](Email& email) { dcpomatic_log->log("Email content follows", LogEntry::TYPE_DEBUG_EMAIL); dcpomatic_log->log(email.email(), LogEntry::TYPE_DEBUG_EMAIL); dcpomatic_log->log("Email session follows", LogEntry::TYPE_DEBUG_EMAIL); diff --git a/src/lib/send_notification_email_job.cc b/src/lib/send_notification_email_job.cc index f40e8fefa..a2f3016f9 100644 --- a/src/lib/send_notification_email_job.cc +++ b/src/lib/send_notification_email_job.cc @@ -22,7 +22,7 @@ #include "send_notification_email_job.h" #include "exceptions.h" #include "config.h" -#include "emailer.h" +#include "email.h" #include "compose.hpp" #include "i18n.h" @@ -71,7 +71,7 @@ SendNotificationEmailJob::run () } set_progress_unknown (); - Emailer email (config->notification_from(), { config->notification_to() }, config->notification_subject(), _body); + Email email(config->notification_from(), { config->notification_to() }, config->notification_subject(), _body); for (auto i: config->notification_cc()) { email.add_cc (i); } diff --git a/src/lib/send_problem_report_job.cc b/src/lib/send_problem_report_job.cc index 34822b156..9569aca3b 100644 --- a/src/lib/send_problem_report_job.cc +++ b/src/lib/send_problem_report_job.cc @@ -26,7 +26,7 @@ #include "film.h" #include "log.h" #include "version.h" -#include "emailer.h" +#include "email.h" #include "environment_info.h" #include @@ -108,8 +108,8 @@ SendProblemReportJob::run () body += "---<8----\n"; } - Emailer emailer (_from, {"carl@dcpomatic.com"}, "DCP-o-matic problem report", body); - emailer.send ("main.carlh.net", 2525, EmailProtocol::STARTTLS); + Email email(_from, {"carl@dcpomatic.com"}, "DCP-o-matic problem report", body); + email.send("main.carlh.net", 2525, EmailProtocol::STARTTLS); set_progress (1); set_state (FINISHED_OK); diff --git a/src/lib/wscript b/src/lib/wscript index 0d61d7a69..7dd3bfe35 100644 --- a/src/lib/wscript +++ b/src/lib/wscript @@ -86,7 +86,7 @@ sources = """ dkdm_recipient.cc dkdm_wrapper.cc dolby_cp750.cc - emailer.cc + email.cc empty.cc encoder.cc encode_server.cc diff --git a/src/tools/dcpomatic.cc b/src/tools/dcpomatic.cc index 459ce341c..1b1ef0629 100644 --- a/src/tools/dcpomatic.cc +++ b/src/tools/dcpomatic.cc @@ -71,7 +71,7 @@ #include "lib/dcpomatic_log.h" #include "lib/dcpomatic_socket.h" #include "lib/dkdm_wrapper.h" -#include "lib/emailer.h" +#include "lib/email.h" #include "lib/encode_server_finder.h" #include "lib/exceptions.h" #include "lib/ffmpeg_encoder.h" @@ -1127,9 +1127,9 @@ private: error_dialog (this, _("You must enter a valid email address when sending translations, " "otherwise the DCP-o-matic maintainers cannot credit you or contact you with questions.")); } else { - Emailer emailer(dialog.email(), { "carl@dcpomatic.com" }, "DCP-o-matic translations", body); + Email email(dialog.email(), { "carl@dcpomatic.com" }, "DCP-o-matic translations", body); try { - emailer.send ("main.carlh.net", 2525, EmailProtocol::STARTTLS); + email.send("main.carlh.net", 2525, EmailProtocol::STARTTLS); } catch (NetworkError& e) { error_dialog (this, _("Could not send translations"), std_to_wx(e.what())); } diff --git a/src/wx/full_config_dialog.cc b/src/wx/full_config_dialog.cc index 4c26d038c..c1c36c4a4 100644 --- a/src/wx/full_config_dialog.cc +++ b/src/wx/full_config_dialog.cc @@ -49,7 +49,7 @@ #include "lib/config.h" #include "lib/cross.h" #include "lib/dcp_content_type.h" -#include "lib/emailer.h" +#include "lib/email.h" #include "lib/exceptions.h" #include "lib/filter.h" #include "lib/log.h" @@ -980,7 +980,7 @@ private: return; } - Emailer emailer( + Email email( wx_to_std(dialog.from()), { wx_to_std(dialog.to()) }, wx_to_std(_("DCP-o-matic test email")), @@ -988,7 +988,7 @@ private: ); auto config = Config::instance(); try { - emailer.send(config->mail_server(), config->mail_port(), config->mail_protocol(), config->mail_user(), config->mail_password()); + email.send(config->mail_server(), config->mail_port(), config->mail_protocol(), config->mail_user(), config->mail_password()); } catch (NetworkError& e) { error_dialog(_panel, std_to_wx(e.summary()), std_to_wx(e.detail().get_value_or(""))); return; -- cgit v1.2.3 From 0a93c0df8fd6a40b627cc53d51a249628db6b795 Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Thu, 7 Mar 2024 16:40:03 +0100 Subject: Store attachment contents in the Email object. --- src/lib/email.cc | 5 ++--- src/lib/email.h | 3 ++- src/lib/kdm_with_metadata.cc | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/email.cc b/src/lib/email.cc index 5017f4d28..8557b40e0 100644 --- a/src/lib/email.cc +++ b/src/lib/email.cc @@ -77,7 +77,7 @@ void Email::add_attachment(boost::filesystem::path file, string name, string mime_type) { Attachment a; - a.file = file; + a.file = dcp::ArrayData(file); a.name = name; a.mime_type = mime_type; _attachments.push_back (a); @@ -171,8 +171,7 @@ Email::send(string server, int port, EmailProtocol protocol, string user, string } bio = BIO_push (b64, bio); - ArrayData data (i.file); - BIO_write (bio, data.data(), data.size()); + BIO_write(bio, i.file.data(), i.file.size()); (void) BIO_flush (bio); char* out; diff --git a/src/lib/email.h b/src/lib/email.h index 3bcb8af4a..36398bfd8 100644 --- a/src/lib/email.h +++ b/src/lib/email.h @@ -30,6 +30,7 @@ public: void add_cc (std::string cc); void add_bcc (std::string bcc); + /** Add attachment, copying the contents of the file into memory */ void add_attachment (boost::filesystem::path file, std::string name, std::string mime_type); void send (std::string server, int port, EmailProtocol protocol, std::string user = "", std::string password = ""); @@ -61,7 +62,7 @@ private: std::vector _bcc; struct Attachment { - boost::filesystem::path file; + dcp::ArrayData file; std::string name; std::string mime_type; }; diff --git a/src/lib/kdm_with_metadata.cc b/src/lib/kdm_with_metadata.cc index e7d9aa31c..971eeac76 100644 --- a/src/lib/kdm_with_metadata.cc +++ b/src/lib/kdm_with_metadata.cc @@ -268,6 +268,7 @@ send_emails ( } email.add_attachment (zip_file, container_name_format.get(first->name_values(), ".zip"), "application/zip"); + dcp::filesystem::remove(zip_file); auto log_details = [](Email& email) { dcpomatic_log->log("Email content follows", LogEntry::TYPE_DEBUG_EMAIL); @@ -279,13 +280,10 @@ send_emails ( try { email.send (config->mail_server(), config->mail_port(), config->mail_protocol(), config->mail_user(), config->mail_password()); } catch (...) { - dcp::filesystem::remove(zip_file); log_details (email); throw; } log_details (email); - - dcp::filesystem::remove(zip_file); } } -- cgit v1.2.3 From 9bebb9724c5b7f254e3cea62a5cdb3c5e0e8571e Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Thu, 7 Mar 2024 21:03:31 +0100 Subject: Use dcp_name (probably ISDCF name) for CPL_NAME in KDM emails (#2787). --- src/wx/dkdm_dialog.cc | 2 +- src/wx/kdm_dialog.cc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wx/dkdm_dialog.cc b/src/wx/dkdm_dialog.cc index 26f521573..34a8d6284 100644 --- a/src/wx/dkdm_dialog.cc +++ b/src/wx/dkdm_dialog.cc @@ -178,7 +178,7 @@ DKDMDialog::make_clicked () return; } - auto result = _output->make (kdms, film->name(), bind(&DKDMDialog::confirm_overwrite, this, _1)); + auto result = _output->make(kdms, film->dcp_name(), bind(&DKDMDialog::confirm_overwrite, this, _1)); if (result.first) { JobManager::instance()->add (result.first); } diff --git a/src/wx/kdm_dialog.cc b/src/wx/kdm_dialog.cc index c88a1ac40..5ab13b4ce 100644 --- a/src/wx/kdm_dialog.cc +++ b/src/wx/kdm_dialog.cc @@ -227,7 +227,7 @@ KDMDialog::make_clicked () return; } - auto result = _output->make (kdms, film->name(), bind (&KDMDialog::confirm_overwrite, this, _1)); + auto result = _output->make(kdms, film->dcp_name(), bind (&KDMDialog::confirm_overwrite, this, _1)); if (result.first) { JobManager::instance()->add (result.first); } -- cgit v1.2.3 From 04b5957318df591f56e0a5d39720df143dc8230d Mon Sep 17 00:00:00 2001 From: Carl Hetherington Date: Thu, 7 Mar 2024 23:33:03 +0100 Subject: Fix screen name order in KDM emails. --- src/lib/kdm_with_metadata.cc | 9 ++++----- src/lib/util.cc | 28 ++++++++++++++++++++++++++++ src/lib/util.h | 1 + test/util_test.cc | 11 +++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/lib/kdm_with_metadata.cc b/src/lib/kdm_with_metadata.cc index 971eeac76..f7ff84435 100644 --- a/src/lib/kdm_with_metadata.cc +++ b/src/lib/kdm_with_metadata.cc @@ -238,14 +238,13 @@ send_emails ( auto subject = substitute_variables(config->kdm_subject()); auto body = substitute_variables(config->kdm_email()); - string screens; + vector screens; for (auto kdm: kdms_for_cinema) { - auto screen_name = kdm->get('s'); - if (screen_name) { - screens += *screen_name + ", "; + if (auto screen_name = kdm->get('s')) { + screens.push_back(*screen_name); } } - boost::algorithm::replace_all (body, "$SCREENS", screens.substr (0, screens.length() - 2)); + boost::algorithm::replace_all(body, "$SCREENS", screen_names_to_string(screens)); auto emails = first->emails(); std::copy(extra_addresses.begin(), extra_addresses.end(), std::back_inserter(emails)); diff --git a/src/lib/util.cc b/src/lib/util.cc index 7f6e9da5a..fe6602de3 100644 --- a/src/lib/util.cc +++ b/src/lib/util.cc @@ -1123,3 +1123,31 @@ word_wrap(string input, int columns) return output; } + +string +screen_names_to_string(vector names) +{ + if (names.empty()) { + return {}; + } + + auto number = [](string const& s) { + return s.find_first_not_of("0123456789") == string::npos; + }; + + if (std::find_if(names.begin(), names.end(), [number](string const& s) { return !number(s); }) == names.end()) { + std::sort(names.begin(), names.end(), [](string const& a, string const& b) { + return dcp::raw_convert(a) < dcp::raw_convert(b); + }); + } else { + std::sort(names.begin(), names.end()); + } + + string result; + for (auto const& name: names) { + result += name + ", "; + } + + return result.substr(0, result.length() - 2); +} + diff --git a/src/lib/util.h b/src/lib/util.h index b92869b25..fd6fd6164 100644 --- a/src/lib/util.h +++ b/src/lib/util.h @@ -97,5 +97,6 @@ extern std::string error_details(boost::system::error_code ec); extern bool contains_assetmap(boost::filesystem::path dir); extern std::string word_wrap(std::string input, int columns); extern void capture_ffmpeg_logs(); +extern std::string screen_names_to_string(std::vector names); #endif diff --git a/test/util_test.cc b/test/util_test.cc index 49d0b3bc2..afcc4cfc9 100644 --- a/test/util_test.cc +++ b/test/util_test.cc @@ -155,3 +155,14 @@ BOOST_AUTO_TEST_CASE(word_wrap_test) BOOST_CHECK(word_wrap("hello this is a longer bit of text and it should be word-wrapped", 31) == string{"hello this is a longer bit of \ntext and it should be word-\nwrapped\n"}); BOOST_CHECK_EQUAL(word_wrap("hellocan'twrapthissadly", 5), "hello\ncan't\nwrapt\nhissa\ndly\n"); } + + +BOOST_AUTO_TEST_CASE(screen_names_to_string_test) +{ + BOOST_CHECK_EQUAL(screen_names_to_string({"1", "2", "3"}), "1, 2, 3"); + BOOST_CHECK_EQUAL(screen_names_to_string({"3", "2", "1"}), "1, 2, 3"); + BOOST_CHECK_EQUAL(screen_names_to_string({"39", "3", "10", "1", "2"}), "1, 2, 3, 10, 39"); + BOOST_CHECK_EQUAL(screen_names_to_string({"Sheila", "Fred", "Jim"}), "Fred, Jim, Sheila"); + BOOST_CHECK_EQUAL(screen_names_to_string({"Sheila", "Fred", "Jim", "1"}), "1, Fred, Jim, Sheila"); +} + -- cgit v1.2.3