Browse Source

Merge remote-tracking branch 'origin/master' into redesign

Conflicts:
	app/Resources/FOSUserBundle/views/Profile/show.html.twig
	src/Packagist/WebBundle/Command/IndexPackagesCommand.php
	src/Packagist/WebBundle/Controller/UserController.php
	src/Packagist/WebBundle/Controller/WebController.php
	src/Packagist/WebBundle/DependencyInjection/Compiler/RepositoryPass.php
	src/Packagist/WebBundle/Form/Handler/OAuthRegistrationFormHandler.php
	src/Packagist/WebBundle/PackagistWebBundle.php
	src/Packagist/WebBundle/Resources/public/css/main.css
	src/Packagist/WebBundle/Resources/views/Web/search.html.twig
	src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig
	src/Packagist/WebBundle/Resources/views/layout.html.twig
Jordi Boggiano 9 years ago
parent
commit
478331e71c
33 changed files with 1150 additions and 239 deletions
  1. 1 1
      app/Resources/FOSUserBundle/views/Profile/show.html.twig
  2. 4 0
      app/config/config_test.yml
  3. 2 0
      app/config/defaults.yml
  4. 2 0
      app/config/parameters.yml.dist
  5. 2 0
      doc/schema.xml
  6. 16 4
      src/Packagist/WebBundle/Command/IndexPackagesCommand.php
  7. 2 32
      src/Packagist/WebBundle/Controller/ApiController.php
  8. 1 1
      src/Packagist/WebBundle/Controller/PackageController.php
  9. 1 0
      src/Packagist/WebBundle/Controller/UserController.php
  10. 212 62
      src/Packagist/WebBundle/Controller/WebController.php
  11. 0 45
      src/Packagist/WebBundle/DependencyInjection/Compiler/RepositoryPass.php
  12. 19 0
      src/Packagist/WebBundle/Entity/Package.php
  13. 33 32
      src/Packagist/WebBundle/Entity/PackageRepository.php
  14. 3 1
      src/Packagist/WebBundle/Entity/VersionRepository.php
  15. 18 0
      src/Packagist/WebBundle/EventListener/CacheListener.php
  16. 1 5
      src/Packagist/WebBundle/Form/Handler/OAuthRegistrationFormHandler.php
  17. 49 0
      src/Packagist/WebBundle/Form/Model/OrderBy.php
  18. 15 0
      src/Packagist/WebBundle/Form/Model/SearchQuery.php
  19. 48 0
      src/Packagist/WebBundle/Form/Type/OrderByType.php
  20. 5 0
      src/Packagist/WebBundle/Form/Type/SearchQueryType.php
  21. 37 24
      src/Packagist/WebBundle/Package/SymlinkDumper.php
  22. 4 5
      src/Packagist/WebBundle/Package/Updater.php
  23. 1 7
      src/Packagist/WebBundle/PackagistWebBundle.php
  24. 5 0
      src/Packagist/WebBundle/Resources/config/services.yml
  25. 66 0
      src/Packagist/WebBundle/Resources/public/css/main.css
  26. 58 3
      src/Packagist/WebBundle/Resources/public/js/search.js
  27. 3 1
      src/Packagist/WebBundle/Resources/views/Web/search.html.twig
  28. 29 3
      src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig
  29. 1 1
      src/Packagist/WebBundle/Resources/views/Web/versionDetails.html.twig
  30. 7 3
      src/Packagist/WebBundle/Resources/views/Web/viewPackage.html.twig
  31. 3 5
      src/Packagist/WebBundle/Resources/views/layout.html.twig
  32. 416 4
      src/Packagist/WebBundle/Tests/Controller/WebControllerTest.php
  33. 86 0
      src/Packagist/WebBundle/Tests/Package/SymlinkDumperTest.php

+ 1 - 1
app/Resources/FOSUserBundle/views/Profile/show.html.twig

@@ -24,7 +24,7 @@
         <hr>
 
         <h3 class="font-normal">Bitbucket POST Service</h3>
-        <p>To enable the Bitbucket service hook, go to your BitBucket repository, open the "Admin" tab and select "Services" in the menu. Pick "POST" in the list and add it to your repository. Afterwards, you have to enter the Packagist endpoint, containing both your username and API token (see above). Enter <code>https://packagist.org/api/bitbucket?username={{ app.user.username }}&amp;apiToken=&hellip;</code> for the service's URL. Save your changes and you're done.</p>
+        <p>To enable the Bitbucket service hook, go to your BitBucket repository, open the settings and select "Hooks" in the menu. Pick "POST" in the list and add it to your repository. Afterwards, you have to enter the Packagist endpoint, containing both your username and API token (see above). Enter <code>https://packagist.org/api/bitbucket?username={{ app.user.username }}&amp;apiToken=&hellip;</code> for the service's URL. Save your changes and you're done.</p>
 
         <hr>
     {%- endif %}

+ 4 - 0
app/config/config_test.yml

@@ -1,6 +1,10 @@
 imports:
     - { resource: config_dev.yml }
 
+parameters:
+    database_name: %database_name_test%
+    redis_dsn: %redis_dsn_test%
+
 framework:
     test: ~
     session:

+ 2 - 0
app/config/defaults.yml

@@ -2,3 +2,5 @@ parameters:
     packagist_host: ~
     packagist_metadata_dir: "%kernel.cache_dir%/composer-packages-build"
     session_save_path: %kernel.cache_dir%/sessions
+    database_name_test: packagist_test
+    redis_dsn_test: redis://127.0.0.1/14

+ 2 - 0
app/config/parameters.yml.dist

@@ -4,6 +4,7 @@ parameters:
     database_name: packagist
     database_user: root
     database_password:
+    database_name_test: packagist_test
 
     mailer_transport:
     mailer_host: localhost
@@ -17,6 +18,7 @@ parameters:
     # router.request_context.scheme: https
 
     redis_dsn: redis://localhost/1
+    redis_dsn_test: redis://127.0.0.1/14
 
     locale: en
 

+ 2 - 0
doc/schema.xml

@@ -237,6 +237,8 @@
    <field name="tags" type="text_general_rev" indexed="true" stored="true" multiValued="true"/>
    <field name="type" type="text_general_rev" indexed="true" stored="true"/>
    <field name="trendiness" type="float" indexed="true" stored="true" />
+   <field name="downloads" type="int" indexed="true" stored="true" />
+   <field name="favers" type="int" indexed="true" stored="true" />
    <field name="repository" type="string" indexed="false" stored="true" />
    <field name="abandoned" type="int" indexed="false" stored="true" />
    <field name="replacementPackage" type="string" indexed="false" stored="true" />

+ 16 - 4
src/Packagist/WebBundle/Command/IndexPackagesCommand.php

@@ -13,6 +13,9 @@
 namespace Packagist\WebBundle\Command;
 
 use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Model\DownloadManager;
+use Packagist\WebBundle\Model\FavoriteManager;
+use Solarium_Document_ReadWrite;
 use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
@@ -61,6 +64,8 @@ class IndexPackagesCommand extends ContainerAwareCommand
         $doctrine = $this->getContainer()->get('doctrine');
         $solarium = $this->getContainer()->get('solarium.client');
         $redis = $this->getContainer()->get('snc_redis.default');
+        $downloadManager = $this->getContainer()->get('packagist.download_manager');
+        $favoriteManager = $this->getContainer()->get('packagist.favorite_manager');
 
         $lock = $this->getContainer()->getParameter('kernel.cache_dir').'/composer-indexer.lock';
         $timeout = 600;
@@ -118,7 +123,7 @@ class IndexPackagesCommand extends ContainerAwareCommand
 
                 try {
                     $document = $update->createDocument();
-                    $this->updateDocumentFromPackage($document, $package, $redis);
+                    $this->updateDocumentFromPackage($document, $package, $redis, $downloadManager, $favoriteManager);
                     $update->addDocument($document);
 
                     $package->setIndexedAt(new \DateTime);
@@ -145,7 +150,7 @@ class IndexPackagesCommand extends ContainerAwareCommand
                                 $document->setField('replacementPackage', '');
                                 $update->addDocument($document);
                             } catch (\Exception $e) {
-                                $output->writeln('<error>Exception: '.$e->getMessage().', skipping package '.$package->getName().':provide:'.$provide->getPackageName().'</error>');
+                                $output->writeln('<error>'.get_class($e).': '.$e->getMessage().', skipping package '.$package->getName().':provide:'.$provide->getPackageName().'</error>');
                             }
                         }
                     }
