IndexController.php #1

  • //
  • guest/
  • perforce_software/
  • chronicle/
  • main/
  • application/
  • content/
  • controllers/
  • IndexController.php
  • View
  • Commits
  • Open Download .zip Download (51 KB)
<?php
/**
 * Displays and manages content.
 *
 * There are a number of 'tags' that will be used to clear cache entries
 * when content is modified. You can tag your cache entries with:
 *  p4cms_content                   - present on many entries; should only be cleared rarely
 *  p4cms_content_type              - also present on many entries; cleared on a reset of types
 *  p4cms_content_<binhex(id)>      - cleared when the given entry is edited/deleted
 *  p4cms_content_type_<binhex(id)> - cleared when the given type is edited/deleted
 *  p4cms_content_list              - cleared when any content is added/edited/deleted intended for
 *                                    updating aggregate lists of content
 * The <> brackets are just to show position, only the binhex'd id will be present.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */
class Content_IndexController extends Zend_Controller_Action
{
    // arbitrary limit for image scaling operations
    // 4k allows scaling upto 10 megapixel output and uses about 1/4 GB of ram.
    const   MAX_SCALE   = 4000;

    public  $contexts   = array(
        'add'               => array('dojoio' => 'post', 'json' => 'post'),
        'browse'            => array('json', 'partial'),
        'edit'              => array('dojoio' => 'post', 'json' => 'post'),
        'choose-type'       => array('partial'),
        'delete'            => array('json', 'partial'),
        'form'              => array('partial', 'dojoio' => 'post'),
        'index'             => array('json'),
        'sub-form'          => array('partial'),
        'toolbar'           => array('partial'),
        'validate-field'    => array('json'),
        'view'              => array('json', 'preview', 'partial'),
        'opened'            => array('json')
    );

    /**
     * Initialize object
     *
     * Extends parent to add content and content type tags to page cache
     * if it is present.
     */
    public function init()
    {
        parent::init();

        if (P4Cms_Cache::canCache('page')) {
            P4Cms_Cache::getCache('page')
                ->addTag('p4cms_content')
                ->addTag('p4cms_content_type');
        }
    }

    /**
     * Display a list of recent content.
     */
    public function indexAction()
    {
        $this->view->canAdd = $this->acl->isAllowed('content', 'add');
        if (!$this->acl->isAllowed('content', 'access')) {
            return;
        }

        $this->view->recent = P4Cms_Content::fetchAll(
            P4Cms_Record_Query::create()
            ->setMaxRows(5)
            ->setSortBy(P4Cms_Record_Query::SORT_DATE)
            ->setReverseOrder(true)
        );

        // tag the page cache so it can be appropriately cleared later
        if (P4Cms_Cache::canCache('page')) {
            P4Cms_Cache::getCache('page')->addTag('p4cms_content_list');
        }
    }

    /**
     * List content for management.
     *
     * To provide an action the grid participants subscribe to the topic:
     *  p4cms.content.grid.actions
     *
     * One argument will be passed to subscribers:
     *  P4Cms_Navigation    $actions    a navigation container to hold all actions
     *
     * They are expected to add/modify/etc. pages to the navigation container.
     * The pages will be rendered to a Menu Dijit so utilizing the onClick
     * and, optionally, onShow events is advised to control menu item behaviour.
     *
     * The default actions are added during module init. To modify/remove a default
     * action, subscribe during module load, or later, to ensure the default nav
     * entries are already present.
     */
    public function manageAction()
    {
        // enforce permissions.
        $this->acl->check('content', 'manage');

        // generate page with data grid and form
        $this->browseAction();

        // the request can specify which columns appear - only permit column names.
        $request = $this->getRequest();
        $columns = $request->getParam('columns', array('type', 'title', 'modified', 'actions'));
        $columns = array_filter($columns, 'is_string');

        // enable manage-specific settings
        $view                       = $this->view;
        $view->showAddLink          = $this->acl->isAllowed('content', 'add');
        $view->showDeleteButton     = $this->acl->isAllowed('content', 'delete');
        $view->selectionMode        = 'extended';
        $view->columns              = $columns;

        $view->headTitle()->set('Manage Content');
        $this->getHelper('helpUrl')->setUrl('content.html');
    }

