#!/usr/bin/perl

###############################################################################
#                                                                             #
# Odot - A task list manager                                                  #
# Copyright (C) 2003-2005 Torsten Schoenfeld                                  #
#                                                                             #
# This program 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.                                                          #
#                                                                             #
# This program 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 this program; if not, write to the Free Software Foundation, Inc., 59  #
# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
#                                                                             #
###############################################################################

# $Id: odot,v 1.44 2005/12/03 18:06:22 torsten Exp $

package main;

use strict;
use warnings;

unless (Gtk2 -> CHECK_VERSION(2, 2, 0)) {
  die "Odot requires at least GTK+ version 2.2.0\n";
}

my $file = undef;

if (@ARGV) {
  $file = $ARGV[0];
}
elsif (-e $ENV{ HOME } . "/.odot") {
  $file = $ENV{ HOME } . "/.odot";
}

Odot -> new($file) -> run();

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Accessors;

use strict;
use warnings;

sub create {
  my ($self, @fields) = @_;
  my ($class) = caller;

  no strict "refs";

  foreach (@fields) {
    my $field = $_;

    my $setter = sub {
      my ($self, $new) = @_;
      $self -> { "_" . $field } = $new;
    };

    my $getter = sub {
      my ($self) = @_;
      return $self -> { "_" . $field };
    };

    *{$class . "::" . "set_" . $_} = $setter;
    *{$class . "::" . "get_" . $_} = $getter;
  }
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot;

use strict;
use warnings;
use open ":utf8";

use Glib qw(TRUE FALSE);
use Gtk2 1.040, -init;
use Gtk2::Gdk::Keysyms;

###############################################################################

use constant DUE_THRESHOLD    => 2;

use constant COLUMN_TASK      => 0;
use constant COLUMN_DUE_DATE  => 1;
use constant COLUMN_WEIGHT    => 2;
use constant COLUMN_STYLE     => 3;
use constant COLUMN_COLOR     => 4;
use constant COLUMN_UNDERLINE => 5;

use constant COLUMNS => [
  {
    column    => COLUMN_TASK,
    type      => "Glib::String",
    renderer  => "Odot::CellRendererText",
    title     => "Task",
    sizing    => "autosize",
    alignment => 0.0,
    expand    => TRUE,
    move      => TRUE
  },
  {
    column    => COLUMN_DUE_DATE,
    type      => "Glib::String",
    renderer  => "Odot::CellRendererDate",
    title     => "Due Date",
    sizing    => "autosize",
    alignment => 1.0,
    expand    => FALSE,
    move      => FALSE
  },
  { column => COLUMN_WEIGHT,    type => "Gtk2::Pango::Weight" },
  { column => COLUMN_STYLE,     type => "Gtk2::Pango::Style" },
  { column => COLUMN_COLOR,     type => "Glib::String" },
  { column => COLUMN_UNDERLINE, type => "Gtk2::Pango::Underline" }
];

###############################################################################

BEGIN {
  Odot::Accessors -> create(qw(backend
                               window
                               view
                               model
                               menu_widgets
                               accel_group
                               box
                               column_widgets));
}

###############################################################################

our $INSTANCES = 0;

sub new {
  my ($class, $source) = @_;

  my $self = bless {}, $class;

  $self -> register_stock_icons();

  $self -> create_window();
  $self -> create_menubar();
  $self -> create_view();
  $self -> create_separator();
  $self -> create_button_box();

  $self -> set_backend(Odot::Backend -> new($self -> get_window(),
                                            $self -> get_view(),
                                            $self -> get_model(),
                                            $self -> get_menu_widgets(),
                                            $self -> get_column_widgets(),
                                            $source));

  $INSTANCES++;

  return $self;
}

###############################################################################

sub register_stock_icons {
  my ($self) = @_;

  my @template = (
    '48 48 2 1',
    ' 	c None',
    '.	c <color>',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................',
    '................................................'
  );

  foreach ([important => "#FF0000"],
           [work => "#FF8C00"],
           [personal => "#008B00"],
           [todo => "#0000FF"],
           [later => "#8B008B"]) {
    my ($id, $color) = @{$_};

    my @copy = @template;
    map { s/<color>/$color/ } @copy;

    Gtk2::Stock -> add({stock_id => $id});
    my $icon_set = Gtk2::IconSet -> new_from_pixbuf(
                     Gtk2::Gdk::Pixbuf -> new_from_xpm_data(@copy));

    my $icon_factory = Gtk2::IconFactory -> new();
    $icon_factory -> add ($id, $icon_set);
    $icon_factory -> add_default();
  }
}

###############################################################################

sub create_window {
  my ($self) = @_;

  my $accel_group = Gtk2::AccelGroup -> new();
  my $window = Gtk2::Window -> new("toplevel");
  my $box = Gtk2::VBox -> new(FALSE, 0);

  $window -> set_default_size(400, 500);
  $window -> add_accel_group($accel_group);

  $window -> signal_connect(delete_event => sub {
    $self -> file_quit();
    return FALSE;
  });

  $window -> signal_connect(window_state_event => sub {
    my ($window, $state) = @_;

    $window -> { _maximized } = $state -> new_window_state & "maximized"
                                  ? TRUE
                                  : FALSE;
  });

  $window -> add($box);

  $self -> set_accel_group($accel_group);
  $self -> set_window($window);
  $self -> set_box($box);
}

###############################################################################

sub create_menubar {
  my ($self) = @_;
  my $factory = Gtk2::ItemFactory -> new("Gtk2::MenuBar", "<Odot>",
                                         $self -> get_accel_group());

  $factory -> create_items(undef,
    {
      path => "/_File",
      item_type => "<Branch>"
    },
    {
      path => "/File/_New",
      callback => sub { $self -> file_new(); },
      item_type => "<StockItem>",
      extra_data => "gtk-new"
    },
    {
      path => "/File/_Open...",
      callback => sub { $self -> file_open(); },
      item_type => "<StockItem>",
      extra_data => "gtk-open"
    },
    {
      path => "/File/_Open DB...",
      callback => sub { $self -> file_open_db(); }
    },
    {
      path => "/File/Sep1",
      item_type => "<Separator>"
    },
    {
      path => "/File/_Save",
      callback => sub { $self -> file_save(); },
      item_type => "<StockItem>",
      extra_data => "gtk-save"
    },
    {
      path => "/File/Save _As...",
      callback => sub { $self -> file_save_as(); },
      item_type => "<StockItem>",
      extra_data => "gtk-save-as"
    },
    {
      path => "/File/Save To _DB...",
      callback => sub { $self -> file_save_as_db(); }
    },
    {
      path => "/File/Sep2",
      item_type => "<Separator>"
    },
    {
      path => "/File/_Quit",
      callback => sub { $self -> file_quit(); },
      item_type => "<StockItem>",
      extra_data => "gtk-quit"
    },
    {
      path => "/_Edit",
      item_type => "<Branch>"
    },
    {
      path => "/Edit/_Undo",
      callback => sub { $self -> edit_undo(); },
      item_type => "<StockItem>",
      extra_data => "gtk-undo",
      accelerator => "<Ctrl>Z"
    },
    {
      path => "/Edit/_Redo",
      callback => sub { $self -> edit_redo(); },
      item_type => "<StockItem>",
      extra_data => "gtk-redo",
      accelerator => "<Shift><Ctrl>Z"
    },
    {
      path => "/Edit/Sep3",
      item_type => "<Separator>"
    },
    {
      path => "/Edit/Cu_t",
      callback => sub { $self -> edit_cut(); },
      item_type => "<StockItem>",
      extra_data => "gtk-cut"
    },
    {
      path => "/Edit/_Copy",
      callback => sub { $self -> edit_copy(); },
      item_type => "<StockItem>",
      extra_data => "gtk-copy"
    },
    {
      path => "/Edit/_Paste",
      callback => sub { $self -> edit_paste(); },
      item_type => "<StockItem>",
      extra_data => "gtk-paste"
    },
    {
      path => "/_View",
      item_type => "<Branch>"
    },
    {
      path => "/View/Column _Headings",
      callback => sub {
        my ($data, $id, $widget) = @_;
        $self -> view_headings($widget -> get_active());
      },
      item_type => "<CheckItem>"
    },
    {
      path => "/View/_Due Date",
      callback => sub {
        my ($data, $id, $widget) = @_;
        $self -> view_due_date($widget -> get_active());
      },
      item_type => "<CheckItem>"
    }
  );

  $self -> get_box() -> pack_start($factory -> get_widget("<Odot>"),
                                   FALSE, FALSE, 0);

  $self -> set_menu_widgets({
    open_db    => $factory -> get_widget("/File/Open DB..."),
    save       => $factory -> get_widget("/File/Save"),
    save_as_db => $factory -> get_widget("/File/Save To DB..."),
    undo       => $factory -> get_widget("/Edit/Undo"),
    redo       => $factory -> get_widget("/Edit/Redo"),
    headings   => $factory -> get_widget("/View/Column Headings"),
    due_date   => $factory -> get_widget("/View/Due Date")
  });
}

###############################################################################

sub create_view {
  my ($self) = @_;

  my $container = Gtk2::ScrolledWindow -> new();
  my $model = Gtk2::TreeStore -> new(map { $_ -> { type } } (@{COLUMNS()}));
  my $view = Gtk2::TreeView -> new($model);

  foreach my $column (@{COLUMNS()}) {
    if (exists $column -> { title }) {
      my $cell = $column -> { renderer } -> new();

      if ($column -> { move }) {
        my $editable = $cell -> get("editable-widget");

        $editable -> signal_connect(key_press_event => sub {
          my ($editable, $event) = @_;

          if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Down } ||
              $event -> keyval == $Gtk2::Gdk::Keysyms{ Up }) {
            $self -> editable_move($editable, $event -> keyval);
            return TRUE;
          }

          return FALSE;
        });

        $editable -> signal_connect(focus_in_event => sub {
          $self -> get_window()
                -> remove_accel_group($self -> get_accel_group());
        });

        $editable -> signal_connect(focus_out_event => sub {
          $self -> get_window()
                -> add_accel_group($self -> get_accel_group());
        });
      }

      # save changes.
      $cell -> signal_connect(edited => sub {
        my ($cell, $path, $new) = @_;

        $self -> get_backend() -> update($column -> { column }, $path, $new);
      });

      # dismiss changes, including undo/redo information.  FIXME: move to the
      # backend?
      if (Gtk2 -> CHECK_VERSION(2, 4, 0)) {
        $cell -> signal_connect(editing_canceled => sub {
          my $stack = $self -> get_backend() -> get_stack();

          # if we're on the air then this node was just added and should thus be
          # removed.
          if ($stack -> get_recording()) {
            my ($model, $iterator) = $view -> get_selection() -> get_selected();

            if (defined $model && defined $iterator) {
              $model -> remove($iterator);
            }

            $stack -> set_recording(FALSE);
          }
        });
      }

      $cell -> set(xalign => $column -> { alignment });
      $cell -> set(editable => TRUE);

      my $view_column = Gtk2::TreeViewColumn -> new_with_attributes(
                          $column -> { title },
                          $cell,
                          text => $column -> { column },
                          weight => COLUMN_WEIGHT,
                          style => COLUMN_STYLE,
                          foreground => COLUMN_COLOR,
                          underline => COLUMN_UNDERLINE);

      $view_column -> set_min_width(100);
      $view_column -> set_sizing($column -> { sizing });
      $view_column -> set_alignment($column -> { alignment });
      if (Gtk2 -> CHECK_VERSION(2, 4, 0)) {
        $view_column -> set_expand($column -> { expand });
      }

      $view_column -> set_sort_column_id($column -> { column });

      # $view_column -> signal_connect(clicked => sub {
      #   my ($view_column) = @_;

      #   my ($sort_column, $sort_order) = $model -> get_sort_column_id();
      #   my ($new_sort_column, $new_sort_order, $new_show_arrow);
      #   if ($sort_column >= 0) {
      #     if ($sort_order eq "ascending") {
      #       $new_sort_column = $sort_column;
      #       $new_sort_order = "descending";
      #       $new_show_arrow = 1;
      #     }
      #     else {
      #       $new_sort_column = -2;
      #       $new_sort_order = "ascending";
      #       $new_show_arrow = 0;
      #     }
      #   }
      #   else {
      #     $new_sort_column = $column -> { column };
      #     $new_sort_order = "ascending";
      #     $new_show_arrow = 1;
      #   }

      #   $model -> set_sort_column_id($new_sort_column, $new_sort_order);
      #   $view_column -> set_sort_indicator($new_show_arrow);
      #   $view_column -> set_sort_order($new_sort_order);
      # });

      $view -> append_column($view_column);
    }
  }

  $self -> set_column_widgets([$view -> get_columns()]);

  $view -> get_selection() -> set_mode("single");
  $view -> set_reorderable(TRUE);
  $view -> set_headers_visible(FALSE);
  $view -> set_headers_clickable(FALSE);

  # Whenever a row is collapsed, find out if it has due children.  If so,
  # highlight it.
  $view -> signal_connect(row_collapsed => sub {
    my ($view, $iterator, $path) = @_;

    $self -> highlight_row($path);
  });

  $view -> signal_connect(row_expanded => sub {
    my ($view, $iterator, $path) = @_;

    $self -> unhighlight_row($iterator);
  });

  # When a drag is initiated, install a one-shot row-inserted handler that
  # highlights the new parent if necessary.
  $view -> signal_connect(drag_begin => sub {
    my ($view) = @_;
    my $model = $self -> get_model();

    my $id;
    $id = $model -> signal_connect(row_inserted => sub {
      my ($view, $path) = @_;

      # $path is the dropped node.  Move it up to the parent and install an
      # idle handler that highlights it if necessary.
      if ($path -> up() && $path -> get_depth() > 0) {
        # We need to use a row reference here because the model might still
        # change before the idle callback will be invoked.  We need an idle
        # callback because the model hasn't been fully updated at this point.
        # We need to copy the path because it would be invalid in the idle
        # callback if we didn't.
        my $reference =
          Gtk2::TreeRowReference -> new($model, $path -> copy());

        Glib::Idle -> add(sub {
          if ($reference -> valid()) {
            $self -> highlight_row($reference -> get_path());
          }

          return FALSE;
        });
      }

      $model -> signal_handler_disconnect($id);
    });
  });

  $view -> signal_connect(drag_begin => sub {
    my ($model, $iterator) = $view -> get_selection() -> get_selected();
    my $backend = $self -> get_backend();

    my $from = $model -> get_path($iterator) -> to_string();
    my $data = $backend -> get_serializer() -> generate($iterator);
    my $to;

    my $row_inserted_id;
    $row_inserted_id = $model -> signal_connect(row_inserted => sub {
      my ($model, $path, $iterator) = @_;
      $to = $model -> get_path($iterator) -> to_string();

      $model -> signal_handler_disconnect($row_inserted_id);
    });

    my $drag_end_id;
    $drag_end_id = $view -> signal_connect(drag_end => sub {
      $backend -> get_stack() -> push({
        action => "move",
        data  => [$from, $to, $data]
      });

      $view -> signal_handler_disconnect($drag_end_id);
    });
  });

  $view -> signal_connect(key_press_event => sub {
    my ($view, $event) = @_;

    if ($event -> state & qw(mod1-mask)) {
      if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Down } ||
          $event -> keyval == $Gtk2::Gdk::Keysyms{ Up }) {
        $self -> node_move($event -> keyval);
        return TRUE;
      }
      elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Right }) {
        $self -> node_indent();
        return TRUE;
      }
      elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Left }) {
        $self -> node_unindent();
        return TRUE;
      }
    }

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Return } ||
        $event -> keyval == $Gtk2::Gdk::Keysyms{ KP_Enter }) {
      if ($event -> state & qw(shift-mask)) {
        my $selection = $view -> get_selection();
        my ($model, $iterator) = $selection -> get_selected();

        if (defined $model && defined $iterator) {
          my $sibling = $model -> insert_after(undef, $iterator);

          $self -> get_backend() -> edit($sibling);

          return TRUE;
        }
      }

      if ($event -> state & qw(control-mask)) {
        $self -> node_add();
        return TRUE;
      }
    }

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Delete }) {
      $self -> node_delete();
      return TRUE;
    } elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Tab }) {
      $self -> node_indent();
      return TRUE;
    } elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ BackSpace }) {
      $self -> node_unindent();
      return TRUE;
    }

    return FALSE;
  });

  $view -> signal_connect(button_press_event => sub {
    my ($view, $event) = @_;

    if ($event -> button == 3 &&
        $event -> window == $view -> get_bin_window()) {
      return $self -> popup($event);
    }

    return FALSE;
  });

  $view -> signal_connect(popup_menu => sub {
    my ($view) = @_;

    $self -> popup();
  });

  $container -> add($view);
  $container -> set_policy("automatic", "automatic");
  $container -> set_border_width(3);

  $self -> set_model($model);
  $self -> set_view($view);

  $self -> get_box() -> pack_start($container, TRUE, TRUE, 0);
}