@@ -163,13 +168,20 @@ class IndexPackagesCommand extends ContainerAwareCommand
         unlink($lock);
     }
 
-    private function updateDocumentFromPackage(\Solarium_Document_ReadWrite $document, Package $package, $redis)
-    {
+    private function updateDocumentFromPackage(
+        Solarium_Document_ReadWrite $document,
+        Package $package,
+        $redis,
+        DownloadManager $downloadManager,
+        FavoriteManager $favoriteManager
+    ) {
         $document->setField('id', $package->getId());
         $document->setField('name', $package->getName());
         $document->setField('description', $package->getDescription());
         $document->setField('type', $package->getType());
         $document->setField('trendiness', $redis->zscore('downloads:trending', $package->getId()));
+        $document->setField('downloads', $downloadManager->getTotalDownloads($package));
+        $document->setField('favers', $favoriteManager->getFaverCount($package));
         $document->setField('repository', $package->getRepository());
         if ($package->isAbandoned()) {
             $document->setField('abandoned', 1);

+ 2 - 32
src/Packagist/WebBundle/Controller/ApiController.php

@@ -50,39 +50,9 @@ class ApiController extends Controller
             return new Response(file_get_contents($rootJson));
         }
 
-        if ($req->getHost() === 'packagist.org') {
-            $this->get('logger')->alert('packages.json is missing and the fallback controller is being hit');
+        $this->get('logger')->alert('packages.json is missing and the fallback controller is being hit, you need to use app/console packagist:dump');
 
-            return new Response('Horrible misconfiguration or the dumper script messed up', 404);
-        }
-
-        $em = $this->get('doctrine')->getManager();
-
-        gc_enable();
-
-        $packages = $em->getRepository('Packagist\WebBundle\Entity\Package')
-            ->getFullPackages();
-
-        $notifyUrl = $this->generateUrl('track_download', array('name' => 'VND/PKG'));
-
-        $data = array(
-            'notify' => str_replace('VND/PKG', '%package%', $notifyUrl),
-            'packages' => array(),
-        );
-        foreach ($packages as $package) {
-            $versions = array();
-            foreach ($package->getVersions() as $version) {
-                $versions[$version->getVersion()] = $version->toArray();
-                $em->detach($version);
-            }
-            $data['packages'][$package->getName()] = $versions;
-            $em->detach($package);
-        }
-        unset($versions, $package, $packages);
-
-        $response = new Response(json_encode($data), 200);
-        $response->setSharedMaxAge(120);
-        return $response;
+        return new Response('Horrible misconfiguration or the dumper script messed up, you need to use app/console packagist:dump', 404);
     }
 
     /**

+ 1 - 1
src/Packagist/WebBundle/Controller/PackageController.php

@@ -92,7 +92,7 @@ class PackageController extends Controller
             $form->bind($request->request->get('package'));
             if ($form->isValid()) {
                 $package->setAbandoned(true);
-                $package->setReplacementPackage($form->get('replacement')->getData());
+                $package->setReplacementPackage(str_replace('https://packagist.org/packages/', '', $form->get('replacement')->getData()));
                 $package->setIndexedAt(null);
 
                 $em = $this->getDoctrine()->getManager();

+ 1 - 0
src/Packagist/WebBundle/Controller/UserController.php

@@ -12,6 +12,7 @@
 
 namespace Packagist\WebBundle\Controller;
 
+use Doctrine\ORM\NoResultException;
 use FOS\UserBundle\Model\UserInterface;
 use Packagist\WebBundle\Entity\Package;
 use Packagist\WebBundle\Entity\User;

+ 212 - 62
src/Packagist/WebBundle/Controller/WebController.php

@@ -57,7 +57,7 @@ class WebController extends Controller
      */
     public function indexAction()
     {
-        return array('page' => 'home', 'searchForm' => $this->createSearchForm()->createView());
+        return array('page' => 'home');
     }
 
     /**
@@ -81,7 +81,6 @@ class WebController extends Controller
 
         $data['packages'] = $this->setupPager($packages, $page);
         $data['meta'] = $this->getPackagesMetadata($data['packages']);
-        $data['searchForm'] = $this->createSearchForm()->createView();
 
         return $data;
     }
@@ -118,7 +117,6 @@ class WebController extends Controller
             'newlyReleased' => $newReleases,
             'random' => $random,
             'popular' => $popular,
-            'searchForm' => $this->createSearchForm()->createView(),
         );
 
         return $data;
@@ -166,7 +164,6 @@ class WebController extends Controller
 
         $data = array(
             'packages' => $packages,
-            'searchForm' => $this->createSearchForm()->createView(),
         );
         $data['meta'] = $this->getPackagesMetadata($data['packages']);
 
@@ -236,20 +233,24 @@ class WebController extends Controller
         return new JsonResponse(array('packageNames' => $names));
     }
 
-    /**
-     * Initializes the pager for a query.
-     *
-     * @param \Doctrine\ORM\QueryBuilder $query Query for packages
-     * @param int                        $page  Page number to retrieve.
-     * @return \Pagerfanta\Pagerfanta
-     */
-    protected function setupPager($query, $page)
+    public function searchFormAction(Request $req)
     {
-        $paginator = new Pagerfanta(new DoctrineORMAdapter($query, true));
-        $paginator->setMaxPerPage(15);
-        $paginator->setCurrentPage($page, false, true);
+        $form = $this->createForm(new SearchQueryType, new SearchQuery);
 
-        return $paginator;
+        $filteredOrderBys = $this->getFilteredOrderedBys($req);
+        $normalizedOrderBys = $this->getNormalizedOrderBys($filteredOrderBys);
+
+        $this->computeSearchQuery($req, $filteredOrderBys);
+
+        if ($req->query->has('search_query')) {
+            $form->bind($req);
+        }
+
+        $orderBysViewModel = $this->getOrderBysViewModel($req, $normalizedOrderBys);
+        return $this->render('PackagistWebBundle:Web:searchForm.html.twig', array(
+            'searchForm' => $form->createView(),
+            'orderBys' => $orderBysViewModel
+        ));
     }
 
     /**
@@ -258,12 +259,12 @@ class WebController extends Controller
      */
     public function searchAction(Request $req)
     {
-        $form = $this->createSearchForm();
+        $form = $this->createForm(new SearchQueryType, new SearchQuery);
 
-        // transform q=search shortcut
-        if ($req->query->has('q')) {
-            $req->query->set('search_query', array('query' => $req->query->get('q')));
-        }
+        $filteredOrderBys = $this->getFilteredOrderedBys($req);
+        $normalizedOrderBys = $this->getNormalizedOrderBys($filteredOrderBys);
+
+        $this->computeSearchQuery($req, $filteredOrderBys);
 
         $typeFilter = $req->query->get('type');
         $tagsFilter = $req->query->get('tags');
@@ -299,8 +300,13 @@ class WebController extends Controller
                 $select->addFilterQuery($filterQuery);
             }
 
+            if (!empty($filteredOrderBys)) {
+                $select->addSorts($normalizedOrderBys);
+            }
+
             if ($req->query->has('search_query')) {
                 $form->bind($req);
+
                 if ($form->isValid()) {
                     $escapedQuery = $select->getHelper()->escapeTerm($form->getData()->getQuery());
                     $escapedQuery = preg_replace('/(^| )\\\\-(\S)/', '$1-$2', $escapedQuery);
@@ -317,10 +323,10 @@ class WebController extends Controller
             $perPage = $req->query->getInt('per_page', 15);
             if ($perPage <= 0 || $perPage > 100) {
                 if ($req->getRequestFormat() === 'json') {
-                    return new JsonResponse(array(
+                    return JsonResponse::create(array(
                         'status' => 'error',
                         'message' => 'The optional packages per_page parameter must be an integer between 1 and 100 (default: 15)',
-                    ), 400);
+                    ), 400)->setCallback($req->query->get('callback'));
                 }
 
                 $perPage = max(0, min(100, $perPage));
@@ -329,13 +335,13 @@ class WebController extends Controller
 
             $paginator->setCurrentPage($req->query->get('page', 1), false, true);
 
-            try {
-                $metadata = $this->getPackagesMetadata($paginator);
-            } catch (\Solarium_Client_HttpException $e) {
-                return new JsonResponse(array(
-                    'status' => 'error',
-                    'message' => 'Could not connect to the search server',
-                ), 500);
+            $metadata = array();
+
+            foreach ($paginator as $package) {
+                if (is_numeric($package->id)) {
+                    $metadata['downloads'][$package->id] = $package->downloads;
+                    $metadata['favers'][$package->id] = $package->favers;
+                }
             }
 
             if ($req->getRequestFormat() === 'json') {
@@ -345,10 +351,10 @@ class WebController extends Controller
                         'total' => $paginator->getNbResults(),
                     );
                 } catch (\Solarium_Client_HttpException $e) {
-                    return new JsonResponse(array(
+                    return JsonResponse::create(array(
                         'status' => 'error',
                         'message' => 'Could not connect to the search server',
-                    ), 500);
+                    ), 500)->setCallback($req->query->get('callback'));
                 }
 
                 foreach ($paginator as $package) {
@@ -391,7 +397,7 @@ class WebController extends Controller
                     $result['next'] = $this->generateUrl('search', $params, true);
                 }
 
-                return new JsonResponse($result);
+                return JsonResponse::create($result)->setCallback($req->query->get('callback'));
             }
 
             if ($req->isXmlHttpRequest()) {
@@ -405,25 +411,24 @@ class WebController extends Controller
                     if (!$e->getPrevious() instanceof \Solarium_Client_HttpException) {
                         throw $e;
                     }
-                    return new JsonResponse(array(
+                    return JsonResponse::create(array(
                         'status' => 'error',
                         'message' => 'Could not connect to the search server',
-                    ), 500);
+                    ), 500)->setCallback($req->query->get('callback'));
                 }
             }
 
             return $this->render('PackagistWebBundle:Web:search.html.twig', array(
                 'packages' => $paginator,
                 'meta' => $metadata,
-                'searchForm' => $form->createView(),
             ));
+        } elseif ($req->getRequestFormat() === 'json') {
+            return JsonResponse::create(array(
+                'error' => 'Missing search query, example: ?q=example'
+            ), 400)->setCallback($req->query->get('callback'));
         }
 
-        if ($req->getRequestFormat() === 'json') {
-            return new JsonResponse(array('error' => 'Missing search query, example: ?q=example'), 400);
-        }
-
-        return $this->render('PackagistWebBundle:Web:search.html.twig', array('searchForm' => $form->createView(), 'packages' => array()));
+        return $this->render('PackagistWebBundle:Web:search.html.twig');
     }
 
     /**
@@ -436,13 +441,13 @@ class WebController extends Controller
         $package->setEntityRepository($this->getDoctrine()->getRepository('PackagistWebBundle:Package'));
         $package->setRouter($this->get('router'));
         $form = $this->createForm(new PackageType, $package);
+        $user = $this->getUser();
+        $package->addMaintainer($user);
 
         if ('POST' === $req->getMethod()) {
             $form->bind($req);
             if ($form->isValid()) {
                 try {
-                    $user = $this->getUser();
-                    $package->addMaintainer($user);
                     $em = $this->getDoctrine()->getManager();
                     $em->persist($package);
                     $em->flush();
@@ -457,7 +462,7 @@ class WebController extends Controller
             }
         }
 
-        return array('form' => $form->createView(), 'page' => 'submit', 'searchForm' => $this->createSearchForm()->createView());
+        return array('form' => $form->createView(), 'page' => 'submit');
     }
 
     /**
@@ -469,6 +474,8 @@ class WebController extends Controller
         $package->setEntityRepository($this->getDoctrine()->getRepository('PackagistWebBundle:Package'));
         $package->setRouter($this->get('router'));
         $form = $this->createForm(new PackageType, $package);
+        $user = $this->getUser();
+        $package->addMaintainer($user);
 
         $response = array('status' => 'error', 'reason' => 'No data posted.');
         if ('POST' === $req->getMethod()) {
@@ -537,8 +544,7 @@ class WebController extends Controller
             'packages' => $packages,
             'meta' => $this->getPackagesMetadata($packages),
             'vendor' => $vendor,
-            'paginate' => false,
-            'searchForm' => $this->createSearchForm()->createView()
+            'paginate' => false
         );
     }
 
@@ -584,8 +590,7 @@ class WebController extends Controller
             'name' => $name,
             'packages' => $providers,
             'meta' => $this->getPackagesMetadata($providers),
-            'paginate' => false,
-            'searchForm' => $this->createSearchForm()->createView()
+            'paginate' => false
         ));
     }
 
@@ -662,10 +667,19 @@ class WebController extends Controller
             $version = $versionRepo->getFullVersion(reset($versions)->getId());
         }
 
+        $expandedVersion = reset($versions);
+        foreach ($versions as $v) {
+            if (!$v->isDevelopment()) {
+                $expandedVersion = $v;
+                break;
+            }
+        }
+
         $data = array(
             'package' => $package,
             'version' => $version,
             'versions' => $versions,
+            'expandedVersion' => $expandedVersion,
         );
 
         try {
@@ -677,7 +691,6 @@ class WebController extends Controller
         } catch (ConnectionException $e) {
         }
 
-        $data['searchForm'] = $this->createSearchForm()->createView();
         if ($maintainerForm = $this->createAddMaintainerForm($package)) {
             $data['addMaintainerForm'] = $maintainerForm->createView();
         }
@@ -986,7 +999,6 @@ class WebController extends Controller
             }
         }
 
-        $data['searchForm'] = $this->createSearchForm()->createView();
         return $data;
     }
 
@@ -1042,7 +1054,6 @@ class WebController extends Controller
             }
         }
 
-        $data['searchForm'] = $this->createSearchForm()->createView();
         return $data;
     }
 
@@ -1149,15 +1160,6 @@ class WebController extends Controller
         return new RedirectResponse('http://getcomposer.org/', 301);
     }
 
-    public function render($view, array $parameters = array(), Response $response = null)
-    {
-        if (!isset($parameters['searchForm'])) {
-            $parameters['searchForm'] = $this->createSearchForm()->createView();
-        }
-
-        return parent::render($view, $parameters, $response);
-    }
-
     private function createAddMaintainerForm($package)
     {
         if (!$user = $this->getUser()) {
@@ -1210,8 +1212,156 @@ class WebController extends Controller
         return $this->createFormBuilder(array())->getForm();
     }
 
-    private function createSearchForm()
+    /**
+     * Initializes the pager for a query.
+     *
+     * @param \Doctrine\ORM\QueryBuilder $query Query for packages
+     * @param int                        $page  Pagenumber to retrieve.
+     * @return \Pagerfanta\Pagerfanta
+     */
+    protected function setupPager($query, $page)
+    {
+        $paginator = new Pagerfanta(new DoctrineORMAdapter($query, true));
+        $paginator->setMaxPerPage(15);
+        $paginator->setCurrentPage($page, false, true);
+
+        return $paginator;
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function getFilteredOrderedBys(Request $req)
+    {
+        $orderBys = $req->query->get('orderBys', array());
+        if (!$orderBys) {
+            $orderBys = $req->query->get('search_query');
+            $orderBys = isset($orderBys['orderBys']) ? $orderBys['orderBys'] : array();
+        }
+
+        if ($orderBys) {
+            $allowedSorts = array(
+                'downloads' => 1,
+                'favers' => 1
+            );
+
+            $allowedOrders = array(
+                'asc' => 1,
+                'desc' => 1,
+            );
+
+            $filteredOrderBys = array();
+
+            foreach ($orderBys as $orderBy) {
+                if (isset($orderBy['sort'])
+                    && isset($allowedSorts[$orderBy['sort']])
+                    && isset($orderBy['order'])
+                    && isset($allowedOrders[$orderBy['order']])) {
+                    $filteredOrderBys[] = $orderBy;
+                }
+            }
+        } else {
+            $filteredOrderBys = array();
+        }
+
+        return $filteredOrderBys;
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function getNormalizedOrderBys(array $orderBys)
+    {
+        $normalizedOrderBys = array();
+
+        foreach ($orderBys as $sort) {
+            $normalizedOrderBys[$sort['sort']] = $sort['order'];
+        }
+
+        return $normalizedOrderBys;
+    }
+
+    protected function getOrderBysViewModel(Request $req, $normalizedOrderBys)
     {
-        return $this->createForm(new SearchQueryType, new SearchQuery);
+        $makeDefaultArrow = function ($sort) use ($normalizedOrderBys) {
+            if (isset($normalizedOrderBys[$sort])) {
+                if (strtolower($normalizedOrderBys[$sort]) === 'asc') {
+                    $val = 'icon-arrow-up';
+                } else {
+                    $val = 'icon-arrow-down';
+                }
+            } else {
+                $val = '';
+            }
+
+            return $val;
+        };
+
+        $makeDefaultHref = function ($sort) use ($req, $normalizedOrderBys) {
+            if (isset($normalizedOrderBys[$sort])) {
+                if (strtolower($normalizedOrderBys[$sort]) === 'asc') {
+                    $order = 'desc';
+                } else {
+                    $order = 'asc';
+                }
+            } else {
+                $order = 'desc';
+            }
+
+            $query = $req->query->get('search_query');
+            $query = isset($query['query']) ? $query['query'] : '';
+
+            return '?' . http_build_query(array(
+                'q' => $query,
+                'orderBys' => array(
+                    array(
+                        'sort' => $sort,
+                        'order' => $order
+                    )
+                )
+            ));
+        };
+
+        return array(
+            'downloads' => array(
+                'title' => 'Sort by downloads',
+                'class' => 'icon-download',
+                'arrowClass' => $makeDefaultArrow('downloads'),
+                'href' => $makeDefaultHref('downloads')
+            ),
+            'favers' => array(
+                'title' => 'Sort by favorites',
+                'class' => 'icon-star',
+                'arrowClass' => $makeDefaultArrow('favers'),
+                'href' => $makeDefaultHref('favers')
+            ),
+        );
+    }
+
+    private function computeSearchQuery(Request $req, array $filteredOrderBys)
+    {
+        // transform q=search shortcut
+        if ($req->query->has('q') || $req->query->has('orderBys')) {
+            $searchQuery = array();
+
+            $q = $req->query->get('q');
+
+            if ($q !== null) {
+                $searchQuery['query'] = $q;
+            }
+
+            if (!empty($filteredOrderBys)) {
+                $searchQuery['orderBys'] = $filteredOrderBys;
+            }
+
+            $req->query->set(
+                'search_query',
+                $searchQuery
+            );
+        }
     }
 }

+ 0 - 45
src/Packagist/WebBundle/DependencyInjection/Compiler/RepositoryPass.php

@@ -1,45 +0,0 @@
-<?php
-
-/*
- * This file is part of Packagist.
- *
- * (c) Jordi Boggiano <j.boggiano@seld.be>
- *     Nils Adermann <naderman@naderman.de>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Packagist\WebBundle\DependencyInjection\Compiler;
-
-use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
-use Symfony\Component\DependencyInjection\ContainerBuilder;
-use Symfony\Component\DependencyInjection\Reference;
-
-/**
- * Adds VCS repository providers to the main repository_provider service
- *
- * @author Jordi Boggiano <j.boggiano@seld.be>
- */
-class RepositoryPass implements CompilerPassInterface
-{
-    public function process(ContainerBuilder $container)
-    {
-        if (!$container->hasDefinition('packagist.repository_provider')) {
-            return;
-        }
-
-        $provider = $container->getDefinition('packagist.repository_provider');
-        $providers = array();
-
-        foreach ($container->findTaggedServiceIds('packagist.repository_provider') as $id => $tags) {
-            $providers[$id] = isset($tags[0]['priority']) ? (int) $tags[0]['priority'] : 0;
-        }
-
-        arsort($providers);
-
-        foreach ($providers as $id => $priority) {
-            $provider->addMethodCall('addProvider', array(new Reference($id)));
-        }
-    }
-}

+ 19 - 0
src/Packagist/WebBundle/Entity/Package.php

@@ -32,6 +32,7 @@ use Symfony\Component\Validator\ExecutionContextInterface;
  *     }
  * )
  * @Assert\Callback(methods={"isPackageUnique"})
