src/Component.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

namespace BootPress\Asset;

use BootPress\Page\Component as Page;
use BootPress\SQLite\Component as SQLite;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use League\Glide\Responses\SymfonyResponseFactory;
use League\Glide\ServerFactory;
use MatthiasMullie\Minify;
use phpUri;

/**
 * Caches and delivers assets of every sort, from any location, with hands-off versioning. Manipulates images on-the-fly. Minifies and combines (on-demand) css and javascript files.
 *
 * ``Asset::cached()`` is a one-stop method for all of your asset caching needs. This should be the first thing that you call. It checks to see if the page is looking for a cached asset. If it is, then it will return a response that you can ``$page->send()``. If not, then just continue on your merry way. When you ``$page->display()`` your html, it will look for all of your assets, and convert them to cached urls.
 *
 * - If an asset is found we give it a unique (5 character) id that then becomes the "folder", and we add the ``basename()`` to the end for reference / seo sakes.
 *   - *http://example.com/page/dir/bootstrap.css* will become *http://example.com/...../bootstrap.css* where '**bootstrap.css**' means nothing, and '**.....**' is the actual asset location.
 *   - 60 alphanumeric characters (no 0's) ^ 5 (character length) gives 777,600,000 possible combinations.
 * - If a '**#fragment**' is located immediately after the asset, we'll remove the fragment and ...
 *   - If it is a .css or .js file then we will combine them together so that *http://example.com/page/dir/bootstrap.css#../default.css#user/custom.css* will become *http://example.com/.....0.....0...../bootstrap-default-custom.css* and we'll minify and serve the *'/page/dir/bootstrap.css'*, *'/page/default.css'*, and *'/page/dir/user/custom.css'* files all at once.
 *   - Otherwise we'll replace the name with it ie. *http://example.com/page/dir/image.jpg#seo* will become *http://example.com/...../seo.jpg*
 * - If you add a '**?query=string**' to images, we'll remove and save it with the filename ie. *http://example.com/page/dir/image.jpg?w=150#seo* will become *http://example.com/...../seo.jpg* only '**.....**' will be different from the previous example, and the image.jpg's width will be 150 pixels.
 *   - To see all of the options here, check out the [Quick Reference "Glide"](http://glide.thephpleague.com/1.0/api/quick-reference/).
 * - The ``filemtime()`` is saved so that when an asset changes, we can give it a new unique filename that the browser will then come looking for and cache all over again.
 *   - This allows us to tell browsers to never come looking for the asset again, because it will never change.
 *   - There is no better way to make your pages load any faster than this.
 */
class Component
{
    /** @var string The supported asset types. */
    const PREG_TYPES = 'jpe?g|gif|png|ico|js|css|pdf|ttf|otf|svg|eot|woff2?|swf|tar|t?gz|g?zip|csv|xls?x?|word|docx?|pptx?|psd|ogg|wav|mp3|mp4|mpeg?|mpg|mov|qt';

    /** @var object $this.  Enables static methods for brevity.  Made public to facilitate testing. */
    public static $instance;

    /** @var array Assets that were linked to, but do not exist. */
    public static $not_found = array();

    /** @var array An ``array($link => $cached, ...)`` of urls that were converted. */
    public static $urls = array();

    /** @var string The directory where assets are cached. */
    private $cached = null;

    /** @var object A BootPress\SQLite\Component instance. */
    private $db = null;

