Plasma GitLab Archive
Projects Blog Knowledge

(*
 * <COPYRIGHT>
 * Copyright 2003 Gerd Stolpmann
 *
 * <GPL>
 * This file is part of WTimer.
 *
 * WTimer 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.
 *
 * WTimer 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 WDialog; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 * </>
 *)

(* $Id: editor.ml,v 1.5 2003/03/23 11:59:14 gerd Exp $
 * ----------------------------------------------------------------------
 *
 *)

open Wd_dialog
open Wd_run_cgi
open Wd_types
open Db_types.Types
open Db_types
open Db.Types
open Definitions

exception Syntax_error ;;    
  (* Indicates ill-formed form fields *)

exception Force_dialog of dialog_type ;;  
  (* Like Change_dialog, but bypasses the check on unsaved changes *)

type row_id = [ `Db_id of int | `Virtual_id of int ]
;;

type row = { r_id : row_id;
             r_day : date;
             r_start : time option;
             r_end : time option;
             r_duration : interval option;
             r_project : string;
             r_description : string;
           }
;;

(* Rows are like entries (Db.Types.entry), but:
 * - Rows can have virtual IDs. These IDs are used for newly created
 *   rows until they are saved for the first time. The virtual IDs
 *   have the string representation "_<ID>", i.e. there is an underscore
 *   before the ID's decimal representation.
 * - Rows are ordered by appearance in row lists, whereas entries of the
 *   same day are ordered by the index.
 *)


let date_list month =
  (* Creates a list of all dates of [month] in increasing order. [month]
   * is an arbitrary date of the meant month.
   *)
  let n_days = Date.days_of_month month in
  let rec next_date n =
    if n <= n_days then
      let m = Date.access month in
      (Date.create { m with mday = n }) :: next_date (n+1)
    else
      []
  in
  next_date 1
;;


let map_entries f days =
  List.flatten
    (List.map
       (fun day ->
	  List.map f day.d_entries
       )
       days
    )
;;


let row_id_string row =
  match row.r_id with
      `Db_id id -> string_of_int id
    | `Virtual_id id -> "_" ^ string_of_int id
;;


let row_id_variant id_str =
  if id_str.[0] = '_' then
    `Virtual_id(int_of_string("0" ^ id_str))
  else
    `Db_id(int_of_string id_str)
;;