+ * @Assert\Callback(methods={"isVendorWritable"})
  * @Assert\Callback(methods={"isRepositoryValid"}, groups={"Update", "Default"})
  * @author Jordi Boggiano <j.boggiano@seld.be>
  */
@@ -247,6 +248,24 @@ class Package
         } catch (\Doctrine\ORM\NoResultException $e) {}
     }
 
+    public function isVendorWritable(ExecutionContextInterface $context)
+    {
+        try {
+            $vendor = $this->getVendor();
+            if ($vendor && $this->entityRepository->isVendorTaken($vendor, reset($this->maintainers))) {
+                $context->addViolationAt(
+                    'repository',
+                    'The vendor is already taken by someone else. '
+                        . 'You may ask them to add your package and give you maintainership access. '
+                        . 'The packages already in that vendor namespace can be found at '
+                        . '<a href="'.$this->router->generate('view_vendor', array('vendor' => $vendor)).'">'.$vendor.'</a>',
+                    array(),
+                    null
+                );
+            }
+        } catch (\Doctrine\ORM\NoResultException $e) {}
+    }
+
     /**
      * Get id
      *

+ 33 - 32
src/Packagist/WebBundle/Entity/PackageRepository.php

@@ -191,13 +191,16 @@ class PackageRepository extends EntityRepository
 
         return $conn->fetchAll(
             'SELECT p.id FROM package p
-            WHERE p.crawledAt IS NULL
-            OR (p.autoUpdated = 0 AND p.crawledAt < :crawled)
-            OR (p.crawledAt < :autocrawled)
+            WHERE p.abandoned = false
+            AND (
+                p.crawledAt IS NULL
+                OR (p.autoUpdated = 0 AND p.crawledAt < :crawled)
+                OR (p.crawledAt < :autocrawled)
+            )
             ORDER BY p.id ASC',
             array(
-                'crawled' => date('Y-m-d H:i:s', strtotime('-4hours')),
-                'autocrawled' => date('Y-m-d H:i:s', strtotime('-1week')),
+                'crawled' => date('Y-m-d H:i:s', strtotime('-1week')),
+                'autocrawled' => date('Y-m-d H:i:s', strtotime('-1month')),
             )
         );
     }
@@ -236,33 +239,6 @@ class PackageRepository extends EntityRepository
         return $qb->getQuery()->getSingleResult();
     }
 
-    public function getFullPackages(array $ids = null, $filters = array())
-    {
-        $qb = $this->getEntityManager()->createQueryBuilder();
-        $qb->select('p', 'v', 't', 'a', 'req', 'devReq', 'sug', 'rep', 'con', 'pro')
-            ->from('Packagist\WebBundle\Entity\Package', 'p')
-            ->leftJoin('p.versions', 'v')
-            ->leftJoin('v.tags', 't')
-            ->leftJoin('v.authors', 'a')
-            ->leftJoin('v.require', 'req')
-            ->leftJoin('v.devRequire', 'devReq')
-            ->leftJoin('v.suggest', 'sug')
-            ->leftJoin('v.replace', 'rep')
-            ->leftJoin('v.conflict', 'con')
-            ->leftJoin('v.provide', 'pro')
-            ->orderBy('v.development', 'DESC')
-            ->addOrderBy('v.releasedAt', 'DESC');
-
-        if (null !== $ids) {
-            $qb->where($qb->expr()->in('p.id', ':ids'))
-                ->setParameter('ids', $ids);
-        }
-
-        $this->addFilters($qb, $filters);
-
-        return $qb->getQuery()->getResult();
-    }
-
     public function getPackagesWithVersions(array $ids = null, $filters = array())
     {
         $qb = $this->getEntityManager()->createQueryBuilder();
@@ -300,6 +276,31 @@ class PackageRepository extends EntityRepository
         return $qb;
     }
 
+    public function isVendorTaken($vendor, User $user)
+    {
+        $query = $this->getEntityManager()
+            ->createQuery(
+                "SELECT p.name, m.id user_id
+                FROM Packagist\WebBundle\Entity\Package p
+                JOIN p.maintainers m
+                WHERE p.name LIKE :vendor")
+            ->setParameters(array('vendor' => $vendor.'/%'));
+
+        $rows = $query->getArrayResult();
+        if (!$rows) {
+            return false;
+        }
+
+        foreach ($rows as $row) {
+            if ($row['user_id'] === $user->getId()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+
     private function addFilters(QueryBuilder $qb, array $filters)
     {
         foreach ($filters as $name => $value) {

+ 3 - 1
src/Packagist/WebBundle/Entity/VersionRepository.php

@@ -97,8 +97,10 @@ class VersionRepository extends EntityRepository
         $qb->select('v')
             ->from('Packagist\WebBundle\Entity\Version', 'v')
             ->where('v.development = false')
+            ->andWhere('v.releasedAt < :now')
             ->orderBy('v.releasedAt', 'DESC')
-            ->setMaxResults($count);
+            ->setMaxResults($count)
+            ->setParameter('now', date('Y-m-d H:i:s'));
 
         return $qb->getQuery()->useResultCache(true, 900, 'new_releases')->getResult();
     }

+ 18 - 0
src/Packagist/WebBundle/EventListener/CacheListener.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Packagist\WebBundle\EventListener;
+
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+
+class CacheListener
+{
+    public function onResponse(FilterResponseEvent $e)
+    {
+        $resp = $e->getResponse();
+
+        // add nginx-cache compatible header
+        if ($resp->headers->hasCacheControlDirective('public') && ($cache = $resp->headers->getCacheControlDirective('s-maxage'))) {
+            $resp->headers->set('X-Accel-Expires', $cache);
+        }
+    }
+}

+ 1 - 5
src/Packagist/WebBundle/Form/Handler/OAuthRegistrationFormHandler.php

@@ -15,7 +15,6 @@ namespace Packagist\WebBundle\Form\Handler;
 use FOS\UserBundle\Model\UserManagerInterface;
 use FOS\UserBundle\Util\TokenGeneratorInterface;
 use HWI\Bundle\OAuthBundle\Form\RegistrationFormHandlerInterface;
-use HWI\Bundle\OAuthBundle\OAuth\Response\AdvancedUserResponseInterface;
 use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
 use Symfony\Component\Form\Form;
 use Symfony\Component\HttpFoundation\Request;
@@ -52,10 +51,7 @@ class OAuthRegistrationFormHandler implements RegistrationFormHandlerInterface
         // Try to get some properties for the initial form when coming from github
         if ('GET' === $request->getMethod()) {
             $user->setUsername($this->getUniqueUsername($userInformation->getNickname()));
-
-            if ($userInformation instanceof AdvancedUserResponseInterface) {
-                $user->setEmail($userInformation->getEmail());
-            }
+            $user->setEmail($userInformation->getEmail());
         }
 
         $form->setData($user);

+ 49 - 0
src/Packagist/WebBundle/Form/Model/OrderBy.php

@@ -0,0 +1,49 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Model;
+
+/**
+ * @author Benjamin Michalski <benjamin.michalski@gmail.com>
+ */
+class OrderBy
+{
+    /**
+     * @var string
+     */
+    protected $sort;
+
+    /**
+     * @var string
+     */
+    protected $order;
+
+    public function setSort($sort)
+    {
+        $this->sort = $sort;
+    }
+
+    public function getSort()
+    {
+        return $this->sort;
+    }
+
+    public function setOrder($order)
+    {
+        $this->order = $order;
+    }
+
+    public function getOrder()
+    {
+        return $this->order;
+    }
+}