    /**
     * Check if the current page is a cached asset you need to ``$page->send()``.
     *
     * @param string $dir   The folder you want to cache all your assets in.
     * @param array  $glide Optional parameters to use when setting up the [Glide Server Factory](http://glide.thephpleague.com/1.0/config/setup/).  The only ones we'll use are:
     *
     * - '**group_cache_in_folders**' => Whether to group cached images in folders
     * - '**watermarks**' => Watermarks filesystem
     * - '**driver**' => Image driver (gd or imagick)
     * - '**max_image_size**' => Image size limit
     *          
     * @return bool|object Either false, or a Symfony\Component\HttpFoundation\Response for you to send.
     *
     * ```php
     * use BootPress\Page\Component as Page;
     * use BootPress\Asset\Component as Asset;
     *
     * $page = Page::html();
     * if ($asset = Asset::cached('assets')) {
     *     $page->send($asset);
     * }
     * ```
     */
    public static function cached($dir, array $glide = array())
    {
        $page = Page::html();
        static::$instance = new static();
        $asset = static::$instance;
        $asset->cached = $page->dir($dir);
        $type = strtolower($page->url['format']);
        if ($type == 'html') {
            $page->filter('page', array($asset, 'urls'));

            return false;
        } elseif (!preg_match('/^'.implode('', array(
            '(?P<paths>([1-9a-z]{5}[0]?)+)',
            '(\/.*)?',
            '\.('.self::PREG_TYPES.')',
        )).'$/i', $page->url['path'], $matches)) {
            return false;
        }
        $paths = explode('0', rtrim($matches['paths'], '0'));
        foreach ($paths as $key => $value) {
            $paths[$key] = '"'.$value.'"';
        }
        $minify = array();
        $image = null;
        $file = null;
        $asset->openDatabase();
        if ($stmt = $asset->db->query(array(
            'SELECT p.tiny, f.file, f.query',
            'FROM paths as p',
            'INNER JOIN files AS f ON p.file_id = f.id',
            'WHERE '.$asset->db->inOrder('p.tiny', $paths),
        ), '', 'assoc')) {
            while ($row = $asset->db->fetch($stmt)) {
                if ($type == strtolower(pathinfo($row['file'], PATHINFO_EXTENSION))) {
                    switch ($type) {
                        case 'js':
                        case 'css':
                            if (is_file($row['file'])) {
                                $minify[$row['file']] = $row;
                            }
                            break 1;
                        case 'jpeg':
                        case 'jpg':
                        case 'gif':
                        case 'png':
                            if (!empty($row['query'])) {
                                $source = $page->dir();
                                $image = substr($row['file'], strlen($source));
                                parse_str($row['query'], $params);
                                $setup = $glide;
                                $glide = array(
                                    'cache' => $asset->cached.'glide/',
                                    'source' => $source,
                                    'response' => new SymfonyResponseFactory($page->request),
                                );
                                if (isset($setup['group_cache_in_folders']) && is_bool($setup['group_cache_in_folders'])) {
                                    $glide['group_cache_in_folders'] = $setup['group_cache_in_folders'];
                                }
                                if (isset($setup['watermarks'])) {
                                    $glide['watermarks'] = rtrim(str_replace('\\', '/', $setup['watermarks']), '/');
                                }
                                if (isset($setup['driver']) && in_array($setup['driver'], array('gd', 'imagick'))) {
                                    $glide['driver'] = $setup['driver'];
                                }
                                if (isset($setup['max_image_size']) && is_numeric($setup['max_image_size'])) {
                                    $glide['max_image_size'] = (int) $setup['max_image_size'];
                                }
                                $glide = ServerFactory::create($glide);
                                $image = $glide->getImageResponse($image, $params);
                                break 2;
                            }
                            // Otherwise we treat is as any other (default) file
                        default:
                            $file = $row['file'];
                            break 2;
                    }
                }
            }
            $asset->db->close($stmt);
        }
        $asset->closeDatabase();
        if (!empty($minify)) {
            $paths = array();
            foreach ($minify as $row) {
                $paths[] = $row['tiny'];
            }
            $file = implode('/', array(
                $asset->cached.'minify',
                md5($page->url['base']),
                implode('0', $paths),
            )).'.'.$type;
            if (!is_dir(dirname($file))) {
                mkdir(dirname($file), 0755, true);
            }
            if (!is_file($file)) {
                switch ($type) {
                    case 'js':
                        $minifier = new Minify\JS();
                        foreach ($minify as $js => $row) {
                            $minifier->add($js);
                        }
                        $minifier->minify($file);
                        break;
                    case 'css':
                        $minifier = new Minify\CSS();
                        foreach ($minify as $css => $row) {
                            $minifier->add($asset->css($css, $row));
                        }
                        $minifier->minify($file);
                        break;
                }
            }
        }

        return ($image) ? $image : static::dispatch($file, array('expires' => 31536000));
    }

