#!/usr/bin/perl -w

# $Id: odot,v 1.11 2004/04/26 18:42:42 torsten Exp $

###############################################################################
#                                                                             #
# Odot - A task list manager                                                  #
# Copyright (C) 2003-2004 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                          #
#                                                                             #
###############################################################################

package main;

Odot -> new(@ARGV ? $ARGV[0] : $ENV{ HOME } . "/.odot") -> run();

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

package Odot::Accessors;

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 open ":utf8";

use Gtk2 1.038, -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_UNDERLINE => 4;
use constant COLUMN_COLOR     => 5;

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

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

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

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

our $INSTANCES = 0;

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

  my $self = bless({}, $class);

  $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(),
                                            $source));

  $INSTANCES++;

  return $self;
}

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

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

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

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

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

  $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);
      },
      item_type => "<CheckItem>"
    }
  );

  $self -> get_box() -> pack_start($factory -> get_widget("<Odot>"), 0, 0, 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..."),
    headings => $factory -> get_widget("/View/Column Headings"),
    undo => $factory -> get_widget("/Edit/Undo"),
    redo => $factory -> get_widget("/Edit/Redo")
  });
}

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

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 }) {
        $cell -> get("editable-widget")
              -> signal_connect(key_press_event => sub {
          my ($editable, $event) = @_;

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

          return 0;
        });
      }

      # 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?
      $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(0);
        }
      });

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

      my $view_column =
        Gtk2::TreeViewColumn -> new_with_attributes($column -> { title },
                                                    $cell,
                                                    text => $column -> { column },
                                                    weight => COLUMN_WEIGHT,
                                                    style => COLUMN_STYLE,
                                                    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);
    }
  }

  # foreach my $signal (qw(row-changed row-deleted row-inserted rows-reordered)) {
  #   $model -> signal_connect($signal => sub {
  #     warn $signal;
  #   });
  # }

  $view -> get_selection() -> set_mode("single");
  $view -> set_reorderable(1);
  $view -> set_headers_visible(0);
  $view -> set_headers_clickable(0);

  # 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);
  });

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

    if ($event -> keyval == $Gtk2::Gdk::Keysyms{ Delete }) {
      $self -> node_delete();
      return 1;
    }

    return 0;
  });

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

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

    # Always returning false here to avoid funny d'n'd stuff.
    return 0;
  });

  $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, 1, 1, 0);
}

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

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

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

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

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

  my $box = Gtk2::HBox -> new(0, 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, 1, 1, 0);
  $box -> pack_start($delete, 1, 1, 0);

  $self -> get_box() -> pack_start($box, 0, 0, 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 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, $widget) = @_;
  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($widget -> get_active());
  }
}

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

sub move_editable {
  my ($self, $editable, $keyval) = @_;
  my $view = $self -> get_view();

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

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

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

    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 and then
        # down to the next sibling.  Repeat.
        while (($path -> get_indices())[-1] + 1 ==
               $model -> iter_n_children(
                 my $parent = $model -> iter_parent($iterator))) {
          $path -> up();
          $iterator = $parent;
        }

        $path -> next();
      }
    }
    else {
      # 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));
        }
      }
    }

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

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

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

  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 0;
  });

  # 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)) and
           $parent_path -> is_ancestor($model -> get_path($iterator)) ||
           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();
  my $button = defined($event) ? $event -> button : 0;

  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())[0] >= 0) {
      push(@menu, (
        {
          path => "/Separator",
          item_type => "<Separator>"
        },
        {
          path => "/_Sort",
          callback => sub { $self -> node_sort(0); },
          item_type => "<StockItem>",
          extra_data => "gtk-sort-ascending"
        },
        {
          path => "/Sort _Recursively",
          callback => sub { $self -> node_sort(1); },
          item_type => "<StockItem>",
          extra_data => "gtk-sort-ascending"
        }
      ));
    }
  }

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

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

  $item_factory -> get_widget("<OdotPopup>") -> popup(undef,
                                                      undef,
                                                      undef,
                                                      undef,
                                                      $button,
                                                      0); # $event -> time?
}

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