###############################################################################

sub create_separator {
  my ($self) = @_;

  $self -> get_box() -> pack_start(Gtk2::HSeparator -> new(), FALSE, FALSE, 0);
}

###############################################################################

sub create_button_box {
  my ($self) = @_;

  my $box = Gtk2::HBox -> new(FALSE, 0);

  my $add = Gtk2::Button -> new_from_stock("gtk-add");
  my $delete = Gtk2::Button -> new_from_stock("gtk-delete");

  $add -> signal_connect(clicked => sub {
    $self -> node_add();
  });

  $delete -> signal_connect(clicked => sub {
    $self -> node_delete();
  });

  $box -> set_border_width(2);

  $box -> pack_start($add, TRUE, TRUE, 0);
  $box -> pack_start($delete, TRUE, TRUE, 0);

  $self -> get_box() -> pack_start($box, FALSE, FALSE, 0);
}

###############################################################################

sub run {
  my ($self) = @_;

  $self -> get_window() -> show_all();
  Gtk2 -> main() unless (Gtk2 -> main_level());
}

###############################################################################

sub quit {
  my ($self) = @_;

  $self -> get_window() -> destroy();
  Gtk2 -> main_quit() unless (--$INSTANCES);
}

###############################################################################

sub node_add {
  my ($self) = @_;

  $self -> get_backend() -> add();
}

sub node_delete {
  my ($self) = @_;

  $self -> get_backend() -> delete();
}

sub node_sort {
  my ($self, $recursive) = @_;

  $self -> get_backend() -> sort($recursive);
}

sub node_indent {
  my ($self) = @_;

  $self -> get_backend() -> indent();
}

sub node_unindent {
  my ($self) = @_;

  $self -> get_backend() -> unindent();
}

sub node_label {
  my ($self, $label) = @_;

  $self -> get_backend() -> label($label);
}

sub node_move {
  my ($self, $keyval) = @_;

  $self -> get_backend() -> move($keyval);
}

###############################################################################

sub file_new {
  my ($self) = @_;

  Odot -> new() -> run();
}

sub file_open {
  my ($self) = @_;

  return $self -> get_backend() -> open();
}

sub file_open_db {
  my ($self) = @_;

  return $self -> get_backend() -> open_db();
}

sub file_save {
  my ($self) = @_;

  return $self -> get_backend() -> save();
}

sub file_save_as {
  my ($self) = @_;

  return $self -> get_backend() -> save_as();
}

sub file_save_as_db {
  my ($self) = @_;

  return $self -> get_backend() -> save_as_db();
}

sub file_quit {
  my ($self) = @_;

  if ($self -> get_backend() -> get_changed()) {
    my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                            "modal",
                                            "question",
                                            "none",
                                            "Save changes before exiting?");

    $dialog -> add_buttons("_Exit without Saving" => 0,
                           "gtk-cancel" => 1,
                           "gtk-save" => 2);

    my $response = $dialog -> run();
    $dialog -> destroy();

    if ($response eq "delete-event" || $response == 1) {
      return;
    }
    elsif ($response == 0) {
      $self -> quit();
    }
    elsif ($response == 2) {
      $self -> file_save() && $self -> quit();
    }
  }
  else {
    $self -> quit();
  }
}

###############################################################################

sub edit_undo {
  my ($self) = @_;

  $self -> get_backend() -> undo();
}

sub edit_redo {
  my ($self) = @_;

  $self -> get_backend() -> redo();
}

sub edit_cut {
  my ($self) = @_;

  $self -> get_backend() -> cut();
}

sub edit_copy {
  my ($self) = @_;

  $self -> get_backend() -> copy();
}

sub edit_paste {
  my ($self) = @_;

  $self -> get_backend() -> paste();
}

###############################################################################

sub view_headings {
  my ($self, $active) = @_;
  my $backend = $self -> get_backend();

  # the check is necessary because the widget might be activated before the
  # backend has been created.  FIXME.
  if (defined $backend) {
    $backend -> set_show_headings($active);
  }
}

sub view_due_date {
  my ($self, $active) = @_;
  my $backend = $self -> get_backend();

  # the check is necessary because the widget might be activated before the
  # backend has been created.  FIXME.
  if (defined $backend) {
    $backend -> set_show_due_date($active);
  }
}

###############################################################################

sub path_move {
  my ($self, $keyval, $path, $iterator) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();

  if ($keyval == $Gtk2::Gdk::Keysyms{ Down }) {
    # We have children and are collapsed, move to the first child.
    if ($model -> iter_has_child($iterator) &&
        $view -> row_expanded($path)) {
      $path -> down();
    }
    else {
      # We're on the last child, move up to the parent.  Repeat.  Then down
      # to the next sibling.
      while (($path -> get_indices())[-1] + 1 ==
             $model -> iter_n_children(
               my $parent = $model -> iter_parent($iterator))) {
        $path -> up();
        $iterator = $parent;
      }

      $path -> next();
    }
  }
  elsif ($keyval == $Gtk2::Gdk::Keysyms{ Up }) {
    # We're the first row, do nothing.
    if ($path -> to_string() eq "0") {
      return;
    }
    # We're the first child, move up to the parent.
    elsif (($path -> get_indices())[-1] == 0) {
      $path -> up();
    }
    else {
      $path -> prev();

      my $sibling = $model -> get_iter($path);

      # We moved to a sibling which has children and is expanded;
      # move to its last child.  Repeat.
      while ($model -> iter_has_child($sibling) &&
             $view -> row_expanded($path)) {
        $path = $model -> get_path(
          $sibling = $model -> iter_nth_child(
            $sibling,
            $model -> iter_n_children($sibling) - 1));
      }
    }
  }

  return $path;
}