+ 15 - 0
src/Packagist/WebBundle/Form/Model/SearchQuery.php

@@ -21,6 +21,11 @@ class SearchQuery
      */
     protected $query;
 
+    /**
+     * @var array
+     */
+    protected $orderBys;
+
     public function setQuery($query)
     {
         $this->query = $query;
@@ -30,4 +35,14 @@ class SearchQuery
     {
         return $this->query;
     }
+
+    public function setOrderBys($orderBys)
+    {
+        $this->orderBys = $orderBys;
+    }
+
+    public function getOrderBys()
+    {
+        return $this->orderBys;
+    }
 }

+ 48 - 0
src/Packagist/WebBundle/Form/Type/OrderByType.php

@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolverInterface;
+
+/**
+ * @author Benjamin Michalski <benjamin.michalski@gmail.com>
+ */
+class OrderByType extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder->add('sort');
+        $builder->add('order', 'choice', array(
+            'choices' => array(
+                'asc' => 'asc',
+                'desc' => 'desc'
+            )
+        ));
+    }
+
+    public function setDefaultOptions(OptionsResolverInterface $resolver)
+    {
+        $resolver->setDefaults(array(
+            'data_class' => 'Packagist\WebBundle\Form\Model\OrderBy',
+            'csrf_protection' => false,
+        ));
+    }
+
+
+    public function getName()
+    {
+        return 'order_by';
+    }
+}