package Odot::CellEditableDate;

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(0, 0);

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

  my $hbox = Gtk2::HBox -> new(0, 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 }));
  });

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

  $vbox -> pack_start($calendar, 1, 1, 0);
  $vbox -> pack_start($hbox, 0, 0, 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 1;
      }
    }

    return 0;
  });

  $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 1;
    }
    elsif ($event -> keyval == $Gtk2::Gdk::Keysyms{ Escape }) {
      $self -> remove_widget();
      return 1;
    }

    return 0;
  });

  $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,
                            1,
                            [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 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);

  # Necessary to get the correct allocation of the popup.
  $popup -> move(-500, -500);
  $popup -> show_all();

  # Align the top right edge of the popup with the the bottom right edge of the
  # cell.
  my ($x_origin, $y_origin) =  $view -> get_bin_window() -> get_origin();

  $popup -> move(
    $x_origin + $cell_area -> x + $cell_area -> width - $popup -> allocation -> width,
    $y_origin + $cell_area -> y + $cell_area -> height
  );

  return $editable;
}

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

package Odot::CellEditableText;

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

use Glib::Object::Subclass
  Gtk2::TextView::,
  interfaces => [ Gtk2::CellEditable:: ];

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

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

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

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

sub select_all {
  my ($editable) = @_;

  $editable -> signal_emit(select_all => 1);
}

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

package Odot::CellRendererText;

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 } = 1;
      $editable -> editing_done();
      $editable -> remove_widget();

      return 1;
    }

    # 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 } = 0;
      $editable -> editing_done();
      $editable -> remove_widget();

      return 1;
    }

    return 0;
  });

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

    $editable -> { _editing_canceled } ?
      $cell -> editing_canceled() :
      $cell -> signal_emit(edited => $editable -> { _path }, $editable -> get_text());
  });

  $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 } = 0;
  $editable -> { _path } = $path;

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

  return $editable;
}

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

package Odot::Backend;

use Date::Calc qw(Delta_Days);
use XML::Parser;

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

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

  my $self = bless({}, $class);

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menu_widgets);

  $self -> set_source($source);

  $self -> set_stack(Odot::Stack -> new($menu_widgets));

  $self -> setup_parser();
  $self -> setup_clipboard();

  $menu_widgets -> { save } -> set_sensitive(0);

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

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

  $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) = @{$task}{ qw(path expanded due_date title) };

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

    $path -> up();
    my $iterator = $model -> append($path -> get_depth() > 0 ?
                                      $model -> get_iter($path) :
                                      undef);

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

    $self -> check_due_date($iterator);

    # 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, 0) unless ($view -> row_expanded($path));
    }
  }

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

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

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

  $self -> set_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();

        if ($element =~ m/^(path|expanded|due_date|title)$/) {
          $self -> { _tasks } -> [$self -> { _current_task }] -> { $1 } .= $string;
        }
        elsif ($element eq "sorting") {
          $self -> { _sorting } .= $string;
        }
        elsif ($element eq "show_headings") {
          $self -> { _show_headings } .= $string;
        }
        elsif ($element =~ m/^(width|height|x|y)$/) {
          $self -> { _geometry } -> { $1 } .= $string;
        }
      },
      End => sub {
        my ($expat, $element) = @_;
        $self -> { _current_task }++ if ($element eq "task");
      }
    }
  ));
}

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

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

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

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

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

  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, $path, $iterator) = @_;

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

  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);

  $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>
    </task>
