Tuesday, October 26, 2010

Symfony Tutorials: Using sfValidatorCallback

Many times when you're validating forms in symfony you need to do more than what the standard validators allow, and since you're lazy you don't feel like creating your own custom validator. sfValidatorCallback to the rescue!

sfValidatorCallback allows you to define your own validation function to be used for a given form field. It also allows you to scrub the data in any way you see fit. We'll look at both uses.
A Psudo-example

Since you can use the callback validator for literally anything you want, here's a simple example of how to use a function in your model to validate a field on the form for that model object

In MyObjectForm.class.php:

1.public function configure() {
2. ...
3. $this->widgetSchema['some_text_field'] =
4. new sfWidgetFormTextArea(array('required'=>false));
5.
6. $this->validatorSchema['some_text_field'] = new sfValidatorCallback(
7. array('callback'=>
8. array($this->getObject(), 'someTextFieldValidatorHook')
9. )
10. );
11. ...
12. }


Here we see that we're setting the validator for some_text_field to our call back. The first parameter to the callback takes our options. For these we need to pass a callback, which takes the same parameters as PHP's call_user_func. Since we want to call a method in our base model object, we'll use an array as the first parameter to pass our object and the method we want to call. $this->getObject(); returns the model object of the form, and someTextFieldValidatorHook is what we want to call.

So, in MyObject.php, in your model folder:

1. public function someTextFieldValidatorhook($validator, $value) {
2.
3. //test $value, set $pass if its ok
4.
5. if($pass) {
6. return $value;
7. }
8.
9. else {
10.
11. $message = "I can write whatever I want here based on the results ".
12. "of my test. The point is that this field didn't validate though";
13.
14. throw new sfValidatorError($validator, $message);
15.
16. }
17.
18. }


We can see here that the magic is throwing the exception when your test fails. The exception bubbles up and is caught by the form stuff on a higher level to render the error message to the user as you would expect.

Now, there are are a few things to keep in mind:

* sfValidatorError can take a message code and error messages as its second and third parameters. However, most of the time you use the call back, you'll want to generate the error message internally, since its dependant on the test you just wrote. Still, remember that you can respond with canned error messages if you want.
* Your data hasn't been saved yet, so you really have no notion of what the other fields are unless you grab the request object and look at them.
* You can make your validation method static if you want, but sometimes its helpful to have the old values of your current object, or you may have expanded your model object to do things beyond the standard get/set stuff.

Is This Cool?

Some may argue that this kind of validation doesn't belong in your model object, and a separate validator object should be created. I can see that point, and I think I mostly agree with it, but there are exceptions to every rule. Sometimes this simple approach is the correct one, and it can streamline your development.

Still, you should keep in mind that your validation function is going to throw a validator exception, so indicate that you'll need to catch it in your documentation if the test fails, or name the method something to show that it should only be used as a hook to the validator, as I've done with someTextFieldValidatorHook.
Scrubbing

One last benefit of the validator is that it gives you a good way to scrub your data before you save it. Since your callback function (someTextFieldValidatorHook in our case) takes the value in to verify it and returns it at the end if the test passed, you can technically change it to whatever you want. Some possible ideas are:

1. Removing HTML
2. Converting text links to actual links (bad example as you should probably do this on output)
3. Formatting Phone Numbers
4. Etc, etc

That's It

Hope you found this useful. Feel free to share how you've used the callback validator in your applications.



You can find the original post here : http://www.pbtg.com/blogs/jim/tag/symfony

A new perspective on the future development of Joomla!



There have been endless discussions around how Joomla! is developed and maintained. One thing is clear though: development is quite slow and new significant features for the end user have not yet been introduced since the Mambo days... It's one of the reasons we decided to build K2, so we could introduce more modern and popular features into Joomla! now.

But how could the development of Joomla! not be slow? Given that the core team are human beings and not robots that work 24 hours a day on coding and debugging Joomla!, it's really simple to realize why the development process is slow. Additionally, it is not easy to contribute code to Joomla! because there are certain protocols need to be followed that make the entire process even slower.

Obviously this is not a post to bash the core team but a new thought on how we could possibly see Joomla! being development in the not so distant future.
So how could we speedup Joomla! development?

It's really simple. Joomla! is modular by nature. So why not make development modular as well?

