Categorisation: programmatically add select attribute values derived from page names

Permalink
Hello all

I'm hoping to create a kind of automated categorisation system using page attributes (specifically the select attribute). Because the site's structure will have multiple categories and subcategories, with child pages that need to reference those (sub)categories, I need a way of referencing them from other pages (for example, a news article might have the attribute set on it, so in the sidebar on the category page I can have 'Latest news' and it'll show news article related only to that category).

My proposed way of doing this is to programmatically add a value to a pre-created select page attribute when a category/subcategory page is created. One problem with this approach is that if a category is updated, the relevant option in the select attribute would need to be updated too, which might cause untold problems. Perhaps tagging would be more appropriate?

Does this make sense? Or am I re-inventing the wheel (I have a feeling that might be the case...)?

melat0nin
 
jordanlev replied on at Permalink Best Answer Reply
jordanlev
I would just create an attribute type that reads the pages -- save the cID of the selected page as the attribute value, and don't worry about maintaining the list to keep it in sync with the sitemap.

Try this:
1) Create the following directory(ies) on your server:
SITEROOT/models/attribute/types/page_name/


2) Create a new file in that new folder called "controller.php", and paste this code into there:
<?php defined('C5_EXECUTE') or die(_('Access Denied.'));
Loader::model('attribute/types/default/controller');
class PageNameAttributeTypeController extends DefaultAttributeTypeController {
   public function form() {
      $this->set('fieldPostName', $this->field('value'));
      $availablePages = $this->getAvailablePages();
      $this->set('availablePages', $availablePages);
      $val = is_object($this->attributeValue) ? $this->getAttributeValue()->getValue() : 0;
      $this->set('fieldValue', $val);
   }
   private function getAvailablePages() {
      Loader::model('page_list');
      $pl = new PageList;
      $pl->filterByPath('/path/to/parent/page', false); //<--change to TRUE to include all children pages
      $pages = $pl->get();

NOTE: You *will* need to tweak the code in the getAvailablePages() function to retrieve the pages you want from the sitemap.

3) Create another new file in that new folder, called "form.php", and paste this code into there:
<?php defined('C5_EXECUTE') or die("Access Denied.");
$form = Loader::helper('form');
echo $form->label($fieldPostName, 'Category:');
echo $form->select($fieldPostName, $availablePages, $fieldValue);
?>


4) Install the attribute type via Dashboard -> System & Settings -> Attributes -> Types (install it and then associate it with "Collections").
melat0nin replied on at Permalink Reply
melat0nin
@jordan

That is an awesome way of doing it!

I've decided however to make use of the site's hierarchy instead, to categorise things. I'll be using the Composer to create the pages, so the user will have to decide which parent page to put them under, which gives them a de facto category (I can use $c->getCollectionParentID() etc to pull in information from child and parent pages).

But you've opened my eyes to a new way of dealing with attributes.. I'll definitely be using that technique at some point in the future!

Thanks!
melat0nin replied on at Permalink Reply
melat0nin
Hi Jordan

