Apostrophe Manual
Developer's Guide
At this point you should already be familiar with the preceding material, which covers Apostrophe installation, the end-user experience of managing an Apostrophe site, and front end design issues. The designer's guide in particular is required reading to make good use of the following section, which is devoted to extending Apostrophe in new ways with your own code.
We strongly recommend that you complete the Symfony tutorial before attempting to add new features to Apostrophe. We'll assume familiarity with Symfony development in this section.
Creating Custom Slot Types
You are not limited to the slot types provided with apostrophePlugin! Anyone can create new slot types by taking advantage of normal Symfony features: modules, components, actions, templates and Doctrine model classes.
You can speed this process enormously by using the apostrophe:generate-slot-type task:
./symfony apostrophe:generate-slot-type --application=frontend --type=mynewtypename
This task generates all of the scaffolding for a new, working slot type. The above example generates the necessary module, model class and form class in the frontend application of the project. You can also generate the scaffolding in an existing or new Symfony plugin:
./symfony apostrophe:generate-slot-type --plugin=mynewplugin --type=mynewtypename
We recommend the latter as it is easier to reuse your slot type in another project this way. Don't put your slot in apostrophePlugin itself. Make a plugin of your own, or add the slot at the project level. This way you won't have problems later when you update apostrophePlugin.
Reminder: to activate your slot you must add it to the list of allowed slot types for your project in app.yml, and also include it in the allowed_types option for individual Apostrophe areas in which you want to allow editors to add it. In app.yml, you add a new slot type like this:
all:
a:
slot_types:
mynewtypename: "Nice Label For Add Slot Menu"
When inserting an area in a template or layout, you specify the allowed slot types like this:
a_area('body', array('allowed_types' => array('aRichText', 'mynewtypename')));
You can also use your new slot type in standalone slots with the a_slot helper.
Of course, to understand how to customize the behavior of your new slot type, you need to know how slot types work. So let's dig into the workings of the aFeed slot, which was generated with the apostrophe:generate-slot-type task and then edited to implement its own features.
1) A module. In this case, the aFeedSlot module. This contains actions, components and partials to edit and render the slot within a page. If the module lives in a plugin, don't forget to enable the plugin in your ProjectConfiguration class and the module in your settings.yml file.
2) A form class, such as aFeedForm.
3) A model class, such as aFeedSlot, which inherits from the aSlot model class via Doctrine's column aggregation inheritance feature. (Yes, we set this up for you when you use the apostrophe:generate-slot-type task.)
Slot Modules, Part I: Edit and Normal Views
With a few notable exceptions like our aImage slot, most slots have an "edit view" and a "normal view," rendered by editView and normalView components in the slot's module. The normal view presents the slot as a user will see it when not editing. The edit view displays the same slot as the user will see it after clicking the "Edit" button. For better editing performance, both views are present in the HTML whenever the logged-in user has sufficient privileges to edit the slot.
A simple _editView partial just echoes the form class associated with the slot:
<?php echo $form ?>
Of course, you can render the form differently if you wish.
A slot type generated with the apostrophe:generate-slot-type task will already contain the necessary supporting code in the editView component, in modules/aFeedSlot/actions/components.class.php:
public function executeEditView()
{
// Must be at the start of both view components
$this->setup();
// Careful, don't clobber a form object provided to us with validation errors
// from an earlier pass
if (!isset($this->form))
{
$this->form = new aFeedForm($this->id, $this->slot->getArrayValue());
}
}
Notice that the form is initialized with two parameters: a unique identifier that distinguishes it from other forms in the page, and a value fetched from the slot. Most slots store their data in the value column of the slot table, using serialize and unserialize to store PHP data in any way they see fit. The aSlot::getArrayValue and aSlot::setArrayValue methods are conveniences that simplify this for you. aSlot::getArrayValue always returns a valid array, even if the slot is new (in which case the array will be empty). And aSlot::setArrayValue accepts an array and serializes it into the value column. You don't need to worry about any of that... although it is possible for you to implement custom database columns instead of using Doctrine column aggregation inheritance as explained below. This is usually not worth the trouble and the database schema changes it causes. However it can be worthwhile if you need foreign key relationships and must avoid extra queries. You can also approach that problem by referencing the id column of the a_slot table from a foreign key in a related table, rather than the other way around.
The normal view takes advantage of the information stored in the slot to render it with its normal, non-editing appearance. The feed slot's normal view component fetches the feed, via the web or via an sfFileCache object if it has already been fetched recently, and the normal view partial renders it.
Your normal view's implementation is up to you. Try the slot type generator task to see a very simple working example. One thing your normal view must do is provide an edit button at the top of the partial, or provide some other way to edit the slot's settings. The task generates code like this in the _normalView partial:
<?php include_partial('a/simpleEditWithVariants', array('name' => $name, 'permid' => $permid, 'pageid' => $pageid, 'slot' => $slot)) ?>
This code displays the edit button, and also offers a menu of slot variants if any have been configured for this slot type on this particular site.
Slot Forms
Most slots, specifically slots that have an edit button and take advantage of the edit view component, will have a form associated with them. By default this form is automatically echoed by the edit view component, fully initialized and complete with any validation errors from unsuccessful edits.
Here's the aFeedForm class:
class aFeedForm extends sfForm
{
// Ensures unique IDs throughout the page
protected $id;
public function __construct($id, $defaults)
{
$this->id = $id;
parent::__construct();
$this->setDefaults($defaults);
}
public function configure()
{
$this->setWidgets(array('url' => new sfWidgetFormInputText(array('label' => 'RSS Feed URL'))));
$this->setValidators(array('url' => new sfValidatorUrl(array('required' => true, 'max_length' => 1024))));
// Ensures unique IDs throughout the page
$this->widgetSchema->setNameFormat('slotform-' . $this->id . '[%s]');
$this->widgetSchema->setFormFormatterName('aAdmin');
}
}
Notice that this form class is not a Doctrine form class. Yes, aFeedSlot does inherit from aSlot via Doctrine column aggregation inheritance. However, Doctrine forms do not distinguish between column aggregation inheritance subclasses and will include all of the columns of all of the subclasses. Also, it's usually best to avoid custom columns and use setArrayValue and getArrayValue instead, which requires that we set up our own fields in the form class.
This class is only slightly changed from what the task generated. The url field has been added and given a suitable label and a limit of 1024 characters. The name format has been set in the usual way for a slot form and should not be changed (for a rare exception check out aRichTextForm, which must cope with certain limitations of FCK). The form formatter in use here is the Apostrophe form formatter, which produces nicely styled markup, but you may use another or render the form one element at a time in the _editView partial.
Slot Components, Part II: Actions
The edit action of your slot's module saves new settings in the form, or reports a validation error and refuses to do so.
Here's the edit action for aFeedSlot, which is exactly as the apostrophe:generate-slot-type task generated it:
public function executeEdit(sfRequest $request)
{
$this->editSetup();
$value = $this->getRequestParameter('slotform-' . $this->id);
$this->form = new aFeedForm($this->id, array());
$this->form->bind($value);
if ($this->form->isValid())
{
// Serializes all of the values returned by the form into the 'value' column of the slot.
// This is only one of many ways to save data in a slot. You can use custom columns,
// including foreign key relationships (see schema.yml), or save a single text value
// directly in 'value'. serialize() and unserialize() are very useful here and much
// faster than extra columns
$this->slot->setArrayValue($this->form->getValues());
return $this->editSave();
}
else
{
// Makes $this->form available to the next iteration of the
// edit view so that validation errors can be seen, if any
return $this->editRetry();
}
}
This action begins by calling $this->editSetup(), which takes care of determining what slot the action will be working with. The action then fetches the appropriate parameter, binds the form, validates it, and if successful saves the form's data in the slot with setArrayValue and calls $this->editSave() to save and redisplay the slot. If there is a validation error, $this->editRetry() should be called instead.
Depending on your needs you might not need to modify this action at all. The methods called by this action take care of version control, access control and everything else associated with the slot and let you get on with your job.
Custom Validation
Sometimes $this->form isn't quite enough to meet your needs. You might have more than one Symfony form in the slot (although you should look at embedForm() and mergeForm() first before you say that). Or you might not be using Symfony form classes at all.
Fortunately there's a way to pass validation messages from the executeEdit action to the next iteration of the editView component:
// Set it in the action
$this->validationData['custom'] = 'My error message';
// Grab it in the component
$this->error = $this->getValidationData('custom');
// ... And display it in the template
<?php if ($error): ?>
<h2><?php echo $this->error ?></h2>
<?php endif ?>
Note that $this->validationData['form'] is used internally to store $this->form, if it exists in the action. So we suggest that you use other names for your validation data fields.
Additional Actions
Things get interesting when you need to edit your slot with additional actions, possibly actions that go to different pages like the "Choose Image" buttons of our media slots.
You can write your own actions that use $this->editSetup(), $this->editSave() and $this->editRetry(). The tricky part is linking to these actions, from your normalView partial or elsewhere. Here's an example of a possible target for a form submission or redirect that would successfully invoke such an action:
url_for('mySlot/myAction') . "?" .
http_build_query(
array(
"slot" => $name,
// The slug of the page where the slot lives.
// Could be a virtual page if this is a global slot etc.
"slug" => $page->slug,
// The actual page we were looking at when we began editing the slot
"actual_slug" => aTools::getRealPage()->getSlug(),
"permid" => $permid,
// Optional: use this if you are redirecting
// from another page and need the entire page to render
"noajax" => 1))
For a fully worked example, we recommend taking a look at the aButtonSlot module, which uses both an edit view with a form (for the title and link) and a separate action that eventually redirects back (for selecting the image).
Adding Database Columns
The apostrophe:generate-slot-type task takes care of setting up the model classes for you so that Apostrophe can distinguish between your slot and other slots, even though all of them are stored in the a_slot table.
If you are using getArrayValue and setArrayValue or otherwise storing your data in the value column, you'll never have to worry about this directly.
However, if you do need to add custom columns, you'll need to know how this looks in config/doctrine/schema.yml.
Here's how aFeedSlot is configured:
aFeedSlot:
# Doctrine doesn't produce useful forms with column aggregation inheritance anyway,
# and slots often use serialization into the value column... the Doctrine forms are not
# of much use here and they clutter the project
options:
symfony:
form: false
filter: false
# columns:
#
# You can add columns here. However, if you do not need foreign key relationships it is
# often easier to store your data in the 'value' column via serialize(). If you do add columns,
# their names must be unique across all slots in your project, so use a unique prefix
# for your company.
# This is how we are able to retrieve slots of various types with a single query from
# a single table
inheritance:
extends: aSlot
type: column_aggregation
keyField: type
keyValue: 'aFeed'
Take a look at the inheritance section. The extends keyword specifies the class we are inheriting from, while the keyValue field must contain the name of the type. Doctrine uses this to figure out what class of object to create when loading a record from the a_slot table. The slot type name is recorded in the type column, already in the aSlot class. You don't need to worry about the details, but for more information about them, see the excellent Doctrine documentation.
Note that the keyValue setting does not include the word Slot.
To add extra columns at the database level, uncomment columns: and add new columns precisely as you would for any Doctrine model class. You can add new relations as well.
YOU MUST PREFIX YOUR CUSTOM COLUMN NAMES WITH A UNIQUE PREFIX to avoid collisions with other slots. Doctrine does not do this automatically, so please take care to avoid names that may lead to conflicts down the road.
Opening the Edit View Automatically For New Slots
By default, when a user adds a new slot to an area the user must then click the edit button before making changes to the slot. To take the user straight to the editView of your slot, you have to set the member variable editDefault to true. See the aRichTextSlot or aTextSlot for an example.
If your slot lives in a plugin, the right class to edit is plugins/myPlugin/lib/model/doctrine/PluginmySlot.class.php. If your slot lives at the project level, the right file is lib/model/doctrine/mySlot.class.php.
Managing Global Admin Buttons to the Apostrophe Admin Menu
When a user with editing privileges is logged in and visiting a page for which they have such privileges, a bar appears at the top of each page offering links to appropriate administrative features. Admins will see a button offering access to the sfGuardUser admin module. Editors in general will have access to the media module. You can add links of your own, or change the order of the buttons to be displayed.
Adding, Removing and Reordering Buttons via app.yml
The simplest way to add new buttons is to set app_a_extra_admin_buttons in app.yml. This allows you to add buttons that point to Symfony actions you coded yourself. By default, this is equivalent to:
all:
a:
extra_admin_buttons:
users:
label: Users
action: 'aUserAdmin/index'
class: 'a-users'
reorganize:
label: Reorganize
action: a/reorganize
class: a-reorganize
If you override this setting you will almost certainly want to keep these two buttons in the list.
Note that the key and the label field are different. label is shown to the user, and automatically internationalized by Symfony. The label is also looked for in the apostrophe i18n catalog. The key is used in app.yml settings to reorder buttons.
Note that the built-in media repository will always add a Media button to this list (with the name media), and the blog plugin, if installed, will add Blog and Events buttons.
To specify the order of the buttons, or discard buttons that were added by plugins, use app_a_global_button_order:
all:
a:
global_button_order:
- users
- reorganize
- media
- blog
Global buttons will be displayed in the order specified here (specify button keys, not button labels). Any buttons you leave off the list will not be displayed at all, even if they were added by plugins, the media repository, etc.
If you do not specify app_a_global_button_order the buttons will be displayed in alphabetical order by name.
Adding Buttons Programmatically
app.yml is the easiest way to add buttons, but sometimes it's not enough. Perhaps you're writing a plugin that adds new features to Apostrophe and should register new buttons on its own. Or perhaps you want to add a button that targets a specific engine page. You can do both of these things by responding to the a.getGlobalButtons event.
First provide a static method in a class belonging to your own plugin or application-level code which invokes aTools::addGlobalButtons to add one or more buttons to the bar:
class aMediaCMSSlotsTools
{
// You too can do this in a plugin dependent on apostrophePlugin, see
// the provided stylesheet for how to correctly specify an icon to go
// with your button. See the apostrophePluginConfiguration class for the
// registration of the event listener.
static public function getGlobalButtons()
{
aTools::addGlobalButtons(array(
new aGlobalButton('media', 'Media', 'aMedia/index', 'a-media')));
}
}
The first argument to the aGlobalButton constructor is the name of the button. This is used to refer to that button elsewhere in your code and in app.yml. The second argument is the label of the button, which may contain markup and will be automatically internationalized. The third is the action (in your own code, typically). And the fourth is a CMS class to be added to the button, which is typically used to supply your own icon and a left offset for the image to reside in.
If your own plugin, like our media system, implements its administrative page as an apostrophe CMS engine page under /admin and also might have public engine pages elsewhere on the site, you'll want to make sure your button targets the "official" version. You can do that by providing the right engine page as a fifth argument to the aGlobalButton constructor:
static public function getGlobalButtons()
{
$mediaEnginePage = aPageTable::retrieveBySlug('/admin/media');
// Only if we have suitable credentials
$user = sfContext::getInstance()->getUser();
if ($user->hasCredential('media_admin') || $user->hasCredential('media_upload'))
{
aTools::addGlobalButtons(array(
new aGlobalButton('media', 'Media', 'aMedia/index', 'a-media', $mediaEnginePage)));
}
}
For more information see "Engines: Grafting Symfony Modules Into the CMS Page Tree" below.
Now, in the initialize method of your plugin or project's configuration class, make the following call to register interest in the event:
// Register an event so we can add our buttons to the set of global
// CMS back end admin buttons that appear when the apostrophe is clicked.
$this->dispatcher->connect('a.getGlobalButtons',
array('aMediaCMSSlotsTools', 'getGlobalButtons'));
The bar at the top of each page will now feature your additional button or buttons.
Note: you should not add large numbers of buttons to the bar. Usually no more than one per plugin is advisable. It's important that the bar remain manageable and convenient for site admins.
Engines: Grafting Symfony Modules Into the CMS Page Tree
Suitably coded Symfony modules can now be grafted into the page tree at any point in a flexible way that allows admins to switch any page from operating as a normal template page to operating as an engine page, with all URLs beginning with that page slug remapped to the actions of the engine module. When the engine page is moved within the site, all of the virtual "pages" associated with the actions of the module move as well.
A single engine module can now be grafted into more than one location on a site. To take advantage of this feature, you must disable the Symfony routing cache. Disabling the routing cache is the default in Symfony 1.3 and 1.4 because the routing cache causes performance problems rather than performance gains in most cases (and in some cases they are quite severe and unpredictable). However, if you require the Symfony routing cache, you can still use engines as long as you don't install the same engine at two points in the same site. Even without multiple instances, engines still allow components such as a staff directory to be located at the point in the site where the client wishes to put them without the need to edit configuration files.
Engine modules are written using normal actions and templates and otherwise-normal routes of the aRoute and aDoctrineRoute classes.
This is a very powerful way to integrate non-CMS pages into your site. The media browser of apostrophePlugin already takes advantage of it, and the forthcoming apostropheBlogPlugin will as well.
Engines should always be used when you find yourself wishing to create a tree of dynamic "pages" representing something other than normal CMS pages, beginning at a point somewhere within the CMS page tree.
To create a a engine, begin by creating an ordinary Symfony module. Feel free to test its functionality normally at this point. Then change the parent class from sfActions to aEngineActions.
NOTE: if your actions class has a preExecute method of its own, be sure to call parent::preExecute from that method. Otherwise it will not work as an engine.
Now, create routes for all of the actions of your module, or a catch-all route for all of them. Make sure you give these routes the aRoute class in routing.yml. The following are sample routes for a module called enginetest:
# Engine rules must precede any catch-all rules
enginetest_index:
url: /
param: { module: enginetest, action: index }
class: aRoute
enginetest_foo:
url: /foo
param: { module: enginetest, action: foo }
class: aRoute
enginetest_bar:
url: /bar
param: { module: enginetest, action: bar }
class: aRoute
enginetest_baz:
url: /baz
param: { module: enginetest, action: baz }
class: aRoute
You can also use more complex rules to avoid writing a separate rule for each action, exactly as you would for a normal Symfony module. This example could replace the foo, bar, and baz rules above:
enginetest_action:
url: /:action
param: { module: enginetest }
class: aRoute
You can also use Doctrine routes. Configure them as you normally would, but set the class name to aDoctrineRoute:
a_event_show:
url: /:slug
param: { module: aEvent, action: show }
options: { model: Event, type: object }
class: aDoctrineRoute
requirements: { slug: '[\w-]+' }
In general, you may use all of the usual features available to Symfony routes.
Note that the URLs for these rules are very short and appear to be at the root of the site. aRoute will automatically remap these routes based on the portion of the URL that follows the slug of the "engine page" in question.
That is, if an engine page is located here:
/test1
And the user requests the following URL:
/test1/foo
The aRoute class will automatically locate the engine page in the stem of the URL, remove the slug from the beginning of the URL, and match the remaining part:
/foo
To the appropriate rule.
As a special case, when the engine page is accessed with no additional components in the URL, aRoute will match it to the rule with the URL /.
Note that as a natural consequence of this design, engine pages cannot have subpages in the CMS. In general, it is appropriate to use engines only when you wish to implement "virtual pages" below the level of the CMS page. If you simply wish to customize the behavior of just part of a page, a custom page template or custom slot will better suit your needs.
Once you have established your routes, you can create subnavigation between the actions of your module by writing normal link_to and url_for calls:
echo link_to('Bar', 'enginetest/bar')
To make the user interface aware of your engine, add the following to app.yml:
all:
a:
engines:
'': 'Template-Based'
enginetest: 'Engine Test'
Substitute the name of your module for enginetest. Be sure to keep the "template-based" entry in place, as otherwise normal CMS pages are not permitted on your site.
Linking to the "index" action of an engine page is as simple as linking to any other page on the site. But what if you need to generate a link to a specific engine action from an unrelated page? For instance, what if you wish to link to a particular employee's profile within an engine page that contains a directory of staffers?
Just call link_to exactly as you did before:
echo link_to('Bar', 'enginetest/bar')
If the current page is not an engine page matching the route in question, the a routing system will find the first engine page in the site that does match the route, and generate a link to that engine page.
Note: if there is currently no engine page for the given engine, this will throw an exception and generate a 500 error. This makes sense: trying to generate a link to an engine page that doesn't exist is a lot like trying to use a route that doesn't exist. You can test to make sure the engine page exists like this:
<?php if (aPageTable::getFirstEnginePage('enginetest')): ?>
<?php echo link_to('Bar', 'enginetest/bar') ?>
<?php endif ?>
Which Engine Page Does My Link Point To?
When there is just one engine page on the site for a particular engine module, things are simple: links to routes for that engine always point to a URL beginning with that page. With multiple instances of the same module, things get trickier. Here's how to sort it out.
There are three simple rules:
1. When there is only one engine page on the site for a particular engine module 'blog', links always target that page by default. For many purposes, this is all you need.
2. When the page for which the link is being generated (the current CMS page) is an engine page for 'blog', links generated on that page will point back to that page by default, even if other engine pages for that engine module do exist. If the current page is not an engine page for the engine in question, the first matching engine page found in the database is used by default.
3. When you wish to target a specific engine page with link_to and url_for calls, you add an extra engine-slug parameter to the Symfony URL (beginning in Apostrophe 1.4):
<?php echo link_to('Jane's Blog', 'blog/index?engine-slug=/janesblog') ?>
The engine-slug parameter will automatically be removed from the URL and will not appear in the query string. It is used only to determine which engine page to target. If you do not specify this parameter, you get the first matching engine page, as explained above. If you have an aPage object and wish to target it just set engine-slug to $myPage->slug.
There is an alternative to engine-slug which may be appropriate if you wish to override the engine slug for a large block of code or a partial you are about to include:
<?php aRouteTools::pushTargetEnginePage('/janes-blog') ?>
<?php echo link_to('Jane's Blog', 'blog/index') ?>
<?php aRouteTools::popTargetEnginePage('blog') ?>
For convenience, you may pass either a page slug (like /janes-blog) or an aPage object to the aRouteTools::pushTargetEnginePage method.
Now all aRoute and aDoctrineRoute-based URLs generated between the push and pop calls that use the blog module will target the "Jane's Blog" page. URLs generated after the pop call revert to the usual behavior.
Since you may find yourself writing partials and components that are included in other pages, it is advisable to always pop after pushing a different engine page in order to avoid side effects.
We recommend using engine-slug rather than pushing and popping engine pages wherever practical, as it adheres more closely to the philosophy of newer versions of Symfony which emphasize dependency injection and frown on global state.
Again, you must not enable the Symfony routing cache if you wish to include multiple engine pages for the same engine in your site. The routing cache is turned off by default in both Symfony 1.3 and 1.4. If you have upgraded an older project you may need to manually shut it off in apps/frontend/config/routing.yml.
Extending the Page Settings Form: Creating an Engine Settings Form
"If I have separate engine pages for Jane's blog and Bob's blog, both using the blog engine, how do I distinguish them?" That part is easy. Just use the id of the engine page as a foreign key in your own Doctrine table and keep the details that distinguish them in that table. This is how our media repository associates particular engine pages with particular media categories.
"But how can editors change settings for that particular engine page?" Well, you could create your own settings action of course, and link to it from your engine page. Remember, Apostrophe is still Symfony, and you can always do normal Symfony development.
But we also provide a convenient way to extend the page settings form that rolls down when you click "This Page" and then click on the gear.
Assuming your engine module is called blog, this is all you have to do:
- Create a form class called blogEngineForm. The constructor of this class must accept an aPage object as its only argument.
- Create a blog/settings partial that renders $form. This partial can be as simple as <?php echo $form?> if you wish.
Apostrophe will automatically look for this form class. If it exists, Apostrophe will render both the standard page settings form and your engine settings form if that engine is selected. Apostrophe will automatically fetch your form on the fly if the user switches the template of the page to blog.
The page settings will not be saved unless both forms validate successfully. If both forms validate, Apostrophe will save them consecutively (the page settings form, followed by your engine settings form).
One simple way to create a form that works with this approach is to add a relation between aPage and your own table in your application or plugin schema. Then you can extend the aPageForm class and immediately remove all fields, then add back the fields you're interested in. Take a look at aMediaEngineForm:
class aMediaEngineForm extends aPageForm
{
public function configure()
{
$this->useFields();
$this->setWidget('media_categories_list', new sfWidgetFormDoctrineChoice(array('multiple' => true, 'model' => 'aMediaCategory')));
$this->widgetSchema->setLabel('media_categories_list', 'Media Categories');
$this->widgetSchema->setHelp('media_categories_list','(Defaults to All Cateogories)');
$this->setValidator('media_categories_list', new sfValidatorDoctrineChoice(array('multiple' => true, 'model' => 'aMediaCategory', 'required' => false)));
$this->widgetSchema->setNameFormat('enginesettings[%s]');
$this->widgetSchema->setFormFormatterName('aAdmin');
$this->widgetSchema->getFormFormatter()->setTranslationCatalogue('apostrophe');
}
}
The corresponding schema is:
aMediaCategory:
tableName: a_media_category
actAs:
Timestampable: ~
Sluggable: ~
columns:
id:
type: integer(4)
primary: true
autoincrement: true
name:
type: string(255)
unique: true
description:
type: string
relations:
MediaItems:
class: aMediaItem
local: media_category_id
foreign: media_item_id
foreignAlias: MediaCategories
refClass: aMediaItemCategory
# Used to implement media engine pages dedicated to displaying one or more
# specific categories
Pages:
class: aPage
local: media_category_id
foreign: page_id
foreignAlias: MediaCategories
refClass: aMediaPageCategory
This form takes advantage of the fact that Doctrine will automatically save the MediaCategories relation when save() is called on the form, looking for a widget named media_categories_list.
You don't have to extend aPageForm and use a relation in this way, but if you don't you'll need to make sure your updateObject() and save() methods do the right thing in your own way.
Testing Your Engine
After executing symfony cc, you will begin to see your new engine module as a choice in the new "Page Engine" dropdown menu in the page settings form. Select your engine and save your changes. The page will refresh and display your engine.
Note that engine pages can be moved about the site using the normal drag and drop interface.
You can create your own subnavigation within your engine page. We suggest overriding appropriate portions of your page layout via Symfony slots.
Internationalization
Internationalization is supported at a basic level: separate versions of content are served depending on the result of calling getCulture() for the current user. When you edit, you are editing the version of the content for your current culture. The user's culture defaults, as usual, to the sf_default_culture settings.yml setting. The search index also distinguishes between cultures. Webmasters who make use of internationalization will want to add a "culture switcher" to their sites so that a user interface is available to make these features visible. Thanks to Quentin Dugauthier for his assistance in debugging these features.
The user interface for editors is not yet internationalized. We plan to do this in the future.
Refreshing Slots
This is not necessary for any of our standard slot types. However, if your custom slot types contain metadata that should be refreshed nightly, you might wish to take advantage of the apostrophe:refresh task, which updates all current slots by calling their refreshSlot() method:
./symfony apostrophe:refresh --env=prod --application=frontend
Again, currently our own media slots do not require the use of this task. Thanks to architectural improvements deleting an item from the media plugin immediately updates the related slots. In future we may implement a handshake with YouTube? via this task to check whether video slots are still pointing to valid videos.
For performance reasons this task only looks at the latest version of each slot. You can use the --allversions option to specify that older versions of slots should be refreshed as well:
./symfony apostrophe:refresh --env=prod --application=frontend --allversions
Extending Search
You can override the a/search action at the application level. Just like any other Symfony application.
You can do that in three ways. The easiest way is to override the template that displays the results (a/searchSuccess), and bring in more results for other types of data via a Symfony component of your own. You can grab the search query string from $sf_data->getRaw('q').
This is Zend Lucene search, so Lucene syntax is allowed:
http://framework.zend.com/manual/en/zend.search.lucene.query-language.html
You can use Lucene to index your own data and take advantage of that fully. Keep in mind that the entire Zend library is available to you already since it's one of Apostrophe's requirements.
Following this approach you don't need to know much about Apostrophe's internals at all.
The second supported approach is to store (or mirror) your additional data using Apostrophe virtual pages, as introduced in ManualDesignersGuide. Virtual pages are included in Apostrophe search results if the virtual page slug appears to be a valid Symfony URL: that is, if it begins with a @ or contains a / internally (not at the beginning).
apostropheBlogPlugin uses this technique to create valid links when a search matches a published blog post. In the blog plugin, all content is stored as Apostrophe virtual pages, and the page slugs look like this:
@a_blog_search_redirect?id=50
This is a powerful and effective approach but may not suit your needs if you don't wish to store or mirror your custom content in Symfony slots.
The third supported way is to interleave your own results with the page search results, based on their search ranking. This makes sense only if the results are somewhat reasonable to compare - for instance, articles and web pages are reasonably similar and Zend will probably produce search rankings that mix reasonably well.
Fortunately, Apostrophe's a/search action is designed to be extended in this way. All you have to do is implement the searchAddResults method in your application-level aActions class, which should extend BaseaActions?.
The code below demonstrates how you might handle search results for a blog that chooses not to use our virtual pages approach:
// This is at the application level,
apps/frontend/modules/a/actions/actions.class.php
class aActions extends BaseaActions
{
protected function searchAddResults(&$values, $q)
{
// $values is the set of results so far, passed by reference so
you can append more.
// $q is the Zend query the user typed.
//
// Override me! Add more items to the $values array here (note
that it was passed by reference).
// Example: $values[] = array('title' => 'Hi there', 'summary' =>
'I like my blog',
// 'link' => 'http://thissite/wherever', 'class' => 'blog_post',
'score' => 0.8)
//
// 'class' is used to set a CSS class (see searchSuccess.php) to
distinguish result types.
//
// Best when used with results from a
aZendSearch::searchLuceneWithValues call.
//
// IF YOU CHANGE THE ARRAY you must return true, otherwise it will
not be sorted by score.
// return true;
}
}
This method's job is to add more results to the results array (note that it is passed by reference), which then get sorted by score with everything else and presented as part of the search results.
If you are wondering how to integrate Zend Search into your own modules, check out our aZendSearch class (apostrophePlugin/lib/toolkit/aZendSearch.class.php), which does it for our own data types. That class provides methods you can call from your model classes to add search indexing to them, and also expects your class to provide some methods of its own that get called back.
For a working example, see the aMediaItem class. The save() method calls:
// Let the culture be the user's culture return aZendSearch::saveInDoctrineAndLucene($this, null, $conn);
That method calls back to your doctrineSave method, which is usually a simple wrapper around parent::save for a Doctrine model class, but you can extend it as needed:
public function doctrineSave($conn)
{
$result = parent::save($conn);
return $result;
}
And it also calls back to updateLuceneIndex, which should invoke aZendSearch::updateLuceneIndex with an associative array of fields to be included in the search index:
public function updateLuceneIndex()
{
aZendSearch::updateLuceneIndex($this, array(
'type' => $this->getType(),
'title' => $this->getTitle(),
'description' => $this->getDescription(),
'credit' => $this->getCredit(),
'tags' => implode(", ", $this->getTags())
));
}
The array we pass as the second argument to updateLuceneIndex contains fields that should be indexed so that we can search on them, but not stored in full for display purposes. That's great here because with media we know we'll want to retrieve those objects from Doctrine later anyway. But it is also possible to ask Lucene to actually store some fields for you. And that is crucial if you want to display complete search results with our unmodified a/search template.
Specifically, you'll need to store:
- The title ('title')
- The summary text ('summary')
- The URL ('url'), which can be a Symfony URL or a regular URL
- 'view_is_secure' (a boolean flag indicating whether logged-out users
and logged-in users without guest permissions are allowed to see this search result)
So the complete call to aZendSearch::updateLuceneIndex might be:
aZendSearch::updateLuceneIndex($this,
array('text' => $this->getFullSearchText()),
null, // Or perhaps $this->getCulture() depending on your needs
array('title' => $this->title,
'summary' => $this->getShortSummary(),
'url' => 'mymodule/show?id=' . $this->id,
'view_is_secure' => false));
Note the second argument, which is the culture for this object. You can pass null to use the current user's culture which often makes sense if they have just edited the object. Apostrophe search returns only results for your current culture.
Your 'getFullSearchText' method would typically just append all of the fields that contain text relevant to searching (title, tags, actual body text) into a single string and return that.
You must also call aZendSearch::deleteFromDoctrineAndLucene from your delete method (I'm leaving out some stuff specific to the media item class here):
public function delete(Doctrine_Connection $conn = null)
{
return aZendSearch::deleteFromDoctrineAndLucene($this, null, $conn);
}
That method will call back to your doctrineDelete method, which is usually just a wrapper around parent::delete:
public function doctrineDelete($conn)
{
return parent::delete($conn);
}
You may wonder why there are so many aZendSearch-related methods. Basically, we would have used multiple inheritance here, but PHP doesn't have it. So instead we provide helper methods in the aZendSearch class which give us a flexible way to "inherit" the searchable behavior without explicit support for multiple inheritance in PHP.
Manipulating Slots Programmatically
Adding and Updating Slots
Usually you'll want to create a new slot type and let BaseaSlotActions and BaseaSlotComponents do the dirty work for you. But sometimes you may want to manipulate slots directly.
You can modify a slot object and save it, but if you do, you're not creating a history that the user can roll back.
To do that, make a *new* slot object and use newAreaVersion to add it to the history:
$page = aPageTable::retrieveBySlugWithSlots('/foo');
$slot = $page->createSlot('aText');
$slot->value = $title;
$slot->save();
$page->newAreaVersion('title', 'update',
array(
'permid' => 1,
'slot' => $slot));
Note that you want to use a new slot object. Often it's easiest to copy the previous version:
$slot = $slot->copy(); // Make your changes to $slot, then call newAreaVersion
If you want to add an entirely new slot, specify 'add' rather than 'update'. You do not have to specify a permid since that is generated for you when adding a new slot:
$page->newAreaVersion('myareaname', 'add', array('slot' => $slot));
You can explicitly specify that it should or should not be at the top of the area:
$page->newAreaVersion('myareaname', 'add', array('slot' => $slot,
'top' => false));
The default is to add the slot at the top.
Looping Over All Slots in a Page
Looping over slots is dangerous, depending on your goals, because there can be slots that are not actually used in the current template if a page has changed templates.
You can do it with a loop like this after you retrieveBySlugWithSlots:
foreach ($this->Areas as $area)
{
$areaVersion = $area->AreaVersions[0];
foreach ($areaVersion->AreaVersionSlots as $areaVersionSlot)
{
$slot = $areaVersionSlot->Slot;
$permid = $areaVersionSlot->permid;
// Now you can do things with $slot
}
}
Recall that areas can contain multiple slots (as a result of the "add slot" button). The permid is the slot's unique identifier within its area. All versions of the same slot will have the same permid. You need the permid to save a new revision of the slot.
Conclusion
Thanks for checking out Apostrophe! We hope you're excited to experiment with our CMS. If you've read this far, there's a good chance you'll build custom slots and engines... and even send us bug reports. Hey, we love bug reports. Visit the Apostrophe Trac to submit your reports, requests and concerns. And also be sure to join the apostrophenow Google group to share your experiences with other Apostrophe developers.