    /**
     * Handle requests to display a list of content
     * Prepares list options form for tradtional requests.
     * Prepares content query for context specific requests.
     *
     * @publishes   p4cms.content.grid.actions
     *              Modify the passed menu (add/modify/delete items) to influence the actions shown
     *              on entries in the Manage Content grid.
     *              P4Cms_Navigation            $actions    A menu to hold grid actions.
     *
     * @publishes   p4cms.content.grid.data.item
     *              Return the passed item after applying any modifications (add properties, change
     *              values, etc.) to influence the row values sent to the Manage Content grid.
     *              array                       $item       The item to potentially modify.
     *              mixed                       $model      The original object/array that was used
     *                                                      to make the item.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.content.grid.data
     *              Adjust the passed data (add properties, modify values, etc.) to influence the
     *              row values sent to the Manage Content grid.
     *              Zend_Dojo_Data              $data       The data to be filtered.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.content.grid.populate
     *              Adjust the passed query (possibly based on values in the passed form) to filter
     *              which content entries will be shown on the Manage Content grid.
     *              P4Cms_Record_Query          $query      The query to filter content entries.
     *              P4Cms_Form_PubSubForm       $form       A form containing filter options.
     *
     * @publishes   p4cms.content.grid.render
     *              Make adjustments to the datagrid helper's options pre-render (e.g. change
     *              options to add columns) for the Manage Content grid.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.content.grid.form
     *              Make arbitrary modifications to the Manage Content filters form.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *
     * @publishes   p4cms.content.grid.form.subForms
     *              Return a Form (or array of Forms) to have them added to the Manage Content
     *              filters form. The returned form(s) should have a 'name' set on them to allow
     *              them to be uniquely identified.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *
     * @publishes   p4cms.content.grid.form.preValidate
     *              Allows subscribers to adjust the Manage Content filters form prior to
     *              validation of the passed data. For example, modify element values based on
     *              related selections to permit proper validation.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     An associative array of form values.
     *
     * @publishes   p4cms.content.grid.form.validate
     *              Return false to indicate the Manage Content filters form is invalid. Return true
     *              to indicate your custom checks were satisfied, so form validity should be
     *              unchanged.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     An associative array of form values.
     *
     * @publishes   p4cms.content.grid.form.populate
     *              Allows subscribers to adjust the Manage Content filters form after it has been
     *              populated with the passed data.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     The values passed to the populate
     *                                                      method.
     */
    public function browseAction()
    {
        // ensure users are allowed to access content before the list is displayed
        $this->acl->check('content', 'access');

        // get list option sub-forms.
        $request        = $this->getRequest();
        $gridNamespace  = 'p4cms.content.grid';
        $form           = new Ui_Form_GridOptions(
            array(
                'namespace'   => $gridNamespace
            )
        );
        $form->populate($request->getParams());

        // the request can specify which columns appear - only permit column names.
        $columns = $request->getParam('columns', array('type', 'title', 'modified'));
        $columns = array_filter($columns, 'is_string');

        // if the title column is requested, ensure that it isn't "linkified"
        $columns = array_flip($columns);
        if (isset($columns['title'])) {
            $columns['title'] = array(
                'formatter' => 'p4cms.content.grid.Formatters.titleNoLink'
            );
        }

        // setup access-restricted view, manageAction will expand these settings as needed
        $view                       = $this->view;
        $view->form                 = $form;
        $view->pageSize             = $request->getParam('count', 100);
        $view->rowOffset            = $request->getParam('start', 0);
        $view->pageOffset           = round($view->rowOffset / $view->pageSize, 0) + 1;
        $view->columns              = $columns;
        $view->showAddLink          = false;
        $view->showDeleteButton     = false;
        $view->selectionMode        = $request->getParam('selectionMode', 'single');

        // set DataGrid view helper namespace
        $helper = $view->dataGrid();
        $helper->setNamespace($gridNamespace);

        // early exit for standard requests (ie. not json or partial)
        if (!$this->contextSwitch->getCurrentContext()) {
            $this->_helper->layout->setLayout('manage-layout');
            return;
        }

        // construct list query - allow third-parties to influence query.
        $query = new P4Cms_Record_Query;
        $query->setRecordClass('P4Cms_Content');
        try {
            $result = P4Cms_PubSub::publish($gridNamespace . '.populate', $query, $form);
        } catch (Exception $e) {
            P4Cms_Log::logException("Error building content list query.", $e);
        }

        // prepare sorting options
        $sortKey = $sortKeyOriginal = $request->getParam('sort', '-#REdate');
        $sortFlags = array();
        // handle sort order; descending sort identified with '-' prefix.
        if (substr($sortKey, 0, 1) == '-') {
            $sortKey = substr($sortKey, 1);
            $sortFlags[] = P4Cms_Record_Query::SORT_DESCENDING;
        }

        // if we're sorting via an internal attribute, use the traditional
        // syntax by knocking out the query options and reversing the
        // results if necessary.
        if (strpos($sortKey, '#') === 0) {
            $sortFlags = null;
            $query->setReverseOrder($sortKey === $sortKeyOriginal ? false : true);
        }

        // some column names differ from the model, so we need a map.
        $sortKeyMap = array(
            'type' => 'contentType'
        );

        // look up requested sort column in our map.
        $sortKey = isset($sortKeyMap[$sortKey])
            ? $sortKeyMap[$sortKey]
            : $sortKey;

        $query->setSortBy($sortKey, $sortFlags);

        // add query to the view.
        $view->query = $query;
    }

    /**
     * Choose a content type to add.
     * Prompts the user to select from the available content types.
     */
    public function chooseTypeAction()
    {
        // load types into view.
        $this->view->typeGroups = P4Cms_Content_Type::fetchGroups();
        $this->view->headTitle()->set('Choose Content Type');
    }

