image ID list * translators, eg: * * \li the item "fred" will search the image_tags table to find image IDs with the fred tag * \li the item "size=640x480" will search the images table to find image IDs of 640x480 images * * So the search "fred size=640x480" will calculate two lists and take the * intersection. (There are some optimisations in there making it more * complicated behind the scenes, but as long as you can turn a single word * into a list of image IDs, making a search plugin should be simple) */ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * Classes * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /** * Class Image * * An object representing an entry in the images table. * * As of 2.2, this no longer necessarily represents an * image per se, but could be a video, sound file, or any * other supported upload type. */ class Image { private static $tag_n = 0; // temp hack public static $order_sql = null; // this feels ugly /** @var null|int */ public $id = null; /** @var int */ public $height; /** @var int */ public $width; /** @var string */ public $hash; public $filesize; /** @var string */ public $filename; /** @var string */ public $ext; /** @var string[]|null */ public $tag_array; public $owner_id, $owner_ip; public $posted; public $source; public $locked; /** * One will very rarely construct an image directly, more common * would be to use Image::by_id, Image::by_hash, etc. * * @param null|mixed $row */ public function __construct($row=null) { assert('is_null($row) || is_array($row)'); if(!is_null($row)) { foreach($row as $name => $value) { // some databases use table.name rather than name $name = str_replace("images.", "", $name); $this->$name = $value; // hax, this is likely the cause of much scrutinizer-ci complaints. } $this->locked = bool_escape($this->locked); assert(is_numeric($this->id)); assert(is_numeric($this->height)); assert(is_numeric($this->width)); } } /** * Find an image by ID. * * @param int $id * @return Image */ public static function by_id(/*int*/ $id) { assert('is_numeric($id)'); global $database; $row = $database->get_row("SELECT * FROM images WHERE images.id=:id", array("id"=>$id)); return ($row ? new Image($row) : null); } /** * Find an image by hash. * * @param string $hash * @return Image */ public static function by_hash(/*string*/ $hash) { assert('is_string($hash)'); global $database; $row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", array("hash"=>$hash)); return ($row ? new Image($row) : null); } /** * Pick a random image out of a set. * * @param string[] $tags * @return Image */ public static function by_random($tags=array()) { assert('is_array($tags)'); $max = Image::count_images($tags); if ($max < 1) return null; // From Issue #22 - opened by HungryFeline on May 30, 2011. $rand = mt_rand(0, $max-1); $set = Image::find_images($rand, 1, $tags); if(count($set) > 0) return $set[0]; else return null; } /** * Search for an array of images * * @param int $start * @param int $limit * @param string[] $tags * @throws SCoreException * @return Image[] */ public static function find_images(/*int*/ $start, /*int*/ $limit, $tags=array()) { assert('is_numeric($start)'); assert('is_numeric($limit)'); assert('is_array($tags)'); global $database, $user, $config; $images = array(); if($start < 0) $start = 0; if($limit < 1) $limit = 1; if(SPEED_HAX) { if(!$user->can("big_search") and count($tags) > 3) { throw new SCoreException("Anonymous users may only search for up to 3 tags at a time"); } } $result = null; if(SEARCH_ACCEL) { $result = Image::get_accelerated_result($tags, $start, $limit); } if(!$result) { $querylet = Image::build_search_querylet($tags); $querylet->append(new Querylet(" ORDER BY ".(Image::$order_sql ?: "images.".$config->get_string("index_order")))); $querylet->append(new Querylet(" LIMIT :limit OFFSET :offset", array("limit"=>$limit, "offset"=>$start))); #var_dump($querylet->sql); var_dump($querylet->variables); $result = $database->execute($querylet->sql, $querylet->variables); } while($row = $result->fetch()) { $images[] = new Image($row); } Image::$order_sql = null; return $images; } /** * @param string[] $tags * @return boolean */ public function validate_accel($tags) { $yays = 0; $nays = 0; foreach($tags as $tag) { if(!preg_match("/^-?[a-zA-Z0-9_]+$/", $tag)) { return false; } if($tag[0] == "-") $nays++; else $yays++; } return ($yays > 1 || $nays > 0); } /** * @param string[] $tags * @param int $offset * @param int $limit * @return null|PDOStatement * @throws SCoreException */ public function get_accelerated_result($tags, $offset, $limit) { global $database; if(!Image::validate_accel($tags)) { return null; } $yays = array(); $nays = array(); foreach($tags as $tag) { if($tag[0] == "-") { $nays[] = substr($tag, 1); } else { $yays[] = $tag; } } $req = array( "yays" => $yays, "nays" => $nays, "offset" => $offset, "limit" => $limit, ); $fp = fsockopen("127.0.0.1", 21212); if (!$fp) { return null; } fwrite($fp, json_encode($req)); $data = fgets($fp, 1024); fclose($fp); $response = json_decode($data); $list = implode(",", $response); if($list) { $result = $database->execute("SELECT * FROM images WHERE id IN ($list) ORDER BY images.id DESC"); } else { $result = $database->execute("SELECT * FROM images WHERE 1=0 ORDER BY images.id DESC"); } return $result; } /* * Image-related utility functions */ /** * Count the number of image results for a given search * * @param string[] $tags * @return int */ public static function count_images($tags=array()) { assert('is_array($tags)'); global $database; $tag_count = count($tags); if($tag_count === 0) { $total = $database->cache->get("image-count"); if(!$total) { $total = $database->get_one("SELECT COUNT(*) FROM images"); $database->cache->set("image-count", $total, 600); } return $total; } else if($tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) { return $database->get_one( $database->scoreql_to_sql("SELECT count FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)"), array("tag"=>$tags[0])); } else { $querylet = Image::build_search_querylet($tags); $result = $database->execute($querylet->sql, $querylet->variables); return $result->rowCount(); } } /** * Count the number of pages for a given search * * @param string[] $tags * @return float */ public static function count_pages($tags=array()) { assert('is_array($tags)'); global $config; return ceil(Image::count_images($tags) / $config->get_int('index_images')); } /* * Accessors & mutators */ /** * Find the next image in the sequence. * * Rather than simply $this_id + 1, one must take into account * deleted images and search queries * * @param string[] $tags * @param bool $next * @return Image */ public function get_next($tags=array(), $next=true) { assert('is_array($tags)'); assert('is_bool($next)'); global $database; if($next) { $gtlt = "<"; $dir = "DESC"; } else { $gtlt = ">"; $dir = "ASC"; } if(count($tags) === 0) { $row = $database->get_row(' SELECT images.* FROM images WHERE images.id '.$gtlt.' '.$this->id.' ORDER BY images.id '.$dir.' LIMIT 1 '); } else { $tags[] = 'id'. $gtlt . $this->id; $querylet = Image::build_search_querylet($tags); $querylet->append_sql(' ORDER BY images.id '.$dir.' LIMIT 1'); $row = $database->get_row($querylet->sql, $querylet->variables); } return ($row ? new Image($row) : null); } /** * The reverse of get_next * * @param string[] $tags * @return Image */ public function get_prev($tags=array()) { return $this->get_next($tags, false); } /** * Find the User who owns this Image * * @return User */ public function get_owner() { return User::by_id($this->owner_id); } /** * Set the image's owner. * * @param User $owner */ public function set_owner(User $owner) { global $database; if($owner->id != $this->owner_id) { $database->execute(" UPDATE images SET owner_id=:owner_id WHERE id=:id ", array("owner_id"=>$owner->id, "id"=>$this->id)); log_info("core_image", "Owner for Image #{$this->id} set to {$owner->name}", false, array("image_id" => $this->id)); } } /** * Get this image's tags as an array. * * @return string[] */ public function get_tag_array() { global $database; if(!isset($this->tag_array)) { $this->tag_array = $database->get_col(" SELECT tag FROM image_tags JOIN tags ON image_tags.tag_id = tags.id WHERE image_id=:id ORDER BY tag ", array("id"=>$this->id)); } return $this->tag_array; } /** * Get this image's tags as a string. * * @return string */ public function get_tag_list() { return Tag::implode($this->get_tag_array()); } /** * Get the URL for the full size image * * @return string */ public function get_image_link() { return $this->get_link('image_ilink', '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext'); } /** * Get the URL for the thumbnail * * @return string */ public function get_thumb_link() { return $this->get_link('image_tlink', '_thumbs/$hash/thumb.jpg', 'thumb/$id.jpg'); } /** * Check configured template for a link, then try nice URL, then plain URL * * @param string $template * @param string $nice * @param string $plain * @return string */ private function get_link($template, $nice, $plain) { global $config; $image_link = $config->get_string($template); if(!empty($image_link)) { if(!(strpos($image_link, "://") > 0) && !startsWith($image_link, "/")) { $image_link = make_link($image_link); } return $this->parse_link_template($image_link); } else if($config->get_bool('nice_urls', false)) { return $this->parse_link_template(make_link($nice)); } else { return $this->parse_link_template(make_link($plain)); } } /** * Get the tooltip for this image, formatted according to the * configured template. * * @return string */ public function get_tooltip() { global $config; $tt = $this->parse_link_template($config->get_string('image_tip'), "no_escape"); // Removes the size tag if the file is an mp3 if($this->ext === 'mp3'){ $iitip = $tt; $mp3tip = array("0x0"); $h_tip = str_replace($mp3tip, " ", $iitip); // Makes it work with a variation of the default tooltips (I.E $tags // $filesize // $size) $justincase = array(" //", "// ", " //", "// ", " "); if(strstr($h_tip, " ")) { $h_tip = html_escape(str_replace($justincase, "", $h_tip)); }else{ $h_tip = html_escape($h_tip); } return $h_tip; } else { return $tt; } } /** * Figure out where the full size image is on disk. * * @return string */ public function get_image_filename() { return warehouse_path("images", $this->hash); } /** * Figure out where the thumbnail is on disk. * * @return string */ public function get_thumb_filename() { return warehouse_path("thumbs", $this->hash); } /** * Get the original filename. * * @return string */ public function get_filename() { return $this->filename; } /** * Get the image's mime type. * * @return string */ public function get_mime_type() { return getMimeType($this->get_image_filename(), $this->get_ext()); } /** * Get the image's filename extension * * @return string */ public function get_ext() { return $this->ext; } /** * Get the image's source URL * * @return string */ public function get_source() { return $this->source; } /** * Set the image's source URL * * @param string $new_source */ public function set_source(/*string*/ $new_source) { global $database; $old_source = $this->source; if(empty($new_source)) $new_source = null; if($new_source != $old_source) { $database->execute("UPDATE images SET source=:source WHERE id=:id", array("source"=>$new_source, "id"=>$this->id)); log_info("core_image", "Source for Image #{$this->id} set to: $new_source (was $old_source)", false, array("image_id" => $this->id)); } } /** * Check if the image is locked. * @return bool */ public function is_locked() { return $this->locked; } /** * @param bool $tf * @throws SCoreException */ public function set_locked($tf) { global $database; $ln = $tf ? "Y" : "N"; $sln = $database->scoreql_to_sql('SCORE_BOOL_'.$ln); $sln = str_replace("'", "", $sln); $sln = str_replace('"', "", $sln); if(bool_escape($sln) !== $this->locked) { $database->execute("UPDATE images SET locked=:yn WHERE id=:id", array("yn"=>$sln, "id"=>$this->id)); log_info("core_image", "Setting Image #{$this->id} lock to: $ln", false, array("image_id" => $this->id)); } } /** * Delete all tags from this image. * * Normally in preparation to set them to a new set. */ public function delete_tags_from_image() { global $database; if($database->get_driver_name() == "mysql") { //mysql < 5.6 has terrible subquery optimization, using EXISTS / JOIN fixes this $database->execute(" UPDATE tags t INNER JOIN image_tags it ON t.id = it.tag_id SET count = count - 1 WHERE it.image_id = :id", array("id"=>$this->id) ); } else { $database->execute(" UPDATE tags SET count = count - 1 WHERE id IN ( SELECT tag_id FROM image_tags WHERE image_id = :id ) ", array("id"=>$this->id)); } $database->execute(" DELETE FROM image_tags WHERE image_id=:id ", array("id"=>$this->id)); } /** * Set the tags for this image. * * @param string[] $tags * @throws Exception */ public function set_tags($tags) { assert('is_array($tags) && count($tags) > 0', var_export($tags, true)); global $database; if(count($tags) <= 0) { throw new SCoreException('Tried to set zero tags'); } if(implode(" ", $tags) != $this->get_tag_list()) { // delete old $this->delete_tags_from_image(); // insert each new tags foreach($tags as $tag) { if(mb_strlen($tag, 'UTF-8') > 255){ flash_message("The tag below is longer than 255 characters, please use a shorter tag.\n$tag\n"); continue; } $id = $database->get_one( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag) "), array("tag"=>$tag) ); if(empty($id)) { // a new tag $database->execute( "INSERT INTO tags(tag) VALUES (:tag)", array("tag"=>$tag)); $database->execute( "INSERT INTO image_tags(image_id, tag_id) VALUES(:id, (SELECT id FROM tags WHERE tag = :tag))", array("id"=>$this->id, "tag"=>$tag)); } else { // user of an existing tag $database->execute(" INSERT INTO image_tags(image_id, tag_id) VALUES(:iid, :tid) ", array("iid"=>$this->id, "tid"=>$id)); } $database->execute( $database->scoreql_to_sql(" UPDATE tags SET count = count + 1 WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag) "), array("tag"=>$tag) ); } log_info("core_image", "Tags for Image #{$this->id} set to: ".implode(" ", $tags), null, array("image_id" => $this->id)); $database->cache->delete("image-{$this->id}-tags"); } } /** * Send list of metatags to be parsed. * * @param string[] $metatags * @param int $image_id */ public function parse_metatags($metatags, $image_id) { foreach($metatags as $tag) { $ttpe = new TagTermParseEvent($tag, $image_id, TRUE); send_event($ttpe); } } /** * Delete this image from the database and disk */ public function delete() { global $database; $this->delete_tags_from_image(); $database->execute("DELETE FROM images WHERE id=:id", array("id"=>$this->id)); log_info("core_image", 'Deleted Image #'.$this->id.' ('.$this->hash.')', false, array("image_id" => $this->id)); unlink($this->get_image_filename()); unlink($this->get_thumb_filename()); } /** * This function removes an image (and thumbnail) from the DISK ONLY. * It DOES NOT remove anything from the database. */ public function remove_image_only() { log_info("core_image", 'Removed Image File ('.$this->hash.')', false, array("image_id" => $this->id)); @unlink($this->get_image_filename()); @unlink($this->get_thumb_filename()); } /** * Someone please explain this * * @param string $tmpl * @param string $_escape * @return string */ public function parse_link_template($tmpl, $_escape="url_escape") { global $config; // don't bother hitting the database if it won't be used... $tags = ""; if(strpos($tmpl, '$tags') !== false) { // * stabs dynamically typed languages with a rusty spoon * $tags = $this->get_tag_list(); $tags = str_replace("/", "", $tags); $tags = preg_replace("/^\.+/", "", $tags); } $base_href = $config->get_string('base_href'); $fname = $this->get_filename(); $base_fname = strpos($fname, '.') ? substr($fname, 0, strrpos($fname, '.')) : $fname; $tmpl = str_replace('$id', $this->id, $tmpl); $tmpl = str_replace('$hash_ab', substr($this->hash, 0, 2), $tmpl); $tmpl = str_replace('$hash_cd', substr($this->hash, 2, 2), $tmpl); $tmpl = str_replace('$hash', $this->hash, $tmpl); $tmpl = str_replace('$tags', $_escape($tags), $tmpl); $tmpl = str_replace('$base', $base_href, $tmpl); $tmpl = str_replace('$ext', $this->ext, $tmpl); $tmpl = str_replace('$size', "{$this->width}x{$this->height}", $tmpl); $tmpl = str_replace('$filesize', to_shorthand_int($this->filesize), $tmpl); $tmpl = str_replace('$filename', $_escape($base_fname), $tmpl); $tmpl = str_replace('$title', $_escape($config->get_string("title")), $tmpl); $tmpl = str_replace('$date', $_escape(autodate($this->posted, false)), $tmpl); // nothing seems to use this, sending the event out to 50 exts is a lot of overhead if(!SPEED_HAX) { $plte = new ParseLinkTemplateEvent($tmpl, $this); send_event($plte); $tmpl = $plte->link; } static $flexihash = null; static $fh_last_opts = null; $matches = array(); if(preg_match("/(.*){(.*)}(.*)/", $tmpl, $matches)) { $pre = $matches[1]; $opts = $matches[2]; $post = $matches[3]; if($opts != $fh_last_opts) { $fh_last_opts = $opts; $flexihash = new Flexihash\Flexihash(); foreach(explode(",", $opts) as $opt) { $parts = explode("=", $opt); $parts_count = count($parts); $opt_val = ""; $opt_weight = 0; if($parts_count === 2) { $opt_val = $parts[0]; $opt_weight = $parts[1]; } elseif($parts_count === 1) { $opt_val = $parts[0]; $opt_weight = 1; } $flexihash->addTarget($opt_val, $opt_weight); } } $choice = $flexihash->lookup($pre.$post); $tmpl = $pre.$choice.$post; } return $tmpl; } /** * @param string[] $terms * @return \Querylet */ private static function build_search_querylet($terms) { assert('is_array($terms)'); global $database; $tag_querylets = array(); $img_querylets = array(); $positive_tag_count = 0; $negative_tag_count = 0; /* * Turn a bunch of strings into a bunch of TagQuerylet * and ImgQuerylet objects */ $stpe = new SearchTermParseEvent(null, $terms); send_event($stpe); if ($stpe->is_querylet_set()) { foreach ($stpe->get_querylets() as $querylet) { $img_querylets[] = new ImgQuerylet($querylet, true); } } foreach ($terms as $term) { $positive = true; if (is_string($term) && !empty($term) && ($term[0] == '-')) { $positive = false; $term = substr($term, 1); } if (strlen($term) === 0) { continue; } $stpe = new SearchTermParseEvent($term, $terms); send_event($stpe); if ($stpe->is_querylet_set()) { foreach ($stpe->get_querylets() as $querylet) { $img_querylets[] = new ImgQuerylet($querylet, $positive); } } else { // if the whole match is wild, skip this; // if not, translate into SQL if(str_replace("*", "", $term) != "") { $term = str_replace('_', '\_', $term); $term = str_replace('%', '\%', $term); $term = str_replace('*', '%', $term); $tag_querylets[] = new TagQuerylet($term, $positive); if ($positive) $positive_tag_count++; else $negative_tag_count++; } } } /* * Turn a bunch of Querylet objects into a base query * * Must follow the format * * SELECT images.* * FROM (...) AS images * WHERE (...) * * ie, return a set of images.* columns, and end with a WHERE */ // no tags, do a simple search if($positive_tag_count === 0 && $negative_tag_count === 0) { $query = new Querylet(" SELECT images.* FROM images WHERE 1=1 "); } // one positive tag (a common case), do an optimised search else if($positive_tag_count === 1 && $negative_tag_count === 0) { # "LIKE" to account for wildcards $query = new Querylet($database->scoreql_to_sql(" SELECT * FROM ( SELECT images.* FROM images JOIN image_tags ON images.id=image_tags.image_id JOIN tags ON image_tags.tag_id=tags.id WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) GROUP BY images.id ) AS images WHERE 1=1 "), array("tag"=>$tag_querylets[0]->tag)); } // more than one positive tag, or more than zero negative tags else { if($database->get_driver_name() === "mysql") $query = Image::build_ugly_search_querylet($tag_querylets); else $query = Image::build_accurate_search_querylet($tag_querylets); } /* * Merge all the image metadata searches into one generic querylet * and append to the base querylet with "AND blah" */ if(!empty($img_querylets)) { $n = 0; $img_sql = ""; $img_vars = array(); foreach ($img_querylets as $iq) { if ($n++ > 0) $img_sql .= " AND"; if (!$iq->positive) $img_sql .= " NOT"; $img_sql .= " (" . $iq->qlet->sql . ")"; $img_vars = array_merge($img_vars, $iq->qlet->variables); } $query->append_sql(" AND "); $query->append(new Querylet($img_sql, $img_vars)); } return $query; } /** * WARNING: this description is no longer accurate, though it does get across * the general idea - the actual method has a few extra optimisations * * "foo bar -baz user=foo" becomes * * SELECT * FROM images WHERE * images.id IN (SELECT image_id FROM image_tags WHERE tag='foo') * AND images.id IN (SELECT image_id FROM image_tags WHERE tag='bar') * AND NOT images.id IN (SELECT image_id FROM image_tags WHERE tag='baz') * AND images.id IN (SELECT id FROM images WHERE owner_name='foo') * * This is: * A) Incredibly simple: * Each search term maps to a list of image IDs * B) Runs really fast on a good database: * These lists are calculated once, and the set intersection taken * C) Runs really slow on bad databases: * All the subqueries are executed every time for every row in the * images table. Yes, MySQL does suck this much. * * @param TagQuerylet[] $tag_querylets * @return Querylet */ private static function build_accurate_search_querylet($tag_querylets) { global $database; $positive_tag_id_array = array(); $negative_tag_id_array = array(); foreach ($tag_querylets as $tq) { $tag_ids = $database->get_col( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) "), array("tag" => $tq->tag) ); if ($tq->positive) { $positive_tag_id_array = array_merge($positive_tag_id_array, $tag_ids); if (count($tag_ids) == 0) { # one of the positive tags had zero results, therefor there # can be no results; "where 1=0" should shortcut things return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } } else { $negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids); } } assert('$positive_tag_id_array || $negative_tag_id_array'); $wheres = array(); if (!empty($positive_tag_id_array)) { $positive_tag_id_list = join(', ', $positive_tag_id_array); $wheres[] = "tag_id IN ($positive_tag_id_list)"; } if (!empty($negative_tag_id_array)) { $negative_tag_id_list = join(', ', $negative_tag_id_array); $wheres[] = "tag_id NOT IN ($negative_tag_id_list)"; } $wheres_str = join(" AND ", $wheres); return new Querylet(" SELECT images.* FROM images WHERE images.id IN ( SELECT image_id FROM image_tags WHERE $wheres_str GROUP BY image_id HAVING COUNT(image_id) >= :search_score ) ", array("search_score"=>count($positive_tag_id_array))); } /** * this function exists because mysql is a turd, see the docs for * build_accurate_search_querylet() for a full explanation * * @param TagQuerylet[] $tag_querylets * @return Querylet */ private static function build_ugly_search_querylet($tag_querylets) { global $database; $positive_tag_count = 0; foreach($tag_querylets as $tq) { if($tq->positive) $positive_tag_count++; } // only negative tags - shortcut to fail if($positive_tag_count == 0) { // TODO: This isn't currently implemented. // SEE: https://github.com/shish/shimmie2/issues/66 return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } // merge all the tag querylets into one generic one $sql = "0"; $terms = array(); foreach($tag_querylets as $tq) { $sign = $tq->positive ? "+" : "-"; $sql .= ' '.$sign.' IF(SUM(tag LIKE :tag'.Image::$tag_n.'), 1, 0)'; $terms['tag'.Image::$tag_n] = $tq->tag; Image::$tag_n++; } $tag_search = new Querylet($sql, $terms); $tag_id_array = array(); foreach($tag_querylets as $tq) { $tag_ids = $database->get_col( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) "), array("tag" => $tq->tag) ); $tag_id_array = array_merge($tag_id_array, $tag_ids); if($tq->positive && count($tag_ids) == 0) { # one of the positive tags had zero results, therefor there # can be no results; "where 1=0" should shortcut things return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } } Image::$tag_n = 0; return new Querylet(' SELECT * FROM ( SELECT images.*, ('.$tag_search->sql.') AS score FROM images LEFT JOIN image_tags ON image_tags.image_id = images.id JOIN tags ON image_tags.tag_id = tags.id WHERE tags.id IN (' . join(', ', $tag_id_array) . ') GROUP BY images.id HAVING score = :score ) AS images WHERE 1=1 ', array_merge( $tag_search->variables, array("score"=>$positive_tag_count) )); } } /** * Class Tag * * A class for organising the tag related functions. * * All the methods are static, one should never actually use a tag object. * */ class Tag { /** * @param string[] $tags * @return string */ public static function implode($tags) { assert('is_array($tags)'); sort($tags); $tags = implode(' ', $tags); return $tags; } /** * Turn a human-supplied string into a valid tag array. * * @param string $tags * @param bool $tagme add "tagme" if the string is empty * @return string[] */ public static function explode($tags, $tagme=true) { global $database; assert('is_string($tags)'); $tags = explode(' ', trim($tags)); /* sanitise by removing invisible / dodgy characters */ $tag_array = array(); foreach($tags as $tag) { $tag = preg_replace("/\s/", "", $tag); # whitespace $tag = preg_replace('/\x20(\x0e|\x0f)/', '', $tag); # unicode RTL $tag = preg_replace("/\.+/", ".", $tag); # strings of dots? $tag = preg_replace("/^(\.+[\/\\\\])+/", "", $tag); # trailing slashes? $tag = trim($tag, ", \t\n\r\0\x0B"); if(mb_strlen($tag, 'UTF-8') > 255){ flash_message("The tag below is longer than 255 characters, please use a shorter tag.\n$tag\n"); continue; } if(!empty($tag)) { $tag_array[] = $tag; } } /* if user supplied a blank string, add "tagme" */ if(count($tag_array) === 0 && $tagme) { $tag_array = array("tagme"); } /* resolve aliases */ $new = array(); $i = 0; $tag_count = count($tag_array); while($i<$tag_count) { $tag = $tag_array[$i]; $negative = ''; if(!empty($tag) && ($tag[0] == '-')) { $negative = '-'; $tag = substr($tag, 1); } $newtags = $database->get_one( $database->scoreql_to_sql(" SELECT newtag FROM aliases WHERE SCORE_STRNORM(oldtag)=SCORE_STRNORM(:tag) "), array("tag"=>$tag) ); if(empty($newtags)) { //tag has no alias, use old tag $aliases = array($tag); } else { $aliases = Tag::explode($newtags); } foreach($aliases as $alias) { if(!in_array($alias, $new)) { if($tag == $alias) { $new[] = $negative.$alias; } elseif(!in_array($alias, $tag_array)) { $tag_array[] = $negative.$alias; $tag_count++; } } } $i++; } /* remove any duplicate tags */ $tag_array = array_iunique($new); /* tidy up */ sort($tag_array); return $tag_array; } } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * Misc functions * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /** * Move a file from PHP's temporary area into shimmie's image storage * hierarchy, or throw an exception trying. * * @param DataUploadEvent $event * @throws UploadException */ function move_upload_to_archive(DataUploadEvent $event) { $target = warehouse_path("images", $event->hash); if(!@copy($event->tmpname, $target)) { $errors = error_get_last(); throw new UploadException( "Failed to copy file from uploads ({$event->tmpname}) to archive ($target): ". "{$errors['type']} / {$errors['message']}" ); } } /** * Add a directory full of images * * @param $base string * @return array */ function add_dir($base) { $results = array(); foreach(list_files($base) as $full_path) { $short_path = str_replace($base, "", $full_path); $filename = basename($full_path); $tags = path_to_tags($short_path); $result = "$short_path (".str_replace(" ", ", ", $tags).")... "; try { add_image($full_path, $filename, $tags); $result .= "ok"; } catch(UploadException $ex) { $result .= "failed: ".$ex->getMessage(); } $results[] = $result; } return $results; } /** * @param string $tmpname * @param string $filename * @param string $tags * @throws UploadException */ function add_image($tmpname, $filename, $tags) { assert(file_exists($tmpname)); $pathinfo = pathinfo($filename); if(!array_key_exists('extension', $pathinfo)) { throw new UploadException("File has no extension"); } $metadata = array(); $metadata['filename'] = $pathinfo['basename']; $metadata['extension'] = $pathinfo['extension']; $metadata['tags'] = $tags; $metadata['source'] = null; $event = new DataUploadEvent($tmpname, $metadata); send_event($event); if($event->image_id == -1) { throw new UploadException("File type not recognised"); } } /** * Given a full size pair of dimensions, return a pair scaled down to fit * into the configured thumbnail square, with ratio intact * * @param int $orig_width * @param int $orig_height * @return int[] */ function get_thumbnail_size(/*int*/ $orig_width, /*int*/ $orig_height) { global $config; if($orig_width === 0) $orig_width = 192; if($orig_height === 0) $orig_height = 192; if($orig_width > $orig_height * 5) $orig_width = $orig_height * 5; if($orig_height > $orig_width * 5) $orig_height = $orig_width * 5; $max_width = $config->get_int('thumb_width'); $max_height = $config->get_int('thumb_height'); $xscale = ($max_height / $orig_height); $yscale = ($max_width / $orig_width); $scale = ($xscale < $yscale) ? $xscale : $yscale; if($scale > 1 && $config->get_bool('thumb_upscale')) { return array((int)$orig_width, (int)$orig_height); } else { return array((int)($orig_width*$scale), (int)($orig_height*$scale)); } }