let make_virtual_id rows =
  let m = ref 0 in
  List.iter
    (fun row ->
       match row.r_id with
	   `Virtual_id k ->
	     m := max !m k
	 | _ ->
	     ()
    )
    rows;
  !m + 1
;;


let make_alist_of_rows f rows =
  List.map
    (fun row ->
       (row_id_string row, f row)
    )
    rows
;;
		    

let make_str_alist_of_rows f =
  make_alist_of_rows (fun x -> String_value(f x))
;;


let rec dates_occuring_in_rows rows =
  match rows with
      row::rows' ->
	let dates' = dates_occuring_in_rows rows' in
	let dates =
	  if List.mem row.r_day dates' then
	    dates'
	  else
	    row.r_day :: dates' in
	dates
    | [] ->
	[]
;;


let rec last_date_in_rows month rows =
  match rows with
      [] -> month
    | [row] -> row.r_day
    | row :: rows' -> last_date_in_rows month rows'
;;


let rec projects_occuring_in_rows rows =
  match rows with
      row::rows' ->
	let prj' = projects_occuring_in_rows rows' in
	let prj =
	  if List.mem row.r_project prj' then
	    prj'
	  else
	    if row.r_project = "" then
	      prj'
	    else
	      row.r_project :: prj' in
	prj
    | [] ->
	[]
;;


let rec insert_before f rows row_id =
  match rows with
      row::rows' ->
	if row.r_id = row_id then
	  let new_rows = f row.r_day in
	  new_rows @ rows
	else
	  row::(insert_before f rows' row_id)
    | [] ->
	assert false
;;


let rec append_after f rows date =
  match rows with
      row::rows' ->
	if Date.cmp row.r_day date > 0 then
	  let new_rows = f date in
	  new_rows @ rows
	else
	  row::(append_after f rows' date)
    | [] ->
	let new_rows = f date in
	new_rows
;;


let day_re = Netstring_pcre.regexp "^day-(.*)$";;


let iter_selection ~select_row ~select_day sel_alist =
  List.iter
    (fun (id_str, sel) ->
       if sel = Enum_value [ "yes" ] then begin
	 match Netstring_pcre.string_match day_re id_str 0 with
	     Some m ->
	       (* Format "day-YYYY-MM-DD" *)
	       let date_str = Netstring_pcre.matched_group m 1 id_str in
	       select_day (Date.from_string date_str)
	   | None ->
	       (* An ID *)
	       let row_id = row_id_variant id_str in
	       select_row row_id
       end
    )
    sel_alist
;;


let rec multiply_list n f arg =
  if n <= 0 then
    []
  else
    (f arg) @ (multiply_list (n-1) f arg)
;;


let to_cb_str (rows : row list) : string =
  Marshal.to_string rows []
;;


let from_cb_str (str : string) : row list =
  Marshal.from_string str 0
;;


class editor db universe name env =
object (self)
  inherit dialog universe name env
  inherit error_behaviour

  val mutable rows = []       (* see read_rows, write_rows *)
  val mutable ins_number = 1
  val mutable errors = []
    
  method private check_auth () =
    check_auth db (self # dialog_variable "session")

  method private load_month() =
    (* Loads the month indicated by session.current-data of the sheet
     * session.current-sheet, and stores it into the dialog variables.
     *)
    let login = User (self # string_variable "session.login-user") in
    let sheet = Instance (self # string_variable "session.current-sheet") in
    let date = self # string_variable "session.current-date" in
    let dates = date_list (Date.from_string date) in
    let days = List.map (Db_ac.Entry.get db login sheet) dates in  
               (* load from db *)
    let entries = map_entries (fun e -> e) days in
    let sorted_entries =
      List.sort
	(fun e1 e2 -> 
	   let x = Date.cmp e1.e_day e2.e_day in
	   if x = 0 then
	     Pervasives.compare e1.e_index e2.e_index
	   else
	     x)
	entries in
    let sorted_rows =
      List.map
	(fun entry ->
	   { r_id = `Db_id (match entry.e_id with 
				Some id -> id 
			      | None -> assert false);
             r_day = entry.e_day;
             r_start = entry.e_start;
             r_end = entry.e_end;
             r_duration = entry.e_duration;
             r_project = entry.e_project;
             r_description = entry.e_description;
           }
	)
	sorted_entries in
    rows <- sorted_rows;
    self # set_variable "checksum" (String_value (self # checksum()));
    self # set_variable "read-only"
      (String_value
	 (if Db.Permission.check db sheet login `Write then "no" else "yes"));
    self # write_rows ();


  method private checksum () =
    (* Returns the checksum of [rows] *)
    let v = self # dump_rows() in
    Digest.to_hex (Digest.string v)

    (* Note: My first attempt was to use the digest of Marshal.to_string 
     * as checksum. This did not work, I do not know why.
     *)


  method private is_modified =
    (* Return whether there are unsaved modifications of data *)
    let db_checksum = self # string_variable "checksum" in
    let cur_checksum = self # checksum() in
    db_checksum <> cur_checksum



  method private dump_rows () =
    (* Creates a string representation of rows *)
    let buffer = Buffer.create 1000 in
    List.iter
      (fun row ->
	 Printf.bprintf 
	   buffer
	   "id=%s day=%s start=%s end=%s dur=%s project=\"%s\" descr=\"%s\"\n"
	   (match row.r_id with
		`Db_id n -> "db_" ^ string_of_int n
	      | `Virtual_id n -> "vt_" ^ string_of_int n
	   )
	   (Date.to_string row.r_day)
	   (match row.r_start with
		None -> "-"
	      | Some x -> Time.to_string x
	   )
	   (match row.r_end with
		None -> "-"
	      | Some x -> Time.to_string x
	   )
	   (match row.r_duration with
		None -> "-"
	      | Some x -> Interval.to_string x
	   )
	   (String.escaped row.r_project)
	   (String.escaped row.r_description)
      )
      rows;
    Buffer.contents buffer


  method private read_rows () =
    (* Read the dialog variables and store contents as [row list] in the
     * instance variable [rows]. When there are errors, [rows] remains empty,
     * and the [errors] variable is filled instead, and the exception
     * [Syntax_error] is raised.
     *)
    let errlist = ref [] in
    let catch_errors prefix id_str f arg =
      try
	f arg
      with
	  Bad_date | Bad_time | Bad_interval | Failure _ ->
	    errlist := (prefix ^ id_str, "yes") :: !errlist;
	    None
    in
    let lookup = self # lookup_string_variable in  (* abbr *)
    let found_rows =
      List.map
	(fun (id_str, date_str) ->
	   let date = 
	     match date_str with 
		 String_value s -> s 
	       | _ -> assert false in
	   let row =
	     { r_id       = row_id_variant id_str;
               r_day      = Date.from_string date;
               r_start    = catch_errors "from-" id_str
			      (fun x ->
				 if x = "" then None 
				 else Some(Time.from_string x) )
			      (lookup "time-from" id_str);
               r_end      = catch_errors "to-" id_str
			      (fun x ->
				 if x = "" then None 
				 else Some(Time.from_string x) )
			      (lookup "time-to" id_str);
               r_duration = catch_errors "duration-" id_str
			      (fun x ->
				 if x = "" then None 
				 else Some(Interval.from_string x) )
			      (lookup "duration" id_str);
               r_project     = lookup "project" id_str;
               r_description = lookup "description" id_str;
             } in
	   match (row.r_start, row.r_end, row.r_duration) with
	       (Some rst), (Some rend), _ ->
		 { row with 
		     r_duration = Some(Interval.distance rend rst)
		 }
	     | _ -> row
	)
	(self # alist_variable "date")
    in
    ( try 
	ins_number <- int_of_string (self # string_variable "ins-number")
      with
	  Failure _ ->
	    errlist := ("ins-number", "") :: !errlist
    );
    if !errlist = [] then (
      rows <- found_rows;
      errors <- [];
    ) else (
      rows <- [];
      errors <- !errlist;
      raise Syntax_error
    )

  method private write_rows () =
    (* Set the dialog variables to the contents of [rows], a [row list] *)
    let month = self # string_variable "session.current-date" in
    let all_dates = date_list (Date.from_string month) in
    self # set_variable "grouped-by-days"
      (Alist_value
	 (List.map 
	    (fun date ->
	       let rows_for_date = 
		 List.find_all (fun r -> r.r_day = date) rows in
	         (* already in the right order! *)
	       let s = 
		 String.concat " " (List.map row_id_string rows_for_date) in

	       (Date.to_string date, String_value s)
	    )
	    all_dates)
      );
    let prj_list = projects_occuring_in_rows rows in
    self # set_variable "grouped-by-projects"
      (Alist_value
	 (List.map 
	    (fun prj ->
	       let rows_for_prj = 
		 List.find_all (fun r -> r.r_project = prj) rows in
	         (* already in the right order! *)
	       let s = 
		 String.concat " " (List.map row_id_string rows_for_prj) in

	       (prj, String_value s)
	    )
	    prj_list)
      );
    self # set_variable "available-projects"
      (Dyn_enum_value (List.map (fun prj -> (prj,prj)) prj_list));
    self # set_variable "date"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> Date.to_string r.r_day) rows));
    self # set_variable "time-from"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> 
				    match r.r_start with
					Some t -> Time.to_string t
				      | None -> "") rows));
    self # set_variable "time-to"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> 
				    match r.r_end with
					Some t -> Time.to_string t
				      | None -> "") rows));
    self # set_variable "duration"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> 
				    match r.r_duration with
					Some i -> Interval.to_string i
				      | None -> "") rows @
	  List.map
	    (fun prj ->
	       let rows_for_prj = 
		 List.find_all (fun r -> r.r_project = prj) rows in
	       ("project-" ^ prj,
		String_value
		  (Interval.to_string
		     (List.fold_left
			(fun sum row -> 
			   match row.r_duration with
			       None -> sum
			     | Some d -> Interval.add sum d)
			(Interval.from_string "00:00")
			rows_for_prj
		     ))
	       )
	    )
	    prj_list
	 )
      );
    self # set_variable "project"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> r.r_project) rows));
    self # set_variable "description"
      (Alist_value
	 (make_str_alist_of_rows (fun r -> r.r_description) rows));
    self # set_variable "selection"
      (Alist_value
	 (make_alist_of_rows (fun _ -> Enum_value [ "no" ]) rows @
	  List.map (fun date -> 
		      ("day-" ^ Date.to_string date),
		      Enum_value [ "no" ]) all_dates
	 )
      );
    self # set_variable "modified" 
      (String_value (if self # is_modified then "yes" else "no"));
    ()


  method private insert () =
    let id = ref (make_virtual_id rows) in
    let something_found = ref false in
    let make_row date =
      let r_id = !id in
      incr id;
      something_found := true;
      [ { r_id = `Virtual_id r_id;
          r_day = date;
          r_start = None;
          r_end = None;
          r_duration = None;
          r_project = "";
          r_description = "";
	}
      ]
    in
    let make_rows date = multiply_list ins_number make_row date in
    let do_insert_before row_id =
      rows <- insert_before make_rows rows row_id in
    let do_append_after date =
      rows <- append_after make_rows rows date in
    iter_selection
      ~select_row:do_insert_before
      ~select_day:do_append_after
      (self # alist_variable "selection");
    if not !something_found then
      let date = 
	Date.from_string(self # string_variable "session.current-date") in
      do_append_after (last_date_in_rows date rows)


  method private paste () =
    let id = ref (make_virtual_id rows) in
    let clipboard_str = self # string_variable "session.clipboard" in
    if clipboard_str <> "" then begin
      let clipboard = from_cb_str clipboard_str in
      let make_rows date =
	List.map
	  (fun row ->
	     let r_id = !id in
	     incr id;
	     { row with
		 r_id = `Virtual_id r_id;
		 r_day = date;
	     }
	  )
	  clipboard
      in
      let do_insert_before row_id =
	rows <- insert_before make_rows rows row_id in
      let do_append_after date =
	rows <- append_after make_rows rows date in
      iter_selection
	~select_row:do_insert_before
	~select_day:do_append_after
	(self # alist_variable "selection");
    end


  method private delete () =
    let something_found = ref false in
    let do_delete row_id =
      something_found := true;
      rows <- List.filter (fun row -> row.r_id <> row_id) rows in
    iter_selection
      ~select_row:do_delete
      ~select_day:(fun _ -> ())
      (self # alist_variable "selection");
    if not !something_found then
      self # add_message "error-missing-selection"


  method private copy_to_clipboard ?(cut = false) () =
    let something_found = ref false in
    let clipboard = ref [] in
    let do_copy row_id =
      something_found := true;
      clipboard := !clipboard @ List.filter (fun row -> row.r_id = row_id) rows
    in
    iter_selection
      ~select_row:do_copy
      ~select_day:(fun _ -> ())
      (self # alist_variable "selection");
    if not !something_found then
      self # add_message "error-missing-selection"
    else (
      self # set_variable "session.clipboard" 
	    (String_value (to_cb_str !clipboard));
      if cut then self # delete()
    )


  method private save () =
    let sheet = Instance (self # string_variable "session.current-sheet") in
    let dates = dates_occuring_in_rows rows in
    let days =
      List.map 
	(fun date ->
	   let rows_for_date = 
	     List.find_all (fun r -> r.r_day = date) rows in
	   let k = ref 0 in
	   let entries =
	     List.map
	       (fun row ->
		  let index = !k in
		  incr k;
		  { e_id = ( match row.r_id with
				 `Db_id id -> Some id
			       | `Virtual_id _ -> None
			   );
		    e_instance = sheet;
		    e_day = date;
		    e_index = index;
		    e_start = row.r_start;
		    e_end = row.r_end;
		    e_duration = row.r_duration;
		    e_project = row.r_project;
		    e_description = row.r_description;
		  }
	       )
	       rows_for_date in
	   { d_instance = sheet;
	     d_day = date;
	     d_entries = entries;
	   }
	)
	dates in
    let login = User (self # string_variable "session.login-user") in
    List.iter (Db_ac.Entry.update db login) days;   (* store in db *)
    Db.Connection.commit db;
    self # set_variable "checksum" (String_value (self # checksum()));
    self # add_message "saved";


  method private time_prev () =
    let date = 
      Date.from_string(self # string_variable "session.current-date") in
    let month = (Date.access date).month in
    let year = (Date.access date).year in
    if year = 1902 && month = 1 then
      self # add_message "error-invalid-date"
    else
      let date' =
	Date.create { year = if month = 1 then year - 1 else year;
		      month = if month = 1 then 12 else month - 1;
		      mday = 1 
		    } in
      self # move_to date'
	

  method private time_next () =
    let date = 
      Date.from_string(self # string_variable "session.current-date") in
    let month = (Date.access date).month in
    let year = (Date.access date).year in
    if year = 2037 && month = 12 then
      self # add_message "error-invalid-date"
    else
      let date' =
	Date.create { year = if month = 12 then year + 1 else year;
		      month = if month = 12 then 1 else month + 1;
		      mday = 1 
		    } in
      self # move_to date'
	

  method private move_to new_date =
    let session =
      match self # dialog_variable "session" with
	  None -> assert false
	| Some s -> s
    in
    let session' = session # copy in
    session' # set_variable "current-date" 
      (String_value(Date.to_string new_date));
    raise(Change_dialog(!Registry.new_editor universe env (Some session')))



  method private activate_deferred_dialog ?(force = false) () =
    match self # dialog_variable "deferred-dialog" with
	None ->
	  assert false
      | Some dlg ->
	  self # set_variable "deferred-dialog" (Dialog_value None);
	  raise(if force then Force_dialog dlg else Change_dialog dlg)


  method private add_message m =
    let msgs = self # enum_variable "message" in
    self # set_variable "message" (Enum_value(m :: msgs));
    self # set_variable "scroll_position" (String_value "");

  method private try_prepare_page() =
    if self # string_variable "modified" = "load" then begin
      self # load_month()
    end


  method private try_handle() =
    (* Before anything else happens, parse the input boxes: *)
    ( try
	self # set_variable "message" (Enum_value []);
	self # set_variable "error-boxes" (Dyn_enum_value []);
	self # read_rows();                     (* may raise Syntax_error *)
      with
	  Syntax_error ->
	    self # set_variable "error-boxes" (Dyn_enum_value errors);
	    self # add_message "error-syntax";
	    self # set_variable "modified" (String_value "yes");
	    raise(Change_page self#page_name);
    );
    (* Catch trials to leave this dialog, so we have the chance to 
     * warn the user if there are unmodified changes that would
     * be lost
     *)
    try
      (* Check whether a file menu button has been pressed: *)
      try
	raise(Change_dialog(Filemenu.handle (self :> dialog_type) db self#event))
      with
	  Filemenu.Not_competent -> 
	    (* No, it is another button: *)
	    ( match self # event with
		  Button "edit-ins" ->
		    self # insert ()
		| Button "edit-del" ->
		    self # delete ()
		| Button "edit-copy" ->
		    self # copy_to_clipboard ()
		| Button "edit-cut" ->
		    self # copy_to_clipboard ~cut:true ()
		| Button "edit-paste" ->
		    self # paste ()
		| Button "file-save" ->
		    self # save ()
		| Button "time-prev" ->
		    self # time_prev ()
		| Button "time-next" ->
		    self # time_next ();
		| Button "unsaved-save" ->
		    self # save ();
		    self # activate_deferred_dialog();
		| Button "unsaved-discard" ->
		    self # activate_deferred_dialog ~force:true ();
		| Button "unsaved-cancel" ->
		    self # set_variable "deferred-dialog" (Dialog_value None);
		    raise(Change_page "timesheet-chrono")
		| Button "cont-error" ->
		    self # set_next_page "timesheet-chrono"
		| _ ->
		    ()
	    );
	    (* Always write rows (to repair formatting details) *)
	    self # write_rows ()
    with
	Change_dialog new_dlg as change_request ->
	  (* The user tries to leave the dialog. Check whether there are
	   * unsaved changes of data.
	   *)
	  if self # is_modified then begin
	    self # set_variable "deferred-dialog" (Dialog_value (Some new_dlg));
	    raise (Change_page "unsaved")
	  end
	  else 
	    raise change_request

      | Force_dialog new_dlg ->
	  raise(Change_dialog new_dlg)
end
;;

Registry.new_editor := 
  fun universe env opt_session ->
    let dlg = universe # create env "editor" in
    dlg # set_variable "session" (Dialog_value opt_session);
    dlg
;;

(* ======================================================================
 * History:
 * 
 * $Log: editor.ml,v $
 * Revision 1.5  2003/03/23 11:59:14  gerd
 * 	GPL
 *
 * Revision 1.4  2003/02/02 23:27:31  gerd
 * 	"duration" is now always set to the correct value
 *
 * Revision 1.3  2003/01/26 23:45:16  gerd
 * 	Improved error behaviour.
 *
 * Revision 1.2  2003/01/16 00:39:25  gerd
 * 	Continued.
 *
 * Revision 1.1  2002/11/16 12:34:51  gerd
 * 	Initial revision
 *
 * 
 *)

This web site is published by Informatikbüro Gerd Stolpmann
Powered by Caml