sub editable_move {
  my ($self, $editable, $keyval) = @_;

  $editable -> editing_done();
  $editable -> remove_widget();

  my $view = $self -> get_view();
  my ($model, $iterator) = $view -> get_selection() -> get_selected();

  if (defined $model && defined $iterator) {
    my $path = $self -> path_move($keyval,
                                  $model -> get_path($iterator),
                                  $iterator);

    Gtk2 -> main_iteration() while (Gtk2 -> events_pending());

    $view -> set_cursor($path, $view -> get_column(COLUMN_TASK), TRUE);
  }
}

sub highlight_row {
  my ($self, $parent_path) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my @due_descendants = ();

  $model -> foreach(sub {
    my ($model, $path, $iterator) = @_;

    if ($parent_path -> is_ancestor($path)) {
      my $due_date = $model -> get($iterator, COLUMN_DUE_DATE);

      if ($self -> get_backend() -> is_due($due_date)) {
        push @due_descendants, $iterator -> copy();
      }
    }

    return FALSE;
  });

  # For every due child, walk up its ancestry and highlight every ancestor
  # which is either a descendant of the collapsed row or that row itself.
  foreach my $iterator (@due_descendants) {
    while (defined($iterator = $model -> iter_parent($iterator))) {
      my $path = $model -> get_path($iterator);

      if (!$view -> row_expanded($path) and
          $parent_path -> is_ancestor($path) ||
          0 == $parent_path -> compare($model -> get_path($iterator))) {
        $model -> set($iterator, COLUMN_UNDERLINE, "single");
      }
    }
  }
}

sub unhighlight_row {
  my ($self, $iterator) = @_;

  $self -> get_model() -> set($iterator, COLUMN_UNDERLINE, "none");
}

sub popup {
  my ($self, $event) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my $selection = $view -> get_selection();

  if (defined $event) {
    my ($path) = $view -> get_path_at_pos($event -> x, $event -> y);

    if (defined $path) {
      $selection -> select_path($path);
    }
  }

  my $item_factory = Gtk2::ItemFactory -> new("Gtk2::Menu", "<OdotPopup>");

  my @menu = (
    {
      path => "/_Add",
      callback => sub { $self -> node_add(); },
      item_type => "<StockItem>",
      extra_data => "gtk-add"
    }
  );

  if (defined $selection -> get_selected()) {
    push @menu, (
      {
        path => "/_Delete",
        callback => sub { $self -> node_delete(); },
        item_type => "<StockItem>",
        extra_data => "gtk-delete"
      }
    );

    unless ($model -> get_sort_column_id()) {
      push @menu, (
        {
          path => "/Separator",
          item_type => "<Separator>"
        },
        {
          path => "/_Sort",
          callback => sub { $self -> node_sort(FALSE); },
          item_type => "<StockItem>",
          extra_data => "gtk-sort-ascending"
        },
        {
          path => "/Sort _Recursively",
          callback => sub { $self -> node_sort(TRUE); },
          item_type => "<StockItem>",
          extra_data => "gtk-sort-ascending"
        }
      );
    }

    push @menu, (
      {
        path => "/Separator",
        item_type => "<Separator>"
      },
      {
        path => "/_Indent",
        callback => sub { $self -> node_indent(); },
        item_type => "<StockItem>",
        extra_data => "gtk-indent"
      },
      {
        path => "/_Unindent",
        callback => sub { $self -> node_unindent(); },
        item_type => "<StockItem>",
        extra_data => "gtk-unindent"
      }
    );

    push @menu, (
      {
        path => "/Separator",
        item_type => "<Separator>"
      },
      {
        path => "/_Label",
        item_type => "<Branch>"
      },
      {
        path => "/Label/_None",
        callback => sub { $self -> node_label("none"); },
        item_type => "<Item>"
      },
      {
        path => "/Label/Separator",
        item_type => "<Separator>"
      },
      {
        path => "/Label/_Important",
        callback => sub { $self -> node_label("important"); },
        item_type => "<StockItem>",
        extra_data => "important"
      },
      {
        path => "/Label/_Work",
        callback => sub { $self -> node_label("work"); },
        item_type => "<StockItem>",
        extra_data => "work"
      },
      {
        path => "/Label/_Personal",
        callback => sub { $self -> node_label("personal"); },
        item_type => "<StockItem>",
        extra_data => "personal"
      },
      {
        path => "/Label/_To Do",
        callback => sub { $self -> node_label("todo"); },
        item_type => "<StockItem>",
        extra_data => "todo"
      },
      {
        path => "/Label/_Later",
        callback => sub { $self -> node_label("later"); },
        item_type => "<StockItem>",
        extra_data => "later"
      }
    );
  }

  $item_factory -> create_items(undef, @menu);

  Gtk2 -> main_iteration() while (Gtk2 -> events_pending());

  my ($button, $time) = defined $event
    ? ($event -> button, $event -> time)
    : (0, Gtk2 -> get_current_event_time());

  $item_factory -> get_widget("<OdotPopup>") -> popup(undef,
                                                      undef,
                                                      undef,
                                                      undef,
                                                      $button,
                                                      $time);

  return TRUE;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::CellEditableDate;

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use Glib::Object::Subclass
  Gtk2::EventBox::,
  interfaces => [ Gtk2::CellEditable:: ];

sub INIT_INSTANCE {
  my ($self) = @_;

  my $popup = Gtk2::Window -> new("popup");
  my $vbox = Gtk2::VBox -> new(FALSE, 0);

  my $calendar = Gtk2::Calendar -> new();

  my $hbox = Gtk2::HBox -> new(FALSE, 0);

  my $today = Gtk2::Button -> new("_Today");
  my $none = Gtk2::Button -> new("_None");

  # We can't just provide the callbacks now because they might need access to
  # cell-specific variables.  And we can't just connect the signals in
  # START_EDITING because we'd be connecting new signal handlers to the same
  # widgets over and over again.
  $today -> signal_connect(clicked => sub {
    $popup -> { _today_clicked_callback } -> (@_)
      if (exists $popup -> { _today_clicked_callback });
  });

  $none -> signal_connect(clicked => sub {
    $popup -> { _none_clicked_callback } -> (@_)
      if (exists $popup -> { _none_clicked_callback });
  });

  $calendar -> signal_connect(day_selected_double_click => sub {
    $popup -> { _day_selected_double_click_callback } -> (@_)
      if (exists $popup -> { _day_selected_double_click_callback });
  });

  $calendar -> signal_connect(month_changed => sub {
    $popup -> { _month_changed } -> (@_)
      if (exists $popup -> { _month_changed });
  });

  $calendar -> signal_connect(key_press_event => sub {
    my ($calendar, $event) = @_;

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Left } ||
        $event -> keyval == $Gtk2::Gdk::Keysyms{ Right } ||
        $event -> keyval == $Gtk2::Gdk::Keysyms{ Up } ||
        $event -> keyval == $Gtk2::Gdk::Keysyms{ Down }) {
      my ($y, $m, $d) = $calendar -> get_date();
      my $date = DateTime -> new(year => $y, month => $m + 1, day => $d)
                          -> truncate(to => "day");

      if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Left }) {
        $date -> subtract(days => 1);
      }
      elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Right }) {
        $date -> add(days => 1);
      }
      elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Up }) {
        $date -> subtract(days => 7);
      }
      elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Down }) {
        $date -> add(days => 7);
      }

      $calendar -> freeze();
      $calendar -> select_day($date -> day());
      $calendar -> select_month($date -> month() - 1, $date -> year());
      $calendar -> thaw();

      return TRUE;
    }

    return FALSE;
  });

  $hbox -> pack_start($today, TRUE, TRUE, 0);
  $hbox -> pack_start($none, TRUE, TRUE, 0);

  $vbox -> pack_start($calendar, TRUE, TRUE, 0);
  $vbox -> pack_start($hbox, FALSE, FALSE, 0);

  # Find out if the click happened outside of our window.  If so, hide it.
  # Largely copied from Planner (the former MrProject).

  # Implement via Gtk2::get_event_widget?
  $popup -> signal_connect(button_press_event => sub {
    my ($popup, $event) = @_;

    if ($event -> button() == 1) {
      my ($x, $y) = ($event -> x_root, $event -> y_root);
      my ($xoffset, $yoffset) = $popup -> window -> get_root_origin();

      my $allocation = $popup -> allocation;

      my $x1 = $xoffset + 2 * $allocation -> x;
      my $y1 = $yoffset + 2 * $allocation -> y;
      my $x2 = $x1 + $allocation -> width;
      my $y2 = $y1 + $allocation -> height;

      unless ($x > $x1 && $x < $x2 && $y > $y1 && $y < $y2) {
        $self -> remove_widget();
        return TRUE;
      }
    }

    return FALSE;
  });

  $popup -> signal_connect(key_press_event => sub {
    my ($popup, $event) = @_;

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Return } ||
        $event -> keyval == $Gtk2::Gdk::Keysyms{ KP_Enter }) {
      $calendar -> signal_emit("day_selected_double_click");
      return TRUE;
    }
    elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Escape }) {
      $self -> remove_widget();
      return TRUE;
    }

    return FALSE;
  });

  $popup -> add($vbox);

  $self -> { _popup } = $popup;
  $self -> { _calendar } = $calendar;
}

sub START_EDITING {
  my ($self, $event) = @_;
  my $popup = $self -> { _popup };

  Gtk2 -> grab_add($popup);
  $popup -> grab_focus();

  Gtk2::Gdk -> pointer_grab($popup -> window,
                            TRUE,
                            [qw(button-press-mask
                                button-release-mask
                                pointer-motion-mask)],
                            undef,
                            undef,
                            0);
}

sub REMOVE_WIDGET {
  my ($self) = @_;
  my $popup = $self -> { _popup };

  Gtk2 -> grab_remove($popup);
  $popup -> hide();
}

sub EDITING_DONE { warn "editing done: @_"; }

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::CellRendererDate;

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use Glib::Object::Subclass
  Gtk2::CellRendererText::;

sub get_today {
  my ($cell) = @_;

  my ($day, $month, $year) = (localtime)[3, 4, 5];
  $year += 1900;
  $month += 1;

  return $year, $month, $day;
}

sub get_date {
  my ($cell) = @_;

  my $text = $cell -> get("text");
  my ($year, $month, $day) = $text
    ? split /-/, $text
    : $cell -> get_today();

  return $year, $month, $day;
}

sub add_padding {
  my ($cell, $year, $month, $day) = @_;
  return $year, sprintf("%02d", $month), sprintf("%02d", $day);
}

sub INIT_INSTANCE {
  my ($cell) = @_;
  $cell -> { _editable } = Odot::CellEditableDate -> new();
}