    /**
     * Renders the content form for the requested entry or content type.
     *
     * @param   boolean     $getForm    if true, return the content form.
     *
     * The p4cms.content.form events documented on the manage action will
     * also be broadcast when this action is accessed.
     */
    public function formAction($getForm = false)
    {
        $request = $this->getRequest();
        $type    = $request->getParam('contentType');
        $id      = $request->getParam('id');

        // if a version is present, generate rev-spec.
        $revspec = $request->getParam('version');
        if ($revspec) {
            // enforce permissions - viewing historic versions requires 'access-history' privilege.
            $this->acl->check('content', 'access-history');

            $revspec = '#' . $revspec;
        }

        $entry = $id
            ? P4Cms_Content::fetch($id . $revspec, $revspec ? array('includeDeleted' => true) : null)
            : new P4Cms_Content(array(P4Cms_Content::TYPE_FIELD => $type));

        if (!$getForm && $request->isPost()) {
            return $this->editAction($entry, 'edit');
        }

        // construct and populate the content form.
        $form = $this->_getContentForm($entry);
        $form->populate($request->getPost());

        // populate the view.
        $this->view->entry = $entry;
        $this->view->type  = $type;
        $this->view->form  = $form;

        // explicitly set partial context for other requests.
        $this->contextSwitch->initContext('partial');

        return $form;
    }

    /**
     * Renders a specific sub-form of the content form.
     * Forces the 'partial' request context.
     */
    public function subFormAction()
    {
        $form = $this->formAction(true);

        // ensure requested sub-form exists.
        $subForm = $form->getSubForm($this->getRequest()->getParam('form'));
        if (!$subForm instanceof Zend_Form) {
            throw new Content_Exception(
                "Cannot get sub-form. Requested sub-form does not exist."
            );
        }

        // populate the view.
        $this->view->subForm = $subForm;
    }

    /**
     * Add content.
     *
     * If not type has been selected, forwards to choose-type action.
     * If a type is indicated, create new entry and forward to edit action.
     */
    public function addAction()
    {
        // enforce permissions.
        $this->acl->check('content', 'add');

        $request = $this->getRequest();

        // if get request and no valid type specified, prompt user for type.
        // check 'contentType' param first, and 'type' param second.
        $type = $request->getParam('contentType', $request->getParam('type'));
        if ($request->isGet() && (!$type || !P4Cms_Content_Type::exists($type))) {
            return $this->_forward('choose-type');
        }

        // get the content type definition.
        $type = P4Cms_Content_Type::fetch($type);

        // create new content model and set type.
        $entry = new P4Cms_Content;
        $entry->setContentType($type);

        // set the page title.
        $this->view->headTitle()->set("Add '" . $type->getLabel() . "'");

        return $this->editAction($entry, 'edit');
    }

    /**
     * Display content.
     *
     * @param   P4Cms_Content       $entry          optional - content entry to display.
     * @param   null|string|array   $skipAclCheck   optional - pass string or array of strings
     *                                              enumerating the privilegs that can be skipped
     */
    public function viewAction(P4Cms_Content $entry = null, $skipAclCheck = null)
    {
        // enforce permissions.
        if (!in_array('access', (array)$skipAclCheck)) {
            $this->acl->check('content', 'access');
        }

        // if not called with an entry, we must fetch one.
        $request = $this->getRequest();

        // if a version is present, generate rev-spec.
        $revspec = $request->getParam('version');
        if ($revspec) {
            // enforce permissions - viewing historic versions requires 'access-history' privilege.
            if (!in_array('access-history', (array)$skipAclCheck)) {
                $this->acl->check('content', 'access-history');
            }

            // inject javascript to enable the history toolbar button
            // if the user came directly to the view action.
            // the edit action forwards here and doesn't want this.
            if ($request->getActionName() == 'view') {
                $this->view->dojo()->addOnLoad(
                    "function(){ p4cms.content.startHistory(); }"
                );
            }

            $revspec = '#' . $revspec;
        }

        // attempt to fetch content entry.
        try {
            $entry = $entry ?: P4Cms_Content::fetch(
                $request->getParam('id') . $revspec,
                $revspec ? array('includeDeleted' => true) : null
            );
        } catch (Exception $e) {
            // we only have special handling for specific types; rethrow anything else
            if (!$e instanceof P4Cms_Record_NotFoundException
                && !$e instanceof InvalidArgumentException
            ) {
                throw $e;
            }

            return $this->_forward('page-not-found', 'index', 'error');
        }

        // validate the content type - if the type has no id, we assume it
        // is missing and was dynamically generated by the content class.
        $type   = $entry->getContentType();
        $typeId = $entry->getContentTypeId();
        if (!$type->getId()) {
            $message = $typeId
                ? "This content entry requires a missing content type ('$typeId')."
                : "This content entry has no content type.";
            throw new Content_Exception($message);
        }

        // populate the view.
        $view           = $this->view;
        $view->entry    = $entry;
        $view->type     = $type;


        // request can specify an array of fields, true or false for json context.
        $view->fields   = $request->getParam('fields', true);

        // request can request the change be included for json context.
        $view->includeChange = (bool) $request->getParam('includeChange', false);

        // request can request the status be included for json context.
        $view->includeStatus = (bool) $request->getParam('includeStatus', false);

        // request can request the opened status be included for json context.
        $view->includeOpened = (bool) $request->getParam('includeOpened', false);

        // tag the page cache so it can be appropriately cleared later
        if (P4Cms_Cache::canCache('page')) {
            P4Cms_Cache::getCache('page')
                ->addTag('p4cms_content_'      . bin2hex($entry->getId()))
                ->addTag('p4cms_content_type_' . bin2hex($entry->getContentTypeId()));
        }

        // set the page title if entry has one.
        if (strlen($entry->getTitle())) {
            $this->view->headTitle()->set($entry->getTitle());
        }

        // set the meta description from the entry's excerpt
        $excerpt = $entry->getExcerpt(150, array('fullExcerpt' => true));
        if (strlen($excerpt)) {
            $this->view->headMeta()->setName('description', $excerpt);
        }

        // set the contentEntry view helper defautls
        $view->contentEntry()->setDefaults(
            $entry,
            array(Content_View_Helper_ContentEntry::OPT_PRELOAD_FORM => true)
        );

        // record the content id in the widget context to assist any plugins
        // that need to know what content is currently being displayed.
        $this->widgetContext->setValue('contentId', $entry->getId());

        // select the layout and view scripts to use.
        if (!$this->contextSwitch->getCurrentContext()) {
            $this->getHelper('layout')->setLayout($this->_getEntryLayoutScript($entry));
        }
        $this->getHelper('viewRenderer')->setRender($this->_getEntryViewScript($entry));
    }