Here's a realistic example: Joomla! is made up of components like com_content (for articles), com_menus (for the menu management system), com_polls (for the polls) and so on. The Joomla! team could easily "contract" some professional developers and have them undertake the development of a specific component or perhaps module or plugin or whatever!

Imagine this: Joomla! 1.6 will be released without comments (despite original claims for the opposite). How difficult would it be for some developers to create a comments component, contribute it to the default Joomla! 1.6 distribution and also be responsible for maintaining the code!

One thing is for sure. A lot of weight would be lifted off the core team and significantly boost Joomla!'s development. If a bug is introduced on the above example of the comments component, the developers of that component could issue an upgrade and deliver the upgrade through joomla.org to all Joomla! users. Why update an entire Joomla! website when there is a specific problem in some component?

It's how things work in bigger software deployments like operating systems (OS). Teams and sub-teams developing and maintaining individual applications that make up the end product. The best example I guess is from the Linux world where there are applications developed for certain Linux distributions - the bond is so strong that you think they are actually part of the OS (see Ubuntu).

To conclude, I don't think it will be hard for the Joomla! core team to find such developers to code and maintain specific elements of Joomla!. If others can do this in projects with a gazillion lines of code, then Joomla! can do it too.

We (as JoomlaWorks) have offered to code (yes, code, not debug) many times and I know a lot more people who have expressed the same interest as well. And I know it's difficult for the core team to just give away SVN access to others. I wouldn't easily trust other devs to code on K2! But I would certainly trust someone to develop and maintain a module of K2 and have it included in the default distribution.

The only hard part is for the Joomla! core team to actually approve that some developer can deliver quality code by some standards, but it's really no biggie. We are a large community and there's a lot of talent to jump in and help things out. Can you imagine what a positive impact this would have to Joomla!'s PR and especially to users of other content management systems?

What do you think?




You can find the original post here : http://blog.joomlaworks.gr/a-new-perspective-on-the-future-development-o

SOAP WebService in Symfony

One interesting topic on web development is webservice development. There are several techniques to implement a webservice out there, and today I’ll talk about one technique that I worked in the recent past that I really like: SOAP. As per wikipedia:

SOAP, originally defined as Simple Object Access Protocol, is a protocol specification for exchanging structured information in the implementation of Web Services in computer networks. It relies on Extensible Markup Language (XML) as its message format and usually relies on other Application Layer protocols, most notably Remote Procedure Call (RPC) and HTTP for message negotiation and transmission. SOAP forms the foundation layer of the web services protocol stack providing a basic messaging framework upon which abstract layers can be built.

The plan for this tutorial is to build a complete set of webservice methods to interact with the citypicker, built in a previous post. For this, I’ll use a great symfony plugin called ckWebService. This plugin enables the developer to expose your actions as a SOAP webservices. Another great functionality is the built-in WSDL generator, that parses module’s doc comment in order to identify which actions should be exposed and it’s input/output parameters.

Let’s start by installing ckWebService plugin in our symfony project. I’ll not install the latest release, instead I’ll checkout from trunk svn, as it contains some nice improvements if compared to latest release:

info@amphee.com [~/symfony/blog]# cd plugins/
barrosws@barros.ws [~/symfony/blog/plugins]# svn co http://svn.symfony-project.com/plugins/ckWebServicePlugin/trunk ckWebServicePlugin


OBSERVATION: Current trunk version has a small bug (actually a wrong variable name) that must be fixed before continuing:

public function getResultProperty()
{
- return $this->resultMember;
+ return $this->resultProperty;
}


Now we need to configure the plugin in order to make it work. The read-me located at plugin page provides a complete guide to configure it. For this project we use a basic configuration:

apps/frontend/config/app.yml:

soap:
enable_soap_parameter: on
ck_web_service_plugin:
wsdl: soap.wsdl
handler: ckSoapHandler
persist: %SOAP_PERSISTENCE_SESSION%
render: off
result_callback: getSoapResult
soap_options:
encoding: utf-8
soap_version: %SOAP_1_2%


apps/frontend/config/filters.yml:

soap_parameter:
class: ckSoapParameterFilter
param:
condition: %APP_ENABLE_SOAP_PARAMETER%


apps/frondend/config/factories.yml:

soap:
controller:
class: ckWebServiceController


Done! That’s all we need to start exposing actions as SOAP webservices. For now on we can expose any of our previously created action by adding a special tag to the doc comment, like this:

/**
* Action description
* @ws-enable
*
* @param string $name
* @return boolean
*/
public function executeSomeAction($request)
{
/* action here */
}


This doc comment will expose the action and instruct the WSDL generator that this action expects a string input parameter, called $name and that it will return a boolean value. An interesting thing about this plugin is that it will place all input parameters in the $request object, so the action can access it as if it was called from a browser, passing name as a query string or a post value:

...
$name = $request->getParameter('name');
...


Also, notice that $request parameter was removed from the doc comment. This is necessary because if we keep it, the WSDL generator will add $request as a parameter to the webservice, what is not the case here.

Let’s start the implementation for this project. We have three actions that will be exposed:

* executeIndex: to list users;
* executeEdit: to insert/edit users;
* executeDel: to delete users.


One might think that we will need to add @ws-enable to these actions doc comment… well, yes, that’s the original idea, but I prefer using a different approach. My approach is to create a new module, called soap (or whatever you want) and create wrappers to actual actions. This will reduce the number changes needed to be done in the actual actions (sometimes it won’t require any change at all) and will make it possible for the developer to code the entire system without even caring about webservice, all adjustments can be easily made only when actually implementing the webservice. This is not the best way to achieve this result. The correct way to do this is to create a custom SoapHandler, but this will kill WSDL generator, so I’ll stick to my way by now (trunk version has all the necessary changes to make this possible – it’s not the case with latest release).

So, let’s create our new module:

info@amphee.com [~/symfony/blog]# symfony generate:module frontend soap
>> dir+ /home/amphee/symfony/blog/apps/frontend/modules/soap/templates
>> file+ /home/amphee/symfony/blog/app...soap/templates/indexSuccess.php
>> dir+ /home/amphee/symfony/blog/apps/frontend/modules/soap/actions
>> file+ /home/amphee/symfony/blog/app.../soap/actions/actions.class.php
>> file+ /home/amphee/symfony/blog/tes...al/frontend/soapActionsTest.php
>> tokens /home/amphee/symfony/blog/tes...al/frontend/soapActionsTest.php
>> tokens /home/amphee/symfony/blog/app...soap/templates/indexSuccess.php
>> tokens /home/amphee/symfony/blog/app.../soap/actions/actions.class.php


The first action will expose is executeIndex, that will return a list of all users registered in the system. This is the simplest one and I’ll use to explain some important points:

apps/frontend/modules/soap/actions/actions.class.php:

/**
* Get users
*
* @ws-enable
*
* @return SoapUser[]
*/
public function executeGetUsers($request)
{
// call actual action
$this->getController()->forward('citypicker','index');

// set result
$actionInstance = $this->getLastActionInstance();
$actionInstance->result = $actionInstance->users;
}


As I said before, we will create wrappers to actual actions. For this action, we don’t have any input parameter, so we don’t need any extra processing. First thing the action does is a forward to actual action. Note that I use the forward method from the controller instead of forward method from sfAction. This is necessary because we need continue our execution flow AFTER actual action returns (sfAction’s forward won’t return control to us). Return value is expected to be located in the deepest action instance, in our case, citypicker/index action, in a property called result (in our case, we store the result of a UserPeer::doSelect() call – made in citypicker/index action and stored in users property). In order to do this we need to get this action’s instance and that’s what getLastActionInstance method do:

apps/frontend/modules/soap/actions/actions.class.php:

/**
* Get last action instance
*
* @return sfActionInstance
*/
private function getLastActionInstance()
{
return $this->getController()->getActionStack()->getLastEntry()->getActionInstance();
}


This method will simply return last actions instance from the action stack, and we will use it in all of our wrappers. If you look at doc comments, you will notice return value is declared as an array of SoapUser objects. SoapUser class is defined as follows:

lib/soap/SoapUser.class.php



Doc comments are REQUIRED here too, because WSDL generator will use it to build the object definition. When sending result back, our result (array of User objects) will be converted into SoapUser objects, making these properties available.

Our first method is complete. In order to start using it, we need to generate the WSDL definition, using the built-in WSDL generator. The generator will also create the frontend dispatcher, in web/ directory:

info@amphee.com [~/symfony/blog]# symfony webservice:generate-wsdl frontend soap http://blog.barros.ws/symfony
>> file- /home/amphee/symfony/blog/web/soap.php
>> file+ /home/amphee/symfony/blog/web/soap.php
>> tokens /home/amphee/symfony/blog/web/soap.php
>> file+ /home/amphee/symfony/blog/web/soap.wsdl


In order to test it we can use a nice piece of software called SoapUI. This software will read soap.wsdl and build the request, all using a nice GUI. I recommend downloading the trial of PRO version, as it is capable of generating forms (web like) where you can input parameters:

executeDel actions is similar to executeIndex:

apps/frontend/modules/soap/actions/actions.class.php:

/**
* Deletes an user
*
* @ws-enable
* @param integer $id
*
* @return boolean
*/
public function executeDelUser($request)
{
// call actual action
$this->getController()->forward('citypicker','del');

// set result
$actionInstance = $this->getLastActionInstance();
$actionInstance->result = true;
}


Now, executeEdit (executeNewUser in our wrapper) is a bit trickier:

apps/frontend/modules/soap/actions/actions.class.php:

/**
* Creates a new user in the system
*
* @ws-enable
* @param SoapUser $user
*
* @return boolean
*/
public function executeNewUser($request)
{
// convert input param from OBJECT to ARRAY
$request->setParameter('user',get_object_vars($request->getParameter('user')));

// call actual action
$this->getController()->forward('citypicker','edit');

// check errors
$actionInstance = $this->getLastActionInstance();
if(!$actionInstance->form->isValid()) $this->throwSoapFormException($actionInstance->form);

$actionInstance->result = true;
}


First difference we can note is the fact this action requires one input parameters. In doc comment we declare that this action expects an SoapUser object as input, but the actual action expects an simple array. The first step then is to convert received object into an array. For this we use get_object_vars and after conversion, we set it back to the $request object. Finally we call actual action, that will act as if the user had submitted the form. Next difference is that we need to check if there was any error processing input data. We do this by checking if form, in actual action instance, is valid, and if not return an error message. In order to throw an exception with detailed errors, I created an small method called throwSoapFormException, that will iterate through all errors in the form and build single string, with one error per line:

apps/frontend/modules/soap/actions/actions.class.php:

/**
* Throw a SoapFault error based on form errors
*
* @param sfForm $form
*/
public function throwSoapFormException($form)
{
foreach($form->getFormFieldSchema()->getError() as $e)
$errors[] = $e;

throw new SoapFault('ERROR',implode("n",$errors));
}

And that’s it, we can now create new users using the new SOAP interface:



Well, actually one small thing is missing to make it really work… Did u notice that I didn’t touch actual actions yet? Sometimes we don’t need to touch it, but that’s not our case. If you look at citypicker post you will notice that both “del” and “edit” actions redirect the user back to index page on success. We can’t do this when running on soap mode, or we will lose control and we won’t be able to send correct result back to the client. To fix this, we just need to make an small change:

if(!$this->isSoapRequest()) return $this->redirect('citypicker/index');

isSoapRequest is a new method added by ckWebservicePlugin and it will return true when executing the actions via SOAP. Adding this check we just perform the redirect when NOT in SOAP mode.

That’s all we need to talk about how to expose your actions via SOAP, but in order to complete our example, we need to create some methods to fetch countries/states/cities informations. For this we create 6 new actions:

/**
* Get countries list
* @ws-enable
*
* @return SoapGeo[]
*/
public function executeGetCountries($request)
{
$this->result = CountryPeer::doSelect(new Criteria());
}

/**
* Get a country
*
* @ws-enable
* @param integer $id
*
* @return SoapGeo
*/
public function executeGetCountry($request)
{
$this->result = CountryPeer::retrieveByPK($request->getParameter('id'));
}

/**
* Get states list
*
* @ws-enable
* @param integer $country_id
*
* @return SoapGeo[]
*/
public function executeGetStates($request)
{
$country = CountryPeer::retrieveByPK($request->getParameter('country_id'));
if(!$country) throw new SoapFault('ERROR','Invalid country');

$this->result = $country->getStates();
}

/**
* Get a state
*
* @ws-enable
* @param integer $id
*
* @return SoapGeo
*/
public function executeGetState($request)
{
$this->result = StatePeer::retrieveByPK($request->getParameter('id'));
}