I'm thinking I'd like to use this method in conjunction with the page-based content types we discussed in the other thread (http://www.concrete5.org/community/forums/customizing_c5/displaying-only-certain-blocks-from-a-pasted-stack/#330573)

Instead of displaying a standard select list, I'd like to display checkboxes (or a multi-select list) so the client can choose more than one option, much like the standard select attributes where these options are present.

I've played around with the $form->select helper but I'm not getting very far and the docs are rather sparse. Do you have any thoughts?

EDIT: I've managed to display the select list as a multiselect by adding

array('multiple'=>'multiple')


as the last argument in $form->select(), but all that does is display it as a multiselect -- it doesn't (unsurprisingly) handle the saving of the data.
jordanlev replied on at Permalink Reply
jordanlev
Funny you should ask... this is actually what my original code did (I had to modify it for the example above to use a normal select list instead of multiple checkboxes). So here is the original code for a checkbox list. You need to create the same files in the same places and go through the same installation procedure (well, you probably *don't* need to re-create/re-install the files since you've already done so -- could just replace the contents of the two files you already have).

1) controller.php:
<?php defined('C5_EXECUTE') or die(_('Access Denied.'));
Loader::model('attribute/types/default/controller');
class PageNameAttributeTypeController extends DefaultAttributeTypeController {
   public function form() {
      $cIDString = is_object($this->attributeValue) ? $this->getAttributeValue()->getValue() : '';
      $selectedPages = explode("\n", trim($cIDString));
      $this->set('selectedPages', $selectedPages);
      $availablePages = $this->getAvailablePages();
      $this->set('availablePages', $availablePages);
      $this->set('fieldPostName', $this->field('value'));
   }
      private function getAvailablePages() {
      Loader::model('page_list');
      $pl = new PageList;
      $pl->filterByPath('/path/to/parent/page', false); //<--change to TRUE to include all children pages


2) form.php:
<?php defined('C5_EXECUTE') or die(_('Access Denied.'));
$form = Loader::helper('form');
$text = Loader::helper('text');
?>
<div style="overflow-x: hidden; overflow-y: scroll; height: 250px;">
<?php foreach ($availablePages as $cID => $cName): ?>
   <label>
   <?php
   echo $form->checkbox("{$fieldPostName}[]", $cID, in_array($cID, $selectedPages));
   echo $text->entities($cName);
   ?>
   </label>
   <br />
<?php endforeach; ?>
</div>


Cheers,
Jordan
melat0nin replied on at Permalink Reply
melat0nin
I'm pleased to say I had managed to put this together myself, although you've used a bit more API magic (checkbox and text helper) which I'll add to my own code.

I also serialised the data from the checkboxes as opposed to using linebreaks and a string -- it's probably a bit overkill, but it works and I can't think of anything particularly bad about doing it that way.

Thanks for all your help!
jordanlev replied on at Permalink Reply
jordanlev
Cool -- that's great that you were able to figure it out yourself (will be a much better learning experience for you than if you had just copy/pasted my code).

Note that using linebreaks and a string *is* serializing, just maybe in a different format than what you're doing (I presume you're using the php "serialize" function? I didn't think to use that, but in hindsight I should have because it would have saved me time and extraneous code).
melat0nin replied on at Permalink Reply
melat0nin
Yeah I used the serialize() PHP function - I find it incredibly useful. Although converting to a string manually is technically serialisation as you say, I think I prefer to hand off the work to the function to remove the chance of misprocessing the string when processing data into or out of the serialised variable.

Thanks again Jordan!
jordanlev replied on at Permalink Reply
jordanlev
Yeah, I totally agree with you (reading back my response, it comes off as rather argumentative -- sorry about that, wasn't my intention).
melat0nin replied on at Permalink Reply
melat0nin
Hi Jordan

Not at all! Talking on line is always a nightmare because it's so easy to be misinterpreted, but I didn't think you sounded argumentative :)

If I can rely on your knowledge once again... I've got a couple of further issues which you might be able to shed some light on.

(1) I wonder if you've got any ideas for integrating the custom attribute into c5's search? I've managed to list the attribute's values in the search form by creating search.php in the attribute's directory in models, like this:

<?php 
   defined('C5_EXECUTE') or die("Access Denied.");
   foreach ($options as $key=> $option) {
   ?>
   <label class="checkbox inline">
      <input type="checkbox" name="<?=$fieldPostName?>[]" value="<?=$key?>"/> <?=$option?>
   </label>
   <?php
   }


I've also added a search() function to the attribute's controller.php to pass the data through, much like form() does:

public function search() {
         $this->set('fieldPostName', $this->field('value'));
         $options = $this->getAvailablePages();
         $this->set('options', $options);
      }


which renders the checkboxes, names and values correctly, but no results are returned -- probably because I'm not telling the search backend that it's page IDs that I'm providing it with.

(2) If I choose 'customise results' and select the attribute, in its column in the results table I see the serialized data and not the page names (see attached screenshot).

(3) A related problem comes with Advanced Search, where the fields listed are the attribute *type* names and not the individual attribute names. This seems a little problematic aesthetically, because the client might have to guess which attribute type corresponds to the correct attribute name. Not an earth-shattering problem, mind you.

Any thoughts much appreciated :)
jordanlev replied on at Permalink Reply
jordanlev
Hi Laurence,
I unfortunately have never delved into the search functionality with regards to attribute types. I took a quick glance at the built-in "select" attribute code, and I see that in the controller there is a "searchForm()" function, which appears to do some sort of de-serializing as well as interacting with the search index database table. Perhaps that is the functionality you need to make your implementation complete?

-Jordan
melat0nin replied on at Permalink Reply
melat0nin
Thanks Jordan, I looked at the same part. I'm a bit in the dark as to the filter() function and the slightly bizarre-looking query strings being used in searchForm(), but I'll start another forum post about that.
melat0nin replied on at Permalink Reply
melat0nin
Jordan