sub START_EDITING {
  my ($cell, $event, $view, $path, $background_area, $cell_area, $flags) = @_;

  my $editable = $cell -> { _editable };
  my $popup = $editable -> { _popup };
  my $calendar = $editable -> { _calendar };

  # Specify the callbacks.  Will be called by the signal handlers set up in
  # Odot::CellEditableDate::INIT_INSTANCE.
  $popup -> { _today_clicked_callback } = sub {
    my ($button) = @_;
    my ($year, $month, $day) = $cell -> get_today();

    $editable -> remove_widget();
    $cell -> signal_emit("edited",
                         $path,
                         join "-", $cell -> add_padding($year, $month, $day));
  };

  $popup -> { _none_clicked_callback } = sub {
    my ($button) = @_;

    $editable -> remove_widget();
    $cell -> signal_emit(edited => $path, "");
  };

  $popup -> { _day_selected_double_click_callback } = sub {
    my ($calendar) = @_;
    my ($year, $month, $day) = $calendar -> get_date();

    $editable -> remove_widget();
    $cell -> signal_emit("edited",
                         $path,
                         join "-", $cell -> add_padding($year,
                                                        ++$month,
                                                        $day));
  };

  $popup -> { _month_changed } = sub {
    my ($calendar) = @_;

    my ($selected_year, $selected_month) = $calendar -> get_date();
    my ($current_year, $current_month, $current_day) = $cell -> get_today();

    if ($selected_year == $current_year &&
        ++$selected_month == $current_month) {
      $calendar -> mark_day($current_day);
    }
    else {
      $calendar -> unmark_day($current_day);
    }
  };

  my ($year, $month, $day) = $cell -> get_date();

  $calendar -> select_month($month - 1, $year);
  $calendar -> select_day($day);

  # Align the popup's upper right edge with the cell's lower right edge by
  # default.  If the screen's height is not sufficient, position the popup
  # above the cell; if the width's not sufficient, move the popup to the left
  # till it fits.
  $popup -> get_child() -> show_all();
  $popup -> realize();
  my ($requisition) = $popup -> size_request();
  my ($popup_width, $popup_height) = ($requisition -> width,
                                      $requisition -> height);

  my ($screen_width, $screen_height) = ($popup -> get_screen() -> get_width(),
                                        $popup -> get_screen() -> get_height());

  my ($x_origin, $y_origin) =  $view -> get_bin_window() -> get_origin();

  my $popup_x = $x_origin + $cell_area -> x + $cell_area -> width - $popup_width;
  if ($popup_x < 0) {
    $popup_x = 0;
  }
  elsif ($popup_x + $popup_width > $screen_width) {
    $popup_x -= $popup_x + $popup_width - $screen_width;
  }

  my $popup_y = $y_origin + $cell_area -> y + $cell_area -> height;
  if ($popup_y + $popup_height > $screen_height) {
    $popup_y = $y_origin + $cell_area -> y - $popup_height;
  }

  $popup -> move($popup_x, $popup_y);
  $popup -> show();

  return $editable;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::CellEditableText;

# This is inspired by and based on muppet's customrenderer.pl example.

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use Glib::Object::Subclass
  Gtk2::TextView::,
  interfaces => [ Gtk2::CellEditable:: ];

sub set_text {
  my ($editable, $text) = @_;
  $text = "" unless (defined $text);

  $editable -> get_buffer() -> set_text($text);
}

sub get_text {
  my ($editable) = @_;
  my $buffer = $editable -> get_buffer();

  return $buffer -> get_text($buffer -> get_bounds(), TRUE);
}

sub select_all {
  my ($editable) = @_;
  my $buffer = $editable -> get_buffer();

  my ($start, $end) = $buffer -> get_bounds();
  $buffer -> move_mark_by_name(insert => $start);
  $buffer -> move_mark_by_name(selection_bound => $end);
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::CellRendererText;

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use Glib::Object::Subclass
  Gtk2::CellRendererText::,
  properties => [
    Glib::ParamSpec -> object("editable-widget",
                              "Editable widget",
                              "The editable that's used for cell editing.",
                              Odot::CellEditableText::,
                              [qw(readable writable)])
  ];

sub INIT_INSTANCE {
  my ($cell) = @_;

  my $editable = Odot::CellEditableText -> new();

  $editable -> set(border_width => $cell -> get("ypad"));

  $editable -> signal_connect(key_press_event => sub {
    my ($editable, $event) = @_;

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Escape }) {
      $editable -> { _editing_canceled } = TRUE;
      $editable -> editing_done();
      $editable -> remove_widget();

      return TRUE;
    }

    # elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Return } ||
    #        $event -> keyval == $Gtk2::Gdk::Keysyms{ KP_Enter }
    #        and $event -> state & qw(control-mask)) {
    #   # resize the editable somehow ...
    # }

    elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Return } ||
           $event -> keyval == $Gtk2::Gdk::Keysyms{ KP_Enter }
           and not $event -> state & qw(control-mask)) {
      $editable -> { _editing_canceled } = FALSE;
      $editable -> editing_done();
      $editable -> remove_widget();

      return TRUE;
    }

    return FALSE;
  });

  $editable -> signal_connect(editing_done => sub {
    my ($editable) = @_;

    # gtk+ changed semantics in 2.6.  you now need to call stop_editing().
    if (Gtk2 -> CHECK_VERSION(2, 6, 0)) {
      $cell -> stop_editing($editable -> { _editing_canceled });
    }

    # if gtk+ < 2.4.0, emit the signal regardless of whether editing was
    # canceled to make undo/redo work.

    my $new = Gtk2 -> CHECK_VERSION(2, 4, 0);

    if (!$new || ($new && !$editable -> { _editing_canceled })) {
      $cell -> signal_emit("edited",
                           $editable -> { _path },
                           $editable -> get_text());
    }
    else {
      $cell -> editing_canceled();
    }
  });

  $cell -> set(editable_widget => $editable);
}