/**
* Get cities list
*
* @ws-enable
* @param integer $state_id
*
* @return SoapGeo[]
*/
public function executeGetCities($request)
{
$state = StatePeer::retrieveByPK($request->getParameter('state_id'));
if(!$state) throw new SoapFault('ERROR','Invalid state');

$this->result = $state->getCitys();
}

/**
* Get a city
*
* @ws-enable
* @param integer $id
*
* @return SoapGeo
*/
public function executeGetCity($request)
{
$this->result = CityPeer::retrieveByPK($request->getParameter('id'));
}


And to finish, we need to create the SoapGeo class, that will store country name and id:

lib/soap/SoapGeo.class.php:



And we’re done. With this we can now build an external app to create/edit/delete users in the database. I spent several days working with the plugin before coming up with this solution and I hope this will save other developers some time dealing with SOAP implementations.



You can find original post here : http://blog.barros.ws/2008/11/16/soap-webservice-in-symfony/

Tuesday, August 17, 2010

Symfony multiple versions on the same environment

Follow under given steps for best way of running multiple versions of Symfony apps together on the one environment...

Step 1 - Un-install Symfony via PEAR
If you have Symfony installed via PEAR, get rid of it, it will only confuse you with what we are about to do.

express@express-dev:~$ sudo pear uninstall symfony/symfony
uninstall ok: channel://pear.symfony-project.com/symfony-1.1.0

Step 2 - Setup a structure for Symfony
In our case, I still want to install symfony in /usr/share/php/symfony, so lets set that up:

express@express-dev:~$ cd /usr/share/php
express@express-dev:/usr/share/php$ sudo mkdir symfony

Step 3 - Checkout each Symfony version you need
Now lets use SVN checkout to grab each Symfony version we are after, lets put these in a different folder under the base Symfony directory. Note: If you are behind a proxy, change your SVN settings first to go through your proxy. To change your proxy settings:

express@express-dev:/usr/share/php/symfony$ sudo nano /etc/subversion/servers

Now lets checkout each symfony version:
express@express-dev:/usr/share/php$ cd symfony/
express@express-dev:/usr/share/php/symfony$ sudo svn co http://svn.symfony-project.com/branches/1.0 symfony10
...
express@express-dev:/usr/share/php/symfony$ sudo svn co http://svn.symfony-project.com/branches/1.1 symfony11
...
express@express-dev:/usr/share/php/symfony$ sudo svn co http://svn.symfony-project.com/branches/1.2 symfony12
...

We now have created three installations of Symfony.

Step 4 - Create symbolic links for each version
The next step is for us to create symlinks for each version of Symfony. Lets place these in the standard bin directory:

sudo ln -s /usr/share/php/symfony/symfony10/data/bin/symfony /usr/bin/symfony10
sudo ln -s /usr/share/php/symfony/symfony11/data/bin/symfony /usr/bin/symfony11
sudo ln -s /usr/share/php/symfony/symfony12/data/bin/symfony /usr/bin/symfony12

Now lets test the sym links:

express@express-dev:~$ symfony10 -V
symfony version 1.0.19-PRE
express@express-dev:~$ symfony11 -V
symfony version 1.1.5-DEV (/usr/share/php/symfony/symfony11/lib)
express@express-dev:~$ symfony12 -V
symfony version 1.2.0-DEV (/usr/share/php/symfony/symfony12/lib)

What next? - Creating a new project
So to create a new project, you will need to use the relevant Symfony command. For example, to create a Symfony 1.0 project:

sudo symfony10 init-project test1

or to create a Symfony 1.1 or Symfony 1.2 Project:

sudo symfony11 generate:project test11
sudo symfony12 generate:project test12

Once you create a new project, check in the project Config to ensure its picked up the right version. For Symfony 1.0:

express@express-dev:/usr/local/express/projects/$ sudo symfony10 init-project test10
express@express-dev:/usr/local/express/projects/$ cat config/config.php

// symfony directories
$sf_symfony_lib_dir = '/usr/share/php/symfony/symfony10/lib';
$sf_symfony_data_dir = '/usr/share/php/symfony/symfony10/data';

for Symfony 1.1:
express@express-dev:/usr/local/express/projects/test$ cat config/ProjectConfiguration.class.php

require_once '/usr/share/php/symfony/symfony11/lib/autoload/sfCoreAutoload.class.php';
sfCoreAutoload::register();
class ProjectConfiguration extends sfProjectConfiguration
{
public function setup()
{
}
}

