shimmie2/core/imageboard.pack.php

1280 lines
32 KiB
PHP

<?php
/**
* All the imageboard-specific bits of code should be in this file, everything
* else in /core should be standard SCore bits.
*/
/**
* \page search Shimmie2: Searching
*
* The current search system is built of several search item -> 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;
}
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));
}
}