As it happens I think the manual method of serialization is probably better in this instance, because it allows for page list filtering like this (from another of your posts!):

$el->filter(false, "(ak_page_selector LIKE '%\n{$c_id}\n%')");


I don't know how I would unserialize() the available attribute options before filtering, whereas doing it with the linebreak method seems to work no problem.
mkantor replied on at Permalink Reply
mkantor
I recently had similar requirements and this is what I did:

/**
 * How the value is stored for search indexing. Like the (multiple) select 
 * attribute type, this is indexed as a delimited list (with extra 
 * delimiters at the beginning and end). Note that when filtering database 
 * item lists by this attribute, this format is what should be expected 
 * (database item lists use the search index for attribute filters).
 * 
 * @return string
 */
public function getSearchIndexValue() {
   return "\n".implode("\n", $this->getValue())."\n";
}
/**
 * Save the selected values. Should be passed an array.
 */


If you name your inputs such that $_POST will contain an array (using square brackets) then this should magically work. If you want to use different input names you can always build an array yourself in saveForm before calling saveValue.

It's really not all that different from using newline delimiters in the values table as well as the search index, but I sleep better having more structured data.
melat0nin replied on at Permalink Reply
melat0nin
Hi Jordan

Sorry for resurrecting this thread, but it's directly relevant to the page selector attribute, which I've had live on a site now for about 6 months and which has been working great, except for one thing -- if the page an instance of the attribute refers to is removed, the attribute remains set on the other page.

Is there any way to sort it so that the attribute 'checks' to ensure any selected pages are in fact real, and if not they become deselected? A bit like selecting a file in the File attribute then deleting the file -- the attribute them becomes unspecified.

I suspect this might be quite complicated but I'm not sure!
jordanlev replied on at Permalink Reply
jordanlev
Hi, sorry for the delay -- busy time of year.

I will need a little help re-wrapping my brain around what the exact situation is here. Perhaps you can post (or ZIP and attach) all the relevant code -- hopefully something I could install on a test site and work through to see what you mean?

And are you wanting this to check for the existing page at the time that someone is editing the attribute, or at the time something is displayed on a page in the front-end? (Or both?)

In general, I'm sure it's possible... just do Page::getByID() on the attribute value's cID and see if the returned object's ->getCollectionID() gives you a number higher than 0. The tricky part is knowing where exactly to do that check. And also if you're talking about doing the check on the front-end of the site, then you'll want to be careful about doing something inefficient that might slow page loads down (unlikely for something this small, but you never know...).
mkantor replied on at Permalink Reply
mkantor
You could add a check in getValue to make sure the page exists. This way any time the attribute value is used/displayed it should return the right thing (if the page is nonexistent just return null to act the same as an unset attribute).

You'll still have invalid data in the database though. This probably doesn't matter if you only access attribute values in "normal" ways, but if it bugs you there are a few ways you could fix it:

You *could* make getValue clear out bad values as it finds them, but that smells to me.

A better solution would probably be to hook into the on_page_delete event and remove any invalid attribute values then. Depending on your setup you could probably handle this in a static method on the attribute type controller. This would avoid coupling too many things together (your package controller or site_events.php will need to know about the attribute type, but your page controllers can remain blissfully ignorant). Performance-wise this is probably a better bet too, since it's likely more acceptable to take a performance hit when deleting a page rather than when the attribute value is used/displayed.
roketto replied on at Permalink Reply
roketto
Was this issue ever resolved?
melat0nin replied on at Permalink Reply
melat0nin
It was indeed!

I wrote a how to about it, based on this thread, which you can see here: http://www.concrete5.org/documentation/how-tos/developers/create-a-...
jordanlev replied on at Permalink Reply
jordanlev
I was just needing this on a site I'm building, and didn't remember this conversation at all. But I knew it was vaguely familiar so I did some searching and found this. @melat0nin it was extremely awesome of you to figure out the search issue AND to write it up in the how-to. Thanks!
clocktower replied on at Permalink Reply
clocktower
Is there a way to save the selected pages as an array so that they can easily be used within a forearch loop on the frontend?
melat0nin replied on at Permalink Reply
melat0nin
The attribute just stores an array of page IDs, so it's easy enough to loop through them to get the page objects.

Pseudocode:

foreach ($attribute_page_ids as $page_id) {
   $page_object = Page::getByID($page_id);
   echo $page_object->getCollectionName();
}