sub START_EDITING {
  my ($cell, $event, $view, $path, $background_area, $cell_area, $flags) = @_;

  if ($event) {
    return unless ($event -> button == 1);
  }

  my $editable = $cell -> get("editable-widget");

  $editable -> { _editing_canceled } = FALSE;
  $editable -> { _path } = $path;

  $editable -> set_text($cell -> get("text"));
  $editable -> select_all();
  $editable -> show();

  return $editable;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Parser;

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use XML::Parser;

sub new {
  my ($class) = @_;

  my $self = bless {}, $class;

  $self -> { _parser } = XML::Parser -> new(
    # ProtocolEncoding => "UTF-8",
    Handlers => {
      Start => sub {
        my ($expat, $element) = @_;

        if ($element eq "task") {
          $self -> { _tasks } -> [$self -> { _current_task }] = {};
        }
      },
      Char => sub {
        my ($expat, $string) = @_;
        my $element = $expat -> current_element();

        # handle the task-related entries separately, to speed up parsing of
        # copy/paste and undo/redo text.
        if ($element =~ m/^(path|expanded|due_date|title|color)$/) {
          $self -> { _tasks }
                -> [$self -> { _current_task }]
                -> { $1 } .= $string;
        }
        elsif ($element =~ m/^(width|height|x|y|maximized)$/) {
          $self -> { _geometry } -> { $1 } .= $string;
        }
        elsif ($element =~ m/^(sorting|show_headings|show_due_date|selected|view)$/) {
          $self -> { _preferences } -> { $1 } .= $string;
        }
      },
      End => sub {
        my ($expat, $element) = @_;

        if ($element eq "task") {
          $self -> { _current_task }++;
        }
      }
    }
  );

  return $self;
}

sub initialize {
  my ($self) = @_;

  $self -> { _geometry } = {};
  $self -> { _tasks } = [];
  $self -> { _current_task } = 0;
}

sub parse {
  my ($self, $text) = @_;

  $self -> initialize();

  $self -> { _parser } -> parse(
    qq(<?xml version="1.0" encoding="UTF-8"?><odot><tasks>)
  . $text
  . qq(</tasks></odot>)
  );

  return $self -> { _tasks };
}

sub parse_file {
  my ($self, $file) = @_;

  $self -> initialize();

  $self -> { _parser } -> parsefile($file);

  return $self -> { _geometry },
         $self -> { _preferences },
         $self -> { _tasks };
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Serializer;

use strict;
use warnings;

use Glib qw(TRUE FALSE);

sub new {
  my ($class, $view, $model) = @_;

  my $self = {};

  $self -> { _view } = $view;
  $self -> { _model } = $model;

  return bless $self, $class;
}

sub generate_text {
  my ($self, $iterator) = @_;

  my $model = $self -> { _model };

  my $path = $model -> get_path($iterator);
  my ($task, $due_date) = $model -> get($iterator, Odot::COLUMN_TASK,
                                                   Odot::COLUMN_DUE_DATE);

  my $indention = "  " x ($path -> get_depth() - 1);

  return $due_date
    ? $indention . $task . ", $due_date\n"
    : $indention . $task . "\n";
}

sub generate_xml {
  my ($self, $iterator) = @_;

  my $view = $self -> { _view };
  my $model = $self -> { _model };

  my $path = $model -> get_path($iterator);
  my ($task, $due_date, $color) = $model -> get($iterator,
                                                Odot::COLUMN_TASK,
                                                Odot::COLUMN_DUE_DATE,
                                                Odot::COLUMN_COLOR);

  $color = "" unless defined $color;

  my $path_string = $path -> to_string();
  my $expanded = $view -> row_expanded($path) || 0;

  $task =~ s/&/&amp;/g;
  $task =~ s/</&lt;/g;
  $task =~ s/>/&gt;/g;

  return <<"__EOD__";
    <task>
      <path>$path_string</path>
      <expanded>$expanded</expanded>
      <due_date>$due_date</due_date>
      <title>$task</title>
      <color>$color</color>
    </task>
__EOD__
}

sub generate {
  my ($self, $parent) = @_;

  my $model = $self -> { _model };

  my $wantarray = wantarray;

  my $data = "";
  my $data_text = "";

  if (defined $parent) {
    $data = $self -> generate_xml($parent);
    $data_text = $self -> generate_text($parent) if ($wantarray);
  }

  my $children = $model -> iter_n_children($parent);

  if ($children > 0) {
    foreach (0 .. ($children - 1)) {
      my $new_parent = $model -> iter_nth_child($parent, $_);

      if ($wantarray) {
        my ($new_data, $new_data_text) = $self -> generate($new_parent);
        $data .= $new_data;
        $data_text .= $new_data_text;
      }
      else {
        my $new_data = $self -> generate($new_parent);
        $data .= $new_data;
      }
    }
  }

  return $wantarray ? ($data, $data_text) : $data;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Backend;

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use DateTime;

###############################################################################

BEGIN {
  Odot::Accessors -> create(qw(source
                               view
                               model
                               window
                               menu_widgets
                               parser
                               clipboard
                               clipboard_text
                               implementation
                               error
                               stack
                               column_widgets
                               serializer));
}

###############################################################################

sub new {
  my ($class, $window, $view, $model, $menus, $columns, $source) = @_;

  my $self = bless {}, $class;

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menus);
  $self -> set_column_widgets($columns);

  $self -> set_source($source);

  $self -> set_stack(Odot::Stack -> new($menus));
  $self -> set_parser(Odot::Parser -> new());
  $self -> set_serializer(Odot::Serializer -> new($view, $model));

  my $atom = Gtk2::Gdk::Atom -> new("ODOT_CLIPBOARD");
  my $atom_text = Gtk2::Gdk::Atom -> new("CLIPBOARD");

  $self -> set_clipboard(Gtk2::Clipboard -> get($atom));
  $self -> set_clipboard_text(Gtk2::Clipboard -> get($atom_text));

  $menus -> { save } -> set_sensitive(FALSE);

  $self -> set_show_headings(TRUE);
  $self -> set_show_due_date(FALSE);

  eval "use DBI;";
  if ($@) {
    $menus -> { save_as_db } -> set_sensitive(FALSE);
    $menus -> { open_db } -> set_sensitive(FALSE);
  }

  if (defined $source) {
    if (TRUE) { # looks like a file
      $self -> open($source);
    }
    else { # looks like a db
      $self -> open_db($source);
    }
  }

  foreach my $signal (qw(row-changed row-deleted row-inserted)) {
    $model -> signal_connect($signal => sub {
      $self -> set_changed(TRUE);
      return FALSE;
    });
  }

  $window -> set_title($self -> get_title());

  return $self;
}

###############################################################################

sub fill {
  my ($self, $tasks, $expand) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();

  my @expanded = ();
  my $i = 0;

  foreach my $task (@{$tasks}) {
    my ($path_string,
        $expanded,
        $due_date,
        $title,
        $color) = @{$task}{ qw(path expanded due_date title color) };

    my $path = Gtk2::TreePath -> new($path_string);
    push @expanded, $path -> copy() if ($expanded);

    # FIXME: insert() seems to be slower than append(), and it's only needed
    # when undo'ing/redo'ing, not when loading/pasting.  maybe special-case it
    # somehow?
    my $position = ($path -> get_indices())[-1];
    $path -> up();
    my $iterator = $model -> insert($path -> get_depth() > 0
                                      ? $model -> get_iter($path)
                                      : undef,
                                    $position);

    $model -> set($iterator,
                  Odot::COLUMN_TASK, $title || "",
                  Odot::COLUMN_DUE_DATE, $due_date || "");

    if (defined $color && $color ne "") {
      $model -> set($iterator, Odot::COLUMN_COLOR, $color);
    }

    $self -> check_due_date($iterator, $due_date);

    # If the current item is due, walk up its ancestry and highlight every
    # collapsed parent.
    if ($self -> is_due($due_date)) {
      while (my $parent = $model -> iter_parent($iterator)) {
        unless ($view -> row_expanded($model -> get_path($parent))) {
          $model -> set($parent, Odot::COLUMN_UNDERLINE, "single");
        }
        $iterator = $parent;
      }
    }

    # If we're on the first node and $expand is true, expand the parent.
    if ($i++ == 0 && $expand) {
      $view -> expand_row($path, FALSE) unless ($view -> row_expanded($path));
    }
  }

  # Expand every row that was expanded previously.
  $view -> expand_row($_, FALSE) foreach (@expanded);
}

sub replace_path {
  my ($self, $tasks, $new) = @_;
  my $old = $tasks -> [0] -> { path };

  foreach my $task (@{$tasks}) {
    $task -> { path } =~ s/^$old/$new/;
  }

  return $new;
}

sub replace_path_with_parent {
  my ($self, $tasks, $parent) = @_;
  my $model = $self -> get_model();

  # Get the path of the root item.
  my $old_leading_path = $tasks -> [0] -> { path };

  # Get the path that a newly added node would have.
  my $new_leading_path;

  if (defined $parent) {
    my $parent_path = $model -> get_path($parent) -> to_string();

    $new_leading_path = $parent_path . ":" .
                        $model -> iter_n_children($parent);
  }
  else {
    # FIXME: $parent is undef!
    $new_leading_path = $model -> iter_n_children($parent);
  }

  # Change all paths to point to the new location.
  foreach (@{$tasks}) {
    $_ -> { path } =~ s/^$old_leading_path/$new_leading_path/;
  }

  return $new_leading_path;
}

###############################################################################

sub undo {
  my ($self) = @_;
  my $record = $self -> get_stack() -> rewind();

  if ($record -> { action } eq "change") {
    my ($column, $old, $new, $path) = @{$record -> { data }};
    my $model = $self -> get_model();

    $model -> set($model -> get_iter_from_string($path),
                  $column => $old);
  }

  elsif ($record -> { action } eq "add") {
    my $path = $record -> { data } -> [1];
    my $model = $self -> get_model();

    $model -> remove($model -> get_iter_from_string($path));
  }

  elsif ($record -> { action } eq "remove") {
    my ($text, $path) = @{$record -> { data }};
    my $tasks;

    eval {
      $tasks = $self -> get_parser() -> parse($text);
    };
    if ($@) {
      return $self -> error("Could not undo action: $@");
    }

    $self -> replace_path($tasks, $path);
    $self -> fill($tasks, TRUE);
  }

  elsif ($record -> { action } eq "unindent") {
    my ($old_path, $new_path) = @{$record -> { data }};
    $self -> indent($new_path, $old_path);
  }

  elsif ($record -> { action } eq "indent") {
    my ($old_path, $new_path) = @{$record -> { data }};
    $self -> unindent($new_path, $old_path);
  }

  elsif ($record -> { action } eq "swap") {
    my ($source, $destination) = @{$record -> { data }};
    my $model = $self -> get_model();

    $model -> swap($model -> get_iter($source),
                   $model -> get_iter($destination));
  }

  elsif ($record -> { action } eq "move") {
    my ($from, $to, $data) = @{$record -> { data }};
    my $model = $self -> get_model();

    my $tasks = $self -> get_parser() -> parse($data);
    $self -> fill($tasks);

    my $path = Gtk2::TreePath -> new_from_string($to);
    $model -> remove($model -> get_iter($path));
  }
}

sub redo {
  my ($self) = @_;
  my $record = $self -> get_stack() -> fast_forward();

  if ($record -> { action } eq "change") {
    my ($column, $old, $new, $path) = @{$record -> { data }};
    my $model = $self -> get_model();

    $model -> set($model -> get_iter_from_string($path),
                  $column => $new);
  }

  elsif ($record -> { action } eq "add") {
    my ($text, $path) = @{$record -> { data }};
    my $tasks;

    eval {
      $tasks = $self -> get_parser() -> parse($text);
    };
    if ($@) {
      return $self -> error("Could not redo action: $@");
    }

    $self -> replace_path($tasks, $path);
    $self -> fill($tasks, TRUE);
  }

  elsif ($record -> { action } eq "remove") {
    my $path = $record -> { data } -> [1];
    my $model = $self -> get_model();

    $model -> remove($model -> get_iter_from_string($path));
  }

  elsif ($record -> { action } eq "indent") {
    my ($old_path, $new_path) = @{$record -> { data }};
    $self -> indent($old_path, $new_path);
  }

  elsif ($record -> { action } eq "unindent") {
    my ($old_path, $new_path) = @{$record -> { data }};
    $self -> unindent($old_path, $new_path);
  }

  elsif ($record -> { action } eq "swap") {
    my ($source, $destination) = @{$record -> { data }};
    my $model = $self -> get_model();

    $model -> swap($model -> get_iter($source),
                   $model -> get_iter($destination));
  }

  elsif ($record -> { action } eq "move") {
    my ($from, $to, $data) = @{$record -> { data }};
    my $model = $self -> get_model();

    my $tasks = $self -> get_parser() -> parse($data);
    $self -> replace_path($tasks, $to);
    $self -> fill($tasks);

    my $path = Gtk2::TreePath -> new_from_string($from);
    $model -> remove($model -> get_iter($path));
  }
}

###############################################################################

sub cut {
  my ($self) = @_;

  my $view = $self -> get_view();
  my ($model, $iterator) = $view -> get_selection() -> get_selected();

  if (defined $model && defined $iterator) {
    $self -> copy();

    $self -> get_stack() -> push({
      action => "remove",
      data  => [scalar $self -> get_serializer() -> generate($iterator),
                $model -> get_path($iterator) -> to_string()]
    });

    $model -> remove($iterator);
  }
}

sub copy {
  my ($self) = @_;

  my $view = $self -> get_view();
  my ($model, $iterator) = $view -> get_selection() -> get_selected();

  if (defined $model && defined $iterator) {
    my ($data, $data_text) = $self -> get_serializer() -> generate($iterator);

    $self -> get_clipboard() -> set_text($data);
    $self -> get_clipboard_text() -> set_text($data_text);
  }
}

sub paste {
  my ($self) = @_;

  my $view = $self -> get_view();
  my ($model, $parent) = $view -> get_selection() -> get_selected();

  $model = $self -> get_model() unless (defined $model);

  $self -> get_clipboard() -> request_text(sub {
    my ($clipboard, $text) = @_;

    if (defined $text) {
      my $tasks;

      eval {
        $tasks = $self -> get_parser() -> parse($text);
      };
      if ($@) {
        return $self -> error("Could not parse clipboard content: $@");
      }

      my $new_path =
        $self -> replace_path_with_parent($tasks, $parent);

      $self -> fill($tasks, TRUE);

      $self -> get_stack() -> push({
        action => "add",
        data => [$text, $new_path]
      });
    }
  });
}

###############################################################################

sub get_difference {
  my ($self, $due_date) = @_;

  my ($year, $month, $day) = split /-/, $due_date;
  my $duration = DateTime -> new(year => $year, month => $month, day => $day) -
                 DateTime -> today();

  # FIXME: This isn't accurate.
  return $duration -> delta_months() * 30 + $duration -> delta_days();
}

sub is_due {
  my ($self, $due_date) = @_;

  return $due_date &&
         $self -> get_difference($due_date) <= Odot::DUE_THRESHOLD;
}

sub check_due_date {
  my ($self, $iterator, $due_date) = @_;
  my $model = $self -> get_model();

  my $weight = "normal";
  my $style = "normal";

  $due_date = $model -> get($iterator, Odot::COLUMN_DUE_DATE)
    unless (defined $due_date);

  if (defined $due_date && $due_date ne "") {
    my $difference = $self -> get_difference($due_date);

    if ($difference <= Odot::DUE_THRESHOLD && $difference >= 0) {
      $weight = "bold";
    }

    if ($difference <= 0) {
      $style = "italic";
    }
  }

  $model -> set($iterator,
                Odot::COLUMN_WEIGHT, $weight,
                Odot::COLUMN_STYLE, $style);
}

###############################################################################

sub edit {
  my ($self, $iterator) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();

  # Specify that the following change is part of this add action.
  $self -> get_stack() -> set_recording(TRUE);

  Gtk2 -> main_iteration() while (Gtk2 -> events_pending());

  $view -> set_cursor($model -> get_path($iterator),
                      $view -> get_column(Odot::COLUMN_TASK),
                      TRUE);
}

sub add {
  my ($self) = @_;

  my $view = $self -> get_view();

  my ($model, $parent_iterator) = $view -> get_selection() -> get_selected();
  $model = $self -> get_model() unless (defined $model);

  if (defined $parent_iterator && $self -> get_stack() -> get_recording()) {
    my $column = $view -> get_column(Odot::COLUMN_TASK);
    my $renderer = $column -> get_cell_renderers();
    my $editable = $renderer -> get("editable-widget");

    $editable -> editing_done() if (defined $editable);
  }

  my $iterator = $model -> append($parent_iterator);
  $model -> set($iterator, Odot::COLUMN_TASK, "",
                           Odot::COLUMN_DUE_DATE, "");

  if (defined $parent_iterator) {
    my $path = $model -> get_path($parent_iterator);
    $view -> expand_row($path, FALSE) unless ($view -> row_expanded($path));
  }

  $self -> edit($iterator);
}

sub delete {
  my ($self) = @_;

  my $view = $self -> get_view();
  my $selection = $view -> get_selection();
  my ($model, $iterator) = $selection -> get_selected();

  if (defined $model && defined $iterator) {
    my $path = $model -> get_path($iterator);

    $self -> get_stack() -> push({
      action => "remove",
      data  => [scalar $self -> get_serializer() -> generate($iterator),
                $path -> to_string()]
    });

    # Get the path of the soon-to-be-dead iterator so we can select the item
    # that will take its place.  Special-case iterators that are the
    # bottom-most child.
    my $children = $model -> iter_n_children($model -> iter_parent($iterator));

    if (($path -> get_indices())[-1] + 1 == $children) {
      $path -> prev();
    }

    if ($children == 1) {
      $path -> up();
    }

    $model -> remove($iterator);

    $selection -> select_path($path);
  }
}

sub sort {
  my ($self, $recursive) = @_;

  my $view = $self -> get_view();
  my ($model, $iterator) = $view -> get_selection() -> get_selected();

  if (defined $model && defined $iterator) {
    $recursive
      ? $self -> sort_recursively($model, $iterator)
      : $self -> sort_one($model, $iterator);
  }
}

sub sort_one {
  my ($self, $model, $iterator) = @_;
  my $children = $model -> iter_n_children($iterator);

  # Look, son!  A bubble sort!
  foreach my $i (1 .. $children) {
    foreach my $j (1 .. ($children - $i)) {
      my ($a, $b) = ($model -> iter_nth_child($iterator, $j - 1),
                     $model -> iter_nth_child($iterator, $j));

      if (($model -> get($a, Odot::COLUMN_TASK) cmp
           $model -> get($b, Odot::COLUMN_TASK)) == 1) {
        $model -> swap($a, $b);
      }
    }
  }
}

sub sort_recursively {
  my ($self, $model, $iterator) = @_;
  my $children = $model -> iter_n_children($iterator);

  $self -> sort_one($model, $iterator);

  foreach (0 .. ($children - 1)) {
    my $parent = $model -> iter_nth_child($iterator, $_);

    if ($model -> iter_has_child($parent)) {
      $self -> sort_recursively($model, $parent);
    }
  }
}

sub indent {
  my ($self, $source_path_string, $destination_path_string) = @_;

  my $view = $self -> get_view();
  my $selection = $view -> get_selection();

  my ($model, $iterator);

  # If $source_path_string is defined then we're supposed to undo/redo a
  # previous action.
  if (defined $source_path_string) {
    $model = $self -> get_model();
    $iterator = $model -> get_iter(
      Gtk2::TreePath -> new_from_string($source_path_string));
  }
  else {
    ($model, $iterator) = $selection -> get_selected();
  }

  my $abort = FALSE;

  if (defined $model && defined $iterator) {
    my ($tasks, $new_path_string);
    my $text = $self -> get_serializer() -> generate($iterator);

    eval {
      $tasks = $self -> get_parser() -> parse($text);
    };
    if ($@) {
      return $self -> error("Could not indent node: $@");
    }

    if (not defined $destination_path_string) {
      my $old_path = $model -> get_path($iterator);
      my $old_path_string = $old_path -> to_string();

      if ($old_path -> prev()) {
        $new_path_string =
          $self -> replace_path_with_parent($tasks,
                                            $model -> get_iter($old_path));

        $self -> get_stack() -> push({
          action => "indent",
          data  => [$old_path_string, $new_path_string]
        });
      }
      else {
        $abort = TRUE;
      }
    }
    else {
      $new_path_string = $self -> replace_path($tasks,
                                               $destination_path_string);
    }

    unless ($abort) {
      $model -> remove($iterator);
      $self -> fill($tasks, TRUE);

      $selection -> select_path(
        Gtk2::TreePath -> new_from_string($new_path_string));
    }
  }
}

sub unindent {
  my ($self, $source_path_string, $destination_path_string) = @_;

  my $view = $self -> get_view();
  my $selection = $view -> get_selection();

  my ($model, $iterator);

  # If $source_path_string is defined then we're supposed to undo/redo a
  # previous action.
  if (defined $source_path_string) {
    $model = $self -> get_model();
    $iterator = $model -> get_iter(
      Gtk2::TreePath -> new_from_string($source_path_string));
  }
  else {
    ($model, $iterator) = $selection -> get_selected();
  }

  my $abort = FALSE;

  if (defined $model && defined $iterator) {
    my ($tasks, $new_path_string);
    my $text = $self -> get_serializer() -> generate($iterator);

    eval {
      $tasks = $self -> get_parser() -> parse($text);
    };
    if ($@) {
      return $self -> error("Could not unindent node: $@");
    }

    if (not defined $destination_path_string) {
      my $old_path = $model -> get_path($iterator);
      my $old_path_string = $old_path -> to_string();

      if ($old_path -> get_depth() > 1 && $old_path -> up()) {
        $old_path -> next();
        $new_path_string = $self -> replace_path($tasks,
                                                 $old_path -> to_string());

        $self -> get_stack() -> push({
          action => "unindent",
          data  => [$old_path_string, $new_path_string]
        });
      }
      else {
        $abort = TRUE;
      }
    }
    else {
      $new_path_string = $self -> replace_path($tasks,
                                               $destination_path_string);
    }

    unless ($abort) {
      $model -> remove($iterator);
      $self -> fill($tasks, TRUE);

      $selection -> select_path(
        Gtk2::TreePath -> new_from_string($new_path_string));
    }
  }
}

sub label {
  my ($self, $label) = @_;

  my $view = $self -> get_view();
  my $selection = $view -> get_selection();
  my ($model, $iterator) = $selection -> get_selected();

  if (defined $model && defined $iterator) {
    my $default = $self -> get_view() -> style() -> fg("normal");
    my $color = sprintf "#%x%x%x", $default -> red(),
                                   $default -> green(),
                                   $default -> blue();

    # FIXME: Use a lookup table.
    if ($label eq "important") {
      $color = "#ff0000";
    }
    elsif ($label eq "work") {
      $color = "#ff8c00";
    }
    elsif ($label eq "personal") {
      $color = "#008b00";
    }
    elsif ($label eq "todo") {
      $color = "#0000ff";
    }
    elsif ($label eq "later") {
      $color = "#8b008b";
    }

    $model -> set($iterator, Odot::COLUMN_COLOR, $color);
  }
}

sub move {
  my ($self, $keyval) = @_;

  my $view = $self -> get_view();
  my $selection = $view -> get_selection();
  my ($model, $iterator) = $selection -> get_selected();

  if (defined $model && defined $iterator) {
    my $source = $model -> get_path($iterator);
    my $destination = $source -> copy();

    if ($keyval == $Gtk2::Gdk::Keysyms{ Down }) {
      $destination -> next();
    }
    elsif ($keyval == $Gtk2::Gdk::Keysyms{ Up }) {
      $destination -> prev();
    }

    my $one = $model -> get_iter($source);
    my $two = $model -> get_iter($destination);

    if (defined $one && defined $two) {
      $model -> swap($one, $two);

      $self -> get_stack() -> push({
        action => "swap",
        data  => [$source, $destination]
      });
    }
  }
}

###############################################################################

sub error {
  my ($self, $label) = @_;

  my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                          [qw(destroy_with_parent)],
                                          "error",
                                          "ok",
                                          $label);

  $dialog -> run();
  $dialog -> destroy();

  return FALSE;
}

sub question {
  my ($self, $label) = @_;

  my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                          [qw(modal destroy_with_parent)],
                                          "question",
                                          "yes_no",
                                          $label);

  my $response = $dialog -> run();
  $dialog -> destroy();

  return $response eq "yes" ? TRUE : FALSE;
}

