(*
* <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 23 2015-01-14 16:24:21Z gerd $
* ----------------------------------------------------------------------
*
*)
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.
*)
type cb_row = [ `Row of row | `Next_day ]
let rec split_clipboard cb =
match cb with
(`Row _ as row) :: rest ->
let c,r = split_clipboard rest in
(row :: c, r)
| `Next_day :: rest ->
([], rest)
| [] ->
([], [])
;;
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 : cb_row list) : string =
Marshal.to_string rows []
;;
let from_cb_str (str : string) : cb_row list =
Marshal.from_string str 0
;;
let rec remove_dups l =
match l with
h :: l' ->
if List.mem h l' then remove_dups l' else h :: remove_dups l'
| [] ->
[]
;;
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 = []
val mutable db_projects = [] (* Project names from DB *)
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"));
let date_rec = Date.access (Date.from_string date) in
let prj_from =
Date.create
{ year = if date_rec.month = 1 then date_rec.year - 1 else date_rec.year;
month = if date_rec.month = 1 then 12 else date_rec.month - 1;
mday = 1;
} in
let prj_to_0 =
{ year = if date_rec.month = 12 then date_rec.year + 1 else date_rec.year;
month = if date_rec.month = 12 then 1 else date_rec.month + 1;
mday = 1;
} in
let prj_to =
Date.create
{ prj_to_0 with
mday = Date.days_of_month (Date.create prj_to_0) - 1 } in
db_projects <- Db_ac.Entry.projects db login sheet prj_from prj_to;
self # set_variable "db-projects"
(Dyn_enum_value (List.map (fun name -> (name,name)) db_projects));
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
);
db_projects <- List.map fst (self # dyn_enum_variable "db-projects");
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)
);
let avail_prj =
List.filter
(fun n -> n <> "")
(List.sort compare (remove_dups (prj_list @ db_projects))) in
self # set_variable "available-projects"
(Dyn_enum_value (List.map (fun prj -> (prj,prj)) avail_prj));
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 insert_at ins_id =
let id = make_virtual_id rows in
let make_row date =
[ { r_id = `Virtual_id id;
r_day = date;
r_start = None;
r_end = None;
r_duration = None;
r_project = "";
r_description = "";
}
]
in
rows <- insert_before make_row rows ins_id;
method private append_to date =
let id = make_virtual_id rows in
let make_row date =
[ { r_id = `Virtual_id id;
r_day = date;
r_start = None;
r_end = None;
r_duration = None;
r_project = "";
r_description = "";
}
]
in
rows <- append_after make_row rows date;
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 cb_current date =
List.map
(function
`Row row ->
let r_id = !id in
incr id;
{ row with
r_id = `Virtual_id r_id;
r_day = date;
}
| `Next_day ->
assert false
)
cb_current
in
let next_date date =
let date_rec = Date.access date in
let max_mday = Date.days_of_month date in
Date.create { date_rec with
mday = min (date_rec.mday+1) max_mday }
in
let rec do_append_after cb_list date =
prerr_endline ("do_append_after " ^ Date.to_string date);
let cb_current, cb_rest = split_clipboard cb_list in
rows <- append_after (make_rows cb_current) rows date;
if cb_rest <> [] then (
do_append_after cb_rest (next_date date);
)
in
let do_insert_before row_id =
prerr_endline "do_insert_before";
let cb_current, cb_rest = split_clipboard clipboard in
rows <- insert_before (make_rows cb_current) rows row_id;
let date = (List.find (fun row -> row.r_id = row_id) rows).r_day in
if cb_rest <> [] then
do_append_after cb_rest (next_date date);
in
iter_selection
~select_row:do_insert_before
~select_day:(do_append_after clipboard)
(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 delete_at del_id =
rows <- List.filter (fun row -> row.r_id <> del_id) rows
method private copy_to_clipboard ?(cut = false) () =
let last_day = ref None in
let clipboard = ref [] in
let do_copy row_id =
let row = List.find (fun row -> row.r_id = row_id) rows in
( match !last_day with
None -> ()
| Some d ->
if d <> row.r_day then
clipboard := !clipboard @ [`Next_day]
);
clipboard := !clipboard @ [`Row row];
last_day := Some row.r_day
in
iter_selection
~select_row:do_copy
~select_day:(fun _ -> ())
(self # alist_variable "selection");
if !last_day = None 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 date = self # string_variable "session.current-date" in
let dates = date_list (Date.from_string date) 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 get_deferred_dialog () =
match self # dialog_variable "deferred-dialog" with
None ->
assert false
| Some dlg ->
dlg
method private activate_deferred_dialog ?(force = false) () =
let dlg = self # get_deferred_dialog() in
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" ->
let dlg = self # get_deferred_dialog() in
self # set_variable "deferred-dialog" (Dialog_value None);
raise(Change_page dlg#page_name)
| Button "cont-error" ->
self # set_next_page "timesheet-chronocompact"
| Indexed_image_button("insert-here", ins_id, _, _) ->
self # insert_at (row_id_variant ins_id)
| Indexed_image_button("delete-here", del_id, _, _) ->
self # delete_at (row_id_variant del_id)
| Indexed_image_button("append-here", date, _, _) ->
self # append_to (Date.from_string date)
| _ ->
()
);
(* 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
;;