+ 5 - 0
src/Packagist/WebBundle/Form/Type/SearchQueryType.php

@@ -24,6 +24,11 @@ class SearchQueryType extends AbstractType
     public function buildForm(FormBuilderInterface $builder, array $options)
     {
         $builder->add('query', 'search');
+        $builder->add('orderBys', 'collection', array(
+            'type' => new OrderByType(),
+            'allow_add' => true,
+            'allow_delete' => true
+        ));
     }
 
     public function setDefaultOptions(OptionsResolverInterface $resolver)

+ 37 - 24
src/Packagist/WebBundle/Package/SymlinkDumper.php

@@ -550,37 +550,50 @@ class SymlinkDumper
         return !is_dir($path);
     }
 
+    private function getTargetListingBlocks($now)
+    {
+        $blocks = array();
+
+        // monday last week
+        $blocks['latest'] = strtotime('monday last week', $now);
+
+        $month = date('n', $now);
+        $month = ceil($month / 3) * 3 - 2; // 1 for months 1-3, 10 for months 10-12
+        $block = new \DateTime(date('Y', $now).'-'.$month.'-01'); // 1st day of current trimester
+
+        // split last 12 months in 4 trimesters
+        for ($i=0; $i < 4; $i++) {
+            $blocks[$block->format('Y-m')] = $block->getTimestamp();
+            $block->sub(new \DateInterval('P3M'));
+        }
+
+        $year = (int) $block->format('Y');
+
+        while ($year >= 2013) {
+            $blocks[''.$year] = strtotime($year.'-01-01');
+            $year--;
+        }
+
+        return $blocks;
+    }
+
     private function getTargetListing($file)
     {
-        static $limitLatest, $thisYear, $limitArchived;
+        static $blocks;
 
-        if (!$limitLatest) {
-            $limitLatest   = new \DateTime('monday last week');
-            $thisYear      = new \DateTime(date('Y') . '-01-01');
-            $limitArchived = new \DateTime('2012-01-01');
+        if (!$blocks) {
+            $blocks = $this->getTargetListingBlocks(time());
         }
 
-        $mtime = new \DateTime();
-        $mtime->setTimestamp(filemtime($file));
-
-        if ($mtime >= $limitLatest) {
-            $label = 'latest';
-        } elseif ($mtime >= $thisYear) {
-            // split current by chunks of 3 months, current month included
-            // past chunks will never be updated this year
-            $month = $mtime->format('n');
-            $month = ceil($month / 3) * 3;
-            $month = str_pad($month, 2, '0', STR_PAD_LEFT);
-
-            $label = $mtime->format('Y') . '-' . $month;
-        } elseif ($mtime >= $limitArchived) {
-            // split by years, limit at 2012 so we never update 'archived' again
-            $label = $mtime->format('Y');
-        } else {
-            $label = 'archived';
+        $mtime = filemtime($file);
+
+        foreach ($blocks as $label => $block) {
+            if ($mtime >= $block) {
+                return "provider-${label}.json";
+            }
         }
 
-        return "provider-${label}.json";
+        return "provider-archived.json";
     }
 
     private function writeFile($path, $contents, $mtime = null)

+ 4 - 5
src/Packagist/WebBundle/Package/Updater.php

@@ -93,15 +93,11 @@ class Updater
             $start = new \DateTime();
         }
         $pruneDate = clone $start;
-        $pruneDate->modify('-8days');
+        $pruneDate->modify('-1min');
 
         $versions = $repository->getPackages();
         $em = $this->doctrine->getManager();
 
-        if ($repository->hadInvalidBranches()) {
-            throw new InvalidRepositoryException('Some branches contained invalid data and were discarded, it is advised to review the log and fix any issues present in branches');
-        }
-
         usort($versions, function ($a, $b) {
             $aVersion = $a->getVersion();
             $bVersion = $b->getVersion();
@@ -169,6 +165,9 @@ class Updater
         $package->setUpdatedAt(new \DateTime);
         $package->setCrawledAt(new \DateTime);
         $em->flush();
+        if ($repository->hadInvalidBranches()) {
+            throw new InvalidRepositoryException('Some branches contained invalid data and were discarded, it is advised to review the log and fix any issues present in branches');
+        }
     }
 
     private function updateInformation(Package $package, PackageInterface $data, $flags)

+ 1 - 7
src/Packagist/WebBundle/PackagistWebBundle.php

@@ -12,19 +12,13 @@
 
 namespace Packagist\WebBundle;
 
-use Packagist\WebBundle\DependencyInjection\Compiler\RepositoryPass;
-use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\HttpKernel\Bundle\Bundle;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
 
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>
  */
 class PackagistWebBundle extends Bundle
 {
-    public function build(ContainerBuilder $container)
-    {
-        parent::build($container);
 
-        $container->addCompilerPass(new RepositoryPass());
-    }
 }

+ 5 - 0
src/Packagist/WebBundle/Resources/config/services.yml

@@ -10,6 +10,11 @@ services:
         tags:
             - { name: twig.extension }
 
+    packagist.cache_listener:
+        class: Packagist\WebBundle\EventListener\CacheListener
+        tags:
+            - { name: kernel.event_listener, event: kernel.response, method: onResponse }
+
     packagist.package_dumper:
         class: Packagist\WebBundle\Package\SymlinkDumper
         arguments: [ @doctrine, @filesystem, @router, "%kernel.root_dir%/../web/", "%packagist_metadata_dir%" ]

+ 66 - 0
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -673,6 +673,72 @@ ul.packages .abandoned {
     font-size: 22px;
 }
 
+/* Search */
+#search_query_query {
+  width: 890px;
+}
+.no-js #search_query_query {
+  width: 780px;
+}
+
+.sortable #search_query_query {
+  width: 777px;
+  display: inline;
+}
+.sortable .no-js #search_query_query {
+  width: 600px;
+}
+
+#search_query_orderBys {
+  display: none;
+}
+
+.sortable .icon.inactive {
+  color: #555;
+}
+
+.sortable #order-bys-wrapper {
+  margin-left: 0.7em;
+  display: inline;
+  font-size: 24px;
+  position: relative;
+  top: 6px;
+}
+
+.sortable .order-by-group .icon:first-child {
+  margin-right: 5px;
+}
+
+.sortable #order-bys-wrapper a {
+  margin-right: 7px;
+  display: inline-block;
+}
+
+.sortable #order-bys-wrapper a.clear {
+  width: auto;
+  margin-right: 0px;
+}
+
+#search-form .submit-wrapper {
+  width: 100px;
+  float: right;
+  display: none;
+}
+.no-js #search-form .submit-wrapper {
+  display: block;
+}
+#search-form .submit {
+  margin: 0;
+  padding: 6px 20px;
+  width: 100px;
+}
+#search-form p {
+  margin: 0;
+}
+.search-list {
+  margin-top: 10px;
+}
+
 
 .package .action.abandon input, .package .action.un-abandon input {
   background: #ec400b;

+ 58 - 3
src/Packagist/WebBundle/Resources/public/js/search.js

@@ -18,6 +18,9 @@
         list.html(newList.html());
         list.removeClass('hidden');
         list.find('ul.packages li:first').addClass('selected');
+        $('.order-by-group').attr('href', function (index, current) {
+            return current.replace(/q=.*?&/, 'q=' + encodeURIComponent($('input[type="search"]', form).val()) + '&')
+        });
 
         searching = false;
 
@@ -28,7 +31,16 @@
     };
 
     doSearch = function () {
-        var currentQuery;
+        var currentQuery,
+            orderBys,
+            orderBysStrParts,
+            joinedOrderBys,
+            joinedOrderBysQryStrPart,
+            q,
+            pathname,
+            urlPrefix,
+            url,
+            title;
 
         if (searching) {
             searchQueued = true;
@@ -49,11 +61,54 @@
         }
 
         if (window.history.pushState) {
+            orderBys = [];
+
+            $('#search_query_orderBys > div').each(function (i, e) {
+                var sort,
+                    order;
+                sort = $(e).find('input').val();
+                order = $(e).find('select').val();
+
+                orderBys.push({
+                    sort: sort,
+                    order: order
+                });
+            });
+
+            orderBysStrParts = [];
+
+            orderBys.forEach(function (e, i) {
+                orderBysStrParts.push('orderBys[' + i + '][sort]=' + e.sort + '&orderBys[' + i + '][order]=' + e.order);
+            });
+
+            joinedOrderBys = orderBysStrParts.join('&');
+
+            q = encodeURIComponent($('input[type="search"]', form).val());
+
+            pathname = window.location.pathname;
+
+            if (pathname.indexOf('/app_dev.php') === 0) {
+                urlPrefix = '/app_dev.php';
+            } else if (pathname.indexOf('/app.php') === 0) {
+                urlPrefix = '/app.php';
+            } else {
+                urlPrefix = '';
+            }
+
+            if (joinedOrderBys === '') {
+                joinedOrderBysQryStrPart = '';
+            } else {
+                joinedOrderBysQryStrPart = '&' + joinedOrderBys;
+            }
+
+            url = urlPrefix + '/search/?q=' + q + joinedOrderBysQryStrPart;
+            title = 'Search';
+
             if (firstQuery) {
-                window.history.pushState(null, "Search", "/search/?q=" + encodeURIComponent($('input[type="search"]', form).val()));
+                window.history.pushState(null, title, url);
                 firstQuery = false;
             } else {
-                window.history.replaceState(null, "Search", "/search/?q=" + encodeURIComponent($('input[type="search"]', form).val()));
+                window.history.replaceState(null, title, url);
             }
         }
 

+ 3 - 1
src/Packagist/WebBundle/Resources/views/Web/search.html.twig

@@ -6,8 +6,10 @@
     {% endblock %}
 
     {% block content %}
+        {{ render(controller('PackagistWebBundle:Web:searchForm', { req: app.request })) }}
+
         <div class="search-list">
             {{ block('list') }}
         </div>
     {% endblock %}
-{% endembed %}
+{% endembed %}

+ 29 - 3
src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig

@@ -1,6 +1,32 @@
 <form id="search-form" action="{{ path('search.ajax') }}" method="GET" {{ form_enctype(searchForm) }} autocomplete="off">
-    {{ form_errors(searchForm.query) }}
-    {{ form_widget(searchForm.query, {'attr': {'autocomplete': 'off', 'autofocus': 'autofocus', 'placeholder': 'Search packages...', 'tabindex': 1}}) }}
-    {{ form_rest(searchForm) }}
+    <div class="{% if orderBys is defined %}sortable{% endif %}">
+        {{ form_errors(searchForm.query) }}
+        {{ form_widget(searchForm.query, {'attr': {'autocomplete': 'off', 'autofocus': 'autofocus', 'placeholder': 'Search packages...', 'tabindex': 1}}) }}
+
+        {% set hasActiveOrderBy = false %}
+        {% spaceless %}
+        <div id="order-bys-wrapper">
+            {% for sort, param in orderBys %}
+                <a title="{{ param.title }}" href="{{ param.href }}" class="order-by-group">
+                    <i class="icon {{ param.class }}{% if param.arrowClass is empty %} inactive{% endif %}"></i>
+                    {% if param.arrowClass is not empty %}
+                        <i class="icon {{ param.arrowClass }}"></i>
+                        {% set hasActiveOrderBy = true %}
+                    {% endif %}
+                </a>
+            {% endfor %}
+            {% if hasActiveOrderBy %}
+                <a title="Clear order bys" href="?q={{ searchForm.vars.value.query }}" class="clear">
+                    <i class="icon icon-remove-circle"></i>
+                </a>
+            {% endif %}
+        </div>
+        {% endspaceless %}
+        <div
+            class="hidden">
+            {{ form_widget(searchForm.orderBys) }}
+        </div>
+        {{ form_rest(searchForm) }}
+    </div>
 </form>
 

+ 1 - 1
src/Packagist/WebBundle/Resources/views/Web/versionDetails.html.twig

@@ -1,6 +1,6 @@
 {% import "PackagistWebBundle::macros.html.twig" as packagist %}
 
-<p class="requireme">require: <input type="text" readonly="readonly" value="{{ "\"#{version.package.vendor}/#{version.package.packageName}\": \"#{version.hasVersionAlias() ? version.requireVersionAlias : version.requireVersion}\"" }}" /></p>
+<p class="requireme"><input type="text" readonly="readonly" value="composer require {{ "'#{version.package.vendor}/#{version.package.packageName}:#{version.hasVersionAlias() ? version.requireVersionAlias : version.requireVersion}'" }}" /></p>
 
 <h2 class="authors">Author{{ version.authors|length > 1 ? 's' : '' }}</h2>
 <ul>