###############################################################################

sub open {
  my ($self, $source) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my $menu_widgets = $self -> get_menu_widgets();
  my $column_widgets = $self -> get_column_widgets();

  my $implementation = Odot::Backend::XML -> new($window,
                                                 $view,
                                                 $model,
                                                 $menu_widgets,
                                                 $column_widgets,
                                                 $source);

  if ($implementation -> open()) {
    $self -> set_implementation($implementation);

    unless ($implementation -> read()) {
      $self -> error($implementation -> get_error());
      return FALSE;
    }

    $menu_widgets -> { save } -> set_sensitive(TRUE);
    $self -> set_changed(FALSE);

    return TRUE;
  }

  return FALSE;
}

sub open_db {
  my ($self, $source) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my $menu_widgets = $self -> get_menu_widgets();
  my $column_widgets = $self -> get_column_widgets();

  my $implementation = Odot::Backend::DB -> new($window,
                                                $view,
                                                $model,
                                                $menu_widgets,
                                                $column_widgets,
                                                $source);

  if ($implementation -> open()) {
    $self -> set_implementation($implementation);

    unless ($implementation -> read()) {
      $self -> error($implementation -> get_error());
      return FALSE;
    }

    $menu_widgets -> { save } -> set_sensitive(TRUE);
    $self -> set_changed(FALSE);

    return TRUE;
  }

  return FALSE;
}

sub save {
  my ($self) = @_;
  my $implementation = $self -> get_implementation();

  if (defined $implementation) {
    if ($implementation -> save()) {
      $self -> set_changed(FALSE);
      return TRUE;
    }
  }
  else {
    $self -> save_as();
    return TRUE;
  }

  return FALSE;
}

sub save_as {
  my ($self) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my $menu_widgets = $self -> get_menu_widgets();
  my $column_widgets = $self -> get_column_widgets();

  my $implementation = Odot::Backend::XML -> new($window,
                                                 $view,
                                                 $model,
                                                 $menu_widgets,
                                                 $column_widgets);

  if ($implementation -> save_as()) {
    $self -> set_implementation($implementation);

    $menu_widgets -> { save } -> set_sensitive(TRUE);
    $self -> set_changed(FALSE);

    return TRUE;
  }

  return FALSE;
}

sub save_as_db {
  my ($self) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();
  my $menu_widgets = $self -> get_menu_widgets();
  my $column_widgets = $self -> get_column_widgets();

  my $implementation = Odot::Backend::DB -> new($window,
                                                $view,
                                                $model,
                                                $menu_widgets,
                                                $column_widgets);

  if ($implementation -> save_as()) {
    $self -> set_implementation($implementation);

    $menu_widgets -> { save } -> set_sensitive(TRUE);
    $self -> set_changed(FALSE);

    return TRUE;
  }

  return FALSE;
}

###############################################################################

sub update {
  my ($self, $column, $path, $new) = @_;

  my $model = $self -> get_model();
  my $iterator = $model -> get_iter_from_string($path);
  my $old = $model -> get($iterator, $column);

  my $stack = $self -> get_stack();

  unless ($old eq $new) {
    $model -> set($iterator, $column => $new);

    if ($column == Odot::COLUMN_DUE_DATE) {
      $self -> check_due_date($iterator, $new);
    }

    if ($stack -> get_recording()) {
      $stack -> push({
        action => "add",
        data  => [
          scalar $self -> get_serializer() -> generate($iterator),
          $path
        ]
      });
    }
    else {
      $stack -> push({
        action => "change",
        data  => [$column, $old, $new, $path]
      });
    }
  }

  # Always set recording to FALSE regardless of what happened to stay
  # compatible with gtk+ < 2.4.0 which had no editing_canceled -- which is
  # where the stopping normally occurs.
  $stack -> set_recording(FALSE);

  Gtk2 -> main_iteration() while (Gtk2 -> events_pending());
}