    /**
     * Finds all the assets in your **$html**, and caches them.
     *
     * You only need to use this if you are not ``$page->display()``ing the html you want to send.
     * 
     * @param string|array $html
     * 
     * @return string|array The **$html** with all of your asset links cached.
     *
     * ```php
     * $json = array('<p>Content</p>');
     * $page->sendJson(Asset::urls($json));
     * ```
     */
    public static function urls($html)
    {
        if (is_null(static::$instance) || empty($html)) {
            return $html;
        }
        $asset = static::$instance;
        $page = Page::html();
        $array = (is_array($html)) ? $html : false;
        if ($array) {
            $html = array();
            array_walk_recursive($array, function ($value) use (&$html) {
                $html[] = $value;
            });
            $html = implode(' ', $html);
        }
        preg_match_all('/'.implode('', array(
            '('.$page->url['preg'].')',
            '(?P<dir>['.$page->url['chars'].']+)\/',
            '(?P<file>['.$page->url['chars'].'.\/]+\.(?P<ext>'.self::PREG_TYPES.'))',
            '(?P<query>\?['.$page->url['chars'].'&;=.\/]+)?',
            '(#(?P<frag>['.$page->url['chars'].'.\/#]+))?',
        )).'/i', $html, $matches, PREG_SET_ORDER);
        if (empty($matches)) {
            return $array ? $array : $html;
        }
        $asset->openDatabase();
        $cache = array();
        $assets = array();
        $found = $page->dir; // in PHP7 isset($page->dir[$url]) will not work on a private property retrieved via ``__get()``
        foreach ($matches as $url) {
            $dir = $url['dir'];
            if (!isset($found[$dir]) || !is_file($found[$dir].$url['file'])) {
                static::$not_found[] = substr($url[0], strlen($page->url['base']));
                continue;
            }
            $ext = strtolower($url['ext']);
            $file = $url['file'];
            if (isset($url['query']) && in_array($ext, array('jpeg', 'jpg', 'gif', 'png'))) {
                $file .= $url['query'];
            }
            $files = array();
            $files[$file] = pathinfo($url['file'], PATHINFO_FILENAME);
            if (isset($url['frag'])) {
                $frag = explode('#', $url['frag']);
                if ($ext == 'js' || $ext == 'css') {
                    $base = phpUri::parse($page->dir[$dir].$file);
                    foreach ($frag as $file) {
                        $file = $base->join($file);
                        $info = pathinfo($file);
                        if (is_file($file) && $info['extension'] == $ext) {
                            $file = substr($file, strlen($page->dir[$dir]));
                            $files[$file] = $info['filename'];
                        }
                    }
                } else {
                    $files[$file] = pathinfo(array_shift($frag), PATHINFO_FILENAME);
                }
            }
            foreach ($files as $file => $name) {
                $cache[$dir][$file] = array();
            }
            $assets[$url[0]] = array(
                'dir' => $dir,
                'file' => array_keys($files),
                'name' => implode('-', $files),
                'ext' => '.'.$ext,
            );
        }
        $asset->paths($cache);
        $asset->closeDatabase();
        $rnr = array();
        $base = strlen($page->url['base']);
        foreach ($assets as $match => $url) {
            $cached = array();
            foreach ($url['file'] as $file) {
                $cached[] = $cache[$url['dir']][$file];
            }
            $cached = implode(0, $cached);
            if (!is_numeric($url['name'])) {
                $cached .= '/'.$url['name'];
            }
            $cached .= $url['ext'];
            $rnr[$page->url['base'].$cached] = $match; // replace => remove
            static::$urls[substr($match, $base)] = $cached;
        }
        ksort(static::$urls);
        uasort($rnr, function ($a, $b) {
            return mb_strlen($b) - mb_strlen($a);
        });  // ORDER BY strlen(remove) DESC so we don't step on any toes
        $rnr = array_flip($rnr); // remove => replace
        return str_replace(array_keys($rnr), array_values($rnr), $array ? $array : $html);
    }