and for Symfony 1.2, its the same, just make sure its including the right 1.2 files. Thats it! Hope this helps!

Friday, July 16, 2010

HTML5 Samples




Step one


You can use standard object testing to determine if the browser supports GeoLocation.
<script>
/**
* This function is the callback which is passed the result from the .getCurrentPosition()
* function. The pos argument can contain more information than just the latitude/longitude,
* such as altitude, accuracy and speed information.
*
* @param object pos The result from the getCurrentPosition() call
*/
function myCallback(pos)
{
var myLatitude = pos.latitude;
var myLongitude = pos.longitude;
}

/**
* Test for GeoLocation support and make the call
*/
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(myCallback);
} else {
alert("Sorry, your browser doesn't appear to support GeoLocation");
}

</script>

Step two


Once you have tested for it, we can then retrieve the position using the getCurrentPosition() method. You pass this method a callback function which you define. This callback function is given an object (if successful), with various properties:

  • latitude

  • longitude

  • altitude (optional)

  • accuracy

  • altitudeAccuracy (optional)

  • heading (optional)

  • speed (optional)

  • timestamp




more  detail go to


Monday, June 21, 2010

Interesting symfony plugins: sfSyncContentPlugin

With the amount of plugins published in the symfony site, many great plugins get lost in the maze. With this series of posts, we would like to bring some attention to plugins we use every day or that we think are essential for any symfony developer.

sfSyncContentPlugin


Deploying symfony applications is always a key part of developing and maintaining websites that run on symfony. It is always a recommended practice to do development on a local environment or dedicated development server. It is also recommended to have a QA/staging server that is as close as possible to your production server. Using this well proven method you can spot problems and bugs before everybody else sees or experiences them, you know, those bugs that “only” happen in production, don’t tell me that it never happened to you, I won’t believe you.

Anyway, making changes in a live site is not only not recommended, it should never be done!

When developing and testing symfony applications, a lot of times you need to have a copy of the live data. Or you may have a staging server where you make changes before pushing them to a live site in a production server. symfony already provides a way to deploy code changes to a remote server, but what about uploaded and data files? And database content?

Since we discovered and started using it, we can’t live without thesfSyncContentPlugin plugin by Tom Boutell and Alex Gilbert, also developers ofApostrophe CMS. This plugin helps with all the tasks and needs described above. Using it is quite simple. All you need to do is define your servers in config/properties.ini like this:
[qa]
host=qa.example.com
port=22
user=user
dir=/var/www/mysite

[prod]
host=www.example.com
port=22
user=user
dir=/var/www/mysite

[staging]
host=staging.example.com
port=22
user=user
dir=/var/www/mysite

Make sure to use SSH keys to authenticate to your remote servers, so you don’t get asked again and again for passwords. Then just run the following symfony tasks:

# Migrate files and DB from development to qa
./symfony project:sync-content frontend dev to qa@qa

# Migrate files and DB to production (always make a backup of production before doing this!)
./symfony project:sync-content frontend dev to prod@prod

# Migrate files and DB from QA into development
./symfony project:sync-content frontend dev from prod@prod


Files and DB content are copied accordingly, almost magically. It saves so much time, but please make sure you understand and check the order that you apply in the symfony task. With the power this plugin provides, is very easy, by mistake, to overwrite production data, so again, always make a backup!

Thursday, December 31, 2009

CMS With XML Based Data Storage

OPEN SOURCE :)
GetSimple is The Simplest Content Management System EVER.
GetSimple has everything your client needs, and nothing a CMS doesn't

GetSimple is an open-source project licensed under the GNU GENERAL PUBLIC LICENSE.

GetSimple don't use mySQL to store information, but instead depend the simplicity of XML. By utilizing XML, able stay away from introducing an extra layer of slowness and complexity associated with connecting to a mySQL database. Because GetSimple was built specifically for the small-site market, feel this is the absolutely best option for data storage.

GetSimple CMS Home Page
About GetSimple CMS
Demo
Documentation

REQUIREMENTS

  • UNIX/Linux host

  • PHP 5.1.3+

  • Apache

  • No Database

  • 6 minutes to spare


FEATURE LIST

  • XML based data storage

  • Best-in-Class User Interface

  • 'Undo' protection & backups

  • Easy to theme

  • Great documentation

  • Growing community