###############################################################################

sub set_changed {
  my ($self, $changed) = @_;

  $self -> { _changed } = $changed;

  my $title = $self -> get_title();
  $self -> { _window } -> set_title($changed ? $title . " (*)" : $title);
}

sub get_changed {
  my ($self) = @_;

  return $self -> { _changed };
}

###############################################################################

sub set_show_headings {
  my ($self, $active) = @_;

  my $view = $self -> get_view();

  $view -> set_headers_visible($active);
  $view -> set_headers_clickable($active);

  $self -> get_menu_widgets() -> { headings } -> set_active($active);
}

sub get_show_headings {
  my ($self) = @_;

  return $self -> get_menu_widgets() -> { headings } -> get_active();
}

###############################################################################

sub set_show_due_date {
  my ($self, $active) = @_;

  my $view = $self -> get_view();
  my $columns = $self -> get_column_widgets();
  my $n_columns = $#{[$view -> get_columns()]} + 1;

  return unless defined $columns;

  if (defined $columns) {
    # FIXME: magical 1 & 2 shouldn't be used.
    if ($active && $n_columns == 1) {
      $view -> append_column($columns -> [Odot::COLUMN_DUE_DATE]);
    }
    elsif (!$active && $n_columns == 2) {
      $view -> remove_column($columns -> [Odot::COLUMN_DUE_DATE]);
    }
  }

  $self -> get_menu_widgets() -> { due_date } -> set_active($active);
}

sub get_show_due_date {
  my ($self) = @_;

  return $self -> get_menu_widgets() -> { due_date } -> get_active();
}

###############################################################################

sub get_title {
  my ($self) = @_;
  my $implementation = $self -> get_implementation();

  if (defined $implementation) {
    return $implementation -> get_title();
  }

  return "Odot";
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Backend::XML;

use strict;
use warnings;

use base qw(Odot::Backend);
use Glib qw(TRUE FALSE);

###############################################################################

sub new {
  my ($class, $window, $view, $model, $menus, $columns, $source) = @_;

  my $self = bless {}, $class;

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menus);
  $self -> set_column_widgets($columns);

  $self -> set_source($source);
  $self -> set_parser(Odot::Parser -> new());
  $self -> set_serializer(Odot::Serializer -> new($view, $model));

  return $self;
}

###############################################################################

sub get_title {
  my ($self) = @_;
  my $source = $self -> get_source();

  return defined $source
    ? "Odot - $source"
    : "Odot";
}

###############################################################################

sub open {
  my ($self) = @_;

  my $source = $self -> get_source();

  unless (defined $source) {
    my $dialog = Gtk2 -> CHECK_VERSION(2, 4, 0)
      ? Gtk2::FileChooserDialog -> new("Open",
                                       $self -> get_window(),
                                       "open",
                                       "gtk-cancel" => "cancel",
                                       "gtk-open" => "accept")
      : Gtk2::FileSelection -> new("Open");

    my $response = $dialog -> run();
    my $new_source = $dialog -> get_filename();

    $dialog -> destroy();

    if ($response eq "accept") {
      $self -> set_source($new_source);
      return TRUE;
    }

    return FALSE;
  }

  # Always return true if we already have a source.
  return TRUE;
}

sub save {
  my ($self) = @_;

  unless ($self -> write()) {
    my $error = $self -> get_error();

    if (defined $error) {
      my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                              "modal",
                                              "error",
                                              "none",
                                              $error);

      $dialog -> add_buttons("_Retry" => 0,
                             "gtk-cancel" => 1);

      my $response = $dialog -> run();
      $dialog -> destroy();

      if ($response eq "delete-event" || $response == 1) {
        return FALSE;
      }
      elsif ($response == 0) {
        return $self -> save();
      }
    }

    return FALSE;
  }

  return TRUE;
}

sub save_as {
  my ($self) = @_;

  my $dialog = Gtk2 -> CHECK_VERSION(2, 4, 0)
    ? Gtk2::FileChooserDialog -> new("Save As",
                                     $self -> get_window(),
                                     "save",
                                     "gtk-save" => "ok",
                                     "gtk-cancel" => "cancel")
    : Gtk2::FileSelection -> new("Save As");

  my $response = $dialog -> run();
  my $filename = $dialog -> get_filename();

  $dialog -> destroy();

  if ($response eq "ok") {
    if (-e $filename && -f $filename) {
      unless ($self -> question("The file `$filename' exists.  " .
                                "Do you want to overwrite it?")) {
        return FALSE;
      }
    }

    $self -> set_source($filename);
    return $self -> save();
  }

  return FALSE;
}

###############################################################################

sub read {
  my ($self) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();

  my $file = $self -> get_source();

  return FALSE unless (defined $file);
  $self -> set_error("`$file' does not exist"), return FALSE unless (-e $file);
  $self -> set_error("`$file' is no file"), return FALSE unless (-f $file);
  $self -> set_error("`$file' isn't readable"), return FALSE unless (-r $file);

  my ($geometry, $preferences, $tasks);

  eval {
    ($geometry,
     $preferences,
     $tasks) = $self -> get_parser() -> parse_file($file);
  };
  if ($@) {
    $self -> set_error("Could not parse `$file': $@");
    return FALSE;
  }

  $window -> set_default_size($geometry -> { width }, $geometry -> { height });
  # $window -> move($geometry -> { "x" }, $geometry -> { "y" });
  $window -> maximize() if ($geometry -> { maximized });

  if (defined $preferences -> { sorting }) {
    my ($column, $order) = split ", ", $preferences -> { sorting };

    if (defined $column && $column ne "" && $column >= 0) {
      $model -> set_sort_column_id($column, $order);
    }
  }

  $self -> set_show_due_date(defined $preferences -> { show_due_date }
                               ? $preferences -> { show_due_date }
                               : TRUE);

  $self -> set_show_headings(defined $preferences -> { show_headings }
                               ? $preferences -> { show_headings }
                               : FALSE);

  $self -> get_model() -> clear();
  $self -> fill($tasks, FALSE);

  if (defined $preferences -> { selected }) {
    my $path = Gtk2::TreePath -> new_from_string($preferences -> { selected });
    $view -> get_selection() -> select_path($path);
  }

  if (defined $preferences -> { view }) {
    my ($x, $y) = split ", ", $preferences -> { view };

    # FIXME: This causes a weird-looking jump to occur shortly after start-up.
    # But there appears to be no better to do this.
    Glib::Idle -> add(sub {
      $view -> get_hadjustment() -> set_value($x);
      $view -> get_vadjustment() -> set_value($y);
      return FALSE;
    });
  }

  return TRUE;
}

sub write {
  my ($self) = @_;

  my $window = $self -> get_window();
  my $view = $self -> get_view();
  my $model = $self -> get_model();

  my ($x, $y) = $window -> get_position();
  my ($width, $height) = $window -> get_size();
  my $maximized = $window -> { _maximized } ? 1 : 0;
  my ($sort_column, $sort_order) = $model -> get_sort_column_id();
  my $show_headings = $self -> get_show_headings() ? 1 : 0;
  my $show_due_date = $self -> get_show_due_date() ? 1 : 0;

  my $selected_iter = $view -> get_selection() -> get_selected();
  my $selected_path = defined $selected_iter
                        ? $model -> get_path($selected_iter) -> to_string()
                        : "";

  ($sort_column, $sort_order) = (-1, "") unless (defined $sort_column);

  my ($view_x, $view_y) = ($view -> get_hadjustment() -> get_value(),
                           $view -> get_vadjustment() -> get_value());

  my $file = $self -> get_source();

  CORE::open(ODOT, ">$file") or
    $self -> set_error("Could not open `$file' for writing: $!."),
    return FALSE;

  print ODOT <<"__EOD__";
<?xml version="1.0" encoding="UTF-8"?>
<odot>
  <general>
    <x>$x</x>
    <y>$y</y>
    <width>$width</width>
    <height>$height</height>
    <maximized>$maximized</maximized>
    <sorting>$sort_column, $sort_order</sorting>
    <show_headings>$show_headings</show_headings>
    <show_due_date>$show_due_date</show_due_date>
    <selected>$selected_path</selected>
    <view>$view_x, $view_y</view>
  </general>
  <tasks>
__EOD__

  print ODOT scalar $self -> get_serializer() -> generate(undef);

  print ODOT <<"__EOD__";
  </tasks>
</odot>
__EOD__

  close ODOT or
    $self -> set_error("Could not close `$file': $!."), return FALSE;

  return TRUE;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Backend::DB;

use strict;
use warnings;

use base qw(Odot::Backend);
use Encode qw(decode);
use Glib qw(TRUE FALSE);

###############################################################################

sub new {
  my ($class, $window, $view, $model, $menus, $columns, $source) = @_;

  my $self = bless {}, $class;

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menus);
  $self -> set_column_widgets($columns);

  $self -> set_source($source);

  return $self;
}

###############################################################################

sub get_title {
  my ($self) = @_;
  my $source = $self -> get_source();

  return defined $source
    ? "Odot - " . $source -> { username } . " @ " . $source -> { source }
    : "Odot";
}

###############################################################################

sub open {
  my ($self) = @_;

  my $source = $self -> get_source();

  unless (defined $source) {
    my $dialog = Odot::Backend::DB::Dialog -> new("Open DB",
                                                  $self -> get_window(),
                                                  "gtk-cancel" => "cancel",
                                                  "gtk-open" => "accept");

    $dialog -> set_default_response("accept");

    my $response = $dialog -> run();
    my $new_source = $dialog -> get_source();

    $dialog -> destroy();

    if ($response eq "accept") {
      $self -> set_source($new_source);
      return TRUE;
    }

    return FALSE;
  }

  # Always return true if we already have a source.
  return TRUE;
}

sub save {
  my ($self) = @_;
  my $error = $self -> get_error();

  unless ($self -> write()) {
    if (defined $error) {
      my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                              "modal",
                                              "error",
                                              "none",
                                              $error);

      $dialog -> add_buttons("_Retry" => 0,
                             "gtk-cancel" => 1);

      my $response = $dialog -> run();
      $dialog -> destroy();

      if ($response eq "delete-event" || $response == 1) {
        return FALSE;
      }
      elsif ($response == 0) {
        return $self -> save();
      }
    }

    return FALSE;
  }

  return TRUE;
}

sub save_as {
  my ($self) = @_;

  my $dialog = Odot::Backend::DB::Dialog -> new("Save To DB",
                                                $self -> get_window(),
                                                "gtk-cancel" => "cancel",
                                                "gtk-save" => "accept");

  my $response = $dialog -> run();
  my $source = $dialog -> get_source();

  $dialog -> destroy();

  if ($response eq "accept") {
    $self -> set_source($source);
    return $self -> save();
  }

  return FALSE;
}

###############################################################################