__EOD__
}

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

  my $model = $self -> get_model();
  my $parent = $model -> get_path($parent_iterator);

  my $wantarray = wantarray();

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

  my $correct_thread_was_seen = 0;

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

    if ($path -> compare($parent) == 0 or $path -> is_descendant($parent)) {
      $correct_thread_was_seen = 1;

      $data .= $self -> generate_xml($path, $iterator);
      $data_text .= $self -> generate_text($path, $iterator) if ($wantarray);
    }
    elsif ($correct_thread_was_seen) {
      # If we've already been in the correct thread but now aren't anymore,
      # it's safe to assume that there are no children left.
      return 1;
    }

    return 0;
  });

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

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

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

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

    $model -> remove($model -> get_iter(Gtk2::TreePath -> new_from_string($path)));
  }
  elsif ($record -> { action } eq "remove") {
    my ($text, $path) = @{$record -> { data }};

    # FIXME: refactor.  paste() uses identical code.
    $self -> initialize_parser();

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

    if ($@) {
      return $self -> error("Could not redo action: $@");
    }

    my $old_path = $self -> { _tasks } -> [0] -> { path };

    foreach my $task (@{$self -> { _tasks }}) {
      $task -> { path } =~ s/^$old_path/$path/;
    }

    $self -> fill($self -> { _tasks }, 1);
  }
}

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

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

    # FIXME: refactor.  paste() uses identical code.
    $self -> initialize_parser();

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

    if ($@) {
      return $self -> error("Could not redo action: $@");
    }

    my $old_path = $self -> { _tasks } -> [0] -> { path };

    foreach my $task (@{$self -> { _tasks }}) {
      $task -> { path } =~ s/^$old_path/$path/;
    }

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

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

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

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

  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));
}

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 -> generate_recursively($iterator)),
                $model -> get_path($iterator) -> to_string()]
    });

    $model -> remove($iterator);
    $self -> set_changed(1);
  }
}

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 -> generate_recursively($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)) {
      $self -> initialize_parser();

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

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

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

      # Temporarily add an item at the new location to get to the new path.
      my $new_leader = $model -> append($parent);
      my $new_leading_path = $model -> get_path($new_leader) -> to_string();

      $model -> remove($new_leader);

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

      $self -> fill($self -> { _tasks }, 1);
      $self -> set_changed(1);

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

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

sub get_difference {
  my ($self, $due_date) = @_;
  my ($day, $month, $year) = (localtime())[3, 4, 5];

  return Delta_Days($year + 1900, $month + 1, $day, split(/-/, $due_date));
}

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) = @_;
  my $model = $self -> get_model();

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

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

  if ($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 add {
  my ($self) = @_;

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

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

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

  $self -> set_changed(1);

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

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

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

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

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

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

  if (defined($model) && defined($iterator)) {
    if ($model -> iter_has_child($iterator)) {
      return unless ($self -> question("Do you really want to delete the selected item and all of its children?"));
    }

    $model -> remove($iterator);
    $self -> set_changed(1);
  }
}

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);
        $self -> set_changed(1);
      }
    }
  }
}

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 error {
  my ($self, $label) = @_;

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

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

  return 0;
}

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" ? 1 : 0;
}

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

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 $implementation = Odot::Backend::XML -> new($window,
                                                 $view,
                                                 $model,
                                                 $menu_widgets,
                                                 $source);

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

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

    $menu_widgets -> { save } -> set_sensitive(1);
    $self -> set_changed(0);

    return 1;
  }

  return 0;
}

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 $implementation = Odot::Backend::DB -> new($window,
                                                $view,
                                                $model,
                                                $menu_widgets,
                                                $source);

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

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

    $menu_widgets -> { save } -> set_sensitive(1);
    $self -> set_changed(0);

    return 1;
  }

  return 0;
}

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

  if ($self -> get_implementation() -> save()) {
    $self -> set_changed(0);

    return 1;
  }

  return 0;
}

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 $implementation = Odot::Backend::XML -> new($window,
                                                 $view,
                                                 $model,
                                                 $menu_widgets);

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

    $menu_widgets -> { save } -> set_sensitive(1);
    $self -> set_changed(0);

    return 1;
  }

  return 0;
}

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 $implementation = Odot::Backend::DB -> new($window,
                                                $view,
                                                $model,
                                                $menu_widgets);

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

    $menu_widgets -> { save } -> set_sensitive(1);
    $self -> set_changed(0);

    return 1;
  }

  return 0;
}

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

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);
    $self -> check_due_date($iterator);

    $stack -> push({
      action => "add",
      data  => [scalar($self -> generate_recursively($iterator)),
                $path]
    });

    $self -> set_changed(1);
  }

  if ($stack -> get_recording()) {
    $stack -> set_recording(0);
  }

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

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

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

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

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_widget {
  my ($self, $widget) = @_;

  $self -> get_menu_widgets() -> { headings } = $widget;
}

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

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

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

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

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

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

  $self -> get_show_headings_widget() -> set_active($active);
}

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

  return $self -> get_show_headings_widget() -> 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 base qw(Odot::Backend);

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

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

  my $self = bless({}, $class);

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menu_widgets);

  $self -> set_source($source);

  $self -> setup_parser();

  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 1;
    }

    return 0;
  }

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

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 0;
      }
      elsif ($response == 0) {
        return $self -> save();
      }
    }

    return 0;
  }

  return 1;
}

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 0;
      }
    }

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

  return 0;
}

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

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

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

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

  return 0 unless (defined($file) and -e $file);
  $self -> set_error("`$file' is no file"), return 0 unless (-f $file);
  $self -> set_error("`$file' is not readable"), return 0 unless (-r $file);

  $self -> initialize_parser();

  eval { $self -> get_parser() -> parsefile($file); };

  if ($@) {
    $self -> set_error("Could not parse `$file': $@");
    return 0;
  }

  $window -> set_default_size(
    $self -> { _geometry } -> { width },
    $self -> { _geometry } -> { height });

  # $window -> move(
  #   $self -> { _geometry } -> { "x" },
  #   $self -> { _geometry } -> { "y" });

  if (exists($self -> { _sorting })) {
    my ($column, $order) = split(", ", $self -> { _sorting });

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

  $self -> set_show_headings(exists($self -> { _show_headings }) ?
                               $self -> { _show_headings } :
                               0);

  $self -> get_model() -> clear();
  $self -> fill($self -> { _tasks }, 0);

  return 1;
}

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 ($sort_column, $sort_order) = $model -> get_sort_column_id();
  my $show_headings = $self -> get_show_headings() ? 1 : 0;

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

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

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

  $model -> foreach(sub {
    my ($model, $path, $iterator) = @_;
    print ODOT $self -> generate_xml($path, $iterator);
    return 0;
  });

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

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

  return 1;
}

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

