diff --git a/.arcconfig b/.arcconfig new file mode 100644 index 0000000000..4c4ad2bcfc --- /dev/null +++ b/.arcconfig @@ -0,0 +1,10 @@ +{ + "project_id" : "aphront", + "conduit_uri" : "http://tools.epriestley-conduit.dev1557.facebook.com/api/", + "lint_engine" : "PhutilLintEngine", + "unit_engine" : "PhutilUnitTestEngine", + "copyright_holder" : "Facebook, Inc.", + "phutil_libraries" : { + "aphront" : "src/" + } +} diff --git a/.divinerconfig b/.divinerconfig new file mode 100644 index 0000000000..51d539832b --- /dev/null +++ b/.divinerconfig @@ -0,0 +1,7 @@ +{ + "name" : "differential", + "src_base" : "https://github.com/facebook/differential/blob/master", + "groups" : { + } +} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..00394b55de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +._* +/docs/ diff --git a/src/__phutil_library_init__.php b/src/__phutil_library_init__.php new file mode 100644 index 0000000000..5df92cf21a --- /dev/null +++ b/src/__phutil_library_init__.php @@ -0,0 +1,19 @@ + + array( + 'Aphront404Response' => 'aphront/response/404', + 'AphrontApplicationConfiguration' => 'aphront/applicationconfiguration', + 'AphrontController' => 'aphront/controller', + 'AphrontDatabaseConnection' => 'storage/connection/base', + 'AphrontDefaultApplicationConfiguration' => 'aphront/default/configuration', + 'AphrontDefaultApplicationController' => 'aphront/default/controller', + 'AphrontDialogResponse' => 'aphront/response/dialog', + 'AphrontDialogView' => 'view/dialog', + 'AphrontDirectoryCategory' => 'applications/directory/storage/category', + 'AphrontDirectoryCategoryDeleteController' => 'applications/directory/controller/categorydelete', + 'AphrontDirectoryCategoryEditController' => 'applications/directory/controller/categoryedit', + 'AphrontDirectoryCategoryListController' => 'applications/directory/controller/categorylist', + 'AphrontDirectoryController' => 'applications/directory/controller/base', + 'AphrontDirectoryDAO' => 'applications/directory/storage/base', + 'AphrontDirectoryItem' => 'applications/directory/storage/item', + 'AphrontDirectoryItemDeleteController' => 'applications/directory/controller/itemdelete', + 'AphrontDirectoryItemEditController' => 'applications/directory/controller/itemedit', + 'AphrontDirectoryItemListController' => 'applications/directory/controller/itemlist', + 'AphrontDirectoryMainController' => 'applications/directory/controller/main', + 'AphrontErrorView' => 'view/form/error', + 'AphrontFormControl' => 'view/form/control/base', + 'AphrontFormSelectControl' => 'view/form/control/select', + 'AphrontFormSubmitControl' => 'view/form/control/submit', + 'AphrontFormTextAreaControl' => 'view/form/control/textarea', + 'AphrontFormTextControl' => 'view/form/control/text', + 'AphrontFormView' => 'view/form/base', + 'AphrontLiskDAO' => 'aphront/storage/lisk', + 'AphrontMySQLDatabaseConnection' => 'storage/connection/mysql', + 'AphrontNullView' => 'view/null', + 'AphrontPageView' => 'view/page/base', + 'AphrontPanelView' => 'view/layout/panel', + 'AphrontQueryConnectionException' => 'storage/exception/connection', + 'AphrontQueryConnectionLostException' => 'storage/exception/connectionlost', + 'AphrontQueryCountException' => 'storage/exception/count', + 'AphrontQueryException' => 'storage/exception/base', + 'AphrontQueryObjectMissingException' => 'storage/exception/objectmissing', + 'AphrontQueryParameterException' => 'storage/exception/parameter', + 'AphrontQueryRecoverableException' => 'storage/exception/recoverable', + 'AphrontRedirectResponse' => 'aphront/response/redirect', + 'AphrontRequest' => 'aphront/request', + 'AphrontResponse' => 'aphront/response/base', + 'AphrontStandardPageView' => 'view/page/standard', + 'AphrontTableView' => 'view/control/table', + 'AphrontURIMapper' => 'aphront/mapper', + 'AphrontView' => 'view/base', + 'AphrontWebpageResponse' => 'aphront/response/webpage', + 'DifferentialAction' => 'applications/review/constants/action', + 'DifferentialChangeType' => 'applications/review/constants/changetype', + 'DifferentialLintStatus' => 'applications/review/constants/lintstatus', + 'DifferentialRevisionStatus' => 'applications/review/constants/revisionstatus', + 'DifferentialUnitStatus' => 'applications/review/constants/unitstatus', + 'LiskDAO' => 'storage/lisk/dao', + ), + 'function' => + array( + '_qsprintf_check_scalar_type' => 'storage/qsprintf', + '_qsprintf_check_type' => 'storage/qsprintf', + 'qsprintf' => 'storage/qsprintf', + 'queryfx' => 'storage/queryfx', + 'queryfx_all' => 'storage/queryfx', + 'queryfx_one' => 'storage/queryfx', + 'vqsprintf' => 'storage/qsprintf', + 'vqueryfx' => 'storage/queryfx', + 'xsprintf_query' => 'storage/qsprintf', + ), + 'requires_class' => + array( + 'Aphront404Response' => 'AphrontResponse', + 'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration', + 'AphrontDefaultApplicationController' => 'AphrontController', + 'AphrontDialogResponse' => 'AphrontResponse', + 'AphrontDialogView' => 'AphrontView', + 'AphrontDirectoryCategory' => 'AphrontDirectoryDAO', + 'AphrontDirectoryCategoryDeleteController' => 'AphrontDirectoryController', + 'AphrontDirectoryCategoryEditController' => 'AphrontDirectoryController', + 'AphrontDirectoryCategoryListController' => 'AphrontDirectoryController', + 'AphrontDirectoryController' => 'AphrontController', + 'AphrontDirectoryDAO' => 'AphrontLiskDAO', + 'AphrontDirectoryItem' => 'AphrontDirectoryDAO', + 'AphrontDirectoryItemDeleteController' => 'AphrontDirectoryController', + 'AphrontDirectoryItemEditController' => 'AphrontDirectoryController', + 'AphrontDirectoryItemListController' => 'AphrontDirectoryController', + 'AphrontDirectoryMainController' => 'AphrontDirectoryController', + 'AphrontErrorView' => 'AphrontView', + 'AphrontFormControl' => 'AphrontView', + 'AphrontFormSelectControl' => 'AphrontFormControl', + 'AphrontFormSubmitControl' => 'AphrontFormControl', + 'AphrontFormTextAreaControl' => 'AphrontFormControl', + 'AphrontFormTextControl' => 'AphrontFormControl', + 'AphrontFormView' => 'AphrontView', + 'AphrontLiskDAO' => 'LiskDAO', + 'AphrontMySQLDatabaseConnection' => 'AphrontDatabaseConnection', + 'AphrontNullView' => 'AphrontView', + 'AphrontPageView' => 'AphrontView', + 'AphrontPanelView' => 'AphrontView', + 'AphrontQueryConnectionException' => 'AphrontQueryException', + 'AphrontQueryConnectionLostException' => 'AphrontQueryRecoverableException', + 'AphrontQueryCountException' => 'AphrontQueryException', + 'AphrontQueryObjectMissingException' => 'AphrontQueryException', + 'AphrontQueryParameterException' => 'AphrontQueryException', + 'AphrontQueryRecoverableException' => 'AphrontQueryException', + 'AphrontRedirectResponse' => 'AphrontResponse', + 'AphrontStandardPageView' => 'AphrontPageView', + 'AphrontTableView' => 'AphrontView', + 'AphrontWebpageResponse' => 'AphrontResponse', + ), + 'requires_interface' => + array( + ), +)); diff --git a/src/aphront/applicationconfiguration/AphrontApplicationConfiguration.php b/src/aphront/applicationconfiguration/AphrontApplicationConfiguration.php new file mode 100644 index 0000000000..32f9bbef9b --- /dev/null +++ b/src/aphront/applicationconfiguration/AphrontApplicationConfiguration.php @@ -0,0 +1,72 @@ +request = $request; + return $this; + } + + final public function getRequest() { + return $this->request; + } + + final public function buildController() { + $map = $this->getURIMap(); + $mapper = new AphrontURIMapper($map); + $request = $this->getRequest(); + $path = $request->getPath(); + list($controller_class, $uri_data) = $mapper->mapPath($path); + + PhutilSymbolLoader::loadClass($controller_class); + $controller = newv($controller_class, array($request)); + + return array($controller, $uri_data); + } + + final public function setHost($host) { + $this->host = $host; + return $this; + } + + final public function getHost() { + return $this->host; + } + + final public function setPath($path) { + $this->path = $path; + return $this; + } + + final public function getPath() { + return $this->path; + } + +} diff --git a/src/aphront/applicationconfiguration/__init__.php b/src/aphront/applicationconfiguration/__init__.php new file mode 100644 index 0000000000..5635a733d7 --- /dev/null +++ b/src/aphront/applicationconfiguration/__init__.php @@ -0,0 +1,15 @@ +request = $request; + } + + final public function getRequest() { + return $this->request; + } + + public function buildStandardPageResponse($view) { + $page = new AphrontStandardPageView(); + $page->appendChild($view); + $response = new AphrontWebpageResponse(); + $response->setContent($page->render()); + return $response; + } + +} diff --git a/src/aphront/controller/__init__.php b/src/aphront/controller/__init__.php new file mode 100644 index 0000000000..1584e8b289 --- /dev/null +++ b/src/aphront/controller/__init__.php @@ -0,0 +1,13 @@ + array( + '$' => 'RepositoryListController', + 'new/$' => 'RepositoryEditController', + 'edit/(?\d+)/$' => 'RepositoryEditController', + 'delete/(?\d+)/$' => 'RepositoryDeleteController', + ), + '/' => array( + '$' => 'AphrontDirectoryMainController', + ), + '/directory/' => array( + 'item/$' => 'AphrontDirectoryItemListController', + 'item/edit/(?:(?\d+)/)?$' => 'AphrontDirectoryItemEditController', + 'item/delete/(?\d+)/' => 'AphrontDirectoryItemDeleteController', + 'category/$' + => 'AphrontDirectoryCategoryListController', + 'category/edit/(?:(?\d+)/)?$' + => 'AphrontDirectoryCategoryEditController', + 'category/delete/(?\d+)/' + => 'AphrontDirectoryCategoryDeleteController', + ), + '.*' => 'AphrontDefaultApplicationController', + ); + } + + public function buildRequest() { + $request = new AphrontRequest($this->getHost(), $this->getPath()); + $request->setRequestData($_GET + $_POST); + return $request; + } + + public function handleException(Exception $ex) { + + $class = phutil_escape_html(get_class($ex)); + $message = phutil_escape_html($ex->getMessage()); + + $content = + '
'. + '

Unhandled Exception "'.$class.'": '.$message.'

'. + ''.phutil_escape_html((string)$ex).''. + '
'; + + $view = new AphrontStandardPageView(); + $view->appendChild($content); + + $response = new AphrontWebpageResponse(); + $response->setContent($view->render()); + + return $response; + } + + public function willSendResponse(AphrontResponse $response) { + $request = $this->getRequest(); + if ($response instanceof AphrontDialogResponse) { + if (!$request->isAjax()) { + $view = new AphrontStandardPageView(); + $view->appendChild( + '
'. + $response->buildResponseString(). + '
'); + $response = new AphrontWebpageResponse(); + $response->setContent($view->render()); + return $response; + } + } + + return $response; + } + + +} diff --git a/src/aphront/default/configuration/__init__.php b/src/aphront/default/configuration/__init__.php new file mode 100644 index 0000000000..1332403bd2 --- /dev/null +++ b/src/aphront/default/configuration/__init__.php @@ -0,0 +1,17 @@ +getRequest(); + + $path = phutil_escape_html($request->getPath()); + $host = phutil_escape_html($request->getHost()); + $controller_name = phutil_escape_html(get_class($this)); + + $page = new AphrontStandardPageView(); + + $response = new AphrontWebpageResponse(); + $response->setContent($page->render()); + return $response; + } + +} diff --git a/src/aphront/default/controller/__init__.php b/src/aphront/default/controller/__init__.php new file mode 100644 index 0000000000..9140c71c0e --- /dev/null +++ b/src/aphront/default/controller/__init__.php @@ -0,0 +1,16 @@ +map = $map; + } + + final public function mapPath($path) { + $map = $this->map; + foreach ($map as $rule => $value) { + list($controller, $data) = $this->tryRule($rule, $value, $path); + if ($controller) { + foreach ($data as $k => $v) { + if (is_numeric($k)) { + unset($data[$k]); + } + } + return array($controller, $data); + } + } + + return array(null, null); + } + + final private function tryRule($rule, $value, $path) { + $match = null; + if (!preg_match('#^'.$rule.'#', $path, $match)) { + return array(null, null); + } + + if (!is_array($value)) { + return array($value, $match); + } + + $path = substr($path, strlen($match[0])); + foreach ($value as $srule => $sval) { + list($controller, $data) = $this->tryRule($srule, $sval, $path); + if ($controller) { + return array($controller, $data + $match); + } + } + + return array(null, null); + } +} diff --git a/src/aphront/mapper/__init__.php b/src/aphront/mapper/__init__.php new file mode 100644 index 0000000000..46dee32678 --- /dev/null +++ b/src/aphront/mapper/__init__.php @@ -0,0 +1,10 @@ +host = $host; + $this->path = $path; + } + + final public function setRequestData(array $request_data) { + $this->requestData = $request_data; + return $this; + } + + final public function getPath() { + return $this->path; + } + + final public function getHost() { + return $this->host; + } + + final public function getInt($name, $default = null) { + if (isset($this->requestData[$name])) { + return (int)$this->requestData[$name]; + } else { + return $default; + } + } + + final public function getStr($name, $default = null) { + if (isset($this->requestData[$name])) { + return (string)$this->requestData[$name]; + } else { + return $default; + } + } + + final public function getArr($name, $default = null) { + if (isset($this->requestData[$name]) && + is_array($this->requestData[$name])) { + return $this->requestData[$name]; + } else { + return $default; + } + } + + final public function getExists($name) { + return array_key_exists($name, $this->requestData); + } + + final public function isHTTPPost() { + return ($_SERVER['REQUEST_METHOD'] == 'POST'); + } + + final public function isAjax() { + return $this->getExists(self::TYPE_AJAX); + } + + final public function isFormPost() { + return $this->getExists(self::TYPE_FORM) && $this->isHTTPPost(); + } + +} diff --git a/src/aphront/request/__init__.php b/src/aphront/request/__init__.php new file mode 100644 index 0000000000..3b8c8f9a1d --- /dev/null +++ b/src/aphront/request/__init__.php @@ -0,0 +1,10 @@ +request = $request; + return $this; + } + + public function getRequest() { + return $this->request; + } + + public function getHeaders() { + return array(); + } + + public function getCacheHeaders() { + return array( + array('Cache-Control', 'private, no-cache, no-store, must-revalidate'), + array('Expires', 'Sat, 01 Jan 2000 00:00:00 GMT'), + ); + } + + abstract public function buildResponseString(); + +} diff --git a/src/aphront/response/base/__init__.php b/src/aphront/response/base/__init__.php new file mode 100644 index 0000000000..28e5873f34 --- /dev/null +++ b/src/aphront/response/base/__init__.php @@ -0,0 +1,10 @@ +dialog = $dialog; + return $this; + } + + public function buildResponseString() { + return $this->dialog->render(); + } + +} diff --git a/src/aphront/response/dialog/__init__.php b/src/aphront/response/dialog/__init__.php new file mode 100644 index 0000000000..672a4e2b2d --- /dev/null +++ b/src/aphront/response/dialog/__init__.php @@ -0,0 +1,12 @@ +uri = $uri; + return $this; + } + + public function getHeaders() { + return array( + array('Location', $this->uri), + ); + } + + public function buildResponseString() { + return ''; + } + +} diff --git a/src/aphront/response/redirect/__init__.php b/src/aphront/response/redirect/__init__.php new file mode 100644 index 0000000000..f7d1e32537 --- /dev/null +++ b/src/aphront/response/redirect/__init__.php @@ -0,0 +1,12 @@ +content = $content; + return $this; + } + + public function buildResponseString() { + return $this->content; + } + +} diff --git a/src/aphront/response/webpage/__init__.php b/src/aphront/response/webpage/__init__.php new file mode 100644 index 0000000000..2585170caf --- /dev/null +++ b/src/aphront/response/webpage/__init__.php @@ -0,0 +1,12 @@ + 'root', + 'pass' => '', + 'host' => 'localhost', + 'database' => 'aphront_'.$this->getApplicationName(), + )); + + } + + public function getTableName() { + $class = strtolower(get_class($this)); + if (!strncmp($class, 'aphront', 7)) { + $class = substr($class, 7); + } + $app = $this->getApplicationName(); + if (!strncmp($class, $app, strlen($app))) { + $class = substr($class, strlen($app)); + } + return $app.'_'.$class; + } + + abstract public function getApplicationName(); +} diff --git a/src/aphront/storage/lisk/__init__.php b/src/aphront/storage/lisk/__init__.php new file mode 100644 index 0000000000..0509c024bb --- /dev/null +++ b/src/aphront/storage/lisk/__init__.php @@ -0,0 +1,13 @@ +setApplicationName('Directory'); + $page->setBaseURI('/'); + $page->setTitle(idx($data, 'title')); + $page->setTabs( + array( + 'directory' => array( + 'href' => '/', + 'name' => 'Directory', + ), + 'categories' => array( + 'href' => '/directory/category/', + 'name' => 'Categories', + ), + 'items' => array( + 'href' => '/directory/item/', + 'name' => 'Items', + ), + ), + idx($data, 'tab')); + $page->setGlyph("\xE2\x9A\x92"); + $page->appendChild($view); + + $response = new AphrontWebpageResponse(); + return $response->setContent($page->render()); + } + +} diff --git a/src/applications/directory/controller/base/__init__.php b/src/applications/directory/controller/base/__init__.php new file mode 100644 index 0000000000..4ec6c97a37 --- /dev/null +++ b/src/applications/directory/controller/base/__init__.php @@ -0,0 +1,16 @@ +id = $data['id']; + } + + public function processRequest() { + + $category = id(new AphrontDirectoryCategory())->load($this->id); + if (!$category) { + return new Aphront404Response(); + } + + $request = $this->getRequest(); + + if ($request->isFormPost()) { + $category->delete(); + return id(new AphrontRedirectResponse()) + ->setURI('/directory/category/'); + } + + $dialog = new AphrontDialogView(); + $dialog->setTitle('Really delete this category?'); + $dialog->appendChild("Are you sure you want to delete this category?"); + $dialog->addSubmitButton('Delete'); + $dialog->addCancelButton('/directory/category/'); + $dialog->setSubmitURI($request->getPath()); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } + +} diff --git a/src/applications/directory/controller/categorydelete/__init__.php b/src/applications/directory/controller/categorydelete/__init__.php new file mode 100644 index 0000000000..4c3cffe6f8 --- /dev/null +++ b/src/applications/directory/controller/categorydelete/__init__.php @@ -0,0 +1,19 @@ +id = idx($data, 'id'); + } + + public function processRequest() { + + if ($this->id) { + $category = id(new AphrontDirectoryCategory())->load($this->id); + if (!$category) { + return new Aphront404Response(); + } + } else { + $category = new AphrontDirectoryCategory(); + } + + $e_name = true; + $errors = array(); + + $request = $this->getRequest(); + if ($request->isFormPost()) { + $category->setName($request->getStr('name')); + $category->setSequence($request->getStr('sequence')); + + if (!strlen($category->getName())) { + $errors[] = 'Category name is required.'; + $e_name = 'Required'; + } + + if (!$errors) { + $category->save(); + return id(new AphrontRedirectResponse()) + ->setURI('/directory/category/'); + } + } + + $error_view = null; + if ($errors) { + $error_view = id(new AphrontErrorView()) + ->setTitle('Form Errors') + ->setErrors($errors); + } + + $form = new AphrontFormView(); + if ($category->getID()) { + $form->setAction('/directory/category/edit/'.$category->getID().'/'); + } else { + $form->setAction('/directory/category/edit/'); + } + + $categories = id(new AphrontDirectoryCategory())->loadAll(); + $category_map = mpull($categories, 'getName', 'getID'); + + $form + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Name') + ->setName('name') + ->setValue($category->getName()) + ->setError($e_name)) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Order') + ->setName('sequence') + ->setValue((int)$category->getSequence())) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue('Save') + ->addCancelButton('/directory/category/')); + + $panel = new AphrontPanelView(); + if ($category->getID()) { + $panel->setHeader('Edit Directory Category'); + } else { + $panel->setHeader('Create New Directory Category'); + } + + $panel->appendChild($form); + $panel->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array( + 'title' => 'Edit Directory Category', + )); + } + +} diff --git a/src/applications/directory/controller/categoryedit/__init__.php b/src/applications/directory/controller/categoryedit/__init__.php new file mode 100644 index 0000000000..a3f198f981 --- /dev/null +++ b/src/applications/directory/controller/categoryedit/__init__.php @@ -0,0 +1,21 @@ +loadAll(); + $categories = msort($categories, 'getSequence'); + + $rows = array(); + foreach ($categories as $category) { + $rows[] = array( + $category->getID(), + phutil_render_tag( + 'a', + array( + 'href' => '/directory/category/edit/'.$category->getID().'/', + ), + phutil_escape_html($category->getName())), + phutil_render_tag( + 'a', + array( + 'href' => '/directory/category/delete/'.$category->getID().'/', + 'class' => 'button grey small', + ), + 'Delete'), + ); + } + + + $table = new AphrontTableView($rows); + $table->setHeaders( + array( + 'ID', + 'Name', + '', + )); + $table->setColumnClasses( + array( + null, + 'wide', + 'action', + )); + + $panel = new AphrontPanelView(); + $panel->appendChild($table); + $panel->setHeader('Directory Categories'); + $panel->setCreateButton('New Category', '/directory/category/edit/'); + + return $this->buildStandardPageResponse($panel, array( + 'title' => 'Directory Category List', + 'tab' => 'categories', + )); + } + +} diff --git a/src/applications/directory/controller/categorylist/__init__.php b/src/applications/directory/controller/categorylist/__init__.php new file mode 100644 index 0000000000..251b79f269 --- /dev/null +++ b/src/applications/directory/controller/categorylist/__init__.php @@ -0,0 +1,18 @@ +id = $data['id']; + } + + public function processRequest() { + + $item = id(new AphrontDirectoryItem())->load($this->id); + if (!$item) { + return new Aphront404Response(); + } + + $request = $this->getRequest(); + + if ($request->isFormPost()) { + $item->delete(); + return id(new AphrontRedirectResponse()) + ->setURI('/directory/item/'); + } + + $dialog = new AphrontDialogView(); + $dialog->setTitle('Really delete this item?'); + $dialog->appendChild("Are you sure you want to delete this item?"); + $dialog->addSubmitButton('Delete'); + $dialog->addCancelButton('/directory/item/'); + $dialog->setSubmitURI($request->getPath()); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } + +} diff --git a/src/applications/directory/controller/itemdelete/__init__.php b/src/applications/directory/controller/itemdelete/__init__.php new file mode 100644 index 0000000000..f2f92cc759 --- /dev/null +++ b/src/applications/directory/controller/itemdelete/__init__.php @@ -0,0 +1,19 @@ +id = idx($data, 'id'); + } + + public function processRequest() { + + if ($this->id) { + $item = id(new AphrontDirectoryItem())->load($this->id); + if (!$item) { + return new Aphront404Response(); + } + } else { + $item = new AphrontDirectoryItem(); + } + + $e_name = true; + $e_href = true; + $errors = array(); + + $request = $this->getRequest(); + if ($request->isFormPost()) { + $item->setName($request->getStr('name')); + $item->setHref($request->getStr('href')); + $item->setDescription($request->getStr('description')); + $item->setCategoryID($request->getStr('categoryID')); + $item->setSequence($request->getStr('sequence')); + + if (!strlen($item->getName())) { + $errors[] = 'Item name is required.'; + $e_name = 'Required'; + } + + if (!strlen($item->getHref())) { + $errors[] = 'Item link is required.'; + $e_href = 'Required'; + } + + if (!$errors) { + $item->save(); + return id(new AphrontRedirectResponse()) + ->setURI('/directory/item/'); + } + } + + $error_view = null; + if ($errors) { + $error_view = id(new AphrontErrorView()) + ->setTitle('Form Errors') + ->setErrors($errors); + } + + $form = new AphrontFormView(); + if ($item->getID()) { + $form->setAction('/directory/item/edit/'.$item->getID().'/'); + } else { + $form->setAction('/directory/item/edit/'); + } + + $categories = id(new AphrontDirectoryCategory())->loadAll(); + $category_map = mpull($categories, 'getName', 'getID'); + + $form + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Name') + ->setName('name') + ->setValue($item->getName()) + ->setError($e_name)) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel('Category') + ->setName('categoryID') + ->setOptions($category_map) + ->setValue($item->getCategoryID())) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Link') + ->setName('href') + ->setValue($item->getHref()) + ->setError($e_href)) + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setLabel('Description') + ->setName('description') + ->setValue($item->getDescription())) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Order') + ->setName('sequence') + ->setCaption( + 'Items in a category are sorted by "order", then by name.') + ->setValue((int)$item->getSequence())) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue('Save') + ->addCancelButton('/directory/item/')); + + $panel = new AphrontPanelView(); + if ($item->getID()) { + $panel->setHeader('Edit Directory Item'); + } else { + $panel->setHeader('Create New Directory Item'); + } + + $panel->appendChild($form); + $panel->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array( + 'title' => 'Edit Directory Item', + )); + } + +} diff --git a/src/applications/directory/controller/itemedit/__init__.php b/src/applications/directory/controller/itemedit/__init__.php new file mode 100644 index 0000000000..4cdff77aba --- /dev/null +++ b/src/applications/directory/controller/itemedit/__init__.php @@ -0,0 +1,22 @@ +loadAll(); + $items = msort($items, 'getSortKey'); + + $categories = id(new AphrontDirectoryCategory())->loadAll(); + $category_names = mpull($categories, 'getName', 'getID'); + + $rows = array(); + foreach ($items as $item) { + $rows[] = array( + $item->getID(), + phutil_escape_html(idx($category_names, $item->getCategoryID())), + phutil_render_tag( + 'a', + array( + 'href' => '/directory/item/edit/'.$item->getID().'/', + ), + phutil_escape_html($item->getName())), + phutil_render_tag( + 'a', + array( + 'href' => '/directory/item/delete/'.$item->getID().'/', + 'class' => 'button grey small', + ), + 'Delete'), + ); + } + + + $table = new AphrontTableView($rows); + $table->setHeaders( + array( + 'ID', + 'Category', + 'Name', + '', + )); + $table->setColumnClasses( + array( + null, + null, + 'wide', + 'action', + )); + + $panel = new AphrontPanelView(); + $panel->appendChild($table); + $panel->setHeader('Directory Items'); + $panel->setCreateButton('New Item', '/directory/item/edit/'); + + return $this->buildStandardPageResponse($panel, array( + 'title' => 'Directory Items', + 'tab' => 'items', + )); + } + +} diff --git a/src/applications/directory/controller/itemlist/__init__.php b/src/applications/directory/controller/itemlist/__init__.php new file mode 100644 index 0000000000..48ffcb97d7 --- /dev/null +++ b/src/applications/directory/controller/itemlist/__init__.php @@ -0,0 +1,19 @@ +loadAll(); + $items = msort($items, 'getSortKey'); + + $categories = id(new AphrontDirectoryCategory())->loadAll(); + $categories = msort($categories, 'getSequence'); + + $category_map = mpull($categories, 'getName', 'getID'); + $category_map[0] = 'Free Radicals'; + $items = mgroup($items, 'getCategoryID'); + + $content = array(); + foreach ($category_map as $id => $category_name) { + $category_items = idx($items, $id); + if (!$category_items) { + continue; + } + + $item_markup = array(); + foreach ($category_items as $item) { + $item_markup[] = + '
'. + '

'. + phutil_render_tag( + 'a', + array( + 'href' => $item->getHref(), + ), + phutil_escape_html($item->getName())). + '

'. + '

'.phutil_escape_html($item->getDescription()).'

'. + '
'; + } + + $content[] = + '
'. + '

'.phutil_escape_html($category_name).'

'. + '
'. + implode("\n", $item_markup). + '
'. + '
'; + } + + $content = + '
'. + implode("\n", $content). + '
'; + + return $this->buildStandardPageResponse($content, array( + 'title' => 'Directory', + 'tab' => 'directory', + )); + } + +} diff --git a/src/applications/directory/controller/main/__init__.php b/src/applications/directory/controller/main/__init__.php new file mode 100644 index 0000000000..812dc47f7c --- /dev/null +++ b/src/applications/directory/controller/main/__init__.php @@ -0,0 +1,17 @@ +getCategoryID(), + $this->getSequence(), + $this->getName()); + } + +} diff --git a/src/applications/directory/storage/item/__init__.php b/src/applications/directory/storage/item/__init__.php new file mode 100644 index 0000000000..ca9b6a7c4b --- /dev/null +++ b/src/applications/directory/storage/item/__init__.php @@ -0,0 +1,12 @@ + 'commented on', + self::ACTION_ACCEPT => 'accepted', + self::ACTION_REJECT => 'requested changes to', + self::ACTION_ABANDON => 'abandoned', + self::ACTION_COMMIT => 'committed', + self::ACTION_REQUEST => 'requested a review of', + self::ACTION_RECLAIM => 'reclaimed', + self::ACTION_UPDATE => 'updated', + self::ACTION_RESIGN => 'resigned from', + self::ACTION_SUMMARIZE => 'summarized', + self::ACTION_TESTPLAN => 'explained the test plan for', + self::ACTION_CREATE => 'created', + self::ACTION_ADDREVIEWERS => 'added reviewers to', + ); + + if (empty($verbs[$action])) { + return $verbs[$action]; + } else { + return 'brazenly "'.$action.'ed"'; + } + } + +} diff --git a/src/applications/review/constants/action/__init__.php b/src/applications/review/constants/action/__init__.php new file mode 100644 index 0000000000..5620371d9c --- /dev/null +++ b/src/applications/review/constants/action/__init__.php @@ -0,0 +1,10 @@ + 'A', + self::TYPE_CHANGE => 'M', + self::TYPE_DELETE => 'D', + self::TYPE_MOVE_AWAY => 'V', + self::TYPE_COPY_AWAY => 'P', + self::TYPE_MOVE_HERE => 'V', + self::TYPE_COPY_HERE => 'P', + self::TYPE_MULTICOPY => 'P', + self::TYPE_MESSAGE => 'Q', + self::TYPE_CHILD => '@', + ); + return idx($types, coalesce($type, '?'), '~'); + } + + public static function getShortNameForFileType($type) { + static $names = array( + self::FILE_TEXT => null, + self::FILE_DIRECTORY => 'dir', + self::FILE_IMAGE => 'img', + self::FILE_BINARY => 'bin', + self::FILE_SYMLINK => 'sym', + ); + return idx($names, coalesce($type, '?'), '???'); + } + + public static function isOldLocationChangeType($type) { + static $types = array( + DifferentialChangeType::TYPE_MOVE_AWAY => true, + DifferentialChangeType::TYPE_COPY_AWAY => true, + DifferentialChangeType::TYPE_MULTICOPY => true, + ); + return isset($types[$type]); + } + + public static function isNewLocationChangeType($type) { + static $types = array( + DifferentialChangeType::TYPE_MOVE_HERE => true, + DifferentialChangeType::TYPE_COPY_HERE => true, + ); + return isset($types[$type]); + } + + public static function isDeleteChangeType($type) { + static $types = array( + DifferentialChangeType::TYPE_DELETE => true, + DifferentialChangeType::TYPE_MOVE_AWAY => true, + DifferentialChangeType::TYPE_MULTICOPY => true, + ); + return isset($types[$type]); + } + + public static function isCreateChangeType($type) { + static $types = array( + DifferentialChangeType::TYPE_ADD => true, + DifferentialChangeType::TYPE_COPY_HERE => true, + DifferentialChangeType::TYPE_MOVE_HERE => true, + ); + return isset($types[$type]); + } + + public static function isModifyChangeType($type) { + static $types = array( + DifferentialChangeType::TYPE_CHANGE => true, + ); + return isset($types[$type]); + } + + public static function getFullNameForChangeType($type) { + static $types = array( + self::TYPE_ADD => 'Added', + self::TYPE_CHANGE => 'Modified', + self::TYPE_DELETE => 'Deleted', + self::TYPE_MOVE_AWAY => 'Moved Away', + self::TYPE_COPY_AWAY => 'Copied Away', + self::TYPE_MOVE_HERE => 'Moved Here', + self::TYPE_COPY_HERE => 'Copied Here', + self::TYPE_MULTICOPY => 'Deleted After Multiple Copy', + self::TYPE_MESSAGE => 'Commit Message', + self::TYPE_CHILD => 'Contents Modified', + ); + return idx($types, coalesce($type, '?'), 'Unknown'); + } + +} diff --git a/src/applications/review/constants/changetype/__init__.php b/src/applications/review/constants/changetype/__init__.php new file mode 100644 index 0000000000..a37cb89bc3 --- /dev/null +++ b/src/applications/review/constants/changetype/__init__.php @@ -0,0 +1,12 @@ + 'Needs Review', + self::NEEDS_REVISION => 'Needs Revision', + self::ACCEPTED => 'Accepted', + self::COMMITTED => 'Committed', + self::ABANDONED => 'Abandoned', + ); + + return idx($map, coalesce($status, '?'), 'Unknown'); + } + +} diff --git a/src/applications/review/constants/revisionstatus/__init__.php b/src/applications/review/constants/revisionstatus/__init__.php new file mode 100644 index 0000000000..8d5cc9e019 --- /dev/null +++ b/src/applications/review/constants/revisionstatus/__init__.php @@ -0,0 +1,12 @@ +getTransactionKey(); + if (!isset($levels[$key])) { + $levels[$key] = array( + 'read' => 0, + 'write' => 0, + ); + } + + return $levels[$key]; + } + + public function isReadLocking() { + $levels = &$this->getLockLevels(); + return ($levels['read'] > 0); + } + + public function isWriteLocking() { + $levels = &$this->getLockLevels(); + return ($levels['write'] > 0); + } + + public function startReadLocking() { + $levels = &$this->getLockLevels(); + ++$levels['read']; + return $this; + } + + public function startWriteLocking() { + $levels = &$this->getLockLevels(); + ++$levels['write']; + return $this; + } + + public function stopReadLocking() { + $levels = &$this->getLockLevels(); + if ($levels['read'] < 1) { + throw new Exception('Unable to stop read locking: not read locking.'); + } + --$levels['read']; + return $this; + } + + public function stopWriteLocking() { + $levels = &$this->getLockLevels(); + if ($levels['write'] < 1) { + throw new Exception('Unable to stop read locking: not write locking.'); + } + --$levels['write']; + return $this; + } + + protected function &getTransactionStack($key) { + if (!self::$transactionShutdownRegistered) { + self::$transactionShutdownRegistered = true; + register_shutdown_function( + array( + 'LiskConnection', + 'shutdownTransactionStacks', + )); + } + + if (!isset(self::$transactionStacks[$key])) { + self::$transactionStacks[$key] = array(); + } + + return self::$transactionStacks[$key]; + } + + public static function shutdownTransactionStacks() { + foreach (self::$transactionStacks as $stack) { + if ($stack === false) { + continue; + } + + $count = count($stack); + if ($count) { + throw new Exception( + 'Script exited with '.$count.' open transactions! The '. + 'transactions will be implicitly rolled back. Calls to '. + 'openTransaction() should always be paired with a call to '. + 'saveTransaction() or killTransaction(); you have an unpaired '. + 'call somewhere.', + $count); + } + } + } + + public function openTransaction() { + $key = $this->getTransactionKey(); + $stack = &$this->getTransactionStack($key); + + $new_transaction = !count($stack); + + // TODO: At least in development, push context information instead of + // `true' so we can report (or, at least, guess) where unpaired + // transaction calls happened. + $stack[] = true; + + end($stack); + $key = key($stack); + + if ($new_transaction) { + $this->query('START TRANSACTION'); + } else { + $this->query('SAVEPOINT '.$this->getSavepointName($key)); + } + } + + public function isInsideTransaction() { + $key = $this->getTransactionKey(); + $stack = &$this->getTransactionStack($key); + return (bool)count($stack); + } + + public function saveTransaction() { + $key = $this->getTransactionKey(); + $stack = &$this->getTransactionStack($key); + + if (!count($stack)) { + throw new Exception( + "No open transaction! Unable to save transaction, since there ". + "isn't one."); + } + + array_pop($stack); + + if (!count($stack)) { + $this->query('COMMIT'); + } + } + + public function saveTransactionUnless($cond) { + if ($cond) { + $this->killTransaction(); + } else { + $this->saveTransaction(); + } + } + + public function saveTransactionIf($cond) { + $this->saveTransactionUnless(!$cond); + } + + public function killTransaction() { + $key = $this->getTransactionKey(); + $stack = &$this->getTransactionStack($key); + + if (!count($stack)) { + throw new Exception( + "No open transaction! Unable to kill transaction, since there ". + "isn't one."); + } + + $count = count($stack); + + end($stack); + $key = key($stack); + array_pop($stack); + + if (!count($stack)) { + $this->query('ROLLBACK'); + } else { + $this->query( + 'ROLLBACK TO SAVEPOINT '.$this->getSavepointName($key) + ); + } + } + + protected function getSavepointName($key) { + return 'LiskSavepoint_'.$key; + } +} diff --git a/src/storage/connection/base/__init__.php b/src/storage/connection/base/__init__.php new file mode 100644 index 0000000000..79093a7f0d --- /dev/null +++ b/src/storage/connection/base/__init__.php @@ -0,0 +1,12 @@ +configuration = $configuration; + } + + public function escapeString($string) { + if (!$this->connection) { + $this->establishConnection(); + } + return mysql_real_escape_string($string, $this->connection); + } + + public function escapeColumnName($name) { + return '`'.str_replace('`', '\\`', $name).'`'; + } + + public function escapeMultilineComment($comment) { + // These can either terminate a comment, confuse the hell out of the parser, + // make MySQL execute the comment as a query, or, in the case of semicolon, + // are quasi-dangerous because the semicolon could turn a broken query into + // a working query plus an ignored query. + + static $map = array( + '--' => '(DOUBLEDASH)', + '*/' => '(STARSLASH)', + '//' => '(SLASHSLASH)', + '#' => '(HASH)', + '!' => '(BANG)', + ';' => '(SEMICOLON)', + ); + + $comment = str_replace( + array_keys($map), + array_values($map), + $comment); + + // For good measure, kill anything else that isn't a nice printable + // character. + $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment); + + return '/* '.$comment.' */'; + } + + public function escapeStringForLikeClause($value) { + $value = $this->escapeString($value); + // Ideally the query shouldn't be modified after safely escaping it, + // but we need to escape _ and % within LIKE terms. + $value = str_replace( + // Even though we've already escaped, we need to replace \ with \\ + // because MYSQL unescapes twice inside a LIKE clause. See note + // at mysql.com. However, if the \ is being used to escape a single + // quote ('), then the \ should not be escaped. Thus, after all \ + // are replaced with \\, we need to revert instances of \\' back to + // \'. + array('\\', '\\\\\'', '_', '%'), + array('\\\\', '\\\'', '\_', '\%'), + $value); + return $value; + } + + private function getConfiguration($key, $default = null) { + return idx($this->configuration, $key, $default); + } + + private function establishConnection() { + $this->connection = null; + + $user = $this->getConfiguration('user'); + $host = $this->getConfiguration('host'); + + $conn = @mysql_connect( + $host, + $user, + $this->getConfiguration('pass'), + $new_link = true, + $flags = 0); + + if (!$conn) { + $errno = mysql_errno(); + $error = mysql_error(); + throw new AphrontQueryConnectionException( + "Attempt to connect to {$user}@{$host} failed with error #{$errno}: ". + "{$error}."); + } + + $ret = @mysql_select_db($this->getConfiguration('database'), $conn); + if (!$ret) { + $this->throwQueryException($conn); + } + + $this->connection = $conn; + } + + public function getInsertID() { + return mysql_insert_id($this->requireConnection()); + } + + public function getAffectedRows() { + return mysql_affected_rows($this->requireConnection()); + } + + public function getTransactionKey() { + return (int)$this->requireConnection(); + } + + private function requireConnection() { + if (!$this->connection) { + $this->establishConnection(); + } + return $this->connection; + } + + public function selectAllResults() { + $result = array(); + $res = $this->lastResult; + if ($res == null) { + throw new Exception('No query result to fetch from!'); + } + while (($row = mysql_fetch_assoc($res)) !== false) { + $result[] = $row; + } + return $result; + } + + public function executeRawQuery($raw_query) { + $this->lastResult = null; + $retries = 3; + while ($retries--) { + try { + if (!$this->connection) { + $this->establishConnection(); + } + + $result = mysql_query($raw_query, $this->connection); + + if ($result) { + $this->lastResult = $result; + break; + } + + $this->throwQueryException($this->connection); + } catch (AphrontQueryConnectionLostException $ex) { + if (!$retries) { + throw $ex; + } + if ($this->isInsideTransaction()) { + throw $ex; + } + $this->connection = null; + } + } + } + + private function throwQueryException($connection) { + $errno = mysql_errno($connection); + $error = mysql_error($connection); + + switch ($errno) { + case 2013: // Connection Dropped + case 2006: // Gone Away + throw new AphrontQueryConnectionLostException("#{$errno}: {$error}"); + break; + case 1213: // Deadlock + case 1205: // Lock wait timeout exceeded + throw new AphrontQueryRecoverableException("#{$errno}: {$error}"); + break; + default: + // TODO: 1062 is syntax error, and quite terrible in production. + throw new AphrontQueryException("#{$errno}: {$error}"); + } + } + +} diff --git a/src/storage/connection/mysql/__init__.php b/src/storage/connection/mysql/__init__.php new file mode 100644 index 0000000000..7f74d3fddd --- /dev/null +++ b/src/storage/connection/mysql/__init__.php @@ -0,0 +1,18 @@ +query = $query; + } + + public function getQuery() { + return $this->query; + } + +} diff --git a/src/storage/exception/parameter/__init__.php b/src/storage/exception/parameter/__init__.php new file mode 100644 index 0000000000..682623ec52 --- /dev/null +++ b/src/storage/exception/parameter/__init__.php @@ -0,0 +1,12 @@ +setName('Sawyer') + * ->setBreed('Pug') + * ->save(); + * + * Note that **Lisk automatically builds getters and setters for all of your + * object's properties** via __call(). You can override these by defining + * versions yourself. + * + * Calling save() will persist the object to the database. After calling + * save(), you can call getID() to retrieve the object's ID. + * + * To load objects by ID, use the load() method: + * + * $dog = id(new Dog())->load($id); + * + * This will load the Dog record with ID $id into $dog, or ##null## if no such + * record exists (load() is an instance method rather than a static method + * because PHP does not support late static binding, at least until PHP 5.3). + * + * To update an object, change its properties and save it: + * + * $dog->setBreed('Lab')->save(); + * + * To delete an object, call delete(): + * + * $dog->delete(); + * + * That's Lisk CRUD in a nutshell. + * + * = Queries = + * + * Often, you want to load a bunch of objects, or execute a more specialized + * query. Use loadAllWhere() or loadOneWhere() to do this: + * + * $pugs = $dog->loadAllWhere('breed = %s', 'Pug'); + * $sawyer = $dog->loadOneWhere('name = %s', 'Sawyer'); + * + * These methods work like @{function:queryfx}, but only take half of a query + * (the part after the WHERE keyword). Lisk will handle the connection, columns, + * and object construction; you are responsible for the rest of it. + * loadAllWhere() returns a list of objects, while loadOneWhere() returns a + * single object (or null). + * + * @task config Configuring Lisk + * @task load Loading Objects + * @task info Examining Objects + * @task save Writing Objects + * @task hook Hooks and Callbacks + * @task util Utilities + * + * @group storage + */ +abstract class LiskDAO { + + const CONFIG_OPTIMISTIC_LOCKS = 'enable-locks'; + const CONFIG_IDS = 'id-mechanism'; + const CONFIG_TIMESTAMPS = 'timestamps'; + const CONFIG_AUX_GUID = 'auxiliary-guid'; + const CONFIG_SERIALIZATION = 'col-serialization'; + + const SERIALIZATION_NONE = 'id'; + const SERIALIZATION_JSON = 'json'; + const SERIALIZATION_PHP = 'php'; + + const IDS_AUTOINCREMENT = 'ids-auto'; + const IDS_GUID = 'ids-guid'; + const IDS_MANUAL = 'ids-manual'; + + /** + * Build an empty object. + * + * @return obj Empty object. + */ + public function __construct() { + $id_key = $this->getIDKey(); + if ($id_key) { + $this->$id_key = null; + } + } + + abstract protected function establishConnection($mode); + + +/* -( Configuring Lisk )--------------------------------------------------- */ + + + /** + * Change Lisk behaviors, like optimistic locks and timestamps. If you want + * to change these behaviors, you should override this method in your child + * class and change the options you're interested in. For example: + * + * public function getConfiguration() { + * return array( + * Lisk_DataAccessObject::CONFIG_EXAMPLE => true, + * ) + parent::getConfiguration(); + * } + * + * The available options are: + * + * CONFIG_OPTIMISTIC_LOCKS + * Lisk automatically performs optimistic locking on objects, which protects + * you from read-modify-write concurrency problems. Lock failures are + * detected at write time and arise when two users read an object, then both + * save it. In theory, you should detect these failures and accommodate them + * in some sensible way (for instance, by showing the user differences + * between the original record and the copy they are trying to update, and + * prompting them to merge them). In practice, most Lisk tools are quick + * and dirty and don't get to that level of sophistication, but optimistic + * locks can still protect you from yourself sometimes. If you don't want + * to use optimistic locks, you can disable them. The performance cost of + * doing this locking is very very small (optimistic locks were chosen + * because they're simple and cheap, and highly optimized for the case where + * collisions are rare). By default, this option is OFF. + * + * CONFIG_IDS + * Lisk objects need to have a unique identifying ID. The three mechanisms + * available for generating this ID are IDS_AUTOINCREMENT (default, assumes + * the ID column is an autoincrement primary key), IDS_GUID (to generate a + * unique GUID for each object) or IDS_MANUAL (you are taking full + * responsibility for ID management). + * + * CONFIG_TIMESTAMPS + * Lisk can automatically handle keeping track of a `dateCreated' and + * `dateModified' column, which it will update when it creates or modifies + * an object. If you don't want to do this, you may disable this option. + * By default, this option is ON. + * + * CONFIG_AUX_GUID + * This option can be enabled by being set to some truthy value. The meaning + * of this value is defined by your guid generation mechanism. If this option + * is enabled, a `guid' property will be populated with a unique GUID when an + * object is created (or if it is saved and does not currently have one). You + * need to override generateGUID() and hook it into your GUID generation + * mechanism for this to work. By default, this option is OFF. + * + * CONFIG_SERIALIZATION + * You can optionally provide a column serialization map that will be applied + * to values when they are written to the database. For example: + * + * self::CONFIG_SERIALIZATION => array( + * 'complex' => self::SERIALIZATION_JSON, + * ) + * + * This will cause Lisk to JSON-serialize the 'complex' field before it is + * written, and unserialize it when it is read. + * + * + * @return dictionary Map of configuration options to values. + * + * @task config + */ + protected function getConfiguration() { + return array( + self::CONFIG_OPTIMISTIC_LOCKS => false, + self::CONFIG_IDS => self::IDS_AUTOINCREMENT, + self::CONFIG_TIMESTAMPS => true, + ); + } + + + /** + * Determine the setting of a configuration option for this class of objects. + * + * @param const Option name, one of the CONFIG_* constants. + * @return mixed Option value, if configured (null if unavailable). + * + * @task config + */ + public function getConfigOption($option_name) { + static $options = null; + + if (!isset($options)) { + $options = $this->getConfiguration(); + } + + return idx($options, $option_name); + } + + +/* -( Loading Objects )---------------------------------------------------- */ + + + /** + * Load an object by ID. You need to invoke this as an instance method, not + * a class method, because PHP doesn't have late static binding (until + * PHP 5.3.0). For example: + * + * $dog = id(new Dog())->load($dog_id); + * + * @param int Numeric ID identifying the object to load. + * @return obj|null Identified object, or null if it does not exist. + * + * @task load + */ + public function load($id) { + if (!($id = (int)$id)) { + throw new Exception("Bogus ID provided to load()."); + } + + return $this->loadOneWhere( + '%C = %d', + $this->getIDKeyForUse(), + $id); + } + + + /** + * Loads all of the objects, unconditionally. + * + * @return dict Dictionary of all persisted objects of this type, keyed + * on object ID. + * + * @task load + */ + public function loadAll() { + return $this->loadAllWhere('1 = 1'); + } + + + /** + * Load all objects which match a WHERE clause. You provide everything after + * the 'WHERE'; Lisk handles everything up to it. For example: + * + * $old_dogs = id(new Dog())->loadAllWhere('age > %d', 7); + * + * The pattern and arguments are as per queryfx(). + * + * @param string queryfx()-style SQL WHERE clause. + * @param ... Zero or more conversions. + * @return dict Dictionary of matching objects, keyed on ID. + * + * @task load + */ + public function loadAllWhere($pattern/*, $arg, $arg, $arg ... */) { + $args = func_get_args(); + $data = call_user_func_array( + array($this, 'loadRawDataWhere'), + $args); + return $this->loadAllFromArray($data); + } + + + /** + * Load a single object identified by a 'WHERE' clause. You provide + * everything after the 'WHERE', and Lisk builds the first half of the + * query. See loadAllWhere(). This method is similar, but returns a single + * result instead of a list. + * + * @param string queryfx()-style SQL WHERE clause. + * @param ... Zero or more conversions. + * @return obj|null Matching object, or null if no object matches. + * + * @task load + */ + public function loadOneWhere($pattern/*, $arg, $arg, $arg ... */) { + $args = func_get_args(); + $data = call_user_func_array( + array($this, 'loadRawDataWhere'), + $args); + + if (count($data) > 1) { + throw new AphrontQueryCountException( + "More than 1 result from loadOneWhere()!"); + } + + $data = reset($data); + if (!$data) { + return null; + } + + return $this->loadFromArray($data); + } + + + protected function loadRawDataWhere($pattern/*, $arg, $arg, $arg ... */) { + $connection = $this->getConnection('r'); + + $lock_clause = ''; + if ($connection->isReadLocking()) { + $lock_clause = 'FOR UPDATE'; + } else if ($connection->isWriteLocking()) { + $lock_clause = 'LOCK IN SHARE MODE'; + } + + $args = func_get_args(); + $args = array_slice($args, 1); + + $pattern = 'SELECT * FROM %T WHERE '.$pattern.' %Q'; + array_unshift($args, $this->getTableName()); + array_push($args, $lock_clause); + array_unshift($args, $pattern); + + return call_user_func_array( + array($connection, 'queryData'), + $args); + } + + + /** + * Reload an object from the database, discarding any changes to persistent + * properties. If the object uses optimistic locks and you are in a locking + * mode while transactional, this will effectively synchronize the locks. + * This is pretty heady. It is unlikely you need to use this method. + * + * @return this + * + * @task load + */ + public function reload() { + + if (!$this->getID()) { + throw new Exception("Unable to reload object that hasn't been loaded!"); + } + + $use_locks = $this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS); + + if (!$use_locks) { + $result = $this->loadOneWhere( + '%C = %d', + $this->getIDKeyForUse(), + $this->getID()); + } else { + $result = $this->loadOneWhere( + '%C = %d AND %C = %d', + $this->getIDKeyForUse(), + $this->getID(), + 'version', + $this->getVersion()); + } + + if (!$result) { + throw new AphrontQueryObjectMissingException($use_locks); + } + + return $this; + } + + + /** + * Initialize this object's properties from a dictionary. Generally, you + * load single objects with loadOneWhere(), but sometimes it may be more + * convenient to pull data from elsewhere directly (e.g., a complicated + * join via queryData()) and then load from an array representation. + * + * @param dict Dictionary of properties, which should be equivalent to + * selecting a row from the table or calling getProperties(). + * @return this + * + * @task load + */ + public function loadFromArray(array $row) { + $map = array(); + foreach ($row as $k => $v) { + $map[$k] = $v; + } + + $this->willReadData($map); + + foreach ($map as $prop => $value) { + $this->$prop = $value; + } + + $this->didReadData(); + + return $this; + } + + + /** + * Initialize a list of objects from a list of dictionaries. Usually you + * load lists of objects with loadAllWhere(), but sometimes that isn't + * flexible enough. One case is if you need to do joins to select the right + * objects: + * + * function loadAllWithOwner($owner) { + * $data = $this->queryData( + * 'SELECT d.* + * FROM owner o + * JOIN owner_has_dog od ON o.id = od.ownerID + * JOIN dog d ON od.dogID = d.id + * WHERE o.id = %d', + * $owner); + * return $this->loadAllFromArray($data); + * } + * + * This is a lot messier than loadAllWhere(), but more flexible. + * + * @param list List of property dictionaries. + * @return dict List of constructed objects, keyed on ID. + * + * @task load + */ + public function loadAllFromArray(array $rows) { + $result = array(); + + $id_key = $this->getIDKey(); + + foreach ($rows as $row) { + $obj = clone $this; + if ($id_key) { + $result[$row[$id_key]] = $obj->loadFromArray($row); + } else { + $result[] = $obj->loadFromArray($row); + } + } + + return $result; + } + + +/* -( Examining Objects )-------------------------------------------------- */ + + + /** + * Retrieve the unique, numerical ID identifying this object. This value + * will be null if the object hasn't been persisted. + * + * @return int Unique numerical ID. + * + * @task info + */ + public function getID() { + $id_key = $this->getIDKeyForUse(); + return $this->$id_key; + } + + + /** + * Retrieve a list of all object properties. Note that some may be + * "transient", which means they should not be persisted to the database. + * Transient properties can be identified by calling + * getTransientProperties(). + * + * @return dict Dictionary of normalized (lowercase) to canonical (original + * case) property names. + * + * @task info + */ + protected function getProperties() { + static $properties = null; + if (!isset($properties)) { + $class = new ReflectionClass(get_class($this)); + $properties = array(); + foreach ($class->getProperties() as $p) { + $properties[strtolower($p->getName())] = $p->getName(); + } + + $id_key = $this->getIDKey(); + if ($id_key) { + if (!isset($properties[strtolower($id_key)])) { + $properties[strtolower($id_key)] = $id_key; + } + } + + if ($this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS)) { + $properties['version'] = 'version'; + } + + if ($this->getConfigOption(self::CONFIG_TIMESTAMPS)) { + $properties['datecreated'] = 'dateCreated'; + $properties['datemodified'] = 'dateModified'; + } + + if (!$this->isGUIDPrimaryID() && + $this->getConfigOption(self::CONFIG_AUX_GUID)) { + $properties['guid'] = 'guid'; + } + } + return $properties; + } + + + /** + * Check if a property exists on this object. + * + * @return string|null Canonical property name, or null if the property + * does not exist. + * + * @task info + */ + protected function checkProperty($property) { + static $properties = null; + if (!isset($properties)) { + $properties = $this->getProperties(); + } + + return idx($properties, strtolower($property)); + } + + + /** + * Get or build the database connection for this object. + * + * @return LiskDatabaseConnection Lisk connection object. + * + * @task info + */ + protected function getConnection($mode) { + if ($mode != 'r' && $mode != 'w') { + throw new Exception("Unknown mode '{$mode}', should be 'r' or 'w'."); + } + + // TODO: We don't do anything with the read/write mode right now, but + // should. + + if (!isset($this->__connection)) { + $this->__connection = $this->establishConnection($mode); + } + + return $this->__connection; + } + + + /** + * Convert this object into a property dictionary. This dictionary can be + * restored into an object by using loadFromArray() (unless you're using + * legacy features with CONFIG_CONVERT_CAMELCASE, but in that case you should + * just go ahead and die in a fire). + * + * @return dict Dictionary of object properties. + * + * @task info + */ + protected function getPropertyValues() { + $map = array(); + foreach ($this->getProperties() as $p) { + // We may receive a warning here for properties we've implicitly added + // through configuration; squelch it. + $map[$p] = @$this->$p; + } + return $map; + } + + + /** + * Convert this object into a property dictionary containing only properties + * which will be persisted to the database. + * + * @return dict Dictionary of persistent object properties. + * + * @task info + */ + protected function getPersistentPropertyValues() { + $map = $this->getPropertyValues(); + foreach ($this->getTransientProperties() as $p) { + unset($map[$p]); + } + return $map; + } + + +/* -( Writing Objects )---------------------------------------------------- */ + + + /** + * Persist this object to the database. In most cases, this is the only + * method you need to call to do writes. If the object has not yet been + * inserted this will do an insert; if it has, it will do an update. + * + * @return this + * + * @task save + */ + public function save() { + if ($this->shouldInsertWhenSaved()) { + return $this->insert(); + } else { + return $this->update(); + } + } + + + /** + * Save this object, forcing the query to use REPLACE regardless of object + * state. + * + * @return this + * + * @task save + */ + public function replace() { + return $this->insertRecordIntoDatabase('REPLACE'); + } + + + /** + * Save this object, forcing the query to use INSERT regardless of object + * state. + * + * @return this + * + * @task save + */ + public function insert() { + return $this->insertRecordIntoDatabase('INSERT'); + } + + + /** + * Save this object, forcing the query to use UPDATE regardless of object + * state. + * + * @return this + * + * @task save + */ + public function update() { + $use_locks = $this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS); + + $this->willSaveObject(); + $data = $this->getPersistentPropertyValues(); + $this->willWriteData($data); + + $map = array(); + foreach ($data as $k => $v) { + if ($use_locks && $k == 'version') { + continue; + } + $map[$k] = $v; + } + + $conn = $this->getConnection('w'); + + foreach ($map as $key => $value) { + $map[$key] = qsprintf($conn, '%C = %ns', $key, $value); + } + $map = implode(', ', $map); + + if ($use_locks) { + $conn->query( + 'UPDATE %T SET %Q, version = version + 1 WHERE %C = %d AND %C = %d', + $this->getTableName(), + $map, + $this->getIDKeyForUse(), + $this->getID(), + 'version', + $this->getVersion()); + } else { + $conn->query( + 'UPDATE %T SET %Q WHERE %C = %d', + $this->getTableName(), + $map, + $this->getIDKeyForUse(), + $this->getID()); + } + + if ($conn->getAffectedRows() !== 1) { + throw new AphrontQueryObjectMissingException($use_locks); + } + + if ($use_locks) { + $this->setVersion($this->getVersion() + 1); + } + + $this->didWriteData(); + + return $this; + } + + + /** + * Delete this object, permanently. + * + * @return this + * + * @task save + */ + public function delete() { + $this->willDelete(); + + $conn = $this->getConnection('w'); + $conn->query( + 'DELETE FROM %T WHERE %C = %d', + $this->getTableName(), + $this->getIDKeyForUse(), + $this->getID()); + + $this->didDelete(); + + return $this; + } + + + /** + * Internal implementation of INSERT and REPLACE. + * + * @param const Either "INSERT" or "REPLACE", to force the desired mode. + * + * @task save + */ + protected function insertRecordIntoDatabase($mode) { + $this->willSaveObject(); + $data = $this->getPersistentPropertyValues(); + + $id_mechanism = $this->getConfigOption(self::CONFIG_IDS); + switch ($id_mechanism) { + // If we are using autoincrement IDs, let MySQL assign the value for the + // ID column. + case self::IDS_AUTOINCREMENT: + unset($data[$this->getIDKeyForUse()]); + break; + case self::IDS_GUID: + if (empty($data[$this->getIDKeyForUse()])) { + $guid = $this->generateGUID(); + $this->setID($guid); + $data[$this->getIDKeyForUse()] = $guid; + } + break; + case self::IDS_MANUAL: + break; + default: + throw new Exception('Unknown CONFIG_IDs mechanism!'); + } + + if ($this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS)) { + $data['version'] = 0; + } + + $this->willWriteData($data); + + $columns = array_keys($data); + foreach ($columns as $k => $property) { + $columns[$k] = $property; + } + + $conn = $this->getConnection('w'); + + $conn->query( + '%Q INTO %T (%LC) VALUES (%Ls)', + $mode, + $this->getTableName(), + $columns, + $data); + + // Update the object with the initial Version value + if ($this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS)) { + $this->setVersion(0); + } + + // Only use the insert id if this table is using auto-increment ids + if ($id_mechanism === self::IDS_AUTOINCREMENT) { + $this->setID($conn->getInsertID()); + } + + $this->didWriteData(); + + return $this; + } + + + /** + * Method used to determine whether to insert or update when saving. + * + * @return bool true if the record should be inserted + */ + protected function shouldInsertWhenSaved() { + $key_type = $this->getConfigOption(self::CONFIG_IDS); + $use_locks = $this->getConfigOption(self::CONFIG_OPTIMISTIC_LOCKS); + + if ($key_type == self::IDS_MANUAL) { + if ($use_locks) { + // If we are manually keyed and the object has a version (which means + // that it has been saved to the DB before), do an update, otherwise + // perform an insert. + if ($this->getID() && $this->getVersion() !== null) { + return false; + } else { + return true; + } + } else { + throw new Exception( + 'You are not using optimistic locks, but are using manual IDs. You '. + 'must override the shouldInsertWhenSaved() method to properly '. + 'detect when to insert a new record.'); + } + } else { + return !$this->getID(); + } + } + + +/* -( Hooks and Callbacks )------------------------------------------------ */ + + + /** + * Retrieve the database table name. By default, this is the class name. + * + * @return string Table name for object storage. + * + * @task hook + */ + public function getTableName() { + return get_class($this); + } + + + /** + * Helper: Whether this class is configured to use GUIDs as the primary ID. + * @task internal + */ + private function isGUIDPrimaryID() { + return ($this->getConfigOption(self::CONFIG_IDS) === self::IDS_GUID); + } + + + /** + * Retrieve the primary key column, "id" by default. If you can not + * reasonably name your ID column "id", override this method. + * + * @return string Name of the ID column. + * + * @task hook + */ + public function getIDKey() { + return + $this->isGUIDPrimaryID() ? + 'guid' : + 'id'; + } + + + protected function getIDKeyForUse() { + $id_key = $this->getIDKey(); + if (!$id_key) { + throw new Exception( + "This DAO does not have a single-part primary key. The method you ". + "called requires a single-part primary key."); + } + return $id_key; + } + + + /** + * Generate a new GUID, used by CONFIG_AUX_GUID and IDS_GUID. + * + * @return guid Unique, newly allocated GUID. + * + * @task hook + */ + protected function generateGUID() { + throw new Exception( + "To use CONFIG_AUX_GUID or IDS_GUID, you need to overload ". + "generateGUID() to perform GUID generation."); + } + + + /** + * If your object has properties which you don't want to be persisted to the + * database, you can override this method and specify them. + * + * @return list List of properties which should NOT be persisted. + * Property names should be in normalized (lowercase) form. + * By default, all properties are persistent. + * + * @task hook + */ + protected function getTransientProperties() { + return array(); + } + + + /** + * Hook to apply serialization or validation to data before it is written to + * the database. See also willReadData(). + * + * @task hook + */ + protected function willWriteData(array &$data) { + $this->applyLiskDataSerialization($data, false); + } + + + /** + * Hook to perform actions after data has been written to the database. + * + * @task hook + */ + protected function didWriteData() {} + + + /** + * Hook to make internal object state changes prior to INSERT, REPLACE or + * UPDATE. + * + * @task hook + */ + protected function willSaveObject() { + $use_timestamps = $this->getConfigOption(self::CONFIG_TIMESTAMPS); + + if ($use_timestamps) { + if (!$this->getDateCreated()) { + $this->setDateCreated(time()); + } + $this->setDateModified(time()); + } + + if (($this->isGUIDPrimaryID() && !$this->getID())) { + // If GUIDs are the primary ID, the subclass could have overridden the + // name of the ID column. + $this->setID($this->generateGUID()); + } else if ($this->getConfigOption(self::CONFIG_AUX_GUID) && + !$this->getGUID()) { + // The subclass could still want GUIDs. + $this->setGUID($this->generateGUID()); + } + } + + + /** + * Hook to apply serialization or validation to data as it is read from the + * database. See also willWriteData(). + * + * @task hook + */ + protected function willReadData(array &$data) { + $this->applyLiskDataSerialization($data, $deserialize = true); + } + + /** + * Hook to perform an action on data after it is read from the database. + * + * @task hook + */ + protected function didReadData() {} + + /** + * Hook to perform an action before the deletion of an object. + * + * @task hook + */ + protected function willDelete() {} + + /** + * Hook to perform an action after the deletion of an object. + * + * @task hook + */ + protected function didDelete() {} + +/* -( Utilities )---------------------------------------------------------- */ + + + /** + * Applies configured serialization to a dictionary of values. + * + * @task util + */ + protected function applyLiskDataSerialization(array &$data, $deserialize) { + $serialization = $this->getConfigOption(self::CONFIG_SERIALIZATION); + if ($serialization) { + foreach (array_intersect_key($serialization, $data) as $col => $format) { + switch ($format) { + case self::SERIALIZATION_NONE: + break; + case self::SERIALIZATION_PHP: + if ($deserialize) { + $data[$col] = unserialize($data[$col]); + } else { + $data[$col] = serialize($data[$col]); + } + break; + case self::SERIALIZATION_JSON: + if ($deserialize) { + $data[$col] = json_decode($data[$col], true); + } else { + $data[$col] = json_encode($data[$col]); + } + break; + default: + throw new Exception("Unknown serialization format '{$format}'."); + } + } + } + } + + + /** + * Black magic. Builds implied get*() and set*() for all properties. + * + * @param string Method name. + * @param list Argument vector. + * @return mixed get*() methods return the property value. set*() methods + * return $this. + * @task util + */ + public function __call($method, $args) { + if (!strncmp($method, 'get', 3)) { + $property = substr($method, 3); + if (!($property = $this->checkProperty($property))) { + throw new Exception("Bad getter call: {$method}"); + } + if (count($args) !== 0) { + throw new Exception("Getter call should have zero args: {$method}"); + } + return @$this->$property; + } + + if (!strncmp($method, 'set', 3)) { + $property = substr($method, 3); + $property = $this->checkProperty($property); + if (!$property) { + throw new Exception("Bad setter call: {$method}"); + } + if (count($args) !== 1) { + throw new Exception("Setter should have exactly one arg: {$method}"); + } + if ($property == 'ID') { + $property = $this->getIDKeyForUse(); + } + $this->$property = $args[0]; + return $this; + } + + throw new Exception("Unable to resolve method: {$method}."); + } +} diff --git a/src/storage/lisk/dao/__init__.php b/src/storage/lisk/dao/__init__.php new file mode 100644 index 0000000000..94245f8632 --- /dev/null +++ b/src/storage/lisk/dao/__init__.php @@ -0,0 +1,16 @@ + and %<. + * + * %> ("Prefix") + * Escapes a prefix query for a LIKE clause. For example: + * + * // Find all rows where `name` starts with $prefix. + * qsprintf($conn, 'WHERE name LIKE %>', $prefix); + * + * %< ("Suffix") + * Escapes a suffix query for a LIKE clause. For example: + * + * // Find all rows where `name` ends with $suffix. + * qsprintf($conn, 'WHERE name LIKE %<', $suffix); + * + * @group storage + */ +function qsprintf($conn, $pattern/*, ... */) { + $args = func_get_args(); + array_shift($args); + return xsprintf('xsprintf_query', $conn, $args); +} + +/** + * @group storage + */ +function vqsprintf($conn, $pattern, array $argv) { + array_unshift($argv, $pattern); + return xsprintf('xsprintf_query', $conn, $argv); +} + + +/** + * xsprintf() callback for encoding SQL queries. See qsprintf(). + * @group storage + */ +function xsprintf_query($userdata, &$pattern, &$pos, &$value, &$length) { + $type = $pattern[$pos]; + $conn = $userdata; + $next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null; + + $nullable = false; + $done = false; + + $prefix = ''; + + switch ($type) { + case '=': // Nullable test + switch ($next) { + case 'd': + case 'f': + case 's': + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = 's'; + if ($value === null) { + $value = 'IS NULL'; + $done = true; + } else { + $prefix = '= '; + $type = $next; + } + break; + default: + throw new Exception('Unknown conversion, try %=d, %=s, or %=f.'); + } + break; + + case 'n': // Nullable... + switch ($next) { + case 'd': // ...integer. + case 'f': // ...float. + case 's': // ...string. + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = $next; + $nullable = true; + break; + default: + throw new Exception('Unknown conversion, try %nd or %ns.'); + } + break; + + case 'L': // List of.. + _qsprintf_check_type($value, "L{$next}", $pattern); + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = 's'; + $done = true; + + switch ($next) { + case 'd': // ...integers. + $value = implode(', ', array_map('intval', $value)); + break; + case 's': // ...strings. + foreach ($value as $k => $v) { + $value[$k] = "'".$conn->escapeString($v)."'"; + } + $value = implode(', ', $value); + break; + case 'C': // ...columns. + foreach ($value as $k => $v) { + $value[$k] = $conn->escapeColumnName($v); + } + $value = implode(', ', $value); + break; + default: + throw new Exception("Unknown conversion %L{$next}."); + } + break; + } + + if (!$done) { + _qsprintf_check_type($value, $type, $pattern); + switch ($type) { + case 's': // String + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = "'".$conn->escapeString($value)."'"; + } + $type = 's'; + break; + + case 'Q': // Query Fragment + $type = 's'; + break; + + case '~': // Like Substring + case '>': // Like Prefix + case '<': // Like Suffix + $value = $conn->escapeStringForLikeClause($value); + switch ($type) { + case '~': $value = "'%".$value."%'"; break; + case '>': $value = "'" .$value."%'"; break; + case '<': $value = "'%".$value. "'"; break; + } + $type = 's'; + break; + + case 'f': // Float + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = (float)$value; + } + $type = 's'; + break; + + case 'd': // Integer + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = (int)$value; + } + $type = 's'; + break; + + case 'T': // Table + case 'C': // Column + $value = $conn->escapeColumnName($value); + $type = 's'; + break; + + case 'K': // Komment + $value = $conn->escapeMultilineComment($value); + $type = 's'; + break; + + default: + throw new Exception("Unknown conversion '%{$type}'."); + + } + } + + if ($prefix) { + $value = $prefix.$value; + } + $pattern[$pos] = $type; +} + + +/** + * @group storage + */ +function _qsprintf_check_type($value, $type, $query) { + switch ($type) { + case 'Ld': case 'Ls': case 'LC': case 'LA': case 'LO': + if (!is_array($value)) { + throw new AphrontQueryParameterException( + $query, + "Expected array argument for %{$type} conversion."); + } + if (empty($value)) { + throw new AphrontQueryParameterException( + $query, + "Array for %{$type} conversion is empty."); + } + + foreach ($value as $scalar) { + _qsprintf_check_scalar_type($scalar, $type, $query); + } + break; + default: + _qsprintf_check_scalar_type($value, $type, $query); + } +} + + +/** + * @group storage + */ +function _qsprintf_check_scalar_type($value, $type, $query) { + switch ($type) { + case 'Q': case 'LC': case 'T': case 'C': + if (!is_string($value)) { + throw new AphrontQueryParameterException( + $query, + "Expected a string for %{$type} conversion."); + } + break; + + case 'Ld': case 'd': case 'f': + if (!is_null($value) && !is_scalar($value)) { + throw new AphrontQueryParameterException( + $query, + "Expected a scalar or null for %{$type} conversion."); + } + break; + + case 'Ls': case 's': + case '~': case '>': case '<': case 'K': + if (!is_null($value) && !is_scalar($value)) { + throw new AphrontQueryParameterException( + $query, + "Expected a scalar or null for %{$type} conversion."); + } + break; + + case 'LA': case 'LO': + if (!is_null($value) && !is_scalar($value) && + !(is_array($value) && !empty($value))) { + throw new AphrontQueryParameterException( + $query, + "Expected a scalar or null or non-empty array for ". + "%{$type} conversion."); + } + break; + default: + throw new Exception("Unknown conversion '{$type}'."); + } +} diff --git a/src/storage/queryfx/__init__.php b/src/storage/queryfx/__init__.php new file mode 100644 index 0000000000..c64372fa52 --- /dev/null +++ b/src/storage/queryfx/__init__.php @@ -0,0 +1,13 @@ +executeRawQuery($query); +} + +/** + * @group storage + */ +function vqueryfx($conn, $sql, $argv) { + array_unshift($argv, $conn, $sql); + return call_user_func_array('queryfx', $argv); +} + +/** + * @group storage + */ +function queryfx_all($conn, $sql/*, ... */) { + $argv = func_get_args(); + $ret = call_user_func_array('queryfx', $argv); + return $conn->selectAllResults($ret); +} + +/** + * @group storage + */ +function queryfx_one($conn, $sql/*, ... */) { + $argv = func_get_args(); + $ret = call_user_func_array('queryfx_all', $argv); + if (count($ret) > 1) { + throw new AphrontQueryCountException( + 'Query returned more than one row.'); + } else if (count($ret)) { + return reset($ret); + } + return null; +} diff --git a/src/view/base/AphrontView.php b/src/view/base/AphrontView.php new file mode 100755 index 0000000000..263f56b98d --- /dev/null +++ b/src/view/base/AphrontView.php @@ -0,0 +1,52 @@ +children[] = $child; + return $this; + } + + final protected function renderChildren() { + $out = array(); + foreach ($this->children as $child) { + $out[] = $this->renderChild($child); + } + return implode('', $out); + } + + private function renderChild($child) { + if ($child instanceof AphrontView) { + return $child->render(); + } else if (is_array($child)) { + $out = array(); + foreach ($child as $element) { + $out[] = $this->renderChild($element); + } + return implode('', $out); + } else { + return $child; + } + } + + abstract public function render(); + +} diff --git a/src/view/base/__init__.php b/src/view/base/__init__.php new file mode 100644 index 0000000000..7c6ce51617 --- /dev/null +++ b/src/view/base/__init__.php @@ -0,0 +1,10 @@ +data = $data; + } + + public function setHeaders(array $headers) { + $this->headers = $headers; + return $this; + } + + public function setColumnClasses(array $column_classes) { + $this->columnClasses = $column_classes; + return $this; + } + + public function setRowClasses(array $row_classes) { + $this->rowClasses = $row_classes; + return $this; + } + + public function setNoDataString($no_data_string) { + $this->noDataString = $no_data_string; + return $this; + } + + public function setClassName($class_name) { + $this->className = $class_name; + return $this; + } + + public function setZebraStripes($zebra_stripes) { + $this->zebraStripes = $zebra_stripes; + return $this; + } + + public function render() { + $class = $this->className; + if ($class !== null) { + $class = ' class="aphront-table-view '.$class.'"'; + } else { + $class = ' class="aphront-table-view"'; + } + $table = array(''); + + $col_classes = array(); + foreach ($this->columnClasses as $key => $class) { + if (strlen($class)) { + $col_classes[] = ' class="'.$class.'"'; + } else { + $col_classes[] = null; + } + } + + $headers = $this->headers; + if ($headers) { + $table[] = ''; + foreach ($headers as $col_num => $header) { + $class = idx($col_classes, $col_num); + $table[] = ''.$header.''; + } + $table[] = ''; + } + + $data = $this->data; + if ($data) { + $row_num = 0; + foreach ($data as $row) { + while (count($row) > count($col_classes)) { + $col_classes[] = null; + } + $class = idx($this->rowClasses, $row_num); + if ($this->zebraStripes && ($row_num % 2)) { + if ($class !== null) { + $class = 'alt alt-'.$class; + } else { + $class = 'alt'; + } + } + if ($class !== null) { + $class = ' class="'.$class.'"'; + } + $table[] = ''; + $col_num = 0; + foreach ($row as $value) { + $class = $col_classes[$col_num]; + if ($class !== null) { + $table[] = ''; + } else { + $table[] = ''; + } + $table[] = $value.''; + ++$col_num; + } + ++$row_num; + } + } else { + $colspan = max(count($headers), 1); + $table[] = + ''. + coalesce($this->noDataString, 'No data available.'). + ''; + } + $table[] = ''; + return implode('', $table); + } +} + diff --git a/src/view/control/table/__init__.php b/src/view/control/table/__init__.php new file mode 100644 index 0000000000..ccf2f80eda --- /dev/null +++ b/src/view/control/table/__init__.php @@ -0,0 +1,14 @@ +submitURI = $uri; + return $this; + } + + public function setTitle($title) { + $this->title = $title; + return $this; + } + + public function getTitle() { + return $this->title; + } + + public function addSubmitButton($text = 'Okay') { + $this->submitButton = $text; + return $this; + } + + public function addCancelButton($uri) { + $this->cancelURI = $uri; + return $this; + } + + final public function render() { + + $buttons = array(); + if ($this->submitButton) { + $buttons[] = + ''; + } + + if ($this->cancelURI) { + $buttons[] = phutil_render_tag( + 'a', + array( + 'href' => $this->cancelURI, + 'class' => 'button grey', + ), + 'Cancel'); + } + + return phutil_render_tag( + 'form', + array( + 'class' => 'aphront-dialog-view', + 'action' => $this->submitURI, + 'method' => 'post', + ), + ''. + '
'. + phutil_escape_html($this->title). + '
'. + '
'. + $this->renderChildren(). + '
'. + '
'. + implode('', $buttons). + '
'. + '
'); + } + +} diff --git a/src/view/dialog/__init__.php b/src/view/dialog/__init__.php new file mode 100644 index 0000000000..efb4dc14cc --- /dev/null +++ b/src/view/dialog/__init__.php @@ -0,0 +1,14 @@ +action = $action; + return $this; + } + + public function setMethod($method) { + $this->method = $method; + return $this; + } + + public function render() { + return phutil_render_tag( + 'form', + array( + 'action' => $this->action, + 'method' => $this->method, + 'class' => 'aphront-form-view', + ), + $this->renderDataInputs(). + $this->renderChildren()); + } + + private function renderDataInputs() { + $data = $this->data + array( + '__form__' => 1, + ); + $inputs = array(); + foreach ($data as $key => $value) { + $inputs[] = phutil_render_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => $key, + 'value' => $value, + )); + } + return implode("\n", $inputs); + } + +} diff --git a/src/view/form/base/__init__.php b/src/view/form/base/__init__.php new file mode 100644 index 0000000000..74bc6816be --- /dev/null +++ b/src/view/form/base/__init__.php @@ -0,0 +1,14 @@ +label = $label; + return $this; + } + + public function getLabel() { + return $this->label; + } + + public function setCaption($caption) { + $this->caption = $caption; + return $this; + } + + public function getCaption() { + return $this->caption; + } + + public function setError($error) { + $this->error = $error; + return $this; + } + + public function getError() { + return $this->error; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setValue($value) { + $this->value = $value; + return $this; + } + + public function getValue() { + return $this->value; + } + + abstract protected function renderInput(); + abstract protected function getCustomControlClass(); + + final public function render() { + $custom_class = $this->getCustomControlClass(); + + if (strlen($this->getLabel())) { + $label = + ''; + } else { + $label = null; + $custom_class .= ' aphront-form-control-nolabel'; + } + + $input = + '
'. + $this->renderInput(). + '
'; + + if (strlen($this->getError())) { + $error = $this->getError(); + if ($error === true) { + $error = '*'; + } else { + $error = "\xC2\xAB ".$error; + } + $error = + '
'. + phutil_escape_html($error). + '
'; + } else { + $error = null; + } + + if (strlen($this->getCaption())) { + $caption = + '
'. + phutil_escape_html($this->getCaption()). + '
'; + } else { + $caption = null; + } + + return + '
'. + $error. + $label. + $input. + $caption. + '
'. + '
'; + } +} diff --git a/src/view/form/control/base/__init__.php b/src/view/form/control/base/__init__.php new file mode 100644 index 0000000000..0699e96bd3 --- /dev/null +++ b/src/view/form/control/base/__init__.php @@ -0,0 +1,14 @@ +options = $options; + return $this; + } + + public function getOptions() { + return $this->options; + } + + protected function renderInput() { + $options = array(); + foreach ($this->getOptions() as $value => $label) { + $options[] = phutil_render_tag( + 'option', + array( + 'selected' => ($value == $this->getValue()) ? 'selected' : null, + 'value' => $value, + ), + phutil_escape_html($label)); + } + + return phutil_render_tag( + 'select', + array( + 'name' => $this->getName(), + ), + implode("\n", $options)); + } + +} diff --git a/src/view/form/control/select/__init__.php b/src/view/form/control/select/__init__.php new file mode 100644 index 0000000000..a52e7d2455 --- /dev/null +++ b/src/view/form/control/select/__init__.php @@ -0,0 +1,14 @@ +cancelButton = phutil_render_tag( + 'a', + array( + 'href' => $href, + 'class' => 'button grey', + ), + phutil_escape_html($label)); + return $this; + } + + protected function getCustomControlClass() { + return 'aphront-form-control-submit'; + } + + protected function renderInput() { + return phutil_render_tag( + 'button', + array( + 'name' => '__submit__', + ), + phutil_escape_html($this->getValue())). + $this->cancelButton; + } + +} diff --git a/src/view/form/control/submit/__init__.php b/src/view/form/control/submit/__init__.php new file mode 100644 index 0000000000..e77d2ceb4b --- /dev/null +++ b/src/view/form/control/submit/__init__.php @@ -0,0 +1,14 @@ + 'text', + 'name' => $this->getName(), + 'value' => $this->getValue(), + )); + } + +} diff --git a/src/view/form/control/text/__init__.php b/src/view/form/control/text/__init__.php new file mode 100644 index 0000000000..e46731b72e --- /dev/null +++ b/src/view/form/control/text/__init__.php @@ -0,0 +1,14 @@ + $this->getName(), + ), + phutil_escape_html($this->getValue())); + } + +} diff --git a/src/view/form/control/textarea/__init__.php b/src/view/form/control/textarea/__init__.php new file mode 100644 index 0000000000..84284cde8f --- /dev/null +++ b/src/view/form/control/textarea/__init__.php @@ -0,0 +1,14 @@ +title = $title; + return $this; + } + + public function setErrors(array $errors) { + $this->errors = $errors; + return $this; + } + + final public function render() { + + $errors = $this->errors; + if ($errors) { + $list = array(); + foreach ($errors as $error) { + $list[] = phutil_render_tag( + 'li', + array(), + phutil_escape_html($error)); + } + $list = '
    '.implode("\n", $list).'
'; + } else { + $list = null; + } + + $title = $this->title; + if (strlen($title)) { + $title = '

'.phutil_escape_html($title).'

'; + } else { + $title = null; + } + + return + '
'. + $title. + $list. + '
'; + + } +} diff --git a/src/view/form/error/__init__.php b/src/view/form/error/__init__.php new file mode 100644 index 0000000000..6f2261e3a3 --- /dev/null +++ b/src/view/form/error/__init__.php @@ -0,0 +1,14 @@ +createButton = phutil_render_tag( + 'a', + array( + 'href' => $href, + 'class' => 'create-button button green', + ), + $create_button); + + return $this; + } + + public function setHeader($header) { + $this->header = $header; + return $this; + } + + public function setWidth($width) { + $this->width = $width; + return $this; + } + + public function render() { + if ($this->header !== null) { + $header = '

'.$this->header.'

'; + } else { + $header = null; + } + + if ($this->createButton !== null) { + $button = $this->createButton; + } else { + $button = null; + } + + $table = $this->renderChildren(); + + $class = array('aphront-panel-view'); + if ($this->width) { + $class[] = 'aphront-panel-width-'.$this->width; + } + + return + '
'. + $button. + $header. + $table. + '
'; + } + +} diff --git a/src/view/layout/panel/__init__.php b/src/view/layout/panel/__init__.php new file mode 100644 index 0000000000..392b1c72d1 --- /dev/null +++ b/src/view/layout/panel/__init__.php @@ -0,0 +1,14 @@ +renderChildren(); + } + +} diff --git a/src/view/null/__init__.php b/src/view/null/__init__.php new file mode 100644 index 0000000000..3e6bb66b3b --- /dev/null +++ b/src/view/null/__init__.php @@ -0,0 +1,12 @@ +title = $title; + return $this; + } + + public function getTitle() { + return $this->title; + } + + protected function getHead() { + return ''; + } + + protected function getBody() { + return $this->renderChildren(); + } + + protected function getTail() { + return ''; + } + + public function render() { + + $title = $this->getTitle(); + $head = $this->getHead(); + $body = $this->getBody(); + $tail = $this->getTail(); + + return << + + + {$title} + {$head} + + + {$body} + + {$tail} + + +EOHTML; + } + +} diff --git a/src/view/page/base/__init__.php b/src/view/page/base/__init__.php new file mode 100644 index 0000000000..ec0cdcbaa2 --- /dev/null +++ b/src/view/page/base/__init__.php @@ -0,0 +1,12 @@ +applicationName = $application_name; + return $this; + } + + public function getApplicationName() { + return $this->applicationName; + } + + public function setBaseURI($base_uri) { + $this->baseURI = $base_uri; + return $this; + } + + public function getBaseURI() { + return $this->baseURI; + } + + public function setTabs(array $tabs, $selected_tab) { + $this->tabs = $tabs; + $this->selectedTab = $selected_tab; + return $this; + } + + public function getTitle() { + return $this->getGlyph().' '.parent::getTitle(); + } + + protected function getHead() { + return + ''; + } + + public function setGlyph($glyph) { + $this->glyph = $glyph; + return $this; + } + + public function getGlyph() { + return $this->glyph; + } + + protected function getBody() { + + $tabs = array(); + foreach ($this->tabs as $name => $tab) { + $tabs[] = phutil_render_tag( + 'a', + array( + 'href' => idx($tab, 'href'), + 'class' => ($name == $this->selectedTab) + ? 'aphront-selected-tab' + : null, + ), + phutil_escape_html(idx($tab, 'name'))); + } + $tabs = implode('', $tabs); + if ($tabs) { + $tabs = ''.$tabs.''; + } + + return + '
'. + '
'. + 'Aphront '. + phutil_render_tag( + 'a', + array( + 'href' => $this->getBaseURI(), + 'class' => 'aphront-head-appname', + ), + phutil_escape_html($this->getApplicationName())). + $tabs. + '
'. + $this->renderChildren(). + '
'. + '
'; + } + + protected function getTail() { + return ''; + } + +} diff --git a/src/view/page/standard/__init__.php b/src/view/page/standard/__init__.php new file mode 100644 index 0000000000..4e589ecc60 --- /dev/null +++ b/src/view/page/standard/__init__.php @@ -0,0 +1,15 @@ +setHost($host); +$application->setPath($path); +$request = $application->buildRequest(); +$application->setRequest($request); +list($controller, $uri_data) = $application->buildController(); +$controller->willProcessRequest($uri_data); +try { + $response = $controller->processRequest(); +} catch (Exception $ex) { + $response = $application->handleException($ex); +} + +$response = $application->willSendResponse($response); + +$response->setRequest($request); + +$response_string = $response->buildResponseString(); +$headers = $response->getCacheHeaders(); +$headers = array_merge($headers, $response->getHeaders()); +foreach ($headers as $header) { + list($header, $value) = $header; + header("{$header}: {$value}"); +} +echo $response_string; + + +/** + * @group aphront + */ +function setup_aphront_basics() { + $aphront_root = dirname(dirname(__FILE__)); + $libraries_root = dirname($aphront_root); + + ini_set('include_path', ini_get('include_path').':'.$libraries_root.'/'); + @include_once 'libphutil/src/__phutil_library_init__.php'; + if (!@constant('__LIBPHUTIL__')) { + echo "ERROR: Unable to load libphutil. Update your PHP 'include_path' to ". + "include the parent directory of libphutil/.\n"; + exit(1); + } + + if (!ini_get('date.timezone')) { + date_default_timezone_set('America/Los_Angeles'); + } + + phutil_load_library($aphront_root.'/src'); +} + +function __autoload($class_name) { + PhutilSymbolLoader::loadClass($class_name); +} diff --git a/webroot/rsrc/css/base.css b/webroot/rsrc/css/base.css new file mode 100644 index 0000000000..f305a544c2 --- /dev/null +++ b/webroot/rsrc/css/base.css @@ -0,0 +1,534 @@ +html { + overflow-y: scroll; +} + +body, div, dl, dt, dd, ul, ol, li, +h1, h2, h3, h4, h5, h6, +pre, form, fieldset, +p, blockquote, th, td, button { + margin: 0; + padding: 0; + outline: 0; + border: 0; +} + +html { + padding-bottom: 16em; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +fieldset, img { + border: 0; +} + +address, caption, cite, code, dfn, th, var { + font-style: normal; + font-weight: normal; +} + +ol, ul { + list-style: none; +} + +caption, th { + text-align: left; +} + +td, th { + vertical-align: top; +} + +h1, h2, h3, h4, h5, h6 { + font-size: 100%; + font-weight: bold; +} + +body { + font: 13px/1.231 'lucida grande', tahoma, verdana, arial, sans-serif; + background: #ACACAC; + direction: ltr; + text-align: left; + unicode-bidi: embed; + *font-size: small; +} + +select, input, button, textarea, button { + font: 99% 'lucida grande', tahoma, verdana, arial, clean, sans-serif; +} + +table { + font-size: inherit; + font: 100%; +} + +h1 { + font-size: 16px; +} + +h2 { + font-size: 14px; +} + +a { + -moz-outline-style: none; + text-decoration: none; + cursor: pointer; +} + +a:visited { + color: #3b5998; +} + +a:link { + color: #3b5998; +} + +a:hover { + text-decoration: underline; +} + +img { + display: block; +} + +/******************************************************************************/ + +/* Buttons */ + +/******************************************************************************/ + +button, +a.button, +a.button:visited, +input.inputsubmit { + background: #5e77aa url('/rsrc/image/sprite.png') 0 0 repeat-x; + border: 1px solid #29447e; + border-bottom-color: #1a356e; + color: #fff; + cursor: pointer; + font-weight: bold; + text-align: center; + white-space: nowrap; + display: inline-block; + font-size: 13px; + overflow: visible; + padding: 2px 8px 3px 8px; + line-height: 18px; + vertical-align: baseline; + width: auto; + box-shadow: 0px 1px 0px rgba(0,0,0,.12); + -moz-box-shadow: 0px 1px 0px rgba(0,0,0,.12); + -webkit-box-shadow: 0px 1px 0px rgba(0,0,0,.12); +} + +button { + *padding: 2px 4px 1px 8px; + _padding-right: 6px; +} + +a.button, +a.button:visited { + *padding: 3px 8px 4px; +} + +/* Buttons with images (full size only) */ +button.icon, +a.icon, +a.icon:visited { + padding-left: 0; + position: relative; + text-indent: 29px; +} + +/* Fix for IE7 within table cells ? */ +td button { + *width: 100%; + *padding-right: 8px; +} + +button:active, +a.button:active { + background-color: #4f6aa3; + background-position: 0 -100px; + border-bottom-color: #29447e; +} + +button.green, +a.green, +a.green:visited { + background-color: #6da952; + background-position: 0 -50px; + border: 1px solid #3b6e22; + border-bottom-color: #2c5a15; +} + +button.green:active, +a.green:active { + background-color: #5e9d43; + background-position: 0 -150px; + border-bottom-color: #3b6e22; +} + +button.grey, +input.inputaux, +a.grey, +a.grey:visited, +a.button.disabled, +button.disabled { + background-color: #e4e5e5; + background-position: 0 -250px; + border: 1px solid #999; + border-bottom-color: #888; + color: #333; + box-shadow: 0px 1px 0px rgba(0,0,0,.07); + -moz-box-shadow: 0px 1px 0px rgba(0,0,0,.07); + -webkit-box-shadow: 0px 1px 0px rgba(0,0,0,.07); +} + +a.disabled, +button.disabled { + filter:alpha(opacity=50); + -moz-opacity:0.5; + -khtml-opacity: 0.5; + opacity: 0.5; +} + +button.grey:active, +a.grey:active, +button.grey_active { + background-color: #dddddd; + background-position: 0 -200px; + border-bottom-color: #999; +} + +button:active::-moz-focus-inner, +button:focus::-moz-focus-inner { + border-color: #405071; +} + +button.green:active::-moz-focus-inner, +button.green:focus::-moz-focus-inner { + border-color: #4c713b; +} + +button.grey:active::-moz-focus-inner, +button.grey:focus::-moz-focus-inner { + border-color: #666; +} + +a.button:hover { + text-decoration: none; +} + +button.small, +a.small, +a.small:visited { + padding: 2px 7px; + height: auto; + font-size: 11px; + line-height: 16px; +} + + +/******************************************************************************/ + +/* Aphront */ + +/******************************************************************************/ + +.aphront-standard-page { + background: #ffffff; + border-bottom: 1px solid #888888; + font-size: 14px; + + -webkit-box-shadow: 0 0 6px #000; + -mox-box-shadow: 0 0 6px #000; + box-shadow: 0 0 6px #000; +} + +.aphront-standard-header { + background: #003366; + color: white; + padding: 1em 1em 0.5em 1em; + overflow: hidden; + position: relative; +} + +.aphront-standard-header a { + color: white; +} + +.aphront-standard-header .aphront-head-tabs { + padding: 0 1em; + font-size: 13px; + font-weight: bold; +} + +.aphront-standard-header .aphront-head-tabs a { + border-bottom: 3px solid transparent; + padding: 0.5em 0.75em; + position: relative; + bottom: 2px; +} + +.aphront-standard-header .aphront-head-tabs a.aphront-selected-tab { + border-bottom-color: #cccccc; +} + +.aphront-standard-header .aphront-head-appname { + padding: 0 1em; + text-transform: uppercase; +} + + +.aphront-directory-list { + margin: 1em 3% 8em; +} + +.aphront-directory-category h1 { + border-bottom: 1px solid #cccccc; + margin-bottom: .5em; + padding-bottom: .1em; +} + +.aphront-directory-list h2 { + font-size: 14px; + font-weight: bold; + padding: 0; + margin: 0; +} + +.aphront-directory-list p { + color: #444444; + font-size: 12px; + padding: .05em .5em .5em; +} + +.aphront-directory-category { + padding: 10px; + width: 300px; + float: left; +} + +.aphront-directory-group { + padding: 0 .5em 3em; +} + + +.aphront-panel-view { + background: #f3f3f3; + border: 1px solid #c0c0c0; + border-width: 1px 0 0; + padding: 1em 2em; + margin: 1em 2em; +} + +.aphront-panel-view h1 { + font-size: 14px; + font-weight: bold; + padding: 2px 0 8px; +} + +.aphront-panel-view a.create-button { + float: right; +} + +.aphront-panel-width-form { + width: 720px; + margin-right: auto; + margin-left: auto; +} + +.aphront-table-view { + width: 100%; + border-collapse: collapse; + background: #fdfdfd; + border: 1px solid #003366; +} + +.aphront-table-view tr.alt { + background: #efefef; +} + +.aphront-table-view th { + font-size: 12px; + font-weight: bold; + padding: 4px 8px; + background: #003366; + color: white; + white-space: nowrap; +} + +.aphront-table-view td.header { + padding: 4px 8px; + background: #3b5998; + color: white; + white-space: nowrap; + text-align: right; +} + +.aphront-table-view td { + vertical-align: top; + padding: 4px 8px; + font-size: 11px; + white-space: nowrap; +} + +.aphront-table-view td.action { + padding-top: 1px; + padding-bottom: 1px; +} + +.aphront-table-view td.larger { + font-size: 14px; +} + +.aphront-table-view td.pri { + font-weight: bold; +} + +.aphront-table-view td.wide { + white-space: normal; + width: 100%; +} + +.aphront-table-view td.right { + text-align: right; +} + +.aphront-table-view td.mono { + font-family: "Monaco", monospace; + font-size: 10px; +} + +.aphront-table-view tr.no-data td { + padding: 1em; + text-align: center; + color: #888888; + font-style: italic; +} + + +/******************************************************************************/ + +/* forms */ + +/******************************************************************************/ + +.aphront-form-view { + background: #e7e7e7; + border: 1px solid #c4c4c4; + padding: 1em; +} + +.aphront-form-view label { + padding-top: 4px; + width: 14%; + float: left; + text-align: right; + font-weight: bold; + font-size: 13px; + color: #666666; +} + +.aphront-form-input { + margin-left: 15%; + margin-right: 25%; + width: 60%; +} + +.aphront-form-error { + width: 23%; + float: right; + color: #aa0000; + font-weight: bold; + padding-top: 4px; +} + +.aphront-form-input input, +.aphront-form-input textarea { + font-size: 12px; + width: 100%; +} + +.aphront-form-input textarea { + height: 12em; +} + +.aphront-form-control { + padding: 4px; +} + +.aphront-form-control-submit button, +.aphront-form-control-submit a.button { + float: right; + margin: 1em 0 0em 2%; +} + +.aphront-form-view .aphront-form-caption { + font-size: 11px; + color: #444444; + text-align: right; + clear: both; + margin-right: 25%; + margin-left: 15%; +} + +.aphront-error-view { + width: 720px; + margin: 1em auto; + border: 1px solid #aa0000; + padding: 1em; + background: #f9b9bc; +} + + + +/******************************************************************************/ + +/* dialog */ + +/******************************************************************************/ + + +.aphront-dialog-view { + width: 480px; + padding: 8px; + background: #666; + margin: auto; +} + +.aphront-dialog-head { + background: #003366; + border: none; + font-size: 15px; + padding: 5px 12px 6px; + color: #ffffff; +} + + +.aphront-dialog-body { + background: #ffffff; + padding: 16px 12px; + border: none; + overflow: hidden; +} + +.aphront-dialog-tail { + border: none; + background: #ededed; + padding: 0.5em; + text-align: right; +} + +.aphront-dialog-tail button, +.aphront-dialog-tail a.button { + float: right; + margin-left: .5em; +} + + diff --git a/webroot/rsrc/image/sprite.png b/webroot/rsrc/image/sprite.png new file mode 100644 index 0000000000..f4cf0312ff Binary files /dev/null and b/webroot/rsrc/image/sprite.png differ