    /**
     * This action exposes the list of users currently editing a given
     * content entry (along with the entry's status and change details).
     * If posted to with an event parameter set to start/ping/stop the
     * active user will be updated in opened list.
     *
     * If the event param is start, the active user will be added to the
     * opened list with a pingTime of now and editTime of null.
     *
     * If the event param is ping, the active user will have their ping
     * time updated to now. The edit time will be unchanged (the edit
     * time is updated whenever 'validateFieldAction' is called).
     *
     * For the stop event the active user will be removed from
     * the opened list.
     *
     * Lastly, if the event param is missing or unrecognized the status
     * will be returned but no changes will be made to the users opened
     * details.
     *
     * Even if the caller lacks 'access-history' permissions conflicts
     * involving a deleted revision are reported.
     */
    public function openedAction()
    {
        // require edit access as that is the only time this action makes sense
        $this->acl->check('content', 'edit');

        // force json context; without fields the html version won't work well.
        $this->contextSwitch->initContext('json');

        // force the settings we care about to known values, we don't
        // want to risk leaking field values via this action.
        $request = $this->getRequest();
        $request->setParam('fields',        false)
                ->setParam('includeChange', true)
                ->setParam('includeStatus', true)
                ->setParam('includeOpened', true)
                ->setParam('version',       'head');

        // let view take care of fetching the entry and providing output
        $this->viewAction(null, array('access', 'access-history'));

        // exit if no content entry could be retrieved
        $entry = $this->view->entry;
        if (!$entry instanceof P4Cms_Content) {
            return;
        }

        // if its a post deal with the start/ping/stop events
        if ($request->isPost()) {
            $user   = P4Cms_User::fetchActive();
            $record = new P4Cms_Content_Opened;
            $record->setAdapter(P4Cms_Site::fetchActive()->getStorageAdapter())
                   ->setId($entry->getId());

            switch ($request->getParam('event')) {
                case 'start':
                    $record->setUserStartTime($user)
                           ->setUserPingTime($user)
                           ->setUserEditTime($user, null)
                           ->save();
                    break;
                case 'ping':
                    $record->setUserPingTime($user)
                           ->save();
                    break;
                case 'stop':
                    $record->setUserPingTime($user, null)
                           ->setUserEditTime($user, null)
                           ->save();
                    break;
            }
        }
    }

    /**
     * Rollback content.
     *
     * If the record id and change are valid; rolls back the specified
     * entry and redirects to view so the result will be shown.
     */
    public function rollbackAction()
    {
        $request = $this->getRequest();
        $id      = $request->getParam('id');

        // enforce permissions - requires both access-history and edit privileges.
        $message = 'You do not have permission to rollback this content entry.';
        $this->acl->check('content',        'access-history', null, $message);
        $this->acl->check('content/' . $id, 'edit',           null, $message);

        if ($request->getParam('change')) {
            $revSpec = '@' . $request->getParam('change');
        } else if ($request->getParam('version')) {
            $revSpec = '#' . $request->getParam('version');
        } else {
            throw new InvalidArgumentException(
                'A version or change number must be specified'
            );
        }

        // fetch the entry at the requested revision
        $entry = P4Cms_Content::fetch(
            $id . $revSpec,
            array('includeDeleted' => true)
        );

        $version = $entry->toP4File()->getStatus('headRev');

        $entry->save('Rollback to version '  . $version);

        // clear any cached entries related to this page
        P4Cms_Cache::clean(
            Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG,
            array('p4cms_content_' . bin2hex($entry->getId()), 'p4cms_content_list')
        );

        // redirect to view the specified entry
        $this->getHelper('redirector')->gotoUrl($entry->getUri());
    }