+ 7 - 3
src/Packagist/WebBundle/Resources/views/Web/viewPackage.html.twig

@@ -121,6 +121,9 @@
                 {% if version and version.support.wiki is defined %}
                     <span>Wiki:</span> <a href="{{ version.support.wiki }}">{{ version.support.wiki }}</a><br />
                 {% endif %}
+                {% if version and version.support.docs is defined %}
+                    <span>Documentation:</span> <a href="{{ version.support.docs }}">{{ version.support.docs }}</a><br />
+                {% endif %}
             </p>
 
             {% if addMaintainerForm is defined or removeMaintainerForm is defined %}
@@ -160,6 +163,7 @@
             {% if versions|length %}
                 <ul class="versions">
                     {% for version in versions %}
+                        {% set expanded = version.id == expandedVersion.id %}
                         <li class="version{% if loop.last %} last{% endif %}" id="{{ version.version }}">
                             <section>
                                 <h1>
@@ -183,12 +187,12 @@
                                     <span class="license{% if not version.license %} unknown{% endif %}">{{ version.license ? version.license|join(', ') : 'Unknown License' }}</span>
                                 </h1>
 
-                                <div class="details{% if loop.index0 == 0 %} open{% endif %}">
-                                    {% if loop.index0 == 0 %}
+                                <div class="details{% if expanded %} open{% endif %}">
+                                    {% if expanded %}
                                         {% include 'PackagistWebBundle:Web:versionDetails.html.twig' with {version: version} %}
                                     {% endif %}
                                 </div>