    /**
     * Prepares a Symfony Response for you to send.
     * 
     * @param string       $file    Either a file location, or the type of file you are sending eg. html, txt, less, scss, json, xml, rdf, rss, atom, js, css
     * @param array|string $options An array of options if ``$file`` is a location, or the string of data you want to send.  The available options are:
     * @param string|array $options The string of data you want to send, or an array of options if ``$file`` is a location.  The available options are:
     * 
     * - (string) '**name**' => Changes a downloadable asset's file name.
     * - (int) '**expires**' => The max_age (in seconds) to cache the file for.  Defaults to 0 which indicates that it must be constantly revalidated.
     * - (bool) '**xsendfile**' => Whether or not the X-Sendfile-Type header should be trusted.  Defaults to false.
     *
     * If you are sending the content directly and want to cache it, then you can make this an ``array($content, 'expires' => ...)``.
     * 
     * @return object A Symfony\Component\HttpFoundation\Response for you to send.
     *
     * ```php
     * $html = $page->display('<p>Content</p>');
     * $page->send(Asset::dispatch('html', $html));
     * ```
     */
    public static function dispatch($file, $options = array())
    {
        $set = array_merge(array(
            'name' => '',
            'expires' => 0,
            'xsendfile' => false,
        ), (array) $options);
        $page = Page::html();
        if (preg_match('/^(html?|txt|less|scss|json|xml|rdf|rss|atom|js|css)$/', $file)) {
            if (is_array($options)) {
                foreach ($options as $updated => $content) {
                    if (is_numeric($updated)) {
                        break;
                    }
                }
            } else {
                $content = (string) $options;
            }
            $response = new Response($content, Response::HTTP_OK, array(
                'Content-Type' => static::mime($file),
                'Content-Length' => mb_strlen($content),
            ));
            if (isset($updated) && (int) $updated > 631152000) { // 01-01-1990
                $response->setCache(array(
                    'public' => true,
                    'max_age' => $set['expires'],
                    's_maxage' => $set['expires'],
                    'last_modified' => \DateTime::createFromFormat('U', (int) $updated),
                ))->isNotModified($page->request);
            }

            return $response;
        }
        $type = strtolower(pathinfo($file, PATHINFO_EXTENSION));
        if (is_null($file) || !is_file($file)) {
            return new Response('', Response::HTTP_NOT_FOUND);
        }
        if (null === $mime = static::mime($type)) {
            return new Response('', Response::HTTP_NOT_IMPLEMENTED);
        }
        if (preg_match('/^('.implode('|', array(
            '(?P<download>tar|t?gz|g?zip|csv|xls?x?|word|docx?|pptx?|psd)',
            '(?P<stream>ogg|wav|mp3|mp4|mpeg?|mpg|mov|qt)',
        )).')$/', $type, $matches)) {
            $response = new BinaryFileResponse($file);
            $response->headers->set('Content-Type', $mime);
            $response->setContentDisposition(isset($matches['download']) ? 'attachment' : 'inline', $set['name']);
            if ($set['xsendfile']) {
                BinaryFileResponse::trustXSendfileTypeHeader();
            }

            return $response;
        }
        $file = new \SplFileInfo($file);
        $response = new StreamedResponse(function () use ($file) {
            if ($fp = fopen($file->getPathname(), 'rb')) {
                rewind($fp);
                fpassthru($fp);
                fclose($fp);
            }
        }, 200, array(
            'Content-Type' => $mime,
            'Content-Length' => $file->getSize(),
        ));
        $response->setCache(array(
            'public' => true,
            'max_age' => $set['expires'],
            's_maxage' => $set['expires'],
            'last_modified' => \DateTime::createFromFormat('U', $file->getMTime()),
        ));
        $response->isNotModified($page->request);

        return $response;
    }

