Drupal

Getting the total number of results found from Apache Solr searches

On a recent Drupal project, the design for the search results page called for displaying the total number of results found in the page title: 120 results found for the term "school" I was using Apache Solr for the search solution, and to override the page title, I implement THEMENAME_preprocess_page() in template.php:


<?php
function MYTHEME_preprocess_page(&$vars) {
  if(
arg(1) == 'apachesolr_search') {
    if (
apachesolr_has_searched() && ($response apachesolr_static_response_cache())) {
      
$query apachesolr_current_query();
      
$keywords $query->get_query_basic(); 
      
$num_found $response->response->numFound
      
$vars['title'] = $num_found.' results for the term "'.check_plain($keywords).'"';
    } 
  } 

?>

If you use the Context module, set up a context called "search" and use that condition instead:


<?php
function MYTHEME_preprocess_page(&$vars) {
  
$contexts context_active_contexts();
  if(
array_key_exists('search'$contexts)) {
    if (
apachesolr_has_searched() && ($response apachesolr_static_response_cache())) {
      
$query apachesolr_current_query();
      
$keywords $query->get_query_basic();
      
$num_found $response->response->numFound
      
$vars['title'] = $num_found.' results for the term "'.check_plain($keywords).'"'
    } 
  } 

?>

The [Great] Drupal App Store Debate

Drupal legend Robert Douglass has started a conversation on Twitter under the hash tag #drupalappstore, which presents the idea of a "Drupal App Store" up for discussion, arguments, and flame wars.  Robert has challenged people in the Drupal community to blog about this topic, and when Robert says something about Drupal, people listen.  Here's my contribution to the conversation.

As a person who makes his living by providing Drupal services, I have mixed feelings about the concept of an app store.

Ideology

In the open source world, there are many people who believe that it's immoral to charge for open source software.  Free is free, and if you desire to profit from using your talents to code, you're an evil capitalist who threatens the entire culture of open source.

There are many people who subscribe to the idealogy of open source because they simply wish to contribute something useful to the world.  We are free to speak, free to contribute, free to share and this is a very powerful concept.  There's no barrier to entry, no license fees, and no one to prevent your contribution from being shared.

Like many others, I write open source software because it feels good, I get to share the fruits of my labor, and I find it very rewarding to solve a problem with code that others can use.

Culture

The Drupal community embraces these beliefs fully.  Anyone with an idea can get a CVS account on drupal.org and contribute a module or a theme.  These modules range from the essential-can't-build-without-them to modules that provide specific functionality for specific use-cases, to colorful and mostly pointless modules.  The point is, no one stops these modules from being contributed.

Some people are now sharing their code in places outside of Drupal, either on their own servers or on Github or other sites.

Drupal is a highly collaborative culture.  Many Drupal modules are designed to work in conjunction with other modules, and many modules have multiple maintainers.

Feasibility

The very notion of an App store evokes thoughts of how Apple and Google have implemented the concept.  Recently, Wordpress has launched an app store of its own.  In these instances, what people pay for is a single, stand-alone block of code that provides discrete and atomic functionality.  Wordpress plugins rarely require other plugins in order to work.  iPhone apps don't depend on other iPhone apps, and neither to Android apps.

This concept doesn't translate to the Drupal community, as many modules are dependent upon other modules.  The idea that a person pays $9.99 for one module, and then has to pay varying amounts for two or three other modules in order to use that module is really not feasible, in my opinion.  Given that most Drupal sites have a minimum of 20 modules in use, it could get fairly expensive to build a moderately complex site in Drupal if you had to pay for all of the modules you needed.

How about the collaboration argument?  If developer A has a module and developer B writes a similar module, there's no incentive to work together to merge those two modules, as they will both potentially lose money.  Having several modules that perform largely similar functionality only pollutes the ecosystem and makes it difficult to select modules when building projects.  What if a developer writes a module that is so absolutely useful that it ought to be merged into Drupal core?  That developer will lose revenue.

And how about vetting?  Apple has strict policies about what apps can be sold in their store.  Would a Drupal app store do the same thing?  I wouldn't want someone to tell me that I couldn't sell my app because someone else didn't find my code useful or of sufficient quality.

Options

The only way I see a Drupal app store working is if it is implemented in a way that follows the concept of the iPhone app store; that is, fully standalone applications with no dependencies.  What that means to me is that Drupal distributions would fit this model, such as OpenAtrium, or some e-commerce application.  I could easily see a Magento-like application built with Drupal for sale as an app, with additional plugins for sale.  Or perhaps a multi-user blogging distribution for sale.

I think that if this idea was implemented, it would go a long way toward forwarding the concept of Drupal distributions, and companies can be successful with this business model.

Practicality

Why would I pay for any of these solutions when there are so many free alternatives?  Superiority, support, functionality.  If the Drupal community was going to go in the direction of an App store, I would expect that these three things would need to be provided by developers in order to make the case that paying for the application has more value than getting a similar application for free.

 

The Drupal Community and Inclusiveness

Before I begin this post, this is my opinion, and my opinion alone.  I am posting this because as a contributing member of the Drupal community, I am not afraid to express my opinions.   I welcome and encourage counter-opinions, as long as the tone remains respectful.

I will use words such as vagina, uterus, and penis.  If these words offend you, please do not read this post.

There has been a lot of talk lately within the Drupal community about the need for more women to be included in the community, and about reaching out to women specifically as part of a gender-based recruitment strategy.

I find this offensive.

It's wrong for any organization to actively recruit people of a specific gender or race or sexual orientation; gender and race are irrelevant and have absolutely zero to do with improving Drupal.  The implication is that because a person has a uterus, she must be valuable to the community. This is patently false; a person's value and usefulness doesn't stem from the genitalia.  There are just as many women as men who contribute absolutely nothing to the community.  Simply using Drupal doesn't make you a member of the community; to be a member, you must participate.

This may come as a surprise to some women, but your vagina doesn't make you special, any more than Dries' penis makes him special.

Webchick talks about making gender a non-issue, and not falling into the trap of "othering", and I agree 100% with her.  There's nothing to be gained in the community by singling people out because of their gender, and that applies to proposed efforts to recruit women, simply because they are women.  I believe that the existence of the Drupalchix group on groups.drupal.org is just another example of  "othering", despite the claim that the group is for anyone.  The name alone singles out anyone but females, and its purpose is to serve female interests within the Drupal community.  Why is this needed?  Why should "a woman who needs some help/guidance in order to get (more) involved with Drupal and the Drupal community" have to go to the Drupalchix group to get that help, when there are so many all-inclusive resources that will give you the same amount of help and don't care that you have a vagina?

Asians are underrepresented in the Drupal community - should there be an outreach effort to recruit more of them?  How about Indians?  Mexican paraplegics?  One-eyed Russian Jews who enjoy traditional Celtic music?  You might laugh at these examples, but they are no different in concept than the idea of recruiting women for the sake of recruiting women.

The Drupal community needs people with ideas, people who aren't afraid to share and take action.  What I love best about Drupal is that anyone who has an idea can contribute something to the community - modules, themes, snippets, documentation, patches, blog posts - it doesn't matter what your gender is or what nationality you are.  All that matters is that you share something with the community, contribute, and participate.

Drupal needs to actively recruit and encourage people who are willing to do this, and leave the genitals out of it.

Exporting Drupal Content to Microsoft Word

I was recently given an interesting task at work. I was asked to export all blog posts for a given author from a Drupal site into a Microsoft Word document. At first, I wasn't sure how I was going to accomplish this, so I turned to Google and found a few PHP classes that purported to do exactly what I needed. However, a few false starts later, I was unable to get any of them to work. That's when I came across LiveDocX. LiveDocX is a template-based SaaS solution that allows developers to create documents from data across disparate data sources.
It allows developers to create word processing documents by combining user-defined Microsoft Word templates with data from disparate data sources, such as XML files and databases. It is typically used to create professional, print-ready word processing documents in DOCX, DOC, RTF and PDF. LiveDocx is a Web Service that can be easily integrated into any web application without installing or configuring any software on your server. Currently, the following programming languages are supported: * ASP.NET * PHP As LiveDocx is strictly based on open standards, it is simple to add support for more programming languages. As long as SOAP (Simple Object Access Protocol) is available on the client-side system, LiveDocx runs on all operating systems and in all programming languages.
This looked to be the best solution for what I was attempting to do, and best of all, it was free. All I had to do was sign up for an account, and then I was free to begin coding my solution. I knew that I wanted the solution to be dynamic; I didn't want to hard-code the author into my code. Instead, I wanted to be able to export any author's blog posts. So, step 1 was to create a form that would allow site administrators to find the author they are looking for. The form consists of a textfield with autocomplete functionality, and a select box with export options. For the purpose of this example, the only option is to export to MS Word (doc). However, LiveDocX also supports docx, rtf, and pdf.
<?php

function MYMODULE_blog_export_form() {
  
$form = array();
  
$form['export'] = array(
    
'#type' => 'fieldset',
    
'#title' => t('Blog Export Options'),
    
'#collapsed' => false,
    
'#collapsible' => false,
  );
  
$form['export']['method'] = array(
    
'#type' => 'select',
    
'#title' => t('Export output type'),
    
'#options' => array(
      
'msword' => t('Microsoft Word'),
    ),
  );
  
$form['export']['author'] = array(
    
'#type' => 'textfield',
    
'#title' => t('Author name'),
    
'#autocomplete_path' => 'admin/autocomplete/bloggers',
    
'#description' => t('Enter the name of the user.'),
  );
  
$form['submit'] = array(
    
'#type' => 'submit',
    
'#value' => t('Submit'),
  );
  return 
$form;
}

?>
You'll notice that the textfield has an #autocomplete property, which is the path that executes the autocomplete function. This function is defined like this:
<?php

/**
 * Menu callback function to provide autocomplete functionality for
 * searching for users by username
 *
 * @param string $search_string
 */
function MYMODULE_autocomplete_bloggers($search_string) {

  static 
$blogger_roles = array();
  
$result db_query("SELECT r.rid FROM {role} r WHERE
    r.name = '%s'"
'blogger');
  while(
$role db_fetch_object($result)) {
    
$blogger_roles[] = $role->rid;
  }
 
  
$matches = array();
  
$result db_query("SELECT u.uid, u.name FROM {users} u
    LEFT JOIN {users_roles} ur ON ur.uid = u.uid
    LEFT JOIN {role} r ON r.rid = ur.rid
    WHERE u.name LIKE '%s%%' AND
    r.rid IN ("
.join(','$blogger_roles).")
    LIMIT 50"
$search_string);
  while (
$row db_fetch_object($result)) {
    
$matches[$row->name] = $row->name;
  }
  print 
drupal_to_js($matches);
  exit();
}
?>
Now that the autocomplete functionality was hooked up, it was time to define the form's submit handler. The submit handler makes use of Drupal's Batch API to define a batch process and execute it.
<?php

function MYMODULE_blog_export_form_submit($form$form_state) {
  
$uid db_result(db_query("SELECT uid FROM {users} WHERE name = '%s'"$form_state['values']['author']));
 
  if(
$uid) {
    
$start_func 'MYMODULE_blog_export_'.$form_state['values']['method'];
    
$finished_func 'MYMODULE_blog_export_'.$form_state['values']['method'].'_batch_process_finished';
 
    
// Add a batch set with simple operations taking an argument.
    
$batch = array(
      
'title' => t('Blog Export'), // Not displayed.
      
'operations' => array(
        array(
$start_func, array($uid)),
      ),
      
'finished' => $finished_func,
    );
    
batch_set($batch);
    
batch_process('admin/content/blogs/export');
  }
  else {
    
drupal_set_message('An error occurred while trying to process this action.');
  }  
}

?>
The above code defines a batch process with a start function, and an end function. For clarity, the start function is responsible for finding all of the nodes for the specified author. The end function is responsible for sending those results to LiveDocX.
<?php

function MYMODULE_blog_export_msword($uid, &$context) {
  
$limit 5;
  
$context['finished'] = 0;
  if (!isset(
$context['sandbox']['progress'])) {
    
$max_nodes db_result(db_query("SELECT count(n.nid) FROM {node} n WHERE n.uid = %d AND n.type = 'blog' ORDER BY nid ASC"$uid));
    
$context['sandbox']['progress'] = 0;
    
$context['sandbox']['current_node'] = 0;
    
$context['sandbox']['max'] = $max_nodes;
    
$context['sandbox']['results']['author'] = user_load(array('uid' => $uid));
    
$block_values = array();
    
$context['sandbox']['results']['block_values'] =& $block_values;   
  }
 
  
$nodes = array();
  
$result db_query_range("SELECT n.nid, n.type FROM {node} n WHERE n.nid > %d AND n.uid = %d AND n.type = 'blog' ORDER BY nid ASC"$context['sandbox']['current_node'], $uid0$limit);
  while(
$row db_fetch_object($result)) {
    
$nodes[$row->nid] = $row;
  }
  if(
count($nodes) == 0) {
    
cache_set('famed:blog_export_results''cache'serialize($context['sandbox']['results']));
    
$context['finished'] = 1;
  }
  
$context['message'] = t('Processing nodes authored by user %uid', array('%uid' => $uid));
 
  foreach (
$nodes as $node) {
    
// Process the node
    
$node node_load($node->nid);
    if(
$node) {
      
$content node_view($nodefalsetruefalse);
      if(
$content) {
        
$context['sandbox']['results']['block_values'][] = array (
          
'post_title' => $node->title,
          
'content' => strip_tags($node->body),
          
'created' => date('Y-m-d h:m:s'$node->created),
          
'updated' => date('Y-m-d h:m:s'$node->changed),
          
'pub_status' => ($node->status == 1) ? 'Published' 'Unpublished',
          
'tags' => $node->nodewords['keywords'],
        );
      }
    }
    
    
// Update our progress information.
    
$context['message'] = t('Processing blog posts authored by user %uid', array('%uid' => $uid));
    
$context['results'][] = t('Processed node %node', array('%node' => $node->nid));
    
$context['sandbox']['progress']++;
    
$context['sandbox']['current_node'] = $node->nid;
  }
    
  
// Inform the batch engine that we are not finished,
  // and provide an estimation of the completion level we reached.
  
if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
    
$context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
  }
}

?>
This code fetches all of the nodes from the database, and stores information about each node in the $context['sandbox']['results']['block_values'] array. When there are no more nodes to process, this array gets serialized and stored in Drupal's cache, so that the data can be used in the batch finished function. The finished function is responsible for sending all of the data to the LiveDocX web service. It's important that you create your document template before trying to send data to the web service, as LiveDocX works like a mail merge. Your template will consist of named MailMerge fields, and merge blocks for repeating data.
<?php

/**
 * Batch finished handler.
 */
function MYMODULE_blog_export_msword_batch_process_finished($success$results$operations) {

  
// Load the data from Drupal's cache
  
$cache cache_get('famed:blog_export_results''cache');

  
// Unserialize the cache data
  
$blog_data unserialize($cache->data);
  
cache_clear_all('famed:blog_export_results''cache');
  if (
$blog_data) {
    
// Turn up error reporting
    
error_reporting (E_ALL|E_STRICT);
     
    
// Turn off WSDL caching
    
ini_set ('soap.wsdl_cache_enabled'0);
     
    
// Define credentials for LD
    
$credentials = array(
      
'username' => 'my_user_name',
      
'password' => 'my_password',
    );
     
    
// SOAP WSDL endpoint
    
$endpoint 'https://api.livedocx.com/1.2/mailmerge.asmx?WSDL';
     
    
// Define timezone
    
date_default_timezone_set('Europe/Berlin');

  

    
// Create a new instance of the SoapClient object
    
$soap = new SoapClient($endpoint);
    
$soap->LogIn(
      array(
        
'username' => $credentials['username'],
        
'password' => $credentials['password']
      )
    );
    
    
// Upload template
    
$path_to_template './'.drupal_get_path('module''MYMODULE').'/template.doc';
    
$data file_get_contents($path_to_template);
    if(empty(
$data)) {
      
drupal_set_message('Failed to read the template''error');
      
watchdog('famed''Failed to read the template'WATCHDOG_ERROR);
      return;
    }
    
    
$soap->SetLocalTemplate(array(
      
'template' => base64_encode($data),
      
'format'   => 'doc'
    
));
    
    
$fieldValues = array (
      
'author' => $blog_data['author']->name,
      
'email' => $blog_data['author']->mail,
      
'title'  => 'Blog Posts by '.$blog_data['author']->name,
    );
 

    
/**

     * In the template, these field  values are used on the title page of the document,

     * and in the header/footer of the doucment.

     */
    
$soap->SetFieldValues(array (
      
'fieldValues' => assocArrayToArrayOfArrayOfString($fieldValues)
    ));
    

    
// Block values is the repeating data, in this case, the contents of each blog post
    
$soap->SetBlockFieldValues(array(
      
'blockName' => 'blogpost',
      
'blockFieldValues' => multiAssocArrayToArrayOfArrayOfString($blog_data['block_values'])
    ));
    
    
// Build the document
    
$soap->CreateDocument();
    
    
// Get document as DOC
    
$result $soap->RetrieveDocument(array(
      
'format' => 'doc'
    
));

    
// Fetch the document
    
$data $result->RetrieveDocumentResult;
    
$filename './sites/default/files/blog.doc';
    if(
file_exists($filename)) {
      
unlink($filename);
    }

    
// Write the document to the filesystem
    
file_put_contents($filenamebase64_decode($data));

 

    
// Force the browser to download the document
    
if(file_exists($filename)) {
      
header ("Content-type: octet/stream");
      
header ("Content-disposition: attachment; filename=blog.doc;");
      
header("Content-Length: ".filesize($filename));
      
readfile($filename);
      exit;
    }
    else {
      
drupal_set_message('Failed to download the file''error');
    }
  }
  else {
    
// An error occurred.
    // $operations contains the operations that remained unprocessed.
    
$error_operation reset($operations);
    
$message t('An error occurred while processing %error_operation with arguments: @arguments', array('%error_operation' => $error_operation[0], '@arguments' => print_r($error_operation[1], TRUE)));
  }
  
drupal_set_message($message);
 
}
?>
The data structures, which are sent to LiveDocx can be tricky to get right in PHP, so some additional functions are needed to massage the data that gets sent in the SetFieldValues() and SetBlockFieldValues() methods:
<?php

/**
 * Convert a PHP assoc array to a SOAP array of array of string
 *
 * @param array $assoc
 * @return array
 */
function assocArrayToArrayOfArrayOfString ($assoc) {
  
$arrayKeys   array_keys($assoc);
  
$arrayValues array_values($assoc);
  return array (
$arrayKeys$arrayValues);
}
 
/**
 * Convert a PHP multi-depth assoc array to a SOAP array of array of array of string
 *
 * @param array $multi
 * @return array
 */
function multiAssocArrayToArrayOfArrayOfString ($multi){
    
$arrayKeys   array_keys($multi[0]);
    
$arrayValues = array();
 
    foreach (
$multi as $v) {
      
$arrayValues[] = array_values($v);
    }
 
    
$_arrayKeys = array();
    
$_arrayKeys[0] = $arrayKeys;
 
    return 
array_merge($_arrayKeys$arrayValues);
}
?>
The trickiest part for me was getting the template correct in order for the LiveDocX service to work properly. I didn't know how to create merge blocks; as it turns out, it's as simple as inserting bookmarks into your template that follow a specific naming convention: blockstart_ blockend_ It's also important to know that LiveDocX is currently limited to having merge blocks defined in table cells. Future enhancements of the the service will support having merge blocks defined anywhere. I am excited for this to happen, as it will truly make this service a lot more flexible. The full API for LiveDocX can be found here.

Apache Solr: Conditionally Display Facet Blocks

In my last post, I showed a method for conditionally displaying facet blocks with Search Lucene, depending on the type of query being performed.  This can also be adapted to work for Apache Solr, albeit with a different mechanism. The easiest way to do this with Apache Solr is to use the Context module to control which facet blocks are displayed, along with when and where they are displayed.  In this example, I set up a context based on path, where that path was search/apachesolr/*.  I then added all of my Apache Solr facet blocks in the context. With the context configured, I now have the ability to alter that context on the fly, based on whatever conditions I choose. 

<?php 

/**
 * Implementation of hook_context_active_contexts_alter().
 *
 * <a href="http://twitter.com/param">@param</a> mixed $contexts
 *   Associative array of context objects
 */
function MYMODULE_context_active_contexts_alter(&$contexts) {
  
// If one of the active contexts is the apachesolr_search context, remove 
  // some unneccessary blocks
  
if(in_array('search-apachesolr-search'array_keys($contexts))) {
    
/**
     * Look at the type of query that has been performed, and set a flag regarding whether or 
     * not to show the biblio facets if the type:biblio filter is in effect
     */
    
$show_biblio_facets false;
    
$query apachesolr_current_query();
    if(
$query) {
      if(
$query->has_filter('type''biblio')) {
        
$show_biblio_facets true;
      }
    }
    
// Loop through the apachesolr-search blocks and remove the Biblio facet blocks if needed
    
foreach($contexts['search-apachesolr-search']->block as $bid => $block) {
      if(
preg_match('/^apachesolr_biblio_/'$bid)) {
        if(!
$show_biblio_facets) {
          unset(
$contexts['search-apachesolr-search']->block[$bid]);
        }
      }
    }
  }
}

?>
 In this example, I get all of the active contexts, and if my apachesolr-search context is in effect, I look at the current Apache  Solr query to see what filters have been applied.  In the example, a flag is set to show the biblio facets if the current search is filtering by the biblio content type. There may be other methods for displaying facets conditionally in Apache Solr; however, I find this method to be fairly straightforward, and if you're not currently using Context for controlling block placement in Drupal, you should definitely reconsider that decision.

New Drupal Module Released

Back in October, I released my first module for Drupal, the open-source content management system. These days, I seem to be developing exclusively for Drupal, and with a robust API and thriving community, I can only say how much fun it is to work with.

However, like any platforms, there are pieces missing. Fortunately, Drupal is one of those platforms that is very easily extended through modules. I came across one of those missing pieces while working on a project.

Drupal and Taxonomy Weights

I recently worked on a project in Drupal that called for a large number of taxonomy terms. I needed to put the terms in a specific order, but unfortunately, I had more terms than Drupal's weight field supports, which is a range from -10 to +10.

I did a quick search on Drupal, and was horrified to see how many people are hacking core to add a greater range. This is pretty easy to do without hacking core.

Flex and Drupal Paths

At CommonPlaces, each developer has his or her own sandbox to code in. Each sandbox can run n instances of a Drupal application, which all run out of subdirectories from the developer's web root.

Hack-proof Your Drupal App - the Video

I had the pleasure of presenting at DrupalCon in Szeged Hungary, and the topic of my presentation was Drupal security from the perspective of the application. I am pleased to be able to share the video of my presentation. Drupal, DrupalCon, CommonPlaces, Szeged, security, hacking, filters, output

DrupalCon Experiences in Szeged, Hungary

I have been attending DrupalCon this week, hosted in the beautiful Hungarian town of Szeged.

I was fortunate in that my company, CommonPlaces, was generous enough to become a silver sponsor for the conference.

Drupal and Sane Flash Remoting

On my latest project, I was faced with a challenge: build Flash widgets that displayed dynamic data and could be embedded on any web page. Phase two of the widgets called for user interaction with the widget, as opposed to simply displaying content.

Drupal: Cross-domain Widgets

Drupal is incredibly flexible, but in current versions, lacks the ability to export content easily in the form of widgets. However, the Services module gives you that flexibility in a very easy to use manner.

Services allows you to expose pieces of your Drupal site, such as user, node, and views methods.

PHP Debugging Goodness

I have found PHP nirvana in a box.

I was trying to debug the lastest dev version of the userpoints module for Drupal, and was getting nowhere. The process of debugging PHP is tedious to begin with, but the practice of putting print statements into your code in places you think are likely the problem is a nightmare, and a huge black hole for productivity.

DrupalCon Boston 2008: Day1

I have the distinct pleasure of attending DrupalCon 2008, which is being held in Boston, MA this year.

Drupal: Incorrect Pager Results

I've been working on a Drupal module that generates a search form and presents the results below the form.

Syndicate content

About Erich

Erich is a web developer and a native New Englander who is passionate about life, the universe, and everything.

He is a Drupal consultant, previously employed as a senior developer at Harvard University, working on the IQSS OpenScholar project.  Prior to joining the team at Harvard, he was the engineering manager at CommonPlaces e-Solutions, in Hampstead, NH, contributing as the lead engineer on the Greenopolis.com and Twolia.com.

Erich is active in the Drupal community, having contributed modules and patches to the community. He presented at DrupalCon in Szeged Hungary, and co-presented at DrupalCon 2009 in Washington, DC.

Erich lives in New Hampshire with his wife, two sons, and three weimaraners.  When not writing code, Erich enjoys landscaping and woodworking.

Faceted search

Categories

Content type

Project types

Artwork Type

Artwork Tags

Recent comments

Activity Stream

August 29, 2011

August 25, 2011

August 24, 2011

August 23, 2011

August 15, 2011

August 11, 2011

August 10, 2011

August 9, 2011

August 4, 2011

August 3, 2011