-                                <div class="details-toggler{% if loop.index0 == 0 %} open{% endif %}"{% if loop.index0 %} data-load-more="{{ path('view_version', {versionId: version.id, _format: 'json'}) }}"{% endif %}></div>
+                                <div class="details-toggler{% if expanded %} open{% endif %}"{% if not expanded %} data-load-more="{{ path('view_version', {versionId: version.id, _format: 'json'}) }}"{% endif %}></div>
                             </section>
                         </li>
                     {% endfor %}

+ 3 - 5
src/Packagist/WebBundle/Resources/views/layout.html.twig

@@ -131,9 +131,7 @@
 #}
         </header>
 
-        {%- if searchForm is defined %}
-            {{ include("PackagistWebBundle:Web:searchSection.html.twig", {showSearchDesc: showSearchDesc|default('show')}) }}
-        {%- endif %}
+        {{ include("PackagistWebBundle:Web:searchSection.html.twig", {showSearchDesc: showSearchDesc|default('show')}) }}
 
         {% block content_header %}{% endblock %}
 
@@ -191,7 +189,7 @@
         <script src="{{ asset('bundles/packagistweb/js/layout.js?v=2') }}"></script>
         <script src="{{ asset('bundles/packagistweb/js/search.js?v=6')}}"></script>
         <script src="//getbootstrap.com/dist/js/bootstrap.js"></script>
-{#
+
         {%- if not app.debug and google_analytics.ga_key %}
         <script>
             var _gaq=[['_setAccount','{{ google_analytics.ga_key }}'],['_trackPageview']];
@@ -200,7 +198,7 @@
             s.parentNode.insertBefore(g,s)}(document,'script'));
         </script>
         {%- endif %}
-#}
+
         {% block scripts %}{% endblock %}
     </body>
 </html>

+ 416 - 4
src/Packagist/WebBundle/Tests/Controller/WebControllerTest.php

@@ -2,7 +2,12 @@
 
 namespace Packagist\WebBundle\Tests\Controller;
 
+use Exception;
+use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Model\DownloadManager;
+use Packagist\WebBundle\Model\FavoriteManager;
 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 class WebControllerTest extends WebTestCase
 {
@@ -13,7 +18,7 @@ class WebControllerTest extends WebTestCase
         $crawler = $client->request('GET', '/');
         $this->assertEquals('Getting Started', $crawler->filter('.getting-started h1')->text());
     }
-    
+
     public function testPackages()
     {
         $client = self::createClient();
@@ -21,15 +26,422 @@ class WebControllerTest extends WebTestCase
         $crawler = $client->request('GET', '/packages/');
         $this->assertTrue($crawler->filter('.packages li')->count() > 0);
     }
-    
+
     public function testPackage()
     {
         $client = self::createClient();
         //we expect package to be clickable and showing at least 'package' div
         $crawler = $client->request('GET', '/packages/');
         $link = $crawler->filter('.packages li h1 a')->first()->attr('href');
-        
+
         $crawler = $client->request('GET', $link);
         $this->assertTrue($crawler->filter('.package')->count() > 0);
     }