    /**
     * Get the mime type(s) associated with a file extension.
     * 
     * @param string|array $type If this is a string then we'll give you the main mime type (for sending).  If it's an array then we'll give you all of the mime types (for verifying).
     * 
     * @return string|array The mime type(s).
     *
     * ```php
     * echo Asset::mime('html'); // text/html
     *
     * echo implode(', ', Asset::mime(array('html'))); // text/html, application/xhtml+xml, text/plain
     * ```
     */
    public static function mime($type)
    {
        $mime = null;
        $single = (is_array($type)) ? false : true;
        if (is_array($type)) {
            $type = array_shift($type);
        }
        switch (strtolower($type)) {
            case 'htm':
            case 'html':
                $mime = array('text/html', 'application/xhtml+xml', 'text/plain');
                break;
            case 'txt':
                $mime = array('text/plain');
                break;
            case 'less':
                $mime = array('text/x-less', 'text/css', 'text/plain', 'application/octet-stream');
                break;
            case 'scss':
                $mime = array('text/css', 'text/plain', 'application/octet-stream');
                break;
            case 'json':
                $mime = array('application/json', 'application/x-json', 'text/json', 'text/plain');
                break;
            case 'xml':
                $mime = array('application/xml', 'application/x-xml', 'text/xml', 'text/plain');
                break;
            case 'rdf':
                $mime = array('application/rdf+xml');
                break;
            case 'rss':
                $mime = array('application/rss+xml');
                break;
            case 'atom':
                $mime = array('application/atom+xml');
                break;
            case 'jpeg':
            case 'jpg':
                $mime = array('image/jpeg', 'image/pjpeg');
                break;
            case 'gif':
                $mime = array('image/gif');
                break;
            case 'png':
                $mime = array('image/png',  'image/x-png');
                break;
            case 'ico':
                $mime = array('image/x-icon', 'image/vnd.microsoft.icon');
                break;
            case 'js':
                $mime = array('application/javascript', 'application/x-javascript', 'text/javascript', 'text/plain');
                break;
            case 'css':
                $mime = array('text/css', 'text/plain');
                break;
            case 'pdf':
                $mime = array('application/pdf', 'application/force-download', 'application/x-download', 'binary/octet-stream');
                break;
            case 'ttf':
                $mime = array('application/font-sfnt', 'application/font-ttf', 'application/x-font-ttf', 'font/ttf', 'font/truetype', 'application/octet-stream');
                break;
            case 'otf':
                $mime = array('application/font-sfnt', 'application/font-otf', 'application/x-font-otf', 'font/opentype', 'application/octet-stream');
                break;
            case 'svg':
                $mime = array('image/svg+xml', 'application/xml', 'text/xml');
                break;
            case 'eot':
                $mime = array('application/vnd.ms-fontobject', 'application/octet-stream');
                break;
            case 'woff':
                $mime = array('application/font-woff', 'application/x-woff', 'application/x-font-woff', 'font/x-woff', 'application/octet-stream');
                break;
            case 'woff2':
                $mime = array('application/font-woff2', 'font/woff2', 'application/octet-stream');
                break;
            case 'swf':
                $mime = array('application/x-shockwave-flash');
                break;
            case 'tar':
                $mime = array('application/x-tar');
                break;
            case 'tgz':
                $mime = array('application/x-tar', 'application/x-gzip-compressed');
                break;
            case 'gz':
            case 'gzip':
                $mime = array('application/x-gzip');
                break;
            case 'zip':
                $mime = array('application/x-zip', 'application/zip', 'application/x-zip-compressed', 'application/s-compressed', 'multipart/x-zip');
                break;
            case 'csv':
                $mime = array('text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream', 'application/vnd.ms-excel', 'application/x-csv', 'text/x-csv', 'text/csv', 'application/csv', 'application/excel', 'application/vnd.msexcel', 'text/plain');
                break;
            case 'xl':
                $mime = array('application/excel');
                break;
            case 'xls':
                $mime = array('application/vnd.ms-excel', 'application/msexcel', 'application/x-msexcel', 'application/x-ms-excel', 'application/x-excel', 'application/x-dos_ms_excel', 'application/xls', 'application/x-xls', 'application/excel', 'application/download', 'application/vnd.ms-office', 'application/msword');
                break;
            case 'xlsx':
                $mime = array('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip', 'application/vnd.ms-excel', 'application/msword', 'application/x-zip');
                break;
            case 'word':
                $mime = array('application/msword', 'application/octet-stream');
                break;
            case 'doc':
                $mime = array('application/msword', 'application/vnd.ms-office');
                break;
            case 'docx':
                $mime = array('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/msword', 'application/x-zip');
                break;
            case 'ppt':
                $mime = array('application/powerpoint', 'application/vnd.ms-powerpoint', 'application/vnd.ms-office', 'application/msword');
                break;
            case 'pptx':
                $mime = array('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/x-zip', 'application/zip');
                break;
            case 'psd':
                $mime = array('application/x-photoshop', 'image/vnd.adobe.photoshop');
                break;
            case 'ogg':
                $mime = array('audio/ogg');
                break;
            case 'wav':
                $mime = array('audio/x-wav', 'audio/wave', 'audio/wav');
                break;
            case 'mp3':
                $mime = array('audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3');
                break;
            case 'mp4':
                $mime = array('video/mp4');
                break;
            case 'mpe':
            case 'mpeg':
            case 'mpg':
                $mime = array('video/mpeg');
                break;
            case 'mov':
            case 'qt':
                $mime = array('video/quicktime');
                break;
        }

        return ($mime && $single) ? array_shift($mime) : $mime;
    }