sub read {
  my ($self) = @_;

  my $source = $self -> get_source();
  my @tasks = ();

  return FALSE unless (defined $source);

  my $table = $source -> { table };

  my $handle = DBI -> connect(
    "dbi:" . $source -> { driver } . ":" . $source -> { source },
    $source -> { username },
    $source -> { password },
    { AutoCommit => FALSE,
      PrintError => FALSE }
  ) or $self -> set_error("Could not connect to database: $DBI::errstr."),
       return FALSE;

  my $statement = $handle -> prepare(
    "SELECT path, expanded, due_date, title FROM $table"
  ) or $self -> set_error("Could not read from the table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  $statement -> execute()
    or $self -> set_error("Could not read from the table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  while (my $task = $statement -> fetchrow_hashref()) {
    $task -> { title } = decode("utf8", $task -> { title });
    push @tasks, $task;
  }

  if ($DBI::errstr) {
    $self -> set_error("Could not fetch all data: $DBI::errstr.");
    return FALSE;
  }

  $handle -> disconnect()
    or $self -> set_error("Could not disconnect from database: $DBI::errstr."),
       return FALSE;

  $self -> get_model() -> clear();
  $self -> fill(\@tasks, FALSE);

  return TRUE;
}

sub write {
  my ($self) = @_;

  my $view = $self -> get_view();
  my $model = $self -> get_model();

  my $source = $self -> get_source();

  my $table = $source -> { table };

  my $handle = DBI -> connect(
    "dbi:" . $source -> { driver } . ":" . $source -> { source },
    $source -> { username },
    $source -> { password },
    { AutoCommit => FALSE,
      PrintError => FALSE }
  ) or $self -> set_error("Could not connect to database: $DBI::errstr."),
       return FALSE;

  # Check the table ###########################################################

  my $statement = $handle -> prepare(
    "SELECT path, expanded, due_date, title FROM $table WHERE path = '0'"
  ) or $self -> set_error("Could not test the table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  unless ($statement -> execute()) {
    my $dialog = Gtk2::MessageDialog -> new($self -> get_window(),
                                            "modal",
                                            "question",
                                            "none",
                                            "The table `$table' doesn't seem " .
                                            "to exist or is not suitable for " .
                                            "Odot.  Do you want me to create " .
                                            "a correct one?");

    $dialog -> add_buttons("_No, go on anyway" => 0,
                           "gtk-cancel" => 1,
                           "gtk-yes" => 2);

    my $response = $dialog -> run();
    $dialog -> destroy();

    if ($response eq "delete-event" || $response == 1) {
      $self -> set_error(undef);
      $handle -> disconnect();
      return FALSE;
    }
    elsif ($response == 2) {
      # Ignoring all errors here in case the table doesn't exist.
      $statement = $handle -> prepare("DROP TABLE $table");
      $statement -> execute() if ($statement);

      $statement = $handle -> prepare(
        "CREATE TABLE $table (path text,
                              expanded int(1),
                              due_date text,
                              title text)"
      ) or $self -> set_error("Could not create table: $DBI::errstr."),
           $handle -> disconnect(),
           return FALSE;

      $statement -> execute()
        or $self -> set_error("Could not create table: $DBI::errstr."),
           $handle -> disconnect(),
           return FALSE;
    }
  }

  $statement = $handle -> prepare(
    "DELETE FROM $table"
  ) or $self -> set_error("Could not clear table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  $statement -> execute()
    or $self -> set_error("Could not clear table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  # Store the new stuff #######################################################

  $statement = $handle -> prepare(
    "INSERT INTO $table (path, expanded, due_date, title) VALUES (?, ?, ?, ?)"
  ) or $self -> set_error("Could not write to the table: $DBI::errstr."),
       $handle -> disconnect(),
       return FALSE;

  my $aborted = FALSE;

  $model -> foreach (sub {
    my ($model, $path, $iterator) = @_;

    my ($task, $due_date) = $model -> get($iterator, Odot::COLUMN_TASK,
                                                     Odot::COLUMN_DUE_DATE);

    my ($path_string, $expanded) = ($path -> to_string(),
                                    $view -> row_expanded($path) || 0);

    $statement -> execute($path_string, $expanded, $due_date, $task)
      or $self -> set_error("Could not write to the table: $DBI::errstr."),
         $aborted = TRUE,
         return TRUE;

    return FALSE;
  });

  unless ($aborted) {
    $handle -> commit()
      or $self -> set_error("Could not commit changes: $DBI::errstr."),
         return FALSE;
  }
  else {
    $handle -> rollback()
      or $self -> set_error("Could not rollback changes: $DBI::errstr."),
         return FALSE;
  }

  $handle -> disconnect()
    or $self -> set_error("Could not disconnect from database: $DBI::errstr."),
       return FALSE;

  return not $aborted;
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Backend::DB::Dialog;

use strict;
use warnings;

use base qw(Gtk2::Dialog);
use Glib qw(TRUE FALSE);

sub new {
  my ($class, $title, $window, @buttons) = @_;

  my @drivers = DBI -> available_drivers();

  my $dialog = Gtk2::Dialog -> new_with_buttons($title,
                                                $window,
                                                "modal",
                                                @buttons);

  bless $dialog, $class;

  # Database ##################################################################

  my $db_frame = Gtk2::Frame -> new("Database");
  my $db_vbox = Gtk2::VBox -> new(FALSE, 5);

  my $driver_hbox = Gtk2::HBox -> new(FALSE, 5);
  my $driver_label = Gtk2::Label -> new("Driver:");

  my $factory = Gtk2::ItemFactory -> new("Gtk2::OptionMenu",
                                         "<Odot>/OpenDB");

  $factory -> create_items(undef,
    map { { path => "/$_" } } (@drivers)
  );

  my $driver_menu = $factory -> get_widget("<Odot>/OpenDB");

  $driver_hbox -> pack_start($driver_label, FALSE, FALSE, 0);
  $driver_hbox -> pack_end($driver_menu, FALSE, TRUE, 0);

  my $source_hbox = Gtk2::HBox -> new(FALSE, 5);
  my $source_label = Gtk2::Label -> new("Source:");
  my $source_entry = Gtk2::Entry -> new();

  $source_hbox -> pack_start($source_label, FALSE, FALSE, 0);
  $source_hbox -> pack_end($source_entry, FALSE, TRUE, 0);

  my $table_hbox = Gtk2::HBox -> new(FALSE, 5);
  my $table_label = Gtk2::Label -> new("Table:");
  my $table_entry = Gtk2::Entry -> new();

  $table_entry -> set_text("tasks");

  $table_hbox -> pack_start($table_label, FALSE, FALSE, 0);
  $table_hbox -> pack_end($table_entry, FALSE, TRUE, 0);

  $db_vbox -> set_border_width(5);
  $db_vbox -> pack_start($driver_hbox, FALSE, FALSE, 0);
  $db_vbox -> pack_start($source_hbox, FALSE, FALSE, 0);
  $db_vbox -> pack_start($table_hbox, FALSE, FALSE, 0);

  $db_frame -> add($db_vbox);

  # Authentication ############################################################

  my $auth_frame = Gtk2::Frame -> new("Authentication");
  my $auth_vbox = Gtk2::VBox -> new(FALSE, 5);

  my $user_hbox = Gtk2::HBox -> new(FALSE, 5);
  my $user_label = Gtk2::Label -> new("Username:");
  my $user_entry = Gtk2::Entry -> new();

  $user_hbox -> pack_start($user_label, FALSE, FALSE, 0);
  $user_hbox -> pack_end($user_entry, FALSE, TRUE, 0);

  my $pass_hbox = Gtk2::HBox -> new(FALSE, 5);
  my $pass_label = Gtk2::Label -> new("Password:");
  my $pass_entry = Gtk2::Entry -> new();

  $pass_hbox -> pack_start($pass_label, FALSE, FALSE, 0);
  $pass_hbox -> pack_end($pass_entry, FALSE, TRUE, 0);

  $auth_vbox -> set_border_width(5);
  $auth_vbox -> pack_start($user_hbox, FALSE, FALSE, 0);
  $auth_vbox -> pack_start($pass_hbox, FALSE, FALSE, 0);

  $auth_frame -> add($auth_vbox);

  # Packing ###################################################################

  $dialog -> set(has_separator => FALSE);

  $dialog -> vbox -> set_spacing(5);
  $dialog -> vbox -> pack_start($db_frame, FALSE, FALSE, 0);
  $dialog -> vbox -> pack_start($auth_frame, FALSE, FALSE, 0);

  my $size_group = Gtk2::SizeGroup -> new("horizontal");

  foreach ($source_entry, $table_entry, $user_entry, $pass_entry) {
    $_ -> set_activates_default(TRUE);
  }

  $size_group -> add_widget($driver_menu);
  $size_group -> add_widget($source_entry);
  $size_group -> add_widget($table_entry);
  $size_group -> add_widget($user_entry);
  $size_group -> add_widget($pass_entry);

  $dialog -> { _drivers } = [@drivers];

  $dialog -> { _driver_menu } = $driver_menu;
  $dialog -> { _source_entry } = $source_entry;
  $dialog -> { _table_entry } = $table_entry;
  $dialog -> { _user_entry } = $user_entry;
  $dialog -> { _pass_entry } = $pass_entry;

  $dialog -> show_all();

  return $dialog;
}

sub get_source {
  my ($self) = @_;

  return {
    driver => $self -> { _drivers }
                    -> [$self -> { _driver_menu } -> get_history()],
    source => $self -> { _source_entry } -> get_text(),
    table => $self -> { _table_entry } -> get_text(),
    username => $self -> { _user_entry } -> get_text(),
    password => $self -> { _pass_entry } -> get_text()
  }
}

###############################################################################
# --------------------------------------------------------------------------- #
###############################################################################

package Odot::Stack;

use strict;
use warnings;

use Glib qw(TRUE FALSE);

BEGIN {
  Odot::Accessors -> create(qw(recording));
}

sub new {
  my ($class, $menu_widgets) = @_;

  my $self = bless {}, $class;

  $self -> { _menu_widgets } = $menu_widgets;
  $self -> { _stack } = [];
  $self -> { _active } = -1;

  $self -> set_recording(FALSE);

  $menu_widgets -> { undo } -> set_sensitive(FALSE);
  $menu_widgets -> { redo } -> set_sensitive(FALSE);

  return $self;
}

sub push {
  my ($self, $record) = @_;

  $self -> { _active }++;

  $self -> { _menu_widgets } -> { undo } -> set_sensitive(TRUE);
  $self -> { _menu_widgets } -> { redo } -> set_sensitive(FALSE);

  $self -> { _stack } -> [$self -> { _active }] = $record;

  # If there's old stuff, kill it.
  if ($#{$self -> { _stack }} > $self -> { _active }) {
    splice @{$self -> { _stack }},
           $self -> { _active } + 1,
           $#{$self -> { _stack }} - $self -> { _active };
  }
}

sub fast_forward {
  my ($self) = @_;

  $self -> { _active }++;

  if ($self -> { _active } == $#{$self -> { _stack }}) {
    $self -> { _menu_widgets } -> { redo } -> set_sensitive(FALSE);
  }

  $self -> { _menu_widgets } -> { undo } -> set_sensitive(TRUE);

  return $self -> { _stack } -> [$self -> { _active }];
}

sub rewind {
  my ($self) = @_;

  if ($self -> { _active } <= 0) {
    $self -> { _menu_widgets } -> { undo } -> set_sensitive(FALSE);
  }

  $self -> { _menu_widgets } -> { redo } -> set_sensitive(TRUE);

  return $self -> { _stack } -> [$self -> { _active }--];
}