    /**
     * Edit content.
     *
     * HTTP get requests are forwarded to the view action for in-place
     * editing. Post requests are validated and saved.
     *
     * The p4cms.content.form events documented on the manage action will
     * also be broadcast when this action is accessed.
     *
     * @param   P4Cms_Content       $entry          optional - content entry to display.
     * @param   null|string|array   $skipAclCheck   optional - pass string or array of strings
     *                                              enumerating the privilegs that can be skipped
     */
    public function editAction(P4Cms_Content $entry = null, $skipAclCheck = null)
    {
        $request     = $this->getRequest();
        $headVersion = $request->getParam('headVersion');
        $entryId     = $request->getParam('id');

        // If we have a head version and ID ensure we fetch that
        // revision of the entry. This will cause a conflict
        // exception on save should we be out of date.
        if ($entryId && $headVersion) {
            $entryId = $entryId . "#" . $headVersion;
        }

        // enforce permissions.
        if (!in_array('edit', (array)$skipAclCheck)) {
            $this->acl->check('content/' . $entryId, 'edit');
        }

        // forward get requests to view action
        if ($request->isGet()) {

            // inject javascript to enable the appropariate add/edit toolbar button
            $action = $entryId ? 'startEdit' : 'startAdd';
            $this->view->dojo()->addOnLoad(
                "function(){ p4cms.content.$action(); }"
            );

            return $this->viewAction($entry, $skipAclCheck);
        }

        // if not called with an entry, we must fetch one.
        try {
            $entry = $entry ?: P4Cms_Content::fetch($entryId, array('includeDeleted' => true));
        } catch (P4Cms_Record_NotFoundException $e) {
            return $this->_forward('page-not-found', 'index', 'error');
        }

        // construct and populate the content form.
        $form = $this->_getContentForm($entry);
        $form->populate($request->getPost());

        // populate the view.
        $this->view->entry   = $entry;
        $this->view->type    = $entry->getContentType();
        $this->view->form    = $form;
        $this->view->isValid = true;

        // if form was posted and is valid, save it.
        if ($request->isPost() && $form->isValid($request->getPost())) {
            try {
                // if this content entry doesn't have an owner,
                // set the content owner to the current user.
                $entry->setOwner($entry->getOwner() ?: P4Cms_User::fetchActive());

                // if a comment was provided by the user, set it as change
                // description, otherwise provide default value
                $saveForm    = $form->getSubForm('save');
                $description = $saveForm && $saveForm->getValue('comment')
                    ? $saveForm->getValue('comment')
                    : 'Saved content change.';

                // copy form values to the content entry and save it
                // entry is a pub/sub record so third-parties can participate
                // ensure we throw on conflict so we can alert user
                $entry->setValues($form)
                      ->save($description, P4Cms_Content::SAVE_THROW_CONFLICT);

                // clear any cached entries related to this page
                // @todo connect to p4cms.content.record.postSave
                P4Cms_Cache::clean(
                    Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG,
                    array('p4cms_content_' . bin2hex($entry->getId()), 'p4cms_content_list')
                );


                // remove ourselves from the 'opened' list if we had an identified record
                if ($entryId) {
                    $user   = P4Cms_User::fetchActive();
                    $record = new P4Cms_Content_Opened;
                    $record->setAdapter(P4Cms_Site::fetchActive()->getStorageAdapter())
                           ->setId($entry->getId())
                           ->setUserStartTime($user, null)
                           ->setUserPingTime($user,  null)
                           ->setUserEditTime($user,  null)
                           ->save();
                }

                // notify success and redirect for traditional requests.
                if (!$this->contextSwitch->getCurrentContext()) {
                    P4Cms_Notifications::add(
                        'Content saved.',
                        P4Cms_Notifications::SEVERITY_SUCCESS
                    );

                    return $this->_redirect($request->getBaseUrl());
                }
            } catch (P4_Connection_ConflictException $e) {
                // if we received a conflict exception we need to mark
                // the form as being in error and inform the view.
                $form->markAsError();
                $this->view->isConflict = true;
            }
        }

        // save failed validation - include errors in response
        if ($form->isErrors()) {
            $this->view->isValid     = false;
            $this->view->form        = $form;
            $this->view->errors      = array(
                'form'      => $form->getErrorMessages(),
                'elements'  => $form->getMessages()
            );
        }
    }

    /**
     * Delete content. Supports deleting of multiple content entries that will be deleted
     * in a batch - i.e. either all of them or none.
     *
     * List of entry ids to delete are passed in the 'ids' parameter, however the method
     * also accepts passing entry id(s) via 'id' parameter (this will have precedence if
     * both 'id and 'ids' parameters are present).
     *
     * Requires HTTP post request to perform delete. Traditional requests are redirected
     * to the index and a notification is set. Context specific requests are rendered using
     * the appropriate context view script.
     */
    public function deleteAction()
    {
        $request = $this->getRequest();

        // set up the view
        $form       = new Content_Form_Delete;
        $view       = $this->view;
        $view->form = $form;

        // populate the form from the request
        // support passing entry ids in both 'id' and/or 'ids' params,
        // ensure 'ids' param will get the value from 'id' if it was set
        $params        = $request->getParams();
        $params['ids'] = $request->getParam('id', $request->getParam('ids'));
        $form->populate($params);

        // if there are posted data, validate the form and delete selected entries
        if ($request->isPost()) {
            // if form is invalid, set response code and exit
            if (!$form->isValid($params)) {
                $this->getResponse()->setHttpResponseCode(400);
                $view->errors = $form->getMessages();
                return;
            }

            // get adapter for batch and fetch all entries to delete
            $adapter = P4Cms_Content::getDefaultAdapter();
            $entries = P4Cms_Content::fetchAll(array('ids' => (array) $form->getValue('ids')));

            // attempt to delete all specified entries in a batch
            $adapter->beginBatch($form->getValue('comment') ?: 'No description provided.');
            foreach ($entries as $entry) {
                try {
                    // enforce permissions
                    $this->acl->check('content/' . $entry->getId(), 'delete');

                    // delete content entry
                    $entry->delete();
                } catch (Exception $e) {
                    // cannot delete the entry; revert the batch, set the response code and exit
                    $adapter->revertBatch();
                    $this->getResponse()->setHttpResponseCode(400);
                    $view->message = $e->getMessage();
                    return;
                }
            }

            // commit batch
            $adapter->commitBatch();

            // clear any affected cached entries
            $tags       = array('p4cms_content_list');
            $deletedIds = $entries->invoke('getId');
            foreach ($deletedIds as $entryId) {
                $tags[] = 'p4cms_content_' . bin2hex($entryId);
            }
            P4Cms_Cache::clean(Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG, $tags);

            // add notification and redirect to index for traditional requests
            if (!$this->contextSwitch->getCurrentContext()) {
                $message = 'Deleted ' . count($deletedIds)
                    . (count($deletedIds) === 1 ? ' content entry.' : ' content entries.');
                P4Cms_Notifications::add($message, P4Cms_Notifications::SEVERITY_SUCCESS);
                return $this->redirector->gotoSimple('index');
            }

            // pass list of deleted entries to the view
            $view->ids = $deletedIds;
        }
    }

