1/*
2 * Copyright © 2020 Michael Gratton <mike@vee.net>.
3 *
4 * This software is licensed under the GNU Lesser General Public License
5 * (version 2.1 or later). See the COPYING file in this distribution.
6 */
7
8[ModuleInit]
9public void peas_register_types(TypeModule module) {
10    Peas.ObjectModule obj = module as Peas.ObjectModule;
11    obj.register_extension_type(
12        typeof(Plugin.PluginBase),
13        typeof(Plugin.MailMerge)
14    );
15}
16
17/**
18 * Plugin to fill in and send email templates using a spreadsheet.
19 */
20public class Plugin.MailMerge :
21    PluginBase, FolderExtension, EmailExtension, TrustedExtension {
22
23
24    // Translators: Templates folder name alternatives. Separate names
25    // using a vertical bar and put the most common localized name to
26    // the front for the default. English names do not need to be
27    // included.
28    private const string LOC_NAMES = _(
29        "Templates | Template Mail | Template Email | Template E-Mail"
30    );
31    // This must be identical to he above except without translation
32    private const string UNLOC_NAMES = (
33        "Templates | Template Mail | Template Email | Template E-Mail"
34    );
35
36
37    private const string ACTION_EDIT = "edit-template";
38    private const string ACTION_INSERT_FIELD = "insert-field";
39    private const string ACTION_MERGE = "merge-template";
40    private const string ACTION_LOAD = "load-merge-data";
41    private const string ACTION_START = "start-send";
42    private const string ACTION_PAUSE = "pause-send";
43
44    private const int INFO_BAR_PRIORITY = 10;
45
46
47    public FolderContext folders {
48        get; set construct;
49    }
50
51    public EmailContext email {
52        get; set construct;
53    }
54
55    public global::Application.Client client_application {
56        get; set construct;
57    }
58
59    public global::Application.PluginManager client_plugins {
60        get; set construct;
61    }
62
63    private FolderStore? folder_store = null;
64    private EmailStore? email_store = null;
65
66    private global::MailMerge.Folder? merge_folder = null;
67    private InfoBar merge_bar = null;
68
69    private GLib.SimpleAction? edit_action = null;
70    private GLib.SimpleAction? merge_action = null;
71    private GLib.SimpleAction? start_action = null;
72    private GLib.SimpleAction? pause_action = null;
73
74    private Actionable? start_ui = null;
75    private Actionable? pause_ui = null;
76
77    private Gee.List<string> folder_names = new Gee.ArrayList<string>();
78
79    private GLib.Cancellable cancellable = new GLib.Cancellable();
80
81
82    public override async void activate(bool is_startup) throws GLib.Error {
83        // Add localised first, so if we need to create a folder it
84        // will be created localised.
85        Geary.iterate_array(LOC_NAMES.split("|")).map<string>(
86            (name) => name.strip()
87        ).add_all_to(this.folder_names);
88        Geary.iterate_array(UNLOC_NAMES.split("|")).map<string>(
89            (name) => name.strip()
90        ).add_all_to(this.folder_names);
91
92        this.folder_store = yield this.folders.get_folder_store();
93        this.folder_store.folders_available.connect(on_folders_available);
94        this.folder_store.folder_selected.connect(on_folder_selected);
95
96        this.email_store = yield this.email.get_email_store();
97        this.email_store.email_displayed.connect(on_email_displayed);
98
99        this.edit_action = new GLib.SimpleAction(
100            ACTION_EDIT, this.email_store.email_identifier_variant_type
101        );
102        this.edit_action.activate.connect(on_edit_activated);
103        this.plugin_application.register_action(this.edit_action);
104
105        this.merge_action = new GLib.SimpleAction(
106            ACTION_MERGE, this.email_store.email_identifier_variant_type
107        );
108        this.merge_action.activate.connect(on_merge_activated);
109        this.plugin_application.register_action(this.merge_action);
110
111        this.start_action = new GLib.SimpleAction(ACTION_START, null);
112        this.start_action.activate.connect(on_start_activated);
113        this.plugin_application.register_action(this.start_action);
114
115        this.start_ui = new Actionable.with_icon(
116            // Translators: Info bar label for starting sending a mail
117            // merge
118            _("Start"),
119            "media-playback-start-symbolic",
120            this.start_action
121        );
122
123        this.pause_action = new GLib.SimpleAction(ACTION_PAUSE, null);
124        this.pause_action.activate.connect(on_pause_activated);
125        this.plugin_application.register_action(this.pause_action);
126
127        this.pause_ui = new Actionable.with_icon(
128            // Translators: Info bar label for pausing sending a mail
129            // merge
130            _("Pause"),
131            "media-playback-pause-symbolic",
132            this.pause_action
133        );
134
135        this.plugin_application.composer_registered.connect(
136            this.on_composer_registered
137        );
138    }
139
140    public override async void deactivate(bool is_shutdown) throws GLib.Error {
141        this.cancellable.cancel();
142
143        this.plugin_application.deregister_action(this.edit_action);
144        this.edit_action = null;
145
146        this.plugin_application.deregister_action(this.merge_action);
147        this.merge_action = null;
148
149        this.folder_store = null;
150
151        this.email_store.email_displayed.disconnect(on_email_displayed);
152        this.email_store = null;
153
154        this.folder_names.clear();
155    }
156
157    private async void edit_email(EmailIdentifier id) {
158        try {
159            var composer = yield this.plugin_application.compose_with_context(
160                id.account,
161                Composer.ContextType.EDIT,
162                id
163            );
164            var containing = yield this.folder_store.list_containing_folders(
165                id, this.cancellable
166            );
167            var folder = containing.first_match(
168                (f) => f.display_name in this.folder_names
169            );
170
171            composer.save_to_folder(folder);
172            composer.can_send = false;
173            composer.present();
174        } catch (GLib.Error err) {
175            warning("Unable to construct composer: %s", err.message);
176        }
177    }
178
179    private async void merge_email(EmailIdentifier id,
180                                   GLib.File? default_csv_file) {
181        var csv_file = default_csv_file ?? show_merge_data_chooser();
182        if (csv_file != null) {
183            try {
184                var csv_input = yield csv_file.read_async(
185                    GLib.Priority.DEFAULT,
186                    this.cancellable
187                );
188                var csv = yield new global::MailMerge.Csv.Reader(
189                    csv_input, this.cancellable
190                );
191
192                Gee.Collection<Email> emails = yield this.email_store.get_email(
193                    Geary.Collection.single(id),
194                    this.cancellable
195                );
196                if (!emails.is_empty) {
197                    var account_context = this.client_plugins.to_client_account(
198                        id.account
199                    );
200                    var email = Geary.Collection.first(emails);
201
202                    this.merge_folder = yield new global::MailMerge.Folder(
203                        account_context.account,
204                        account_context.account.local_folder_root,
205                        yield load_merge_email(email),
206                        csv_file,
207                        csv
208                    );
209
210                    this.merge_bar = new InfoBar(
211                        this.merge_folder.data_display_name, ""
212                    );
213                    update_merge_folder_info_bar();
214                    this.merge_bar.show_close_button = true;
215                    this.merge_bar.close_activated.connect(on_merge_closed);
216                    this.merge_folder.notify["email-sent"].connect(
217                        () => { update_merge_folder_info_bar(); }
218                    );
219                    this.merge_folder.notify["email-total"].connect(
220                        () => { update_merge_folder_info_bar(); }
221                    );
222
223                    account_context.account.register_local_folder(
224                        this.merge_folder
225                    );
226                    var main = this.client_application.get_active_main_window();
227                    yield main.select_folder(this.merge_folder, true);
228                }
229            } catch (GLib.Error err) {
230                debug("Displaying merge folder failed: %s", err.message);
231            }
232        }
233    }
234
235    private void update_merge_folder_info_bar() {
236        // Translators: Info bar description for the mail merge
237        // folder. The first string substitution the number of email
238        // already sent, the second is the total number to send.
239        this.merge_bar.description = ngettext(
240            "Sent %u of %u",
241            "Sent %u of %u",
242            this.merge_folder.email_total
243        ).printf(
244            this.merge_folder.email_sent,
245            this.merge_folder.email_total
246        );
247        this.merge_bar.primary_button = (
248            (this.merge_folder.is_sending) ? this.pause_ui : this.start_ui
249        );
250    }
251
252    private async void update_email(Email target) {
253        var containing = Gee.Collection.empty<Folder>();
254        try {
255            containing = yield this.folder_store.list_containing_folders(
256                target.identifier, this.cancellable
257            );
258        } catch (GLib.Error err) {
259            warning("Could not load folders for email: %s", err.message);
260        }
261        if (containing.any_match((f) => f.display_name in this.folder_names)) {
262            try {
263                var email = yield load_merge_email(target);
264                if (global::MailMerge.Processor.is_mail_merge_template(email)) {
265                    this.email.add_email_info_bar(
266                        target.identifier,
267                        new_template_email_info_bar(target.identifier),
268                        INFO_BAR_PRIORITY
269                    );
270                }
271            } catch (GLib.Error err) {
272                warning("Error checking email for merge templates: %s",
273                        err.message);
274            }
275        }
276    }
277
278    private async void update_composer(Composer composer) {
279        if (true) {
280            var load_action = new GLib.SimpleAction(ACTION_LOAD, null);
281            load_action.activate.connect(
282                () => { load_composer_data.begin(composer); }
283            );
284            composer.register_action(load_action);
285            composer.append_menu_item(
286                /// Translators: Menu item label for invoking mail
287                /// merge in composer
288                new Actionable(_("Mail Merge"), load_action)
289            );
290        }
291    }
292
293    private async void load_composer_data(Composer composer) {
294        var data = show_merge_data_chooser();
295        if (data != null) {
296            var insert_field_action = new GLib.SimpleAction(
297                ACTION_INSERT_FIELD,
298                GLib.VariantType.STRING
299            );
300            composer.register_action(insert_field_action);
301            insert_field_action.activate.connect(
302                (param) => {
303                    insert_field(composer, (string) param);
304                }
305            );
306
307            try {
308                composer.set_action_bar(
309                    yield new_composer_action_bar(
310                        data,
311                        composer.action_group_name
312                    )
313                );
314            } catch (GLib.Error err) {
315                debug("Error loading CSV: %s", err.message);
316            }
317        }
318
319    }
320
321    private InfoBar new_template_email_info_bar(EmailIdentifier target) {
322        // Translators: Infobar status label for an email mail merge
323        // template
324        var bar = new InfoBar(_("Mail merge template"));
325        bar.primary_button = new Actionable(
326            // Translators: Info bar button label for performing a
327            // mail-merge on an email template
328            _("Merge"),
329            this.merge_action,
330            target.to_variant()
331        );
332        bar.secondary_buttons.add(
333            new Actionable(
334                // Translators: Info bar button label for editing an
335                // existing email template
336                _("Edit"),
337                this.edit_action,
338                target.to_variant()
339            )
340        );
341        return bar;
342    }
343
344    private async ActionBar new_composer_action_bar(GLib.File csv_file,
345                                                    string action_group_name)
346        throws GLib.Error {
347        var info = yield csv_file.query_info_async(
348            GLib.FileAttribute.STANDARD_DISPLAY_NAME,
349            NONE,
350            GLib.Priority.DEFAULT,
351            this.cancellable
352        );
353        var input = yield csv_file.read_async(
354            GLib.Priority.DEFAULT,
355            this.cancellable
356        );
357        var csv = yield new global::MailMerge.Csv.Reader(
358            input, this.cancellable
359        );
360        var record = yield csv.read_record();
361
362        var text_fields_menu = new GLib.Menu();
363        foreach (var field in record) {
364            text_fields_menu.append(
365                field,
366                GLib.Action.print_detailed_name(
367                    action_group_name + "." + ACTION_INSERT_FIELD,
368                    field
369                )
370            );
371        }
372
373        var action_bar = new ActionBar();
374        action_bar.append_item(
375            /// Translators: Action bar menu button label for
376            /// mail-merge plugin
377            new ActionBar.MenuItem(_("Insert field"), text_fields_menu), START
378        );
379        action_bar.append_item(
380            new ActionBar.LabelItem(info.get_display_name()), START
381        );
382        return action_bar;
383    }
384
385    private GLib.File? show_merge_data_chooser() {
386        var chooser = new Gtk.FileChooserNative(
387            /// Translators: File chooser title after invoking mail
388            /// merge in composer
389            _("Mail Merge"),
390            null, OPEN,
391            _("_Open"),
392            _("_Cancel")
393        );
394        var csv_filter = new Gtk.FileFilter();
395        /// Translators: File chooser filer label
396        csv_filter.set_filter_name(_("Comma separated values (CSV)"));
397        csv_filter.add_mime_type("text/csv");
398        chooser.add_filter(csv_filter);
399
400        return (
401            chooser.run() == Gtk.ResponseType.ACCEPT
402            ? chooser.get_file()
403            : null
404        );
405    }
406
407    private void insert_field(Composer composer, string field) {
408        composer.insert_text(global::MailMerge.Processor.to_field(field));
409    }
410
411    private async Geary.Email load_merge_email(Email plugin) throws GLib.Error {
412        Geary.Email? engine = this.client_plugins.to_engine_email(plugin);
413        if (engine != null &&
414            !engine.fields.fulfills(global::MailMerge.Processor.REQUIRED_FIELDS)) {
415            var account_context = this.client_plugins.to_client_account(
416                plugin.identifier.account
417            );
418            engine = yield account_context.emails.fetch_email_async(
419                engine.id,
420                global::MailMerge.Processor.REQUIRED_FIELDS,
421                Geary.Folder.ListFlags.LOCAL_ONLY,
422                this.cancellable
423            );
424        }
425        if (engine == null) {
426            throw new Geary.EngineError.NOT_FOUND("Plugin email not found");
427        }
428        return engine;
429    }
430
431    private void on_edit_activated(GLib.Action action, GLib.Variant? target) {
432        if (this.email_store != null && target != null) {
433            EmailIdentifier? id =
434                this.email_store.get_email_identifier_for_variant(target);
435            if (id != null) {
436                this.edit_email.begin(id);
437            }
438        }
439    }
440
441    private void on_merge_activated(GLib.Action action, GLib.Variant? target) {
442        if (this.email_store != null && target != null) {
443            EmailIdentifier? id =
444                this.email_store.get_email_identifier_for_variant(target);
445            if (id != null) {
446                this.merge_email.begin(id, null);
447            }
448        }
449    }
450
451    private void on_start_activated(GLib.Action action, GLib.Variant? target) {
452        this.merge_folder.set_sending(true);
453        update_merge_folder_info_bar();
454    }
455
456    private void on_pause_activated(GLib.Action action, GLib.Variant? target) {
457        this.merge_folder.set_sending(false);
458        update_merge_folder_info_bar();
459    }
460
461    private void on_composer_registered(Composer registered) {
462        this.update_composer.begin(registered);
463    }
464
465    private void on_merge_closed() {
466        if (this.merge_folder != null) {
467            try {
468                this.merge_folder.account.deregister_local_folder(
469                    this.merge_folder
470                );
471            } catch (GLib.Error err) {
472                warning("Error de-registering merge folder: %s", err.message);
473            }
474            this.merge_folder = null;
475            this.merge_bar = null;
476        }
477    }
478
479    private void on_folders_available(Gee.Collection<Folder> available) {
480        foreach (var folder in available) {
481            var engine_folder = this.client_plugins.to_engine_folder(folder);
482            if (this.merge_folder == engine_folder) {
483                try {
484                    this.folders.register_folder_used_as(
485                        folder,
486                        // Translators: The name of the folder used to
487                        // display merged email
488                        _("Mail Merge"),
489                        "mail-outbox-symbolic"
490                    );
491                } catch (GLib.Error err) {
492                    warning(
493                        "Failed to register %s as merge folder: %s",
494                        folder.persistent_id,
495                        err.message
496                    );
497                }
498            }
499        }
500    }
501
502    private void on_folder_selected(Folder selected) {
503        var engine_folder = this.client_plugins.to_engine_folder(selected);
504        if (this.merge_folder == engine_folder) {
505            this.folders.add_folder_info_bar(
506                selected, this.merge_bar, INFO_BAR_PRIORITY
507            );
508        }
509    }
510
511    private void on_email_displayed(Email email) {
512        this.update_email.begin(email);
513    }
514
515}
516