package Odot::Backend::DB;

use base qw(Odot::Backend);
use Encode qw(decode);

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

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

  my $self = bless({}, $class);

  $self -> set_window($window);
  $self -> set_view($view);
  $self -> set_model($model);
  $self -> set_menu_widgets($menu_widgets);

  $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 1;
    }

    return 0;
  }

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

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 0;
      }
      elsif ($response == 0) {
        return $self -> save();
      }
    }

    return 0;
  }

  return 1;
}

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 0;
}

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

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

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

  return 0 unless (defined($source));

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

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

  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 0;

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

  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 0;
  }

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

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

  return 1;
}

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 => 0,
      PrintError => 0 }
  ) or $self -> set_error("Could not connect to database: $DBI::errstr."),
       return 0;

  # 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 0;

  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 0;
    }
    elsif ($response == 2) {
      # ignoring all errors here in case the table exists.
      $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 0;

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

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

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

  # 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 0;

  my $aborted = 0;

  $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 = 1,
         return 1;

    return 0;
  });

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

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

  return not $aborted;
}

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

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

use base qw(Gtk2::Dialog);

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(0, 5);

  my $driver_hbox = Gtk2::HBox -> new(0, 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, 0, 0, 0);
  $driver_hbox -> pack_end($driver_menu, 0, 1, 0);

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

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

  my $table_hbox = Gtk2::HBox -> new(0, 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, 0, 0, 0);
  $table_hbox -> pack_end($table_entry, 0, 1, 0);

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

  $db_frame -> add($db_vbox);

  # authentication ############################################################

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

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

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

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

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

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

  $auth_frame -> add($auth_vbox);

  # packing ###################################################################

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

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

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

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

  $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;

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(0);

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

  return $self;
}

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

  $self -> { _active }++;

  $self -> { _menu_widgets } -> { undo } -> set_sensitive(1);
  $self -> { _menu_widgets } -> { redo } -> set_sensitive(0);

  $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(0);
  }

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

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

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

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

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

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