    /**
     * Download the first image from a piece of content
     *
     * Looks at the type definition for the first 'imagefile' field of the
     * requested content entry. If it has a valid content serves it.
     *
     * If content type has no imagefile field, serves the content of the
     * first available 'file' field if it contains a valid image.
     *
     * Otherwise, forward to page-not-found.
     */
    public function imageAction()
    {
        // enforce permissions - deny if user doesn't have access permission.
        $this->acl->check('content', 'access');

        // get the content entry to download.
        $request = $this->getRequest();

        // if a version is present, generate rev-spec.
        $revspec = $request->getParam('version');
        $options = null;
        if ($revspec) {
            // enforce permissions - viewing historic versions requires 'access-history' privilege.
            $this->acl->check('content', 'access-history');
            $revspec = '#' . $revspec;
            $options = array('includeDeleted' => true);
        }

        // try to retreive the requested record and type; error out if not found
        try {
            $entry = P4Cms_Content::fetch($request->getParam('id') . $revspec, $options);
        } catch (Exception $e) {
            // we only have special handling for specific types; rethrow anything else
            if (!$e instanceof P4Cms_Record_NotFoundException
                && !$e instanceof InvalidArgumentException
            ) {
                throw $e;
            }

            return $this->_forward('page-not-found', 'index', 'error');
        }

        // check to see if there is an image element on the content type
        $image    = null;
        $elements = $entry->getContentType()->getFormElements();
        foreach ($elements as $element) {
            if ($element instanceof P4Cms_Form_Element_ImageFile) {
                $image = $element->getName();
                break;
            }
        }

        // if there was no image, check the file, which may be an image
        if (!$image && $entry->hasFileContentField()) {
            $image = $entry->getFileContentField();
        }

        // if request specifies a field, use that, otherwise
        // use the field that we detected above
        $image = $request->getParam('field', $image);

        // if we've found something, ensure it's an image and has content,
        // assuming that an empty element won't have mime data.
        // we also allow pdf files to be 'viewed as images' - support for
        // rendering pdf documents directly in the browser is pretty common
        if ($image) {
            $metadata = $entry->getFieldMetadata($image);
            $mimeType = isset($metadata['mimeType'])
                ? $metadata['mimeType']
                : null;

            if (strpos($mimeType, 'image/') !== 0 && $mimeType !== 'application/pdf') {
                $image = null;
            }
        }

        // tag the page cache so it can be appropriately cleared later
        if (P4Cms_Cache::canCache('page')) {
            P4Cms_Cache::getCache('page')
                ->addTag('p4cms_content_'      . bin2hex($entry->getId()))
                ->addTag('p4cms_content_type_' . bin2hex($entry->getContentTypeId()));
        }

        // if we didn't find a valid image, send a 404
        if (!$image) {
            $this->_forward('page-not-found', 'index', 'error');
            return;
        }

        // if we did find a valid image, let the download action handle it
        $request->setParam('field', $image);
        $this->downloadAction($entry, false);
    }

