commit 9677ad4dd3732ec6c85ecd187ae4182c7a7e964a Author: Emmanuel Benoît Date: Sun Feb 5 18:37:25 2012 +0100 Initial import of tasks application This initial import is a heavily modified version of the code I had here, as Arse was modified for other purposes in the meantime and the application no longer worked with it. In addition: * I did not import the user management part yet, * task dependencies are supported in-base, but there is no interface for that yet. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..355afad --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +includes/config.inc.php +database/config.sql +site/.htaccess diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..28b7951 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "arse"] + path = arse + url = git@github.com:tseeker/arse.git diff --git a/README b/README new file mode 100644 index 0000000..50da002 --- /dev/null +++ b/README @@ -0,0 +1,7 @@ +Requires PostgreSQL 9.0+ + +1) Copy/rename database/config-sample.sql to database/config.sql, +2) Set the web app's user name and the database's name in database/config.sql, +3) Run the database.sql script from psql, +4) Copy/rename includes/config-sample.inc.php to includes/config.inc.php, +5) Set user name / password in includes/config.inc.php diff --git a/arse b/arse new file mode 160000 index 0000000..b80ac7e --- /dev/null +++ b/arse @@ -0,0 +1 @@ +Subproject commit b80ac7ee91df3c6aa935e38515907c6f3a0ab63c diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..da5062c --- /dev/null +++ b/database.sql @@ -0,0 +1,17 @@ +\i database/config.sql +\c :db_name + +BEGIN; + +-- Tables from the main database structure +\i database/create-tables.sql + +-- Items tree management and associated functions +\i database/items-tree-triggers.sql +\i database/items-functions.sql + +-- Task management and task dependencies +\i database/tasks-functions.sql +\i database/task-dependencies.sql + +COMMIT; diff --git a/database/config-sample.sql b/database/config-sample.sql new file mode 100644 index 0000000..b840d61 --- /dev/null +++ b/database/config-sample.sql @@ -0,0 +1,2 @@ +\set webapp_user test +\set db_name test diff --git a/database/create-tables.sql b/database/create-tables.sql new file mode 100644 index 0000000..b3d7756 --- /dev/null +++ b/database/create-tables.sql @@ -0,0 +1,133 @@ +-- Sequences +CREATE SEQUENCE items_item_id_seq INCREMENT 1 + MINVALUE 1 MAXVALUE 9223372036854775807 + START 1 CACHE 1; +GRANT SELECT,UPDATE ON items_item_id_seq TO :webapp_user; + +CREATE SEQUENCE users_user_id_seq INCREMENT 1 + MINVALUE 1 MAXVALUE 9223372036854775807 + START 1 CACHE 1; +GRANT SELECT,UPDATE ON users_user_id_seq TO :webapp_user; + +CREATE SEQUENCE tasks_task_id_seq INCREMENT 1 + MINVALUE 1 MAXVALUE 9223372036854775807 + START 1 CACHE 1; +GRANT SELECT,UPDATE ON tasks_task_id_seq TO :webapp_user; + +CREATE SEQUENCE notes_note_id_seq INCREMENT 1 + MINVALUE 1 MAXVALUE 9223372036854775807 + START 1 CACHE 1; +GRANT SELECT,UPDATE ON notes_note_id_seq TO :webapp_user; + +CREATE SEQUENCE task_dependencies_taskdep_id_seq INCREMENT 1 + MINVALUE 1 MAXVALUE 9223372036854775807 + START 1 CACHE 1; + +-- Tables + + +-- Table items +CREATE TABLE items ( + item_id INT NOT NULL DEFAULT NEXTVAL('items_item_id_seq'::TEXT), + item_name VARCHAR(128) NOT NULL, + item_id_parent INT, + item_ordering INT NOT NULL, + PRIMARY KEY(item_id) +); + +CREATE UNIQUE INDEX i_items_unicity ON items (item_name,item_id_parent); +CREATE UNIQUE INDEX i_items_ordering ON items (item_ordering); + +-- Make sure top-level items are unique +CREATE UNIQUE INDEX i_items_top_unicity + ON items ( item_name ) + WHERE item_id_parent IS NULL; + +ALTER TABLE items ADD FOREIGN KEY (item_id_parent) + REFERENCES items(item_id) ON UPDATE NO ACTION ON DELETE CASCADE; + +GRANT SELECT,INSERT,UPDATE,DELETE ON items TO :webapp_user; + + + +-- Table users +CREATE TABLE users ( + user_id INT NOT NULL DEFAULT NEXTVAL('users_user_id_seq'::TEXT), + user_email VARCHAR(256) NOT NULL, + user_salt CHAR(8) NOT NULL, + user_iterations INT NOT NULL, + user_hash CHAR(40) NOT NULL, + PRIMARY KEY(user_id) +); + +CREATE UNIQUE INDEX i_users_user_email ON users (LOWER(user_email)); +GRANT SELECT,INSERT,UPDATE ON users TO :webapp_user; + + + +-- Table tasks +CREATE TABLE tasks ( + task_id INT NOT NULL DEFAULT NEXTVAL('tasks_task_id_seq'::TEXT), + item_id INT NOT NULL REFERENCES items(item_id) ON UPDATE NO ACTION ON DELETE CASCADE, + task_title VARCHAR(256) NOT NULL, + task_priority INT NOT NULL, + task_description TEXT NOT NULL, + task_added TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), + user_id INT NOT NULL REFERENCES users(user_id) ON UPDATE NO ACTION ON DELETE CASCADE, + PRIMARY KEY(task_id) +); + +CREATE UNIQUE INDEX i_tasks_item_id_task_title ON tasks (item_id,task_title); +GRANT SELECT,INSERT,UPDATE,DELETE ON tasks TO :webapp_user; + + + +-- Table items_tree +CREATE TABLE items_tree ( + item_id_parent INT NOT NULL REFERENCES items(item_id) ON UPDATE NO ACTION ON DELETE CASCADE, + item_id_child INT NOT NULL REFERENCES items(item_id) ON UPDATE NO ACTION ON DELETE CASCADE, + pt_depth INT NOT NULL, + PRIMARY KEY(item_id_parent,item_id_child) +); +GRANT SELECT ON items_tree TO :webapp_user; + + + +-- Table completed_tasks +CREATE TABLE completed_tasks ( + task_id INT NOT NULL REFERENCES tasks(task_id) ON UPDATE NO ACTION ON DELETE CASCADE, + completed_task_time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), + user_id INT NOT NULL REFERENCES users(user_id) ON UPDATE NO ACTION ON DELETE CASCADE, + PRIMARY KEY(task_id) +); + +GRANT SELECT,INSERT,UPDATE,DELETE ON completed_tasks TO :webapp_user; + + + +-- Table task_dependencies +CREATE TABLE task_dependencies ( + taskdep_id INT NOT NULL DEFAULT NEXTVAL('task_dependencies_taskdep_id_seq'::TEXT), + task_id INT NOT NULL REFERENCES tasks(task_id) ON UPDATE NO ACTION ON DELETE CASCADE, + task_id_depends INT NOT NULL REFERENCES tasks(task_id) ON UPDATE NO ACTION ON DELETE CASCADE, + PRIMARY KEY(taskdep_id) +); + +CREATE UNIQUE INDEX i_taskdep_unicity ON task_dependencies (task_id,task_id_depends); +CREATE INDEX i_taskdep_bydependency ON task_dependencies (task_id_depends); +GRANT SELECT,INSERT,DELETE ON task_dependencies TO :webapp_user; + + + +-- Table notes +CREATE TABLE notes ( + note_id INT NOT NULL DEFAULT NEXTVAL('notes_note_id_seq'::TEXT), + task_id INT NOT NULL REFERENCES tasks(task_id) ON UPDATE NO ACTION ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(user_id) ON UPDATE NO ACTION ON DELETE CASCADE, + note_added TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), + note_text TEXT NOT NULL, + PRIMARY KEY(note_id) +); + +GRANT SELECT,INSERT,UPDATE,DELETE ON notes TO :webapp_user; + diff --git a/database/items-functions.sql b/database/items-functions.sql new file mode 100644 index 0000000..fcd5dad --- /dev/null +++ b/database/items-functions.sql @@ -0,0 +1,276 @@ +-- +-- Re-order items +-- * first create a temporary table containing text paths +-- * use that temporary table to re-order the main table +-- +CREATE OR REPLACE FUNCTION reorder_items( ) + RETURNS VOID + STRICT VOLATILE + SECURITY INVOKER + AS $$ +DECLARE + i_id INT; + i_parent INT; + i_ordering INT; + i_path TEXT; +BEGIN + -- Create and fill temporary table + CREATE TEMPORARY TABLE items_ordering ( + item_id INT NOT NULL PRIMARY KEY , + item_ordering_path TEXT NOT NULL + ) ON COMMIT DROP; + + FOR i_id , i_parent , i_ordering IN + SELECT p.item_id , p.item_id_parent , p.item_ordering + FROM items p + INNER JOIN items_tree pt + ON pt.item_id_child = p.item_id + GROUP BY p.item_id , p.item_id_parent , p.item_ordering + ORDER BY MAX( pt.pt_depth ) + LOOP + IF i_parent IS NULL THEN + i_path := ''; + ELSE + SELECT INTO i_path item_ordering_path || '/' FROM items_ordering WHERE item_id = i_parent; + END IF; + i_path := i_path || to_char( i_ordering , '000000000000' ); + INSERT INTO items_ordering VALUES ( i_id , i_path ); + END LOOP; + + -- Move all rows out of the way + UPDATE items SET item_ordering = item_ordering + ( + SELECT 1 + 2 * max( item_ordering ) FROM items ); + + -- Re-order items + UPDATE items p1 SET item_ordering = 2 * p2.rn + FROM ( SELECT item_id , row_number() OVER( ORDER BY item_ordering_path ) AS rn FROM items_ordering ) p2 + WHERE p1.item_id = p2.item_id; +END; +$$ LANGUAGE plpgsql; + + +-- Insert a item before another +CREATE OR REPLACE FUNCTION insert_item_before( i_name TEXT , i_before INT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $insert_item_before$ +DECLARE + i_ordering INT; + i_parent INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + SELECT INTO i_ordering , i_parent item_ordering - 1 , item_id_parent + FROM items + WHERE item_id = i_before; + IF NOT FOUND THEN + RETURN 2; + END IF; + + BEGIN + INSERT INTO items ( item_name , item_id_parent , item_ordering ) + VALUES ( i_name , i_parent , i_ordering ); + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + END; + PERFORM reorder_items( ); + RETURN 0; +END; +$insert_item_before$ LANGUAGE plpgsql; + + +-- Insert item as the last child of another +CREATE OR REPLACE FUNCTION insert_item_under( i_name TEXT , i_parent INT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $insert_item_under$ +DECLARE + i_ordering INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + SELECT INTO i_ordering max( item_ordering ) + 1 FROM items; + BEGIN + INSERT INTO items ( item_name , item_id_parent , item_ordering ) + VALUES ( i_name , i_parent , i_ordering ); + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + WHEN foreign_key_violation THEN + RETURN 2; + END; + PERFORM reorder_items( ); + RETURN 0; +END; +$insert_item_under$ LANGUAGE plpgsql; + + +-- Add a item as the last root element +CREATE OR REPLACE FUNCTION insert_item_last( i_name TEXT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $insert_item_last$ +DECLARE + i_ordering INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + SELECT INTO i_ordering max( item_ordering ) + 1 FROM items; + IF i_ordering IS NULL THEN + i_ordering := 0; + END IF; + BEGIN + INSERT INTO items ( item_name , item_ordering ) + VALUES ( i_name , i_ordering ); + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + END; + PERFORM reorder_items( ); + RETURN 0; +END; +$insert_item_last$ LANGUAGE plpgsql; + + +-- Rename a item +CREATE OR REPLACE FUNCTION rename_item( i_id INT , i_name TEXT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $rename_item$ +BEGIN + UPDATE items SET item_name = $2 WHERE item_id = $1; + RETURN 0; +EXCEPTION + WHEN unique_violation THEN + RETURN 1; +END +$rename_item$ LANGUAGE plpgsql; + + + +-- Move a item before another +CREATE OR REPLACE FUNCTION move_item_before( i_id INT , i_before INT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $move_item_before$ +DECLARE + i_ordering INT; + i_parent INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + IF i_before = i_id THEN + RETURN 1; + ELSE + SELECT INTO i_ordering , i_parent item_ordering - 1 , item_id_parent + FROM items + WHERE item_id = i_before; + IF NOT FOUND THEN + RETURN 2; + END IF; + END IF; + + BEGIN + UPDATE items SET item_ordering = i_ordering , item_id_parent = i_parent + WHERE item_id = i_id; + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + END; + + PERFORM reorder_items( ); + RETURN 0; +END; +$move_item_before$ LANGUAGE plpgsql; + + +-- Move a item at the end of another's children +CREATE OR REPLACE FUNCTION move_item_under( i_id INT , i_parent INT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $move_item_under$ +DECLARE + i_ordering INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + IF i_parent = i_id THEN + RETURN 1; + ELSE + SELECT INTO i_ordering MAX( item_ordering ) + 1 + FROM items + WHERE item_id_parent = i_parent; + IF i_ordering IS NULL THEN + i_ordering := 1; + END IF; + END IF; + + BEGIN + UPDATE items SET item_ordering = i_ordering , item_id_parent = i_parent + WHERE item_id = i_id; + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + WHEN foreign_key_violation THEN + RETURN 2; + END; + + PERFORM reorder_items( ); + RETURN 0; +END; +$move_item_under$ LANGUAGE plpgsql; + + +-- Move a item to the end of the tree +CREATE OR REPLACE FUNCTION move_item_last( i_id INT ) + RETURNS INT + STRICT VOLATILE + SECURITY DEFINER + AS $move_item_last$ +DECLARE + i_ordering INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + + SELECT INTO i_ordering MAX( item_ordering ) + 1 + FROM items; + IF i_ordering IS NULL THEN + i_ordering := 0; + END IF; + + BEGIN + UPDATE items SET item_ordering = i_ordering , item_id_parent = NULL + WHERE item_id = i_id; + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + END; + + PERFORM reorder_items( ); + RETURN 0; +END; +$move_item_last$ LANGUAGE plpgsql; + + + +-- Delete a item, moving all children to the item's parent +CREATE OR REPLACE FUNCTION delete_item( i_id INT ) + RETURNS VOID + STRICT VOLATILE + SECURITY DEFINER + AS $delete_item$ +DECLARE + i_parent INT; +BEGIN + PERFORM 1 FROM items FOR UPDATE; + DELETE FROM items WHERE item_id = i_id; + PERFORM reorder_items( ); +END; +$delete_item$ LANGUAGE plpgsql; diff --git a/database/items-tree-triggers.sql b/database/items-tree-triggers.sql new file mode 100644 index 0000000..d33d85b --- /dev/null +++ b/database/items-tree-triggers.sql @@ -0,0 +1,93 @@ +-- +-- Insert tree data for new rows +-- + +CREATE OR REPLACE FUNCTION items_tree_ai( ) + RETURNS TRIGGER + SECURITY DEFINER + AS $items_tree_ai$ +BEGIN + INSERT INTO items_tree( item_id_parent , item_id_child , pt_depth ) + VALUES ( NEW.item_id , NEW.item_id , 0 ); + INSERT INTO items_tree( item_id_parent , item_id_child , pt_depth ) + SELECT x.item_id_parent, NEW.item_id, x.pt_depth + 1 + FROM items_tree x WHERE x.item_id_child = NEW.item_id_parent; + RETURN NEW; +END; +$items_tree_ai$ LANGUAGE 'plpgsql'; + +CREATE TRIGGER items_tree_ai + AFTER INSERT ON items FOR EACH ROW + EXECUTE PROCEDURE items_tree_ai( ); + + +-- +-- Make sure the changes are OK before updating +-- + +CREATE OR REPLACE FUNCTION items_tree_bu( ) + RETURNS TRIGGER + SECURITY DEFINER + AS $items_tree_bu$ +BEGIN + IF NEW.item_id <> OLD.item_id THEN + RAISE EXCEPTION 'Changes to identifiers are forbidden.'; + END IF; + + IF NOT OLD.item_id_parent IS DISTINCT FROM NEW.item_id_parent THEN + RETURN NEW; + END IF; + + PERFORM 1 FROM items_tree + WHERE ( item_id_parent , item_id_child ) = ( NEW.item_id , NEW.item_id_parent ); + IF FOUND THEN + RAISE EXCEPTION 'Update blocked, it would create a loop.'; + END IF; + + RETURN NEW; +END; +$items_tree_bu$ LANGUAGE 'plpgsql'; + +CREATE TRIGGER items_tree_bu + BEFORE UPDATE ON items FOR EACH ROW + EXECUTE PROCEDURE items_tree_bu( ); + + +-- +-- Update tree data when a row's parent is changed +-- + +CREATE OR REPLACE FUNCTION items_tree_au( ) + RETURNS TRIGGER + SECURITY DEFINER + AS $items_tree_au$ +BEGIN + IF NOT OLD.item_id_parent IS DISTINCT FROM NEW.item_id_parent THEN + RETURN NEW; + END IF; + + -- Remove existing lineage for the updated object and its children + IF OLD.item_id_parent IS NOT NULL THEN + DELETE FROM items_tree AS te2 + USING items_tree te1 + WHERE te2.item_id_child = te1.item_id_child + AND te1.item_id_parent = NEW.item_id + AND te2.pt_depth > te1.pt_depth; + END IF; + + -- Create new lineage + IF NEW.item_id_parent IS NOT NULL THEN + INSERT INTO items_tree ( item_id_parent , item_id_child , pt_depth ) + SELECT te1.item_id_parent , te2.item_id_child , te1.pt_depth + te2.pt_depth + 1 + FROM items_tree te1 , items_tree te2 + WHERE te1.item_id_child = NEW.item_id_parent + AND te2.item_id_parent = NEW.item_id; + END IF; + + RETURN NEW; +END; +$items_tree_au$ LANGUAGE 'plpgsql'; + +CREATE TRIGGER items_tree_au + AFTER UPDATE ON items FOR EACH ROW + EXECUTE PROCEDURE items_tree_au( ); diff --git a/database/task-dependencies.sql b/database/task-dependencies.sql new file mode 100644 index 0000000..aa8bc64 --- /dev/null +++ b/database/task-dependencies.sql @@ -0,0 +1,302 @@ +-- +-- Table, indexes and foreign keys +-- + +CREATE TABLE taskdep_nodes( + task_id INT NOT NULL + REFERENCES tasks( task_id ) + ON DELETE CASCADE , + tnode_reverse BOOLEAN NOT NULL , + tnode_id SERIAL NOT NULL , + + tnode_id_parent INT , + tnode_depth INT NOT NULL , + + task_id_copyof INT NOT NULL , + tnode_id_copyof INT , + + taskdep_id INT + REFERENCES task_dependencies( taskdep_id ) + ON DELETE CASCADE , + + PRIMARY KEY( task_id , tnode_reverse , tnode_id ) +); + +CREATE INDEX i_tnode_reversetasks ON taskdep_nodes ( tnode_reverse , tnode_id_parent ); +CREATE INDEX i_tnode_copyof ON taskdep_nodes ( task_id_copyof ); +CREATE INDEX i_tnode_objdep ON taskdep_nodes ( taskdep_id ); + +ALTER TABLE taskdep_nodes + ADD CONSTRAINT fk_tnode_copyof + FOREIGN KEY( task_id_copyof , tnode_reverse , tnode_id_copyof ) + REFERENCES taskdep_nodes( task_id , tnode_reverse , tnode_id ) + ON DELETE CASCADE , + ADD CONSTRAINT fk_tnode_parent + FOREIGN KEY( task_id , tnode_reverse , tnode_id_parent ) + REFERENCES taskdep_nodes( task_id , tnode_reverse , tnode_id ) + ON DELETE CASCADE; + +GRANT SELECT ON taskdep_nodes TO :webapp_user; + + +-- +-- When a task is added, the corresponding dependency tree and +-- reverse dependency tree must be created +-- +CREATE OR REPLACE FUNCTION tgf_task_ai( ) + RETURNS TRIGGER + STRICT VOLATILE + SECURITY DEFINER + AS $tgf_task_ai$ +BEGIN + INSERT INTO taskdep_nodes ( task_id , tnode_reverse , tnode_depth , task_id_copyof ) + VALUES ( NEW.task_id , FALSE , 0 , NEW.task_id ); + INSERT INTO taskdep_nodes ( task_id , tnode_reverse , tnode_depth , task_id_copyof ) + VALUES ( NEW.task_id , TRUE , 0 , NEW.task_id ); + RETURN NEW; +END; +$tgf_task_ai$ LANGUAGE plpgsql; + +CREATE TRIGGER tg_task_ai + AFTER INSERT ON tasks + FOR EACH ROW + EXECUTE PROCEDURE tgf_task_ai( ); + +REVOKE EXECUTE ON FUNCTION tgf_task_ai() FROM PUBLIC; + + +-- +-- Copy the contents of a tree as a child of node on tree . +-- +CREATE OR REPLACE FUNCTION tdtree_copy_tree( + is_reverse BOOLEAN , src_id INT , dest_id INT , + node_id INT , depth INT , dep_id INT + ) + RETURNS VOID + STRICT VOLATILE + AS $tdtree_copy_tree$ +DECLARE + node RECORD; + objid INT; +BEGIN + CREATE TEMPORARY TABLE tdtree_copy_ids( + old_id INT , + new_id INT + ); + + FOR node IN + SELECT * FROM taskdep_nodes nodes + WHERE task_id = src_id + AND tnode_reverse = is_reverse + ORDER BY tnode_depth ASC + LOOP + IF node.tnode_id_copyof IS NULL THEN + node.task_id_copyof := src_id; + node.tnode_id_copyof := node.tnode_id; + END IF; + IF node.tnode_id_parent IS NULL THEN + node.tnode_id_parent := node_id; + node.taskdep_id := dep_id; + ELSE + SELECT INTO node.tnode_id_parent new_id + FROM tdtree_copy_ids + WHERE old_id = node.tnode_id_parent; + END IF; + node.tnode_depth := node.tnode_depth + depth; + + INSERT INTO taskdep_nodes ( task_id , tnode_reverse , tnode_id_parent , + tnode_depth , task_id_copyof , tnode_id_copyof , + taskdep_id ) + VALUES ( dest_id , is_reverse , node.tnode_id_parent , node.tnode_depth , + node.task_id_copyof , node.tnode_id_copyof , + node.taskdep_id ) + RETURNING tnode_id INTO objid; + INSERT INTO tdtree_copy_ids VALUES ( node.tnode_id , objid ); + END LOOP; + + DROP TABLE tdtree_copy_ids; +END; +$tdtree_copy_tree$ LANGUAGE plpgsql; + +REVOKE EXECUTE ON FUNCTION tdtree_copy_tree( BOOLEAN , INT , INT , INT , INT , INT ) FROM PUBLIC; + + +-- +-- Add the contents of tree as a child of the root of tree . +-- Also copy to copies of . +-- +CREATE OR REPLACE FUNCTION tdtree_set_child( is_reverse BOOLEAN , src_id INT , dest_id INT , dep_id INT ) + RETURNS VOID + STRICT VOLATILE + AS $tdtree_set_child$ +DECLARE + tree_id INT; + node_id INT; + depth INT; +BEGIN + FOR tree_id , node_id , depth IN + SELECT task_id , tnode_id , tnode_depth + 1 + FROM taskdep_nodes + WHERE tnode_reverse = is_reverse + AND task_id_copyof = dest_id + LOOP + PERFORM tdtree_copy_tree( is_reverse , src_id , tree_id , node_id , depth , dep_id ); + END LOOP; +END; +$tdtree_set_child$ LANGUAGE plpgsql; + +REVOKE EXECUTE ON FUNCTION tdtree_set_child( BOOLEAN , INT , INT , INT ) FROM PUBLIC; + + +-- +-- When a dependency between tasks is added, the corresponding trees must +-- be updated. +-- +CREATE OR REPLACE FUNCTION tgf_taskdep_ai( ) + RETURNS TRIGGER + STRICT VOLATILE + SECURITY DEFINER + AS $tgf_taskdep_ai$ +BEGIN + PERFORM tdtree_set_child( FALSE , NEW.task_id_depends , NEW.task_id , NEW.taskdep_id ); + PERFORM tdtree_set_child( TRUE , NEW.task_id , NEW.task_id_depends , NEW.taskdep_id ); + RETURN NEW; +END; +$tgf_taskdep_ai$ LANGUAGE plpgsql; + +CREATE TRIGGER tg_taskdep_ai + AFTER INSERT ON task_dependencies + FOR EACH ROW + EXECUTE PROCEDURE tgf_taskdep_ai( ); + +REVOKE EXECUTE ON FUNCTION tgf_taskdep_ai() FROM PUBLIC; + + +-- +-- Before inserting a dependency, we need to lock all trees that have something +-- to do with either nodes. Then we need to make sure there are no cycles and +-- that the new dependency is not redundant. +-- +CREATE OR REPLACE FUNCTION tgf_taskdep_bi( ) + RETURNS TRIGGER + STRICT VOLATILE + SECURITY DEFINER + AS $tgf_taskdep_bi$ +BEGIN + -- Lock all trees + PERFORM 1 + FROM taskdep_nodes n1 + INNER JOIN taskdep_nodes n2 + USING ( task_id ) + WHERE n1.task_id_copyof IN ( NEW.task_id , NEW.task_id_depends ) + FOR UPDATE OF n2; + + -- Check for cycles + PERFORM 1 FROM taskdep_nodes + WHERE task_id = NEW.task_id + AND task_id_copyof = NEW.task_id_depends + AND tnode_reverse; + IF FOUND THEN + RAISE EXCEPTION 'Cycle detected' + USING ERRCODE = 'check_violation'; + END IF; + + -- Check for redundant dependencies + PERFORM 1 + FROM taskdep_nodes n1 + INNER JOIN task_dependencies d + ON d.task_id = n1.task_id_copyof + WHERE n1.task_id = NEW.task_id + AND n1.tnode_reverse + AND d.task_id_depends = NEW.task_id_depends; + IF FOUND THEN + RAISE EXCEPTION '% is the parent of some child of %' , NEW.task_id_depends , NEW.task_id + USING ERRCODE = 'check_violation'; + END IF; + + PERFORM 1 + FROM task_dependencies d1 + INNER JOIN taskdep_nodes n + ON n.task_id = d1.task_id_depends + WHERE d1.task_id = NEW.task_id + AND n.tnode_reverse + AND n.task_id_copyof = NEW.task_id_depends; + IF FOUND THEN + RAISE EXCEPTION '% is the child of some ancestor of %' , NEW.task_id , NEW.task_id_depends + USING ERRCODE = 'check_violation'; + END IF; + + PERFORM 1 FROM taskdep_nodes + WHERE task_id = NEW.task_id + AND task_id_copyof = NEW.task_id_depends + AND NOT tnode_reverse; + IF FOUND THEN + RAISE EXCEPTION '% is already an ancestor of %' , NEW.task_id_depends , NEW.task_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$tgf_taskdep_bi$ LANGUAGE plpgsql; + + +CREATE TRIGGER tg_taskdep_bi + BEFORE INSERT ON task_dependencies + FOR EACH ROW + EXECUTE PROCEDURE tgf_taskdep_bi( ); + +REVOKE EXECUTE ON FUNCTION tgf_taskdep_bi() FROM PUBLIC; + + + +-- +-- List all dependencies that can be added to a task. +-- +CREATE OR REPLACE FUNCTION tasks_possible_dependencies( o_id INT ) + RETURNS SETOF tasks + STRICT STABLE +AS $tasks_possible_dependencies$ + SELECT * FROM tasks + WHERE task_id NOT IN ( + SELECT d.task_id_depends AS id + FROM taskdep_nodes n1 + INNER JOIN task_dependencies d + ON d.task_id = n1.task_id_copyof + WHERE n1.task_id = $1 AND n1.tnode_reverse + UNION ALL SELECT n.task_id_copyof AS id + FROM task_dependencies d1 + INNER JOIN taskdep_nodes n + ON n.task_id = d1.task_id_depends + WHERE d1.task_id = $1 AND n.tnode_reverse + UNION ALL SELECT task_id_copyof AS id + FROM taskdep_nodes + WHERE task_id = $1 + ); +$tasks_possible_dependencies$ LANGUAGE sql; + +REVOKE EXECUTE ON FUNCTION tasks_possible_dependencies( INT ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION tasks_possible_dependencies( INT ) TO :webapp_user; + + +-- +-- Add a dependency +-- +CREATE OR REPLACE FUNCTION tasks_add_dependency( t_id INT , t_dependency INT ) + RETURNS INT + STRICT VOLATILE + SECURITY INVOKER + AS $tasks_add_dependency$ +BEGIN + INSERT INTO task_dependencies( task_id , task_id_depends_on ) + VALUES ( t_id , t_dependency ); + RETURN 0; +EXCEPTION + WHEN foreign_key_violation THEN + RETURN 1; + WHEN check_violation THEN + RETURN 2; +END; +$tasks_add_dependency$ LANGUAGE plpgsql; + +REVOKE EXECUTE ON FUNCTION tasks_add_dependency( INT , INT ) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION tasks_add_dependency( INT , INT ) TO :webapp_user; diff --git a/database/tasks-functions.sql b/database/tasks-functions.sql new file mode 100644 index 0000000..0b3b8ed --- /dev/null +++ b/database/tasks-functions.sql @@ -0,0 +1,78 @@ +-- Create a new task +CREATE OR REPLACE FUNCTION add_task( t_item INT , t_title TEXT , t_description TEXT , t_priority INT , t_user INT ) + RETURNS INT + STRICT VOLATILE + SECURITY INVOKER + AS $add_task$ +BEGIN + INSERT INTO tasks ( item_id , task_title , task_description , task_priority , user_id ) + VALUES ( t_item , t_title , t_description , t_priority , t_user ); + RETURN 0; +EXCEPTION + WHEN unique_violation THEN + RETURN 1; + WHEN foreign_key_violation THEN + RETURN 2; +END; +$add_task$ LANGUAGE plpgsql; + + +-- Mark a task as finished +CREATE OR REPLACE FUNCTION finish_task( t_id INT , u_id INT , n_text TEXT ) + RETURNS INT + STRICT VOLATILE + SECURITY INVOKER + AS $finish_task$ +BEGIN + BEGIN + INSERT INTO completed_tasks ( task_id , user_id ) + VALUES ( t_id , u_id ); + EXCEPTION + WHEN unique_violation THEN + RETURN 1; + END; + + INSERT INTO notes ( task_id , user_id , note_text ) + VALUES ( t_id , u_id , n_text ); + RETURN 0; +END; +$finish_task$ LANGUAGE plpgsql; + + +-- Restart a task +CREATE OR REPLACE FUNCTION restart_task( t_id INT , u_id INT , n_text TEXT ) + RETURNS INT + STRICT VOLATILE + SECURITY INVOKER + AS $restart_task$ +BEGIN + DELETE FROM completed_tasks WHERE task_id = t_id; + IF NOT FOUND THEN + RETURN 1; + END IF; + INSERT INTO notes ( task_id , user_id , note_text ) + VALUES ( t_id , u_id , n_text ); + RETURN 0; +END; +$restart_task$ LANGUAGE plpgsql; + + +-- Update a task +CREATE OR REPLACE FUNCTION update_task( t_id INT , p_id INT , t_title TEXT , t_description TEXT , t_priority INT ) + RETURNS INT + STRICT VOLATILE + SECURITY INVOKER + AS $update_task$ +BEGIN + UPDATE tasks SET item_id = p_id , task_title = t_title , + task_description = t_description , + task_priority = t_priority + WHERE task_id = t_id; + RETURN 0; +EXCEPTION + WHEN unique_violation THEN + RETURN 1; + WHEN foreign_key_violation THEN + RETURN 2; +END; +$update_task$ LANGUAGE plpgsql; diff --git a/includes/config-sample.inc.php b/includes/config-sample.inc.php new file mode 100644 index 0000000..4857c7d --- /dev/null +++ b/includes/config-sample.inc.php @@ -0,0 +1,7 @@ +loginURL = $url; + $this->sessionKey = $key; + } + + + public function handle( Page $page ) + { + session_start( ); + if ( array_key_exists( $this->sessionKey , $_SESSION ) ) { + return null; + } + return $this->loginURL; + } +} + + +class Ctrl_LogInForm + extends Controller +{ + + public function handle( Page $page ) + { + return Loader::Create( 'Form' , 'Log in' , 'login' ) + ->addField( Loader::Create( 'Field' , 'email' , 'text' ) + ->setDescription( 'E-mail address:' ) ) + ->addField( Loader::Create( 'Field' , 'pass' , 'password' ) + ->setDescription( 'Password:' ) ) + ->setSuccessURL( 'home' ) + ->addController( Loader::Ctrl( 'log_in' ) ) + ->controller( ); + } + +} + + +class Ctrl_LogIn + extends Controller + implements FormAware +{ + + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + $email = $this->form->field( 'email' ); + $pass = $this->form->field( 'pass' ); + + $user = Loader::DAO( 'users' )->checkLogin( $email->value( ) , $pass->value( ) ); + if ( $user == null ) { + $email->putError( 'Invalid credentials.' ); + return null; + } + + $_SESSION[ 'uid' ] = $user->user_id; + return true; + } +} + + +class Ctrl_LoggedOut + extends Controller +{ + + public function handle( Page $page ) + { + session_start( ); + if ( array_key_exists( 'uid' , $_SESSION ) ) { + return 'home'; + } + return null; + } + +} diff --git a/includes/t-basics/package.inc.php b/includes/t-basics/package.inc.php new file mode 100644 index 0000000..32df61b --- /dev/null +++ b/includes/t-basics/package.inc.php @@ -0,0 +1,22 @@ +addController( Loader::Ctrl( 'check_session' ) ); + } + + protected function getMenu( ) + { + return array( + 'items' => 'Items' , + 'tasks' => 'Tasks' , + 'logout' => 'Log out' + ); + } +} + + +class Page_TasksHome + extends HTMLPage +{ + + public function __construct( ) + { + parent::__construct( ); + $this->addController( Loader::Ctrl( 'home_page' ) ); + } + + protected function getMenu( ) + { + return array(); + } +} + + +class Page_TasksLogin + extends HTMLPage +{ + + public function __construct() + { + parent::__construct( ); + $this->addController( Loader::Ctrl( 'logged_out' ) ); + $this->addController( Loader::Ctrl( 'log_in_form' ) ); + } + + protected function getMenu( ) + { + return array(); + } + +} + + +class Page_TasksLogout + extends Page_Basic +{ + public function __construct( ) + { + parent::__construct( ); + $this->addController( Loader::Ctrl( 'logout' ) ); + } +} diff --git a/includes/t-data/dao_items.inc.php b/includes/t-data/dao_items.inc.php new file mode 100644 index 0000000..c99ce79 --- /dev/null +++ b/includes/t-data/dao_items.inc.php @@ -0,0 +1,333 @@ +query( 'SELECT insert_item_before( $1 , $2 ) AS error' ); + $result = $query->execute( $name , $before ); + return $result[ 0 ]->error; + } + + + public function createUnder( $name , $under ) + { + $query = $this->query( 'SELECT insert_item_under( $1 , $2 ) AS error' ); + $result = $query->execute( $name , $under ); + return $result[ 0 ]->error; + } + + + public function createLast( $name ) + { + $query = $this->query( 'SELECT insert_item_last( $1 ) AS error' ); + $result = $query->execute( $name ); + return $result[ 0 ]->error; + } + + + public function get( $identifier ) + { + $identifier = (int)$identifier; + if ( array_key_exists( $identifier , $this->loaded ) ) { + return $this->loaded[ $identifier ]; + } + + $getNameQuery = $this->query( 'SELECT item_name FROM items WHERE item_id = $1' , true ); + $result = $getNameQuery->execute( $identifier ); + if ( empty( $result ) ) { + $rObj = null; + } else { + $rObj = new Data_Item( $identifier , $result[ 0 ]->item_name ); + } + $this->loaded[ $identifier ] = $rObj; + + return $rObj; + } + + + public function getLineage( Data_Item $item ) + { + if ( is_array( $item->lineage ) ) { + return; + } + + $query = $this->query( + 'SELECT p.item_id , p.item_name FROM items_tree pt ' + . 'INNER JOIN items p ' + . 'ON p.item_id = pt.item_id_parent ' + . 'WHERE pt.item_id_child = $1 AND pt.pt_depth > 0 ' + . 'ORDER BY pt.pt_depth DESC' ); + $result = $query->execute( $item->id ); + + $stack = array( ); + foreach ( $result as $entry ) { + if ( array_key_exists( $entry->item_id , $this->loaded ) ) { + $object = $this->loaded[ $entry->item_id ]; + } else { + $object = new Data_Item( $entry->item_id , $entry->item_name ); + $this->loaded[ $entry->item_id ] = $object; + } + $object->lineage = $stack; + array_push( $stack , $entry->item_id ); + } + $item->lineage = $stack; + } + + + private function loadTree( ) + { + $query = $this->query( + 'SELECT p.item_id , p.item_name , MAX( t.pt_depth ) as depth ' + . 'FROM items p ' + . 'INNER JOIN items_tree t ON t.item_id_child = p.item_id ' + . 'GROUP BY p.item_id, p.item_name , p.item_ordering ' + . 'ORDER BY p.item_ordering' ); + $result = $query->execute( ); + + $prevEntry = null; + $stack = array( ); + $stackSize = 0; + $this->tree = array( ); + $this->treeList = array( ); + foreach ( $result as $entry ) { + if ( $entry->depth > $stackSize ) { + array_push( $stack , $prevEntry ); + $stackSize ++; + } elseif ( $entry->depth < $stackSize ) { + while ( $stackSize > $entry->depth ) { + array_pop( $stack ); + $stackSize --; + } + } + + if ( array_key_exists( $entry->item_id , $this->loaded ) ) { + $object = $this->loaded[ $entry->item_id ]; + } else { + $object = new Data_Item( $entry->item_id , $entry->item_name ); + $this->loaded[ $entry->item_id ] = $object; + } + $object->children = array( ); + $object->lineage = $stack; + if ( $object->depth = $entry->depth ) { + $object->hasParent = true; + $object->parent = $stack[ $stackSize - 1 ]; + array_push( $this->loaded[ $object->parent ]->children , $object->id ); + } else { + $object->hasParent = false; + } + + $this->loaded[ $object->id ] = $object; + if ( $object->depth == 0 ) { + array_push( $this->tree , $object ); + } + array_push( $this->treeList , $object ); + $prevEntry = $object->id; + } + } + + + public function getTree( ) + { + if ( $this->tree !== null ) { + return $this->tree; + } + $this->loadTree( ); + return $this->tree; + } + + + public function getTreeList( ) + { + if ( $this->tree !== null ) { + return $this->tree; + } + $this->loadTree( ); + return $this->treeList; + } + + + public function getAll( $input ) + { + $output = array( ); + foreach ( $input as $id ) { + array_push( $output , $this->get( $id ) ); + } + return $output; + } + + public function countActiveTasks( ) + { + if ( $this->activeTasksCounted ) { + return; + } + + $query = $this->query( + 'SELECT p.item_id , p.item_name , COUNT(*) AS t_count ' + . 'FROM items p ' + . 'INNER JOIN tasks t USING( item_id ) ' + . 'LEFT OUTER JOIN completed_tasks c USING( task_id ) ' + . 'WHERE c.task_id IS NULL ' + . 'GROUP BY item_id, p.item_name' ); + $results = $query->execute( ); + + foreach ( $results as $entry ) { + if ( array_key_exists( $entry->item_id , $this->loaded ) ) { + $object = $this->loaded[ $entry->item_id ]; + } else { + $object = new Data_Item( $entry->item_id , $entry->item_name ); + $this->loaded[ $entry->item_id ] = $object; + } + $object->activeTasks = $entry->t_count; + } + + $this->activeTasksCounted = true; + } + + + private function checkActiveTasksIn( Data_Item $item ) + { + if ( (int) $item->activeTasks > 0 ) { + return true; + } + + foreach ( $this->getAll( $item->children ) as $child ) { + if ( $this->checkActiveTasksIn( $child ) ) { + return true; + } + } + + return false; + } + + + public function canDelete( Data_Item $item ) + { + if ( $this->tree === null ) { + $this->loadTree( ); + } + $this->countActiveTasks( ); + return ! $this->checkActiveTasksIn( $item ); + } + + + public function getMoveTargetsIn( $tree , $parent , $item ) + { + $positions = array( ); + $count = count( $tree ); + $nameProblem = false; + for ( $i = 0 ; $i <= $count ; $i ++ ) { + // Completely skip the selected item and its children + if ( $i != $count && $tree[ $i ]->id == $item->id ) { + continue; + } + + // Check for invalid positions (i.e. before/after selected item) + $invalidPos = ( $i > 0 && $tree[ $i - 1 ]->id == $item->id ); + + // Check for duplicate name + $nameProblem = $nameProblem || ( $i != $count && $tree[ $i ]->name == $item->name ); + + // Get children positions + if ( $i < $count ) { + $sub = $this->getMoveTargetsIn( $this->getAll( $tree[ $i ]->children ) , $tree[ $i ] , $item ); + } else { + $sub = array( ); + } + + array_push( $positions , array( + 'item' => ( $i < $count ) ? $tree[ $i ]->id : ( is_null( $parent ) ? null : $parent->id ) , + 'end' => ( $i == $count ) ? 1 : 0 , + 'valid' => ! $invalidPos , + 'sub' => $sub + ) ); + } + + // Add all data to output array + $realPos = array( ); + foreach ( $positions as $pos ) { + if ( $pos['valid'] && ! $nameProblem ) { + array_push( $realPos , $pos['end'] . ':' . $pos[ 'item' ] ); + } + $realPos = array_merge( $realPos , $pos[ 'sub' ] ); + } + + return $realPos; + } + + + public function getMoveTargets( Data_Item $item ) + { + // + // A destination is a (parent,position) couple, where the + // position corresponds to the item before which the selected + // item is to be moved. + // + // A destination is valid if: + // - there is no parent or the parent does not have the + // selected item in its lineage; + // - there is no item in the parent (or at the root if + // there is no parent) that uses the same name as the selected + // item, unless that item *is* the selected item; + // - the item at the specified position is not the selected + // item, or there is no item at the specified position; + // - the item before the specified position is not the + // selected item, or the specified position is 0. + // + + $result = $this->getMoveTargetsIn( $this->getTree( ) , null , $item ); + return $result; + } + + public function canMove( Data_Item $item ) + { + $result = $this->getMoveTargets( $item ); + return ! empty( $result ); + } + + + public function moveBefore( $item , $before ) + { + $result = $this->query( 'SELECT move_item_before( $1 , $2 ) AS error' ) + ->execute( $item , $before ); + return $result[ 0 ]->error; + } + + + public function moveUnder( $item , $under ) + { + $result = $this->query( 'SELECT move_item_under( $1 , $2 ) AS error' ) + ->execute( $item , $under ); + return $result[ 0 ]->error; + } + + + public function moveLast( $item ) + { + $result = $this->query( 'SELECT move_item_last( $1 ) AS error' ) + ->execute( $item ); + return $result[ 0 ]->error; + } + + + public function destroy( $item ) + { + $this->query( 'SELECT delete_item( $1 )' )->execute( $item ); + } + + + public function rename( $item , $name ) + { + $result = $this->query( 'SELECT rename_item( $1 , $2 ) AS error' ) + ->execute( $item , $name ); + return $result[ 0 ]->error; + } +} diff --git a/includes/t-data/dao_tasks.inc.php b/includes/t-data/dao_tasks.inc.php new file mode 100644 index 0000000..796f0c1 --- /dev/null +++ b/includes/t-data/dao_tasks.inc.php @@ -0,0 +1,176 @@ + 'Lowest' , + '2' => 'Low' , + '3' => 'Normal' , + '4' => 'High' , + '5' => 'Very high' , + ); + + + public function translatePriority( $value ) + { + return DAO_Tasks::$priorities[ "$value" ]; + } + + + public function getAllTasks( ) + { + return $this->query( + 'SELECT t.task_id AS id, t.item_id AS item, t.task_title AS title, ' + . 't.task_description AS description, t.task_added AS added_at, ' + . 'u1.user_email AS added_by, ct.completed_task_time AS completed_at, ' + . 'u2.user_email AS completed_by , t.task_priority AS priority ' + . 'FROM tasks t ' + . 'INNER JOIN users u1 ON u1.user_id = t.user_id ' + . 'LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id ' + . 'LEFT OUTER JOIN users u2 ON u2.user_id = ct.user_id ' + . 'ORDER BY ( CASE WHEN ct.task_id IS NULL THEN t.task_priority ELSE -1 END ) DESC , ' + . 't.task_added DESC' )->execute( ); + } + + public function getAllActiveTasks( ) + { + return $this->query( + 'SELECT t.task_id AS id, t.item_id AS item, t.task_title AS title, ' + . 't.task_description AS description, t.task_added AS added_at, ' + . 'u1.user_email AS added_by, NULL AS completed_at, NULL AS completed_by , ' + . 't.task_priority AS priority ' + . 'FROM tasks t ' + . 'INNER JOIN users u1 ON u1.user_id = t.user_id ' + . 'LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id ' + . 'WHERE ct.task_id IS NULL ' + . 'ORDER BY t.task_priority DESC , t.task_added DESC' )->execute( ); + } + + + public function getTasksAt( Data_Item $item ) + { + return $this->query( + 'SELECT t.task_id AS id, t.task_title AS title, ' + . 't.task_description AS description, t.task_added AS added_at, ' + . 'u1.user_email AS added_by, ct.completed_task_time AS completed_at, ' + . 'u2.user_email AS completed_by , t.task_priority AS priority ' + . 'FROM tasks t ' + . 'INNER JOIN users u1 ON u1.user_id = t.user_id ' + . 'LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id ' + . 'LEFT OUTER JOIN users u2 ON u2.user_id = ct.user_id ' + . 'WHERE t.item_id = $1' + . 'ORDER BY ( CASE WHEN ct.task_id IS NULL THEN t.task_priority ELSE -1 END ) DESC , ' + . 't.task_added DESC' )->execute( $item->id ); + } + + + public function addTask( $item , $title , $priority , $description ) + { + $result = $this->query( 'SELECT add_task( $1 , $2 , $3 , $4 , $5 ) AS error' ) + ->execute( $item , $title , $description , $priority , $_SESSION[ 'uid' ] ); + return $result[0]->error; + } + + + public function get( $id ) + { + $result = $this->query( + 'SELECT t.task_id AS id, t.task_title AS title, t.item_id AS item ,' + . 't.task_description AS description, t.task_added AS added_at, ' + . 'u1.user_email AS added_by, ct.completed_task_time AS completed_at, ' + . 'u2.user_email AS completed_by, t.user_id AS uid , ' + . 't.task_priority AS priority ' + . 'FROM tasks t ' + . 'INNER JOIN users u1 ON u1.user_id = t.user_id ' + . 'LEFT OUTER JOIN completed_tasks ct ON ct.task_id = t.task_id ' + . 'LEFT OUTER JOIN users u2 ON u2.user_id = ct.user_id ' + . 'WHERE t.task_id = $1' )->execute( $id ); + if ( empty( $result ) ) { + return null; + } + + $task = $result[ 0 ]; + $task->notes = $this->query( + 'SELECT n.note_id AS id , n.user_id AS uid , u.user_email AS author , ' + . 'n.note_added AS added_at , n.note_text AS "text" ' + . 'FROM notes n ' + . 'INNER JOIN users u USING (user_id) ' + . 'WHERE n.task_id = $1 ' + . 'ORDER BY n.note_added DESC' )->execute( $id ); + return $task; + } + + + public function canDelete( $task ) + { + if ( $task->completed_by !== null ) { + $ts = strtotime( $task->completed_at ); + return ( time() - $ts > 7 * 3600 * 24 ); + } + $ts = strtotime( $task->added_at ); + return ( time() - $ts < 600 ) && ( $task->uid == $_SESSION[ 'uid' ] ); + } + + + public function delete( $task ) + { + $this->query( 'DELETE FROM tasks WHERE task_id = $1' )->execute( $task ); + } + + + public function finish( $task , $noteText ) + { + $this->query( 'SELECT finish_task( $1 , $2 , $3 )' ) + ->execute( $task , $_SESSION[ 'uid' ] , $noteText ); + } + + + public function restart( $task , $noteText ) + { + $this->query( 'SELECT restart_task( $1 , $2 , $3 )' ) + ->execute( $task , $_SESSION[ 'uid' ] , $noteText ); + } + + public function updateTask( $id , $item , $title , $priority , $description ) + { + $result = $this->query( 'SELECT update_task( $1 , $2 , $3 , $4 , $5 ) AS error' ) + ->execute( $id , $item , $title , $description , $priority ); + return $result[0]->error; + } + + public function addNote( $task , $note ) + { + $this->query( 'INSERT INTO notes ( task_id , user_id , note_text ) VALUES ( $1 , $2 , $3 )' ) + ->execute( $task , $_SESSION[ 'uid' ] , $note ); + } + + public function getNote( $id ) + { + $query = $this->query( + 'SELECT n.note_id AS id , n.note_text AS text , n.note_added AS added_at , ' + . 'n.task_id AS task , ' + . '( n.user_id = $2 AND t.task_id IS NULL ) AS editable ' + . 'FROM notes n ' + . 'LEFT OUTER JOIN completed_tasks t USING (task_id) ' + . 'WHERE n.note_id = $1' ); + $result = $query->execute( $id , $_SESSION[ 'uid' ] ); + if ( empty( $result ) ) { + return null; + } + $result[ 0 ]->editable = ( $result[ 0 ]->editable == 't' ); + return $result[ 0 ]; + } + + public function deleteNote( $id ) + { + $this->query( 'DELETE FROM notes WHERE note_id = $1' )->execute( $id ); + } + + public function updateNote( $id , $text ) + { + $this->query( 'UPDATE notes SET note_text = $2 , note_added = now( ) WHERE note_id = $1' ) + ->execute( $id , $text ); + } + +} diff --git a/includes/t-data/item.inc.php b/includes/t-data/item.inc.php new file mode 100644 index 0000000..fbb7e49 --- /dev/null +++ b/includes/t-data/item.inc.php @@ -0,0 +1,42 @@ +id = $id; + $this->name = $name; + } + + + public function getIdentifier( ) + { + return $this->id; + } + + + public function getName( ) + { + return $this->name; + } + + + public function getDepth( ) + { + if ( $this->depth === null ) { + throw new Exception( "Method not implemented" ); + } + return $this->depth; + } +} diff --git a/includes/t-data/package.inc.php b/includes/t-data/package.inc.php new file mode 100644 index 0000000..d8ec03e --- /dev/null +++ b/includes/t-data/package.inc.php @@ -0,0 +1,10 @@ +useParameter = $useParameter; + } + + public function handle( Page $page ) + { + $items = Loader::DAO( 'items' ); + $tree = $items->getTree( ); + $items->countActiveTasks( ); + + if ( $this->useParameter ) { + $root = (int) $this->getParameter( $this->useParameter , 'GET' ); + } else { + $root = null; + } + + $buttonURL = 'items/add'; + if ( $root != null ) { + $rootObj = $items->get( $root ); + $tree = $items->getAll( $rootObj->children ); + $boxTitle = 'Child items'; + $buttonURL .= "?from=$root"; + } else { + $boxTitle = null; + } + + return Loader::View( 'box' , $boxTitle , Loader::View( 'items_tree' , $tree ) ) + ->setClass( 'list' ) + ->addButton( BoxButton::create( 'Add item' , $buttonURL ) + ->setClass( 'list-add' ) ); + } + + + public function getTitle( ) + { + return 'Items'; + } +} + + +class Ctrl_ItemDetails + extends Controller +{ + private $item; + + public function __construct( Data_Item $item ) + { + $this->item = $item; + } + + public function handle( Page $page ) + { + $items = Loader::DAO( 'items'); + $items->getTree( ); + + $box = Loader::View( 'box' , 'Details' , Loader::View( 'item_details' , $this->item ) ) + ->addButton( BoxButton::create( 'Edit item' , 'items/edit?id=' . $this->item->id ) + ->setClass( 'icon edit' ) ); + + if ( $items->canMove( $this->item ) ) { + $box->addButton( BoxButton::create( 'Move item' , 'items/move?id=' . $this->item->id ) + ->setClass( 'icon move' ) ); + } + + if ( $items->canDelete( $this->item ) ) { + $box->addButton( BoxButton::create( 'Delete item' , 'items/delete?id=' . $this->item->id ) + ->setClass( 'icon delete' ) ); + } + + return $box; + } + +} + + +class Ctrl_AddItem + extends Controller + implements FormAware +{ + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + $name = $this->form->field( 'name' ); + $before = $this->form->field( 'before' ); + list( $after , $id ) = explode( ':' , $before->value( ) ); + + $items = Loader::DAO( 'items' ); + if ( $id === '' ) { + $error = $items->createLast( $name->value( ) ); + } elseif ( $after == 1 ) { + $error = $items->createUnder( $name->value( ) , $id ); + } else { + $error = $items->createBefore( $name->value( ) , $id ); + } + + switch ( $error ) { + + case 0: + return true; + + case 1: + $name->putError( 'This name is not unique' ); + break; + + case 2: + $before->putError( 'The item you selected no longer exists' ); + break; + + default: + $name->putError( 'An unknown error occurred (' . $error . ')' ); + break; + + } + + return null; + } +} + + +class Ctrl_MoveItem + extends Controller + implements FormAware +{ + private $form; + + + public function setForm( Form $form ) + { + $this->form = $form; + } + + + public function handle( Page $page ) + { + $srcId = (int) $this->form->field( 'id' )->value( ); + $dest = $this->form->field( 'destination' ); + list( $after , $id ) = explode( ':' , $dest->value( ) ); + + $items = Loader::DAO( 'items' ); + if ( $id === '' ) { + $error = $items->moveLast( $srcId ); + } elseif ( $after == 1 ) { + $error = $items->moveUnder( $srcId , $id ); + } else { + $error = $items->moveBefore( $srcId , $id ); + } + + switch ( $error ) { + + case 0: + return true; + + case 1: + $dest->putError( 'Invalid destination' ); + break; + + case 2: + $before->putError( 'The place you selected no longer exists.' ); + break; + + default: + $name->putError( 'An unknown error occurred (' . $error . ')' ); + break; + + } + + return null; + } + +} + + +class Ctrl_DeleteItem + extends Controller + implements FormAware +{ + private $form; + + + public function setForm( Form $form ) + { + $this->form = $form; + } + + + public function handle( Page $page ) + { + $id = (int) $this->form->field( 'id' )->value( ); + + $items = Loader::DAO( 'items' ); + if ( ! $items->canDelete( $items->get( $id ) ) ) { + return false; + } + $items->destroy( $id ); + return true; + } + +} + + +class Ctrl_EditItem + extends Controller + implements FormAware +{ + private $form; + + + public function setForm( Form $form ) + { + $this->form = $form; + } + + + public function handle( Page $page ) + { + $id = (int) $this->form->field( 'id' )->value( ); + $items = Loader::DAO( 'items' ); + $item = $items->get( $id ); + + $name = $this->form->field( 'name' ); + if ( $name->value( ) === $item->name ) { + return true; + } + + $error = $items->rename( $id , $name->value( ) ); + switch ( $error ) { + + case 0: + return true; + + case 1: + $name->putError( 'Ce nom n\'est pas unique' ); + break; + + default: + $name->putError( 'Une erreur inconnue s\'est produite (' . $error . ')' ); + break; + + } + + return null; + } + +} + + +class Ctrl_ItemTasks + extends Controller +{ + private $item; + + public function __construct( Data_Item $item ) + { + $this->item = $item; + } + + + public function handle( Page $page ) + { + $tasks = Loader::DAO( 'tasks' )->getTasksAt( $this->item ); + + return Loader::View( 'box' , 'Tasks' , Loader::View( 'tasks' , $tasks ) ) + ->addButton( BoxButton::create( 'Add task' , 'tasks/add?to=' . $this->item->id ) + ->setClass( 'list-add' ) ); + } +} diff --git a/includes/t-items/fields.inc.php b/includes/t-items/fields.inc.php new file mode 100644 index 0000000..52d25ae --- /dev/null +++ b/includes/t-items/fields.inc.php @@ -0,0 +1,44 @@ +okLocations = $okLocations; + } + + public function replace( $value ) + { + $exploded = explode( ':' , $value ); + if ( count( $exploded ) != 2 ) { + $exploded = array( 0 , $value ); + } + + if ( $exploded[ 1 ] == '' ) { + $exploded[ 0 ] = 1; + } else { + $exploded[ 0 ] = ( $exploded[ 0 ] == '0' ) ? 0 : 1; + $exploded[ 1 ] = (int) $exploded[ 1 ]; + } + + return join( ':' , $exploded ); + } + + public function validate( $value ) + { + list( $inside , $before ) = explode( ':' , $value ); + if ( $before != '' && Loader::DAO( 'items' )->get( $before ) == null ) { + return array( 'This item no longer exists.' ); + } + if ( ! ( empty( $this->okLocations ) || in_array( $value , $this->okLocations ) ) ) { + return array( 'Invalid destination' ); + } + return null; + } +} + diff --git a/includes/t-items/package.inc.php b/includes/t-items/package.inc.php new file mode 100644 index 0000000..8cd5f8d --- /dev/null +++ b/includes/t-items/package.inc.php @@ -0,0 +1,32 @@ +getParameter( 'id' ); + } catch ( ParameterException $e ) { + return $page->getBaseURL() . '/items'; + } + + $item = Loader::DAO( 'items' )->get( $id ); + if ( $item === null ) { + return $page->getBaseURL() . '/items'; + } + $page->setTitle( $item->name . ' (item)' ); + + return array( + Loader::Ctrl( 'item_details' , $item ) , + Loader::Ctrl( 'items_tree' , 'id' ) , + Loader::Ctrl( 'item_tasks' , $item ) + ); + } +} + + +class Ctrl_AddItemForm + extends Controller + implements TitleProvider +{ + private $items; + + public function handle( Page $page ) + { + $this->items = Loader::DAO( 'items' ); + + $locationField = Loader::Create( 'Item_LocationField' , array( ) ); + + $form = Loader::Create( 'Form' , 'Add this item' , 'create-item' , 'New item information' ) + ->addField( Loader::Create( 'Field' , 'name' , 'text' ) + ->setDescription( 'Item name:' ) + ->setModifier( Loader::Create( 'Modifier_TrimString' ) ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'This name' , 2 , 128 ) ) ) + ->addField( $before = Loader::Create( 'Field' , 'before' , 'select' ) + ->setDescription( 'Insert before:' ) + ->setMandatory( false ) + ->setModifier( $locationField ) + ->setValidator( $locationField ) ) + ->addField( Loader::Create( 'Field' , 'from' , 'hidden' ) ) + ->addController( Loader::Ctrl( 'add_item' ) ); + + // Add options to the insert location selector + $this->addTreeOptions( $before , $this->items->getTree( ) ); + $before->addOption( '1:' , 'the end of the list' ); + + // Try to guess return page and default insert location + try { + $from = (int) $this->getParameter( 'from' ); + } catch ( ParameterException $e ) { + $from = 0; + } + if ( $from == 0 ) { + $returnURL = 'items'; + $defBefore = '1:'; + } else { + $returnURL = 'items/view?id=' . $from; + $defBefore = '1:' . $from; + } + $form->setURL( $returnURL ); + $form->field( 'from' )->setDefaultValue( $from ); + $before->setDefaultValue( $defBefore ); + + return $form->controller( ); + } + + private function addTreeOptions( $before , $tree ) + { + foreach ( $tree as $item ) { + $name = '-' . str_repeat( '--' , $item->getDepth( ) ) . ' ' . $item->getName( ); + $before->addOption( '0:' . $item->getIdentifier( ) , $name ); + + if ( !empty( $item->children ) ) { + $this->addTreeOptions( $before , $this->items->getAll( $item->children ) ); + } + + $name = '-' . str_repeat( '--' , $item->getDepth( ) + 1 ) . ' the end of ' . $item->getName( ); + $before->addOption( '1:' . $item->getIdentifier( ) , $name ); + } + } + + public function getTitle( ) + { + return 'Add item'; + } +} + + +class Ctrl_MoveItemForm + extends Controller +{ + private $items; + + public function handle( Page $page ) + { + // Check selected item + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'items'; + } + + $this->items = Loader::DAO( 'items' ); + $item = $this->items->get( $id ); + if ( $item === null ) { + return 'items'; + } + + $destinations = $this->items->getMoveTargets( $item ); + if ( empty( $destinations ) ) { + return 'items/view?id=' . $item->id; + } + $page->setTitle( $item->name . ' (item)' ); + + // Field modifier / validator + $locationField = Loader::Create( 'Item_LocationField' , $destinations ); + + // Generate form + $form = Loader::Create( 'Form' , 'Move item' , 'move-item' ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' )->setDefaultValue( $item->id ) ) + ->addField( $dest = Loader::Create( 'Field' , 'destination' , 'select' ) + ->setDescription( 'Move before:' ) + ->setModifier( $locationField ) + ->setValidator( $locationField ) ) + ->setURL( 'items/view?id=' . $item->id ) + ->addController( Loader::Ctrl( 'move_item' ) ); + $this->addDestinations( $dest , $this->items->getTree( ) , $destinations ); + if ( in_array( '1:' , $destinations ) ) { + $dest->addOption( '1:' , 'the end of the list' ); + } + + return $form->controller( ); + } + + + private function addDestinations( $field , $tree , $destinations ) + { + foreach ( $tree as $item ) { + $id = '0:' . $item->id; + $disabled = ! in_array( $id , $destinations ); + if ( $disabled && ! $this->checkChildren( $item , $destinations ) ) { + continue; + } + + $name = '-' . str_repeat( '--' , $item->getDepth( ) ) . ' ' . $item->getName( ); + $field->addOption( $id , $name , $disabled ); + + if ( !empty( $item->children ) ) { + $this->addDestinations( $field , $this->items->getAll( $item->children ) , $destinations ); + } + + $id = '1:' . $item->id; + if ( ! in_array( $id , $destinations ) ) { + continue; + } + $name = '-' . str_repeat( '--' , $item->getDepth( ) + 1 ) . ' the end of ' . $item->getName( ); + $field->addOption( $id , $name ); + } + } + + + private function checkChildren( $item , $destinations ) + { + $children = $this->items->getAll( $item->children ); + foreach ( $children as $child ) { + if ( in_array( '0:' . $child->id , $destinations ) ) { + return true; + } + } + + if ( in_array( '1:' . $item->id , $destinations ) ) { + return true; + } + + foreach ( $children as $child ) { + if ( $this->checkChildren( $child , $destinations ) ) { + return true; + } + } + return false; + } +} + + +class Ctrl_DeleteItemForm + extends Controller +{ + private $items; + + public function handle( Page $page ) + { + // Check selected item + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'items'; + } + + $items = Loader::DAO( 'items' ); + $item = $items->get( $id ); + if ( $item === null ) { + return 'items'; + } + if ( ! $items->canDelete( $item ) ) { + return 'items/view?id=' . $id; + } + $page->setTitle( $item->name . ' (item)' ); + + // Generate confirmation text + $confText = HTML::make( 'div' ) + ->appendElement( HTML::make( 'p' ) + ->appendText( 'You are about to delete this item.' ) ) + ->appendElement( HTML::make( 'p' ) + ->appendText( 'All child items and all tasks the item contains will be deleted permanently.' ) ) + ->appendElement( HTML::make( 'p' ) + ->appendText( 'It is impossible to undo this operation.' ) ); + + // Generate form + $form = Loader::Create( 'Form' , 'Delete the item' , 'delete-item' , 'Please confirm' ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $item->id ) ) + ->addField( Loader::Create( 'Field' , 'confirm' , 'html' )->setDefaultValue( $confText ) ) + ->setCancelURL( 'items/view?id=' . $item->id ) + ->setSuccessURL( 'items' ) // XXX: use lineage + ->addController( Loader::Ctrl( 'delete_item' ) ); + + return $form->controller( ); + } +} + + +class Ctrl_EditItemForm + extends Controller +{ + private $items; + + public function handle( Page $page ) + { + // Check selected item + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'items'; + } + + $this->items = Loader::DAO( 'items' ); + $item = $this->items->get( $id ); + if ( $item === null ) { + return 'items'; + } + $page->setTitle( $item->name . ' (item)' ); + + return Loader::Create( 'Form' , 'Update item' , 'edit-item' ) + ->setURL( 'items/view?id=' . $item->id ) + ->addField( Loader::Create( 'Field' , 'name' , 'text' ) + ->setDescription( 'Name of the item:' ) + ->setModifier( Loader::Create( 'Modifier_TrimString' ) ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'This name' , 2 , 128 ) ) + ->setDefaultValue( $item->name ) ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $item->id ) ) + ->addController( Loader::Ctrl( 'edit_item' ) ) + ->controller( ); + } + +} diff --git a/includes/t-items/pages.inc.php b/includes/t-items/pages.inc.php new file mode 100644 index 0000000..fb6094d --- /dev/null +++ b/includes/t-items/pages.inc.php @@ -0,0 +1,19 @@ + 'items_tree' , + 'view' => 'view_item' , + 'add' => 'add_item_form' , + 'move' => 'move_item_form' , + 'edit' => 'edit_item_form' , + 'delete' => 'delete_item_form' , + ) ); + } + +} diff --git a/includes/t-items/views.inc.php b/includes/t-items/views.inc.php new file mode 100644 index 0000000..088b383 --- /dev/null +++ b/includes/t-items/views.inc.php @@ -0,0 +1,96 @@ +tree = $tree; + if ( ! empty( $tree ) ) { + $this->minDepth = $tree[ 0 ]->depth; + } else { + $this->minDepth = 0; + } + } + + public function render( ) + { + if ( empty( $this->tree ) ) { + return HTML::make( 'div' ) + ->setAttribute( 'class' , 'no-table' ) + ->appendText( 'No items have been defined.' ); + } + + $table = HTML::make( 'table' ) + ->appendElement( HTML::make( 'tr' ) + ->setAttribute( 'class' , 'header' ) + ->appendElement( HTML::make( 'th' ) + ->appendText( 'Item name' ) ) + ->appendElement( HTML::make( 'th' ) + ->setAttribute( 'class' , 'align-right' ) + ->appendText( 'Tasks' ) ) ); + foreach ( $this->tree as $item ) { + $this->renderItem( $table , $item ); + } + return $table; + } + + private function renderItem( $table , $item ) + { + $children = Loader::DAO( 'items' )->getAll( $item->children ); + $padding = 5 + ( $item->depth - $this->minDepth ) * 16; + $table->appendElement( HTML::make( 'tr' ) + ->appendElement( HTML::make( 'td' ) + ->setAttribute( 'style' , 'padding-left:' . $padding . 'px' ) + ->appendElement( HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/items/view?id=' . $item->id ) + ->appendText( $item->name ) ) ) + ->appendElement( HTML::make( 'td' ) + ->setAttribute( 'class' , 'align-right' ) + ->appendRaw( (int) $item->activeTasks ) ) ); + + foreach ( $children as $child ) { + $this->renderItem( $table , $child ); + } + } +} + + +class View_ItemDetails + extends BaseURLAwareView +{ + private $item; + + public function __construct( Data_Item $item ) + { + $this->item = $item; + } + + public function render( ) + { + $items = Loader::DAO( 'items' ); + + $contents = array( ); + if ( empty( $this->item->lineage ) ) { + array_push( $contents , HTML::make( 'em' ) ->appendText( 'None' ) ); + } else { + foreach ( $items->getAll( $this->item->lineage ) as $ancestor ) { + if ( ! empty( $contents ) ) { + array_push( $contents , ' » ' ); + } + array_push( $contents , HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/items/view?id=' . $ancestor->id ) + ->appendText( $ancestor->name ) ); + } + } + + return HTML::make( 'dl' ) + ->appendElement( HTML::make( 'dt' )->appendText( 'Path:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->setAttribute( 'style' , 'font-size: 10pt' ) + ->append( $contents ) ); + } +} diff --git a/includes/t-tasks/controllers.inc.php b/includes/t-tasks/controllers.inc.php new file mode 100644 index 0000000..12bc91d --- /dev/null +++ b/includes/t-tasks/controllers.inc.php @@ -0,0 +1,309 @@ +form = $form; + } + + public function handle( Page $page ) + { + $item = $this->form->field( 'item' ); + $name = $this->form->field( 'title' ); + $priority = $this->form->field( 'priority' ); + $description = $this->form->field( 'description' ); + + $error = Loader::DAO( 'tasks' )->addTask( (int) $item->value( ) , $name->value( ) , + (int) $priority->value( ) , $description->value( ) ); + switch ( $error ) { + + case 0: + return true; + + case 1: + $name->putError( 'Duplicate task name for this item.' ); + break; + + case 2: + $item->putError( 'This item has been deleted' ); + break; + + default: + $name->putError( "An unknown error occurred ($error)" ); + break; + } + + return null; + } +} + + +class Ctrl_TaskDetails + extends Controller +{ + private $task; + + public function __construct( $task ) + { + $this->task = $task; + } + + + public function handle( Page $page ) + { + if ( $this->task->completed_at !== null ) { + $bTitle = "Completed task"; + } else { + $bTitle = "Active task"; + } + $items = Loader::DAO( 'items' ); + $items->getLineage( $this->task->item = $items->get( $this->task->item ) ); + + $box = Loader::View( 'box' , $bTitle , Loader::View( 'task_details' , $this->task ) ); + if ( $this->task->completed_by === null ) { + $box->addButton( BoxButton::create( 'Edit task' , 'tasks/edit?id=' . $this->task->id ) + ->setClass( 'icon edit' ) ) + ->addButton( BoxButton::create( 'Mark as completed' , + 'tasks/finish?id=' . $this->task->id ) + ->setClass( 'icon stop' ) ); + } else { + $box->addButton( BoxButton::create( 'Re-activate' , 'tasks/restart?id=' . $this->task->id ) + ->setClass( 'icon start' ) ); + $timestamp = strtotime( $this->task->completed_at ); + } + + if ( Loader::DAO( 'tasks' )->canDelete( $this->task ) ) { + $box->addButton( BoxButton::create( 'Delete' , 'tasks/delete?id=' . $this->task->id ) + ->setClass( 'icon delete' ) ); + } + + return $box; + } +} + + +class Ctrl_TaskNotes + extends Controller +{ + private $task; + + public function __construct( $task ) + { + $this->task = $task; + } + + + public function handle( Page $page ) + { + $result = array( ); + foreach ( $this->task->notes as $note ) { + $box = Loader::View( 'box' , null , Loader::View( 'task_note' , $note ) ); + if ( $this->task->completed_at === null && $note->uid == $_SESSION[ 'uid' ] ) { + $box->addButton( BoxButton::create( 'Edit comment' , 'tasks/notes/edit?id=' . $note->id ) + ->setClass( 'icon edit' ) ) + ->addButton( BoxButton::create( 'Delete comment' , 'tasks/notes/delete?id=' . $note->id ) + ->setClass( 'icon delete' ) ); + } + array_push( $result , $box ); + } + return $result; + } +} + + +class Ctrl_DeleteTask + extends Controller + implements FormAware +{ + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + Loader::DAO( 'tasks' )->delete( (int) $this->form->field( 'id' )->value( ) ); + return true; + } +} + + +class Ctrl_ToggleTask + extends Controller +{ + private $restart; + + public function __construct( $restart ) + { + $this->isRestart = $restart; + } + + public function handle( Page $page ) + { + // Check selected task + try { + $id = (int) $this->getParameter( 'id' , 'GET' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $tasks = Loader::DAO( 'tasks' ); + $task = $tasks->get( $id ); + if ( $task === null ) { + return 'tasks'; + } + + if ( $this->isRestart ) { + $tasks->restart( $id , '[AUTO] Task re-activated.' ); + } else { + $tasks->finish( $id , '[AUTO] Task completed.' ); + } + + return 'tasks/view?id=' . $id; + } +} + + +class Ctrl_EditTask + extends Controller + implements FormAware +{ + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + $id = $this->form->field( 'id' ); + $item = $this->form->field( 'item' ); + $name = $this->form->field( 'title' ); + $priority = $this->form->field( 'priority' ); + $description = $this->form->field( 'description' ); + + $error = Loader::DAO( 'tasks' )->updateTask( (int) $id->value( ) , + (int) $item->value( ) , $name->value( ) , + (int) $priority->value( ) , $description->value( ) ); + + switch ( $error ) { + + case 0: + return true; + + case 1: + $name->putError( "Une tâche porte déjà ce nom à cet endroit" ); + break; + + case 2: + $item->putError( "Cet endroit a été supprimé" ); + break; + + default: + $name->putError( "Une erreur inconnue s'est produite ($error)" ); + break; + } + + return null; + } +} + + +class Ctrl_AddTaskNoteForm + extends Controller +{ + private $task; + + public function __construct( $task ) + { + $this->task = $task; + } + + + public function handle( Page $page ) + { + return Loader::Create( 'Form' , 'Add' , 'add-note' , 'Add a comment' ) + ->setSuccessURL( 'tasks/view?id=' . $this->task->id ) + ->setAction( '?id=' . $this->task->id . '#add-note-form' ) + ->addField( Loader::Create( 'Field' , 'text' , 'textarea' ) + ->setDescription( 'Comment:' ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'This comment' , 5 ) ) ) + ->addController( Loader::Ctrl( 'add_task_note' , $this->task ) ) + ->controller( ); + } +} + + +class Ctrl_AddTaskNote + extends Controller + implements FormAware +{ + private $task; + private $form; + + public function __construct( $task ) + { + $this->task = $task; + } + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + Loader::DAO( 'tasks' )->addNote( $this->task->id , $this->form->field( 'text' )->value( ) ); + return true; + } + +} + + +class Ctrl_DeleteNote + extends Controller + implements FormAware +{ + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + Loader::DAO( 'tasks' )->deleteNote( (int) $this->form->field( 'id' )->value( ) ); + return true; + } +} + + +class Ctrl_EditNote + extends Controller + implements FormAware +{ + private $form; + + public function setForm( Form $form ) + { + $this->form = $form; + } + + public function handle( Page $page ) + { + $id = (int) $this->form->field( 'id' )->value( ); + $text = $this->form->field( 'text' )->value( ); + Loader::DAO( 'tasks' )->updateNote( $id , $text ); + return true; + } +} diff --git a/includes/t-tasks/package.inc.php b/includes/t-tasks/package.inc.php new file mode 100644 index 0000000..b0092d8 --- /dev/null +++ b/includes/t-tasks/package.inc.php @@ -0,0 +1,35 @@ +getParameter( 'mode' , 'GET' ); + } catch ( ParameterException $e ) { + $mode = 'active'; + } + + if ( $mode == 'active' ) { + $tasks = Loader::DAO( 'tasks' )->getAllActiveTasks( ); + $title = 'Active tasks'; + $bTitle = 'Display all tasks'; + $bMode = 'all'; + } else { + $mode = 'all'; + $tasks = Loader::DAO( 'tasks' )->getAllTasks( ); + $title = 'All tasks'; + $bTitle = 'Display active tasks only'; + $bMode = 'active'; + } + + $tree = Loader::DAO( 'items' )->getTree( ); + $box = Loader::View( 'box' , $title , Loader::View( 'all_tasks' , $tasks , $mode ) ) + ->addButton( BoxButton::create( $bTitle , 'tasks?mode=' . $bMode ) + ->setClass( 'icon refresh' ) ); + if ( !empty( $tree ) ) { + $box ->addButton( BoxButton::create( 'New task' , 'tasks/add' ) + ->setClass( 'list-add' ) ); + } + return $box; + } + +} + + +abstract class Ctrl_TaskFormBase + extends Controller +{ + + protected final function createPrioritySelector( ) + { + $select = Loader::Create( 'Field' , 'priority' , 'select' ) + ->setDescription( 'Priority:' ) + ->setValidator( Loader::Create( 'Validator_IntValue' , 'Priorité invalide' ) + ->setMinValue( 1 )->setMaxValue( 5 ) ); + $tasks = Loader::DAO( 'tasks' ); + + for ( $i = 5 ; $i >= 1 ; $i -- ) { + $select->addOption( $i , $tasks->translatePriority( $i ) ); + } + + return $select; + } + +} + + + +class Ctrl_AddTaskForm + extends Ctrl_TaskFormBase +{ + + public function handle( Page $page ) + { + try { + $target = (int) $this->getParameter( 'to' ); + } catch ( ParameterException $e ) { + $target = null; + } + + $form = Loader::Create( 'Form' , 'Add this task' , 'create-task' ); + + if ( $target === null ) { + $returnURL = 'tasks'; + if ( ! $this->addItemSelector( $form ) ) { + return 'items'; + } + } else { + $item = Loader::DAO( 'items' )->get( $target ); + if ( $item === null ) { + return 'items'; + } + $returnURL = 'items/view?id=' . $target; + + $form->addField( Loader::Create( 'Field' , 'to' , 'hidden' ) + ->setDefaultValue( $target ) ) + ->addField( Loader::Create( 'Field' , 'item' , 'hidden' ) + ->setDefaultValue( $target ) ); + } + + $page->setTitle( 'New task' ); + + return $form->addField( Loader::Create( 'Field' , 'title' , 'text' ) + ->setDescription( 'Task title:' ) + ->setModifier( Loader::Create( 'Modifier_TrimString' ) ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'This title' , 5 , 256 ) ) ) + ->addField( $this->createPrioritySelector( ) + ->setDefaultValue( 3 ) ) + ->addField( Loader::Create( 'Field' , 'description' , 'textarea' ) + ->setDescription( 'Description:' ) + ->setMandatory( false ) ) + ->setURL( $returnURL ) + ->addController( Loader::Ctrl( 'add_task' ) ) + ->controller( ); + } + + + private function addItemSelector( $form ) + { + $form->addField( $select = Loader::Create( 'Field' , 'item' , 'select' ) + ->setDescription( 'Item:' ) + ->addOption( '' , '(please select an item)' ) ); + + $items = Loader::DAO( 'items' )->getTreeList( ); + if ( empty( $items ) ) { + return false; + } + foreach ( $items as $item ) { + $name = '-' . str_repeat( '--' , $item->depth ) . ' ' . $item->name; + $select->addOption( $item->id , $name ); + } + return true; + + } + +} + + +class Ctrl_ViewTask + extends Controller +{ + + public function handle( Page $page ) + { + try { + $id = (int) $this->getParameter( 'id' , 'GET' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $task = Loader::DAO( 'tasks' )->get( $id ); + if ( $task === null ) { + return 'tasks'; + } + $page->setTitle( $task->title . ' (task)' ); + + $result = array( Loader::Ctrl( 'task_details' , $task ) ); + + if ( $task->completed_by === null ) { + array_push( $result , Loader::Ctrl( 'add_task_note_form' , $task ) ); + } + + array_push( $result , Loader::Ctrl( 'task_notes' , $task ) ); + return $result; + } + +} + + +class Ctrl_DeleteTaskForm + extends Controller +{ + + public function handle( Page $page ) + { + // Check selected task + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $tasks = Loader::DAO( 'tasks' ); + $task = $tasks->get( $id ); + if ( $task === null ) { + return 'tasks'; + } + if ( ! $tasks->canDelete( $task ) ) { + return 'tasks/view?id=' . $id; + } + $page->setTitle( $task->title . ' (task)' ); + + // Generate confirmation text + $confText = HTML::make( 'div' ) + ->appendElement( HTML::make( 'p' ) + ->appendText( "You are about to delete this task, and any comment attached to it." ) ) + ->appendElement( HTML::make( 'p' ) + ->appendText( "This operation cannot be undone." ) ); + + // Generate form + return Loader::Create( 'Form' , 'Delete the task' , 'delete-task' , 'Task deletion' ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $task->id ) ) + ->addField( Loader::Create( 'Field' , 'confirm' , 'html' )->setDefaultValue( $confText ) ) + ->setCancelURL( 'tasks/view?id=' . $task->id ) + ->setSuccessURL( 'items/view?id=' . $task->item ) + ->addController( Loader::Ctrl( 'delete_task' ) ) + ->controller( ); + + } + +} + + + +class Ctrl_EditTaskForm + extends Ctrl_TaskFormBase +{ + + public function handle( Page $page ) + { + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $task = Loader::DAO( 'tasks' )->get( $id ); + if ( $task === null ) { + return 'tasks'; + } + $page->setTitle( $task->title . ' (task)' ); + + + return Loader::Create( 'Form' , 'Update task' , 'edit-task' , 'Editing task' ) + ->setURL( 'tasks/view?id=' . $task->id ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $task->id ) ) + ->addField( $this->createItemSelector( ) + ->setDefaultValue( $task->item ) ) + ->addField( Loader::Create( 'Field' , 'title' , 'text' ) + ->setDescription( 'Title:' ) + ->setModifier( Loader::Create( 'Modifier_TrimString' ) ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'This title' , 5 , 256 ) ) + ->setDefaultValue( $task->title ) ) + ->addField( $this->createPrioritySelector( ) + ->setDefaultValue( $task->priority ) ) + ->addField( Loader::Create( 'Field' , 'description' , 'textarea' ) + ->setDescription( 'Description:' ) + ->setMandatory( false ) + ->setDefaultValue( $task->description ) ) + ->addController( Loader::Ctrl( 'edit_task' ) ) + ->controller( ); + } + + + private function createItemSelector( ) + { + $select = Loader::Create( 'Field' , 'item' , 'select' ) + ->setDescription( 'On item:' ); + + $items = Loader::DAO( 'items' )->getTreeList( ); + foreach ( $items as $item ) { + $name = '-' . str_repeat( '--' , $item->depth ) . ' ' . $item->name; + $select->addOption( $item->id , $name ); + } + return $select; + + } +} + + +class Ctrl_DeleteNoteForm + extends Controller +{ + + public function handle( Page $page ) + { + // Check selected note + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $tasks = Loader::DAO( 'tasks' ); + $note = $tasks->getNote( $id ); + if ( $note === null ) { + return 'tasks'; + } + if ( !$note->editable ) { + return 'tasks/view?id=' . $note->task; + } + $task = $tasks->get( $note->task ); + $page->setTitle( $task->title . ' (task)' ); + + // Generate confirmation text + $confText = HTML::make( 'div' ) + ->appendElement( HTML::make( 'p' ) + ->appendText( 'You are about to delete a comment attached to this task.' ) ) + ->appendElement( HTML::make( 'p' ) + ->appendText( 'This operation cannot be undone.' ) ); + + // Generate form + return Loader::Create( 'Form' , 'Delete this comment' , 'delete-note' , 'Comment deletion' ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $note->id ) ) + ->addField( Loader::Create( 'Field' , 'confirm' , 'html' )->setDefaultValue( $confText ) ) + ->setURL( 'tasks/view?id=' . $note->task ) + ->addController( Loader::Ctrl( 'delete_note' ) ) + ->controller( ); + + } + +} + + +class Ctrl_EditNoteForm + extends Controller +{ + + public function handle( Page $page ) + { + // Check selected note + try { + $id = (int) $this->getParameter( 'id' ); + } catch ( ParameterException $e ) { + return 'tasks'; + } + + $tasks = Loader::DAO( 'tasks' ); + $note = $tasks->getNote( $id ); + if ( $note === null ) { + return 'tasks'; + } + if ( !$note->editable ) { + return 'tasks/view?id=' . $note->task; + } + $task = $tasks->get( $note->task ); + $page->setTitle( $task->title . ' (task)' ); + + // Generate form + return Loader::Create( 'Form' , 'Update comment' , 'edit-note' ) + ->addField( Loader::Create( 'Field' , 'id' , 'hidden' ) + ->setDefaultValue( $note->id ) ) + ->addField( Loader::Create( 'Field' , 'text' , 'textarea' ) + ->setDescription( 'Comment:' ) + ->setValidator( Loader::Create( 'Validator_StringLength' , 'Le texte' , 5 ) ) + ->setDefaultValue( $note->text ) ) + ->setURL( 'tasks/view?id=' . $note->task ) + ->addController( Loader::Ctrl( 'edit_note' ) ) + ->controller( ); + + } + +} diff --git a/includes/t-tasks/pages.inc.php b/includes/t-tasks/pages.inc.php new file mode 100644 index 0000000..54566bf --- /dev/null +++ b/includes/t-tasks/pages.inc.php @@ -0,0 +1,22 @@ + 'all_tasks' , + 'add' => 'add_task_form' , + 'delete' => 'delete_task_form' , + 'edit' => 'edit_task_form' , + 'finish' => array( 'toggle_task' , false ) , + 'restart' => array( 'toggle_task' , true ) , + 'view' => 'view_task' , + 'notes/edit' => 'edit_note_form' , + 'notes/delete' => 'delete_note_form' , + )); + } + +} diff --git a/includes/t-tasks/views.inc.php b/includes/t-tasks/views.inc.php new file mode 100644 index 0000000..09fabea --- /dev/null +++ b/includes/t-tasks/views.inc.php @@ -0,0 +1,301 @@ +dao = Loader::DAO( 'tasks' ); + } + + + public final function render( ) + { + if ( empty( $this->tasks ) ) { + return HTML::make( 'div' ) + ->setAttribute( 'class' , 'no-table' ) + ->appendText( 'No tasks to display.' ); + } + return HTML::make( 'dl' ) + ->append( $this->generateList( ) ) + ->setAttribute( 'class' , 'tasks' ); + } + + + private function generateList( ) + { + $result = array( ); + $prevPriority = 6; + foreach ( $this->tasks as $task ) { + $priority = ( $task->completed_by === null ) ? $task->priority : -1; + if ( $priority !== $prevPriority ) { + if ( $priority == -1 ) { + $text = 'Completed tasks'; + $extraClass = ' completed'; + } else { + $text = $this->dao->translatePriority( $priority ) . ' priority'; + $extraClass = ''; + } + $prevPriority = $priority; + + array_push( $result , HTML::make( 'dt' ) + ->setAttribute( 'class' , 'sub-title' . $extraClass ) + ->appendText( $text ) ); + } + $result = array_merge( $result , $this->generateItem( $task ) ); + } + return $result; + } + + protected abstract function generateItem( $task ); +} + + +class View_AllTasks + extends View_TasksBase +{ + + public function __construct( $tasks ) + { + parent::__construct( ); + $this->tasks = $tasks; + } + + protected function generateItem( $task ) + { + $cell = array( ); + array_push( $cell , HTML::make( 'dt' ) + ->appendElement( HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/tasks/view?id=' . $task->id ) + ->appendText( $task->title ) ) ); + + array_push( $cell , HTML::make( 'dd' )->append( $this->formatPlaceLineage( $task->item ) ) ); + + $addedAt = strtotime( $task->added_at ); + $addedAtDate = date( 'd/m/o' , $addedAt ); + $addedAtTime = date( 'H:i:s' , $addedAt ); + array_push( $cell , + HTML::make( 'dd' )->appendText( "Added $addedAtDate at $addedAtTime by {$task->added_by}" ) ); + + if ( $task->completed_by !== null ) { + $completedAt = strtotime( $task->completed_at ); + $completedAtDate = date( 'd/m/o' , $completedAt ); + $completedAtTime = date( 'H:i:s' , $completedAt ); + array_push( $cell , HTML::make( 'dd' )->appendText( + "Completed $completedAtDate at $completedAtTime by {$task->completed_by}" ) ); + + foreach ( $cell as $entry ) { + $entry->setAttribute( 'class' , 'completed' ); + } + } + + return $cell; + } + + private function formatPlaceLineage( $item ) + { + $item = Loader::DAO( 'items' )->get( $item ); + $lineage = $item->lineage; + array_push( $lineage , $item->id ); + + $contents = array( ); + foreach ( Loader::DAO( 'items' )->getAll( $lineage ) as $ancestor ) { + if ( ! empty( $contents ) ) { + array_push( $contents , ' » ' ); + } + array_push( $contents , HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/items/view?id=' . $ancestor->id ) + ->appendText( $ancestor->name ) ); + } + array_unshift( $contents, 'On ' ); + + return $contents; + } +} + + +class View_Tasks + extends View_TasksBase +{ + public function __construct( $tasks ) + { + parent::__construct( ); + $this->tasks = $tasks; + } + + + protected function generateItem( $task ) + { + $cell = array( ); + array_push( $cell , HTML::make( 'dt' ) + ->appendElement( HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/tasks/view?id=' . $task->id ) + ->appendText( $task->title ) ) ); + + $addedAt = strtotime( $task->added_at ); + $addedAtDate = date( 'd/m/o' , $addedAt ); + $addedAtTime = date( 'H:i:s' , $addedAt ); + array_push( $cell , + HTML::make( 'dd' )->appendText( "Added $addedAtDate at $addedAtTime by {$task->added_by}" ) ); + + if ( $task->completed_by !== null ) { + $completedAt = strtotime( $task->completed_at ); + $completedAtDate = date( 'd/m/o' , $completedAt ); + $completedAtTime = date( 'H:i:s' , $completedAt ); + array_push( $cell , HTML::make( 'dd' )->appendText( + "Completed $completedAtDate at $completedAtTime by {$task->completed_by}" ) ); + foreach ( $cell as $entry ) { + $entry->setAttribute( 'class' , 'completed' ); + } + } + + return $cell; + } + +} + + +class View_TaskDetails + extends BaseURLAwareView +{ + private $task; + + public function __construct( $task ) + { + $this->task = $task; + } + + public function render( ) + { + $list = HTML::make( 'dl' ) + ->setAttribute( 'class' , 'tasks' ) + ->appendElement( HTML::make( 'dt' ) + ->appendText( 'On item:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->append( $this->formatPlaceLineage( $this->task->item ) ) ); + + if ( $this->task->description != '' ) { + $list->appendElement( HTML::make( 'dt' ) + ->appendText( 'Description:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->appendRaw( $this->formatDescription( ) ) ); + } + + $list->appendElement( HTML::make( 'dt' ) + ->appendText( 'Added:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->appendText( $this->formatAction( $this->task->added_at , $this->task->added_by ) ) ); + + if ( $this->task->completed_by === null ) { + $list->appendElement( HTML::make( 'dt' ) + ->appendText( 'Priority:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->appendText( Loader::DAO( 'tasks' ) + ->translatePriority( $this->task->priority ) ) ); + } else { + $list->appendElement( HTML::make( 'dt' ) + ->appendText( 'Completed:' ) ) + ->appendElement( HTML::make( 'dd' ) + ->appendText( $this->formatAction( + $this->task->completed_at , $this->task->completed_by ) ) ); + } + + return $list; + } + + private function formatPlaceLineage( $item ) + { + $lineage = $item->lineage; + array_push( $lineage , $item->id ); + + $contents = array( ); + foreach ( Loader::DAO( 'items' )->getAll( $lineage ) as $ancestor ) { + if ( ! empty( $contents ) ) { + array_push( $contents , ' » ' ); + } + array_push( $contents , HTML::make( 'a' ) + ->setAttribute( 'href' , $this->base . '/items/view?id=' . $ancestor->id ) + ->appendText( $ancestor->name ) ); + } + + return $contents; + } + + + private function formatDescription( ) + { + $description = HTML::from( $this->task->description ); + return preg_replace( '/\n/s' , '
' , $description ); + } + + + private function formatAction( $timestamp , $user ) + { + $ts = strtotime( $timestamp ); + $tsDate = date( 'd/m/o' , $ts ); + $tsTime = date( 'H:i:s' , $ts ); + return "$tsDate at $tsTime by $user"; + } +} + + +class View_TaskNote + implements View +{ + private $note; + + public function __construct( $note ) + { + $this->note = $note; + } + + public function render( ) + { + $text = HTML::make( 'p' ) + ->appendRaw( preg_replace( '/\n/s' , '
' , HTML::from( $this->note->text ) ) ); + + $ts = strtotime( $this->note->added_at ); + $tsDate = date( 'd/m/o' , $ts ); + $tsTime = date( 'H:i:s' , $ts ); + $details = HTML::make( 'div') + ->setAttribute( 'style' , 'font-size: 9pt' ) + ->appendElement( HTML::make( 'em' ) + ->appendText( "Note added $tsDate at $tsTime by {$this->note->author}" ) ); + + return array( $text , $details ); + } +} + + +class View_TaskDependencies + implements View +{ + private $task; + private $reverse; + + public function __construct( $task , $reverse ) + { + $this->task = $task; + $this->reverse = $reverse; + } + + public function render( ) + { + $source = $this->reverse ? 'reverseDependencies' : 'dependencies'; + $list = HTML::make( 'ul' )->setAttribute( 'class' , 'dep-list' ); + foreach ( $this->task->$source as $dependency ) { + $link = HTML::make( 'a' ) + ->setAttribute( 'href' , 'tasks/view?id=' . $dependency->id ) + ->appendText( $dependency->title ); + if ( ! $this->reverse ) { + $link->setAttribute( 'class' , ( $dependency->completed == 't' ) + ? 'satisfied' : 'missing' ); + } + $list->appendElement( HTML::make( 'li' )->appendElement( $link ) ); + } + } +} diff --git a/includes/t-users/package.inc.php b/includes/t-users/package.inc.php new file mode 100644 index 0000000..db6fc56 --- /dev/null +++ b/includes/t-users/package.inc.php @@ -0,0 +1,5 @@ + 0 ); + return $hash; + } + + + public function getUser( $email ) + { + $query = $this->query( 'SELECT * FROM users WHERE user_email = LOWER( $1 )' ); + $results = $query->execute( $email ); + if ( empty( $results ) ) { + return null; + } + return array_shift( $results ); + } + + + public function checkLogin( $email , $password ) + { + $userData = $this->getUser( $email ); + if ( $userData != null ) { + $hashed = $this->hashPassword( $password , + $userData->user_salt , + $userData->user_iterations ); + if ( $hashed === $userData->user_hash ) { + return $userData; + } + } + return null; + } +} diff --git a/site/.htaccess.sample b/site/.htaccess.sample new file mode 100644 index 0000000..bb5709d --- /dev/null +++ b/site/.htaccess.sample @@ -0,0 +1,5 @@ +RewriteEngine on +#RewriteBase / +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php/$1 [L,QSA] diff --git a/site/icons.png b/site/icons.png new file mode 100644 index 0000000..071aee1 Binary files /dev/null and b/site/icons.png differ diff --git a/site/index.php b/site/index.php new file mode 100644 index 0000000..f696f65 --- /dev/null +++ b/site/index.php @@ -0,0 +1,8 @@ +fromPathInfo( ); + + +?> diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..7f17d98 --- /dev/null +++ b/site/style.css @@ -0,0 +1,395 @@ +/* + * PAGE + */ + +body { + text-align: center; + background-color: #3f3f3f; + min-width: 792px; +} + +* { + font-family: "DejaVu Serif", serif; +} + +textarea { + font-family: monospace; +} + +div.page-container { + margin: 10px auto; + width: 752px; + min-height: 300px; + background-color: white; + border-radius: 20px; + padding: 20px 20px 32px 20px; + text-align: left; + box-shadow: 0 0 500px 20px white; +} + +div.page-container > h1:first-child { + background-color: black; + border-color: white; + border-radius: 20px; + border-style: solid; + border-width: 1px; + color: white; + font-size: 16pt; + margin: -20px -20px 0px -20px; + padding: 30px 10px 10px 15px; + box-shadow: 0px 5px 15px #3f3f3f; + text-shadow: 3px 3px 2px #7f7f7f; + height: 30px; +} + +div.page-container > h1.no-menu:first-child { + padding-top: 10px; + margin-bottom: 30px; +} + +div.page-container > ul.page-menu { + margin: -75px -20px 75px -20px; + border-color: white; + border-style: solid; + border-radius: 20px 20px 0px 0px; + border-width: 1px 1px 0px 1px; + background-color: #3f3f7f; + padding: 5px 15px 5px 10px; +} + +div.page-container > ul.page-menu li { + display: inline-block; + list-style-type: none; + margin: 0; + padding: 0px; +} + +div.page-container > ul.page-menu a { + display: block; + width: 100%; + margin: 0 5px; + color: white; + text-decoration: none; + font-size: 10pt; + padding: 0px; +} + +div.page-container > ul.page-menu a:hover { + color: yellow; + text-decoration: underline; +} + + +/* + * CONTENT + */ + +dt { + font-size: 12pt; + color: #3f3f3f; + font-weight: bold; +} + +a { + color: #3f3f7f; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + + +/* + * BOXES (GENERAL) + */ + +div.box { + margin: 20px 0 0 60px; + width: 600px; + padding: 15px; + border-radius: 15px; + box-shadow: 5px 5px 15px #3f3f7f; + border-width: 1px; + border-style: solid; + border-color: #3f3f7f; + background-color: #afafff; +} + +h2.box-title { + font-size: 13pt; + margin: -15px -15px 0 -15px; + padding: 3px 0px 3px 20px; + background-color: #3f3f7f; + color: white; + border-radius: 14px; + text-shadow: 2px 2px 1px #7f7f7f; +} + +div.box-buttons { + margin: -24px 0px 0px 0px; + height: 18px; + width: 610px; + text-align: right; + vertical-align: top; +} + +div.box-buttons a { + display: inline-block; + background-clip: content-box; + background-color: white; + background-origin: content-box; + margin-left: 4px; + color: black; + font-size: 10pt; + text-decoration: none; + text-align: center; + border-radius: 10px; + width: 18px; + height: 18px; +} + +div.box-buttons:first-child a { + border-color: #3f3f7f; + border-width: 1px; + border-style: solid; +} + +div.box-buttons a:hover { + background-color: black; + color: yellow; + box-shadow: 0px 0px 5px 3px yellow; +} + +div.box-buttons:first-child a:hover { + border-color: black; +} + +div.box-buttons a span { + display: none; +} + +div.box-contents { + margin-top: 15px; +} + +div.box-contents:first-child { + margin-top: 0px; +} + + +/* + * FORMS + */ + +div.form a.form-cancel::after { + content: 'X'; + font-weight: bold; +} + +.form form , .form dl { + margin: 0; + padding: 0; +} + +.form dt:first-child { margin: 0 } +.form dt { margin-top: 10px } + +.form dt.mandatory.field label:after { + content: ' (required)'; + color: #0000ff; + font-style: italic; + font-weight: normal; + font-size: 70%; +} + +.form dd.field { padding-top: 3px } + +.form dt.field + dd.form-error { padding-top: 3px } + +.form dd { + margin-right: 40px; + margin-left: 40px; +} + +.form dd.form-error { + font-size: 11pt; + font-weight: bold; + color: #7f0000; +} + +.form input , .form select , .form textarea { + border-radius: 10px; + border-color: #efefff; + border-style: solid; + border-width: 1px; + background-color: #cfcfff; + width: 100%; + padding: 3px 8px; +} + +.form input:focus , .form select:focus, .form textarea:focus { + border-color: #7f7fff; +} + +.form textarea { + font-family: "DejaVu Mono", monospace; + font-size: 10pt; + height: 100px; +} + +.form dd.erroneous * { + border-color: #ff0000; + background-color: #ffcfcf; + color: #7f0000; +} + +.form dd.erroneous select > option:disabled { + color: black; +} + +.form dt.submit-button { margin-top: 20px } +.form dt.submit-button input { + padding: 5px 0; + color: white; + font-weight: bold; + background-color: #3f3f7f; + border-color: black; +} + +.form dd.html-section { + margin: 0; +} + +.form dd.html-section p { + font-size: 11pt; + text-align: justify; + text-indent: 20px; + margin: 10px 0; +} + +.form dd.html-section p:first-child { + padding-top: 15px; +} + +.form dd.html-section p:last-child { + padding-bottom: 15px; +} + + +/* + * LISTS + */ + +div.list div.box-contents { + margin-left: -15px; + margin-right: -15px; + width: 630px; +} + +div.list table { + width: 630px; + border-collapse: collapse; +} + +div.list tr > *:first-child { padding-left: 5px } +div.list tr > *:last-child { padding-right: 5px } +div.list th , div.list td { text-align: left } +div.list tr:nth-child(2) > * { padding-top: 10px } +div.list tr:nth-child(odd) { background-color: #cfcfff } +div.list tr.header { background-color: #afafff } + +div.list .header th { + padding-bottom: 10px; + border-style: solid; + border-width: 0 0 1px 0; + border-color: black; +} + +div.list th.align-center, div.list td.align-center { text-align: center } +div.list th.align-right, div.list td.align-right { text-align: right } + +div.list td a { + display: block; + width: 100%; +} + +div.box-contents div.no-table { + margin: 20px 0 10px 0; + text-align: center; + font-weight: bold; +} + + +/* + * VARIOUS BOX BUTTONS + */ + +div.box-buttons a.icon::after { + content: 'x'; + color: transparent; + font-weight: bold; +} + +div.box-buttons a.icon { + background-image: url( "icons.png" ); +} + +div.box-buttons a.icon + a:not(.icon ) { + vertical-align: top; +} + +div.box-buttons a.move { background-position: 0px 0px } +div.box-buttons a.move:hover { background-position: -18px 0px } + +div.box-buttons a.edit { background-position: 0px -18px } +div.box-buttons a.edit:hover { background-position: -18px -18px } + +div.box-buttons a.delete { background-position: 0px -36px } +div.box-buttons a.delete:hover { background-position: -18px -36px } + +div.box-buttons a.refresh { background-position: 0px -54px } +div.box-buttons a.refresh:hover { background-position: -18px -54px } + +div.box-buttons a.stop { background-position: 0px -72px } +div.box-buttons a.stop:hover { background-position: -18px -72px } + +div.box-buttons a.start { background-position: 0px -90px } +div.box-buttons a.start:hover { background-position: -18px -90px } + +div.box-buttons a.list-add::after { + content: '+'; + font-weight: bold; +} + + +/* + * TASKS + */ + +dl.tasks dt:not(:first-child) { + margin-top: 10px; +} + +dl.tasks dd, dl.tasks dd * { + font-size: 10pt; +} + +dl.tasks dt.sub-title { + margin: 20px -15px 0px -15px; + padding: 22px 5px 2px 5px; + background-color: #5F5FAF; + color: white; + text-shadow: 2px 2px 1px #7F7F7F; + box-shadow: inset 0px 20px 20px #AFAFFF, inset 0px -5px 5px #AFAFFF; + +} + +dl.tasks .completed, dl.tasks .completed * { + color: #3f3f3f +} + +dl.tasks dt.sub-title.completed { + color: #bfbfbf; + text-shadow: 1px 1px 2px #3f3f3f; +}