    private function css($file, $row)
    {
        $page = Page::html();
        $css = file_get_contents($file);
        if (substr($css, 0, 3) == "\xef\xbb\xbf") {
            $css = substr($css, 3); // strip BOM, if any
        }
        $matches = array();
        foreach (array(
            '/url\(\s*(?P<quotes>["\'])?(?P<path>(?!(\s?["\']?(data:|https?:|\/\/))).+?)(?(quotes)(?P=quotes))\s*\)/ix', // url(xxx)
            '/@import\s+(?P<quotes>["\'])(?P<path>(?!(["\']?(data:|https?:|\/\/))).+?)(?P=quotes)/ix', // @import "xxx"
        ) as $regex) {
            if (preg_match_all($regex, $css, $match, PREG_SET_ORDER)) {
                $matches = array_merge($matches, $match);
            }
        }
        $rnr = array();
        $base = phpUri::parse($row['file']);
        $common = dirname($row['file']).'/';
        foreach ($matches as $match) {
            if (preg_match('/(?P<file>[^#\?]*)(?P<extra>.*)/', ltrim($match['path'], '/'), $path)) {
                if (static::mime(pathinfo($path['file'], PATHINFO_EXTENSION))) {
                    $file = $base->join($path['file']).$path['extra'];
                    if ($dir = $page->commonDir(array($common, $file))) {
                        $common = $dir;
                        if (strpos($match[0], '@import') === 0) {
                            $rnr[$match[0]] = '@import "'.$file.'"';
                        } else {
                            $rnr[$match[0]] = 'url("'.$file.'")';
                        }
                    }
                }
            }
        }
        if (!empty($rnr)) {
            $page->dir('set', 'css-dir', $common);
            $url = $page->url['base'].'css-dir/';
            foreach ($rnr as $remove => $replace) {
                $rnr[$remove] = str_replace($common, $url, $replace);
            }
            $css = static::urls(str_replace(array_keys($rnr), array_values($rnr), $css));
        }

        return $css;
    }