    /**
     * Download content.
     *
     * Serves the contents of the requested field or if no field is
     * specified, uses the file field. If the requested content entry
     * does not exist or has no suitable field, forwards to page-not-found.
     *
     * @param  P4Cms_Content  $entry     optional - entry instance to download
     * @param  boolean        $download  optional - indicate whether content should be downloaded
     *                                   defaults to true. influences content-disposition header.
     */
    public function downloadAction(P4Cms_Content $entry = null, $download = true)
    {
        // enforce permissions - deny if user doesn't have access permission.
        $this->acl->check('content', 'access');

        // get the content entry to download.
        $request = $this->getRequest();
        $id      = $request->getParam('id');

        // if a version is present, generate rev-spec.
        $revspec = $request->getParam('version');
        $options = null;
        if ($revspec) {
            // enforce permissions - viewing historic versions requires 'access-history' privilege.
            $this->acl->check('content', 'access-history');
            $revspec = '#' . $revspec;
            $options = array('includeDeleted' => true);
        }

        try {
            $entry = $entry ?: P4Cms_Content::fetch($id . $revspec, $options);
        } catch (Exception $e) {
            // we only have special handling for specific types; rethrow anything else
            if (!$e instanceof P4Cms_Record_NotFoundException
                && !$e instanceof InvalidArgumentException
            ) {
                throw $e;
            }

            return $this->_forward('page-not-found', 'index', 'error');
        }

        // tag the page cache so it can be appropriately cleared later
        if (P4Cms_Cache::canCache('page')) {
            P4Cms_Cache::getCache('page')
                ->addTag('p4cms_content_'      . bin2hex($entry->getId()))
                ->addTag('p4cms_content_type_' . bin2hex($entry->getContentTypeId()));
        }

        // determine what content field to deliver.
        // if the request specifies a field, serve it.
        // fallback to the entry's file content field.
        $field = $request->getParam('field', $entry->getFileContentField());

        // if there is still no field to serve, indicate 404.
        if (!$field) {
            $this->_forward('page-not-found', 'index', 'error');
            return;
        }

        // get entry data to download
        $data = $entry->getValue($field);

        // obtain file metadata.
        $metadata = $entry->getFieldMetadata($field);
        $filename = isset($metadata['filename']) ? $metadata['filename'] : null;
        $mimeType = isset($metadata['mimeType']) ? $metadata['mimeType'] : 'application/octet-stream';

        // if data represents an image, adjust it before sending to output
        if (strpos($mimeType, 'image/') === 0) {
            try {
                $data = $this->_adjustImage($data);
            } catch (Exception $e) {
                P4Cms_Log::log("Image adjust failed: " . $e->getMessage(), P4Cms_Log::WARN);
            }
        }

        $this->getResponse()->setHeader('Content-Type', $mimeType);
        if ($download) {
            $this->getResponse()->setHeader(
                'Content-Disposition',
                'attachment;' . ($filename ? ' filename="' . $filename . '"' : '')
            );
        }

        // if entry's field value is empty, render the page
        if (!$data) {
            return $this->viewAction($entry);
        }

        // disable autorendering for the download
        $this->_helper->viewRenderer->setNoRender();
        $this->_helper->layout->disableLayout();

        print $data;
    }

    /**
     * Validate the passed field
     */
    public function validateFieldAction()
    {
        // force json context.
        $this->contextSwitch->initContext('json');

        // extract field/value from request.
        $request   = $this->getRequest();
        $field     = $request->getParam('field');
        $value     = $request->getParam('value');
        $contentId = $request->getParam('contentId', null);

        // enforce permissions.
        if ($contentId) {
            $this->acl->check('content' . '/' . $contentId, 'edit');
        } else {
            $this->acl->check('content', 'add');
        }

        // verify that field is specified
        if ($field == '') {
            throw new P4Cms_Content_Exception(
                "Cannot validate field - no field given."
            );
        }

        // fetch the type to ensure it is valid
        $type = P4Cms_Content_Type::fetch($request->getParam('contentType'));

        // setup an entry to get the display value from
        $entry = new P4Cms_Content;
        $entry->setContentType($type)
              ->setValue($field, $value);

        // get the content type and element definition.
        $type    = $entry->getContentType();
        $element = $type->getFormElement($field);

        // validate the field.
        $isValid = $element->isValid($value);

        // get the entry at the head revision
        $options    = array('includeDeleted' => true);
        $headEntry  = null;
        if ($contentId && P4Cms_Content::exists($contentId, $options)) {
            $headEntry = P4Cms_Content::fetch($contentId, $options);

            // update the opened info to reflect our edit/ping
            $user   = P4Cms_User::fetchActive();
            $record = new P4Cms_Content_Opened;
            $record->setAdapter(P4Cms_Site::fetchActive()->getStorageAdapter())
                   ->setId($contentId)
                   ->setUserPingTime($user)
                   ->setUserEditTime($user)
                   ->save();
        }

        // populate the view.
        $this->view->type           = $type;
        $this->view->entry          = $headEntry;
        $this->view->element        = $element;
        $this->view->fieldName      = $field;
        $this->view->fieldValue     = $value;
        $this->view->isValid        = $isValid;
        $this->view->errors         = $element->getMessages();
        $this->view->displayValue   = $entry->getDisplayValue($field);
    }

    /**
     * Get the view script to use for the given content entry.
     *
     * Searches view script paths for most specific template.
     *  1. index/view-entry-<id>.phtml
     *  2. index/view-type-<id>.phtml
     *  3. index/view.phtml
     *
     * @param   P4Cms_Content   $entry  the entry to be rendered.
     * @return  string          the name of the view script to use.
     *
     * @publishes   p4cms.content.view.scripts
     *              Return the passed scripts array, making any modifications (add, remove or change
     *              values), to influence the view script that ends up being selected for rendering.
     *              The first view script filename in the list that exists in the view's search
     *              paths gets used. Note that the filename should not include the suffix
     *              (typically ".phtml").
     *              array           $scripts    The list of view script filenames.
     *              P4Cms_Content   $entry      The content entry to render.
     */
    protected function _getEntryViewScript(P4Cms_Content $entry)
    {
        $scripts = array();

        // convention for entry ids.
        if ($entry->getId()) {
            $scripts[] = 'index/view-entry-'. $entry->getId();
        }

        // convention for content types.
        $scripts[] = 'index/view-type-'. $entry->getContentType()->getId();

        // let third-parties add to or alter the view script conventions
        $scripts = P4Cms_PubSub::filter(
            'p4cms.content.view.scripts',
            $scripts,
            $entry
        );

        // find the first template that exists among the possible scripts.
        $suffix = '.' . $this->_helper->viewRenderer->getViewSuffix();
        foreach ($scripts as $script) {
            if ($this->view->getScriptPath($script . $suffix)) {
                return basename($script);
            }
        }

        // no match, fallback to default view.
        return 'view';
    }