-}
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchNoOrderBysAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads();
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByDownloadsAscAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads(
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'asc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByDownloadsDescAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads(
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByFaversAscAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysFavers(
+            array(
+                array(
+                    'sort' => 'favers',
+                    'order' => 'asc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('composer/packagist', 0, 1),
+                    $this->getJsonResult('twig/twig', 0, 2),
+                    $this->getJsonResult('symfony/symfony', 0, 3),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByFaversDescAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysFavers(
+            array(
+                array(
+                    'sort' => 'favers',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 0, 3),
+                    $this->getJsonResult('twig/twig', 0, 2),
+                    $this->getJsonResult('composer/packagist', 0, 1),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderBysCombinationAction()
+    {
+        $userMock = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock1 = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock2 = $this->getMock('Packagist\WebBundle\Entity\User');
+
+        $userMock->method('getId')->will($this->returnValue(1));
+        $userMock1->method('getId')->will($this->returnValue(2));
+        $userMock2->method('getId')->will($this->returnValue(3));
+
+        $json = $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) use (
+                $userMock,
+                $userMock1,
+                $userMock2
+            ) {
+                $downloadManager = $container->get('packagist.download_manager');
+
+                /* @var $downloadManager DownloadManager */
+
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($twigPackage->getId(), 25);
+                }
+                for ($i = 0; $i < 12; $i += 1) {
+                    $downloadManager->addDownload($packagistPackage->getId(), 12);
+                }
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($symfonyPackage->getId(), 42);
+                }
+
+                $favoriteManager = $container->get('packagist.favorite_manager');
+
+                /* @var $favoriteManager FavoriteManager */
+
+                $favoriteManager->markFavorite($userMock, $packagistPackage);
+
+                $favoriteManager->markFavorite($userMock, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock1, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock2, $symfonyPackage);
+            },
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'desc',
+                ),
+                array(
+                    'sort' => 'favers',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 25, 3),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 1),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @param callable $onBeforeIndex TODO Add typehint when migrating to 5.4+
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysAction(
+        $onBeforeIndex,
+        array $orderBys = array()
+    ) {
+        $client = self::createClient();
+
+        $container = $client->getContainer();
+
+        $kernelRootDir = $container->getParameter('kernel.root_dir');
+
+        $this->executeCommand('php '.$kernelRootDir . '/console doctrine:database:drop --env=test --force', false);
+        $this->executeCommand('php '.$kernelRootDir . '/console doctrine:database:create --env=test');
+        $this->executeCommand('php '.$kernelRootDir . '/console doctrine:schema:create --env=test');
+        $this->executeCommand('php '.$kernelRootDir . '/console redis:flushall --env=test -n');
+
+        $lock = $container->getParameter('kernel.cache_dir').'/composer-indexer.lock';
+
+        $this->executeCommand('rm -f ' . $lock);
+
+        $em = $container->get('doctrine')->getManager();
+
+        if (!empty($orderBys)) {
+            $orderBysQryStrPart = '&' . http_build_query(
+                array(
+                    'orderBys' => $orderBys
+                )
+            );
+        } else {
+            $orderBysQryStrPart = '';
+        }
+
+        $twigPackage = new Package();
+
+        $twigPackage->setName('twig/twig');
+        $twigPackage->setRepository('https://github.com/twig/twig');
+
+        $packagistPackage = new Package();
+
+        $packagistPackage->setName('composer/packagist');
+        $packagistPackage->setRepository('https://github.com/composer/packagist');
+
+        $symfonyPackage = new Package();
+
+        $symfonyPackage->setName('symfony/symfony');
+        $symfonyPackage->setRepository('https://github.com/symfony/symfony');
+
+        $em->persist($twigPackage);
+        $em->persist($packagistPackage);
+        $em->persist($symfonyPackage);
+
+        $em->flush();
+
+        $onBeforeIndex($container, $twigPackage, $packagistPackage, $symfonyPackage);
+
+        $this->executeCommand('php '.$kernelRootDir . '/console packagist:index --env=test --force');
+
+        $client->request('GET', '/search.json?q=' . $orderBysQryStrPart);
+
+        $response = $client->getResponse();
+
+        $content = $client->getResponse()->getContent();
+
+        $this->assertSame(200, $response->getStatusCode(), $content);
+
+        return json_decode($content, true);
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysDownloads(
+        array $orderBys = array()
+    ) {
+        return $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) {
+                $downloadManager = $container->get('packagist.download_manager');
+
+                /* @var $downloadManager DownloadManager */
+
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($twigPackage->getId(), 25);
+                }
+                for ($i = 0; $i < 12; $i += 1) {
+                    $downloadManager->addDownload($packagistPackage->getId(), 12);
+                }
+                for ($i = 0; $i < 42; $i += 1) {
+                    $downloadManager->addDownload($symfonyPackage->getId(), 42);
+                }
+            },
+            $orderBys
+        );
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysFavers(
+        array $orderBys = array()
+    ) {
+        $userMock = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock1 = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock2 = $this->getMock('Packagist\WebBundle\Entity\User');
+
+        $userMock->method('getId')->will($this->returnValue(1));
+        $userMock1->method('getId')->will($this->returnValue(2));
+        $userMock2->method('getId')->will($this->returnValue(3));
+
+        return $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) use (
+                $userMock,
+                $userMock1,
+                $userMock2
+            ) {
+                $favoriteManager = $container->get('packagist.favorite_manager');
+
+                /* @var $favoriteManager FavoriteManager */
+
+                $favoriteManager->markFavorite($userMock, $twigPackage);
+                $favoriteManager->markFavorite($userMock1, $twigPackage);
+
+                $favoriteManager->markFavorite($userMock, $packagistPackage);
+
+                $favoriteManager->markFavorite($userMock, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock1, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock2, $symfonyPackage);
+            },
+            $orderBys
+        );
+    }
+
+    /**
+     * Executes a given command.
+     *
+     * @param string $command a command to execute
+     * @param bool $errorHandling
+     *
+     * @throws Exception when the return code is not 0.
+     */
+    protected function executeCommand(
+        $command,
+        $errorHandling = true
+    ) {
+        $output = array();
+
+        $returnCode = null;;
+
+        exec($command, $output, $returnCode);
+
+        if ($errorHandling && $returnCode !== 0) {
+            throw new Exception(
+                sprintf(
+                    'Error executing command "%s", return code was "%s".',
+                    $command,
+                    $returnCode
+                )
+            );
+        }
+    }
+
+    /**
+     * @param string $package
+     * @param int $downloads
+     * @param int $favers
+     *
+     * @return array
+     */
+    protected function getJsonResult($package, $downloads, $favers)
+    {
+        return array(
+            'name' => $package,
+            'description' => '',
+            'url' => 'http://localhost/packages/' . $package,
+            'repository' => 'https://github.com/' . $package,
+            'downloads' => $downloads,
+            'favers' => $favers,
+        );
+    }
+
+    /**
+     * @param array $results
+     *
+     * @return array
+     */
+    protected function getJsonResults(
+        array $results
+    ) {
+        return array(
+            'results' => $results,
+            'total' => count($results)
+        );
+    }
+}

+ 86 - 0
src/Packagist/WebBundle/Tests/Package/SymlinkDumperTest.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Packagist\WebBundle\Tests\Package;
+
+class SymlinkDumperTest extends \PHPUnit_Framework_TestCase
+{
+    private $mockDumper;
+
+    public function setUp()
+    {
+        $this->mockDumper = $this->getMockBuilder('Packagist\WebBundle\Package\SymlinkDumper')
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    public function tearDown()
+    {
+        $this->mockDumper = null;
+    }
+
+    /**
+     * @dataProvider getTestGetTargetListingBlocks
+     */
+    public function testGetTargetListingBlocks($now, array $expected)
+    {
+
+        $blocks = self::invoke($this->mockDumper, 'getTargetListingBlocks', $now);
+
+        $blocks = array_map(function($timestamp) { return date('Y-m-d', $timestamp); }, $blocks);
+
+        $this->assertEquals($expected, $blocks);
+    }
+
+    public function getTestGetTargetListingBlocks()
+    {
+        return array(
+            array(
+                strtotime('2014-12-31'),
+                array(
+                    'latest'  => '2014-12-22',
+                    '2014-10' => '2014-10-01',
+                    '2014-07' => '2014-07-01',
+                    '2014-04' => '2014-04-01',
+                    '2014-01' => '2014-01-01',
+                    '2013'    => '2013-01-01',
+                ),
+            ),
+            array(
+                strtotime('2015-01-01'),
+                array(
+                    'latest'  => '2014-12-22',
+                    '2015-01' => '2015-01-01',
+                    '2014-10' => '2014-10-01',
+                    '2014-07' => '2014-07-01',
+                    '2014-04' => '2014-04-01',
+                    '2014'    => '2014-01-01',
+                    '2013'    => '2013-01-01',
+                ),
+            ),
+            array(
+                strtotime('2015-05-31'),
+                array(
+                    'latest'  => '2015-05-25',
+                    '2015-04' => '2015-04-01',
+                    '2015-01' => '2015-01-01',
+                    '2014-10' => '2014-10-01',
+                    '2014-07' => '2014-07-01',
+                    '2014'    => '2014-01-01',
+                    '2013'    => '2013-01-01',
+                ),
+            ),
+        );
+    }
+
+    private static function invoke($object, $method)
+    {
+        $refl = new \ReflectionMethod($object, $method);
+        $refl->setAccessible(true);
+
+        $args = func_get_args();
+        array_shift($args); // object
+        array_shift($args); // method
+
+        return $refl->invokeArgs($object, $args);
+    }
+}