    private function paths(&$cache)
    {
        $page = Page::html();
        $count = 0;
        foreach ($cache as $dir => $files) {
            $count += count($files);
        }
        $ids = $this->ids($count);
        $insert = array();
        $update = array();
        $stmt = $this->db->prepare(array(
            'SELECT f.id AS file_id, f.updated, p.tiny, p.id AS path_id',
            'FROM files AS f INNER JOIN paths AS p ON f.path_id = p.id',
            'WHERE f.file = ? AND f.query = ?',
            'ORDER BY f.id ASC LIMIT 1',
        ), 'assoc');
        foreach ($cache as $dir => $files) {
            foreach ($files as $path => $tiny) {
                list($file, $query) = explode('?', $path.'?');
                $file = $page->dir[$dir].$file;
                $updated = filemtime($file);
                $this->db->execute($stmt, array($file, $query));
                if ($row = $this->db->fetch($stmt)) {
                    if ($row['updated'] == $updated) {
                        $tiny = $row['tiny'];
                    } else {
                        list($path_id, $tiny) = each($ids);
                        $update[$row['file_id']] = array($path_id, $updated);
                    }
                } else {
                    list($path_id, $tiny) = each($ids);
                    $insert[] = array($path_id, $file, $query, $updated);
                }
                $cache[$dir][$path] = $tiny;
            }
        }
        $this->db->close($stmt);
        if (empty($insert) && empty($update)) {
            return;
        }
        $this->db->exec('BEGIN IMMEDIATE');
        $paths = array();
        if (!empty($insert)) {
            $stmt = $this->db->insert('files', array('path_id', 'file', 'query', 'updated'));
            foreach ($insert as $array) {
                $paths[$array[0]] = $this->db->insert($stmt, $array);
            }
            $this->db->close($stmt);
        }
        if (!empty($update)) {
            $stmt = $this->db->update('files', 'id', array('path_id', 'updated'));
            foreach ($update as $file_id => $array) {
                $this->db->update($stmt, $file_id, $array);
                $paths[$array[0]] = $file_id;
            }
            $this->db->close($stmt);
        }
        if (!empty($paths)) {
            $stmt = $this->db->update('paths', 'id', array('file_id'));
            foreach ($paths as $path_id => $file_id) {
                $this->db->update($stmt, $path_id, array($file_id));
            }
            $this->db->close($stmt);
        }
        $this->db->exec('COMMIT');

        return;
    }

    private function ids($count)
    {
        $ids = array();
        if ($stmt = $this->db->query(array(
            'SELECT id, tiny',
            'FROM paths',
            'WHERE file_id = ?',
            'ORDER BY id DESC LIMIT '.$count,
        ), 0, 'row')) {
            while (list($id, $tiny) = $this->db->fetch($stmt)) {
                $ids[$id] = $tiny;
            }
            $this->db->close($stmt);
        }
        if (count($ids) == $count) {
            return $ids;
        }
        $this->db->exec('BEGIN IMMEDIATE');
        $stmt = $this->db->insert('OR IGNORE INTO paths', array('tiny'));
        $string = '123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
        for ($i = 0; $i < $count + 100; ++$i) {
            $tiny_id = ''; // 60 (characters) ^ 5 (length) gives 777,600,000 possible combinations
            while (strlen($tiny_id) < 5) {
                $tiny_id .= $string[mt_rand(0, 60)];
            }
            $this->db->insert($stmt, array($tiny_id));
        }
        $this->db->close($stmt);
        $this->db->exec('COMMIT');

        return $this->ids($count);
    }

    private function openDatabase()
    {
        if (is_null($this->db)) {
            $this->db = new SQLite($this->cached.'Assets.db');
            if ($this->db->created) {
                $this->db->create('paths', array(
                    'id' => 'INTEGER PRIMARY KEY',
                    'tiny' => 'TEXT UNIQUE NOT NULL DEFAULT ""',
                    'file_id' => 'INTEGER NOT NULL DEFAULT 0',
                ));
                $this->db->create('files', array(
                    'id' => 'INTEGER PRIMARY KEY',
                    'path_id' => 'INTEGER NOT NULL DEFAULT 0',
                    'file' => 'TEXT NOT NULL DEFAULT ""',
                    'query' => 'TEXT NOT NULL DEFAULT ""',
                    'updated' => 'INTEGER NOT NULL DEFAULT 0',
                ), array('unique' => 'file, query'));
            }
        }
    }

    private function closeDatabase()
    {
        if (!is_null($this->db)) {
            $this->db->connection()->close();
        }
        $this->db = null;
    }

    private function __construct()
    {
    }
}