    /**
     * Get the layout script to use for the given content entry.
     * If the content type specifies a valid layout, returns it.
     * Otherwise, returns the current default layout.
     *
     * @param   P4Cms_Content   $entry  the entry to be rendered.
     * @return  string          the name of the layout script to use.
     */
    protected function _getEntryLayoutScript(P4Cms_Content $entry)
    {
        $layout = $entry->getContentType()->getLayout();
        $suffix = '.' . $this->_helper->viewRenderer->getViewSuffix();
        if ($layout && $this->view->getScriptPath($layout . $suffix)) {
            return $layout;
        }

        return $this->_helper->layout->getLayout();
    }

    /**
     * Creates the content form, passing in the formIdPrefix if present
     *
     * @param   P4Cms_Content   $entry  The content entry to make a form for.
     * @return  Content_Form_Content    The completed form.
     */
    protected function _getContentForm(P4Cms_Content $entry)
    {
        $options = array('entry' => $entry);

        // if the entry doesn't have a content type id, we can't build a proper form.
        // this is likely the case of a missing content type or missing attributes.
        if (!$entry->getContentTypeId()) {
            throw new Content_Exception("Cannot get content form. Content type is invalid or missing.");
        }

        // if request specifies an id prefix, add it to options
        $request = $this->getRequest();
        $options['idPrefix'] = $request->getParam('formIdPrefix');

        return new Content_Form_Content($options);
    }

    /**
     * Adjusts given image (represented by $imageData) according to the request params.
     *
     * Following request parameters are recognized:
     *  'width'       - target width in pixels; if not set, but 'height' is set,
     *                  then target width will be computed from 'height' such
     *                  that image keeps same aspect ratio as original
     *  'height'      - target height in pixels; if not set, but 'width' is set,
     *                  then target height will be computed from 'width' such
     *                  that image keeps same aspect ratio as original
     *  'maxWidth'    - maximum target width, image will be proportionally shrunk
     *                  if computed width is greater than this value
     *  'maxHeight'   - maximum target height, image will be proportionally shrunk
     *                  if computed height is greater than this value
     *  'sharpen'     - if set, image will be sharpened (applied after resizing)
     *
     * @param   string  $imageData  input image data
     * @return  string  adjusted image data
     */
    protected function _adjustImage($imageData)
    {
        $request    = $this->getRequest();
        $width      = (int) $request->getParam('width');
        $height     = (int) $request->getParam('height');
        $maxWidth   = (int) $request->getParam('maxWidth');
        $maxHeight  = (int) $request->getParam('maxHeight');
        $sharpen    = $request->getParam('sharpen');

        // early exit if nothing to apply
        if (!$width && !$height && !$maxWidth && !$maxHeight && !$sharpen) {
            return $imageData;
        }

        $image = new P4Cms_Image;
        $image->setData($imageData);

        $dimensions = $image->getDriver()->getImageSize();
        $ratio      = $dimensions['width'] / $dimensions['height'];

        // set target width and height:
        //  - set to original size if neither dimension was specified
        //  - if only one dimension was specified, compute the other one
        //    to keep the aspect ration of the original image
        if (!$width && !$height) {
            $width  = $dimensions['width'];
            $height = $dimensions['height'];
        } else if (!$width) {
            $width  = round($height * $ratio);
        } else if (!$height) {
            $height = round($width / $ratio);
        }

        // lower image dimensions if they exceed maximum dimensions (if provided)
        if ($maxHeight && $maxHeight < $height) {
            $width  = round($width * $maxHeight / $height);
            $height = $maxHeight;
        }
        if ($maxWidth && $maxWidth < $width) {
            $height = round($height * $maxWidth / $width);
            $width  = $maxWidth;
        }

        // resize image according to computed width and height (if they differ from original size)
        if ($width !== $dimensions['width'] || $height !== $dimensions['height']) {
            if ($width > static::MAX_SCALE || $height > static::MAX_SCALE) {
                throw new Content_Exception(
                    "Width or height exceeds maximum scale of " . static::MAX_SCALE . "px."
                );
            }

            $image->transform('scale', array($width, $height));
        }

        // sharpen output image if requested
        if ($sharpen) {
            $image->transform('sharpen');
        }

        return $image->getData();
    }
}
# Change User Description Committed
#1 16170 perforce_software Move Chronicle files to follow new path scheme for branching.
//guest/perforce_software/chronicle/application/content/controllers/IndexController.php
#1 8972 Matt Attaway Initial add of the Chronicle source code