<?PHP
#
#   FILE:  AutoScreenshot.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2011 Edward Almasy and Internet Scout
#   http://scout.wisc.edu
#

/**
* Automatic screenshot plugin.
*/
class AutoScreenshot extends Plugin
{
    /**
    * Register the AutoScreenshot plugin
    */
    function Register()
    {
        $this->Name = "Auto Screenshot";
        $this->Version = "1.0.8";
        $this->Description = "Automatically obtain resources screenshots";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array("CWISCore" => "2.2.2");

        $this->CfgSetup["MinutesBetweenChecks"] = array(
            "Type" => "Number",
            "Label" => "Screenshot Check Interval",
            "Units" => "minutes",
            "MaxVal" => 999999,
            "Help" => "The number of minutes between automatic screenshot generation.",
            );

        $this->CfgSetup["Method"] = array(
            "Type" => "Option",
            "Label" => "Screenshot Method",
            "Help" =>
            "Select what screenshot generating program you want to use.",
            "Options" =>
            array(
                "wkhtmltoimage" => "wkhtmltoimage",
                "firefox"       => "Firefox with Xvfb"
                )
            );

        $this->CfgSetup["SkipIfTrue"] = array(
            "Type" => "MetadataField",
            "Default" => array(),
            "FieldTypes" => MetadataSchema::MDFTYPE_FLAG,
            "AllowMultiple" => TRUE,
            "Label" => "Skip if flags are true",
            "Help" => "When any of the selected flag fields are set to true/on/yes, "
            ."do not attempt automatic screenshots",
            );

        $this->CfgSetup["SkipIfFalse"] = array(
            "Type" => "MetadataField",
            "Default" => array(),
            "FieldTypes" => MetadataSchema::MDFTYPE_FLAG,
            "AllowMultiple" => TRUE,
            "Label" => "Skip if flags are false",
            "Help" => "When any of the selected flag fields are set to false/off/no, "
            ."do not attempt automatic screenshots",
            );

        $this->CfgSetup["TaskPriority"] = array(
            "Type" => "Option",
            "Label" => "Task Priority",
            "Help" => "Priority of the AutoScreenshot tasks in the task queue.",
            "AllowMultiple" => FALSE,
            "Options" => array(
                ApplicationFramework::PRIORITY_BACKGROUND => "Background",
                ApplicationFramework::PRIORITY_LOW => "Low",
                ApplicationFramework::PRIORITY_MEDIUM => "Medium",
                ApplicationFramework::PRIORITY_HIGH => "High")
            );

    }
    /**
    * Install the AutoScreenshot plugin.
    */
    function Install()
    {
        $DB = new Database();

        $DB->Query("CREATE TABLE AutoScreenshot_Failures "
                   ."(ResourceId INT, Messages TEXT, FailureTime TIMESTAMP DEFAULT NOW())");
        $DB->Query("CREATE TABLE AutoScreenshot_ApprovalQueue (ResourceId INT, ImageId INT)");
        $DB->Query("CREATE TABLE AutoScreenshot_Blacklist (ResourceId INT);");

        $this->ConfigSetting("MinutesBetweenChecks", 5);
        $this->ConfigSetting("Method", "wkhtmltoimage");
        $this->ConfigSetting("TaskPriority", ApplicationFramework::PRIORITY_BACKGROUND);
    }

    /**
    * Unistall the Autoscreenshot plugin, cleaning up any screenshots
    * waiting in the approval queue.
    */
    function Uninstall()
    {
        $DB = new Database();

        # Go through the qpproval queue, cleaning up all the images
        #  which live there
        $DB->Query("SELECT * FROM AutoScreenshot_ApprovalQueue");
        $ApprovalQueue = $DB->FetchRows();

        foreach ($ApprovalQueue as $QueueEntry)
        {
            $ThisImage = new SPTImage($QueueEntry["ImageId"]);
            $ThisImage->Delete();
        }

        # Delete all our tables:
        foreach( array(
                     "AutoScreenshot_Failures" => "failed screenshot",
                     "AutoScreenshot_ApprovalQueue" => "screenshot approval queue",
                     "AutoScreenshot_Blacklist" => "screenshot blacklist",
                     ) as $Table => $Desc)
        {
            if (FALSE === $DB->Query("DROP TABLE ".$Table) )
            { return "Could not remove the ".$Desc." table"; }
        }
    }

    /**
    * Upgrade the AutoScreenshot plugin.
    */
    function Upgrade($PreviousVersion)
    {
        $DB = new Database();

        if (version_compare($PreviousVersion, "1.0.2", "<"))
        {
            foreach( array("Failures", "ApprovalQueue", "Blacklist")
                     as $TableName )
            {
                $DB->Query("ALTER TABLE AutoScreenshot".$TableName
                           ." RENAME TO AutoScreenshot_".$TableName);
            }
        }

        if (version_compare($PreviousVersion, "1.0.5", "<"))
        {
            $Database = new Database();

            # remove items for resources that no longer exist or belong to a
            # non-default schema
            $Database->Query("
                DELETE AQ FROM AutoScreenshot_ApprovalQueue AQ
                LEFT JOIN Resources R ON AQ.ResourceId = R.ResourceId
                WHERE R.ResourceId IS NULL
                OR R.SchemaId != '".intval(MetadataSchema::SCHEMAID_DEFAULT)."'");
            $Database->Query("
                DELETE B FROM AutoScreenshot_Blacklist B
                LEFT JOIN Resources R ON B.ResourceId = R.ResourceId
                WHERE R.ResourceId IS NULL
                OR R.SchemaId != '".intval(MetadataSchema::SCHEMAID_DEFAULT)."'");
            $Database->Query("
                DELETE F FROM AutoScreenshot_Failures F
                LEFT JOIN Resources R ON F.ResourceId = R.ResourceId
                WHERE R.ResourceId IS NULL
                OR R.SchemaId != '".intval(MetadataSchema::SCHEMAID_DEFAULT)."'");
        }

        if (version_compare($PreviousVersion, "1.0.7", "<"))
        {
            $this->ConfigSetting("TaskPriority", ApplicationFramework::PRIORITY_BACKGROUND);
        }
    }

    /**
    * Hook the AutoScreenshot plugin into the event system.
    */
    function HookEvents()
    {
        return array(
            "EVENT_COLLECTION_ADMINISTRATION_MENU" => "AddCollectionAdminMenuItems",
            "EVENT_PERIODIC" => "TakeScreenshots",
            "EVENT_APPEND_HTML_TO_FIELD_DISPLAY" => "AppendHtmlToFieldDisplay",
            );
    }

    /**
    * Add the AutoScreenshot approval queue to the collection admin menu.
    */
    function AddCollectionAdminMenuItems()
    {
        return array(
            "ApprovalQueue" => "Automatic Screenshots",
            );
    }

    /**
    * Run external command with timeout
    * @param string $Command The command to run
    * @param string $Stdin Data to feed to the command's stdin
    * @param int $Timeout in seconds (OPTIONAL)
    * @param int $MaxOutput to fetch from each of stdin and stdout, in
    *     bytes (OPTIONAL, default 4k)
    * @return array with keys "Status", "Output", and "Error"
    *
    * Timeout defaults to 75% of the MaxExecutionTime.
    *
    * In the return value,
    *  Status gives the exit code when the process completed successfully,
    *  -1 for processes that were killed, or -2 if the process couldn't be run
    *  Output gives stdout from the process, and Error gives stderr from the process.
    */
    function ExecWithTimeout($Command, $Stdin, $Timeout=NULL, $MaxOutput=4096)
    {
        if ($Timeout===NULL)
        {
            $Timeout = 0.75 * $GLOBALS["AF"]->MaxExecutionTime();
        }

        # set up named indexes for readability
        $StdinIndex = 0;
        $StdoutIndex = 1;
        $StderrIndex = 2;

        # run the helper program
        $DescriptorSpec = array(
            $StdinIndex  => array("pipe","r"),
            $StdoutIndex => array("pipe","w"),
            $StderrIndex => array("pipe","w") );
        $Process = proc_open($Command, $DescriptorSpec, $ProcPipes);

        if (is_resource($Process))
        {
            # if we were able to successfully start the process,
            # feed it data on stdin
            fwrite( $ProcPipes[$StdinIndex], $Stdin );

            # then start a timer and spinwait until either
            # the process completes or we've hit our timeout
            $StartTime = time();
            do
            {
                sleep(1);
                $ProcStatus = proc_get_status( $Process );
            }
            while ( (time()-$StartTime) < $Timeout &&
                    $ProcStatus["running"] );

            if ($ProcStatus["running"])
            {
                # if the process didn't finish within our timeout,
                # kill the misbehaving process
                proc_terminate($Process);

                # set the stdout and stderr pipes to nonblocking
                # so that we can grab the output/error so far
                stream_set_blocking( $ProcPipes[$StdoutIndex], 0);
                stream_set_blocking( $ProcPipes[$StderrIndex], 0);

                $CmdStatus = -1;
            }
            else
            {
                # otherwise, in the case of a happy exit, grab the exit code.
                $CmdStatus = $ProcStatus["exitcode"];
            }

            # determine how much free memory we have to work with right now
            $MemLimit = trim(ini_get('memory_limit'));
            switch (strtolower($MemLimit[strlen($MemLimit)-1]))
            {
            case 'g': $MemLimit *= 1024;
            case 'm': $MemLimit *= 1024;
            case 'k': $MemLimit *= 1024;
            }

            $FreeMemory = $MemLimit - memory_get_usage(TRUE);

            # grab the first 4k of output or 50% of the available memory, whichever is less
            $CmdOut = fread($ProcPipes[$StdoutIndex], min($MaxOutput, floor(0.25 * $FreeMemory)) );
            $CmdErr = fread($ProcPipes[$StderrIndex], min($MaxOutput, floor(0.25 * $FreeMemory)) );

            # clean up
            fclose($ProcPipes[$StdinIndex]);
            fclose($ProcPipes[$StdoutIndex]);
            fclose($ProcPipes[$StderrIndex]);
            proc_close($Process);
        }
        else
        {
            $CmdStatus = -2;
            $CmdOut = "";
            $CmdErr = "";
        }

        return array(
            "Status" => $CmdStatus,
            "Output" => $CmdOut,
            "Error"  => $CmdErr
            );
    }

    /**
    * Get a list of resources waiting for a screenshot.
    *
    * @return An array containing the ResourceIds that are queued for
    * screenshots.
    */
    function GetScreenshotQueue()
    {
        $DB = new Database();
        $Schema = new MetadataSchema();
        $Field = $Schema->GetFieldByName("Screenshot");

        # Get a list of all the resources in the default schema:
        $DB->Query("SELECT ResourceId FROM Resources ".
                   "WHERE ResourceId > 0 AND ".
                   "SchemaId = '".intval(MetadataSchema::SCHEMAID_DEFAULT)."'");

        if ($DB->NumRowsSelected() > 0)
        {
            # Assemble a list of all the resources in the default schema:
            $Resources = array();
            while ($Row = $DB->FetchRow() )
                $Resources []= $Row["ResourceId"];

            # Determine which resources from this list should be excluded
            #  from the autoscreenshot process:
            $ResourcesToFilter = array();

            # Exclude resources with (at least) one screenshot
            $DB->Query("SELECT DISTINCT ResourceId AS ResourceId FROM ResourceImageInts ".
                       "WHERE FieldId=".$Field->Id());
            while ($Row = $DB->FetchRow())
                $ResourcesToFilter []= $Row["ResourceId"];

            # Exclude resources which have previously failed:
            $DB->Query("SELECT ResourceId FROM AutoScreenshot_Failures");
            while ($Row = $DB->FetchRow())
                $ResourcesToFilter []= $Row["ResourceId"];

            # Exclude resources where we have a screenshot that's just waiting for approval:
            $DB->Query("SELECT ResourceId FROM AutoScreenshot_ApprovalQueue");
            while ($Row = $DB->FetchRow())
                $ResourcesToFilter []= $Row["ResourceId"];

            # Exclude resources that have been blacklisted:
            $DB->Query("SELECT ResourceId FROM AutoScreenshot_Blacklist");
            while ($Row = $DB->FetchRow())
                $ResourcesToFilter []= $Row["ResourceId"];

            # Check the config settins for fields we should skip:
            if (count($this->ConfigSetting("SkipIfTrue")))
            {
                foreach ($this->ConfigSetting("SkipIfTrue") as $SkipField)
                {
                    $Field = $Schema->GetField($SkipField);
                    $DB->Query("SELECT ResourceId from Resources WHERE "
                               ." SchemaId = '".intval(MetadataSchema::SCHEMAID_DEFAULT)."' AND "
                               .$Field->DBFieldName()."=1" );
                    while ($Row = $DB->FetchRow())
                        $ResourcesToFilter []= $Row["ResourceId"];
                }
            }

            if (count($this->ConfigSetting("SkipIfFalse")))
            {
                foreach ($this->ConfigSetting("SkipIfFalse") as $SkipField)
                {
                    $Field = $Schema->GetField($SkipField);
                    $DB->Query("SELECT ResourceId from Resources WHERE "
                        ." SchemaId = '".intval(MetadataSchema::SCHEMAID_DEFAULT)."' AND "
                        .$Field->DBFieldName()."=0" );
                    while ($Row = $DB->FetchRow())
                        $ResourcesToFilter []= $Row["ResourceId"];
                }
            }

            # Filter out the resources who need exclusion, returning a list of the remainder:
            return array_diff($Resources, $ResourcesToFilter);
        }
        else
        {
            return array();
        }
    }

    /**
    * Perform a screenshot for a specified resource.
    *
    * @param ResourceId
    */
    function ScreenshotForResource($ResourceId)
    {
        $DB = new Database();
        $Schema = new MetadataSchema();
        $Field = $Schema->GetFieldByName("Screenshot");

        $TargetResource = new Resource($ResourceId);

        $Url = $TargetResource->Get("Url");

        $ScreenshotBinary = dirname(__FILE__)."/bin/screenshot-".
            $this->ConfigSetting("Method").".sh";

        $TmpFileName = "tmp/".md5(mt_rand()).".jpg";

        $CmdString = $ScreenshotBinary.
            " ".escapeshellarg($Url).
            " ".escapeshellarg($TmpFileName);

        $CmdResult = $this->ExecWithTimeout($CmdString, "");

        if (file_exists($TmpFileName) &&
            $CmdResult["Status"]==0 &&
            filesize($TmpFileName) < 409600 )
        {
            $NewImage = new SPTImage(
                $TmpFileName,
                $Field->MaxWidth(), $Field->MaxHeight(),
                $Field->MaxPreviewWidth(), $Field->MaxPreviewHeight(),
                $Field->MaxThumbnailWidth(), $Field->MaxThumbnailHeight());

            $DB->Query("INSERT INTO AutoScreenshot_ApprovalQueue "
                       ."(ResourceId,ImageId) VALUES "
                       ."(".intval($TargetResource->Id()).",".
                       intval($NewImage->Id()).")");
        }
        else
        {
            ob_start();
            print("Command was: ".$CmdString."\n");
            print("\nStdout:\n");
            print($CmdResult["Output"]);
            print("--\n\nStderr:\n");
            print($CmdResult["Error"]);
            print("--\n");
            $ErrorMessages = ob_get_contents();
            ob_end_clean();

            $DB->Query("INSERT INTO AutoScreenshot_Failures "
                       ."(ResourceId,Messages) VALUES (".$TargetResource->Id().","
                       ."'".addslashes($ErrorMessages)."')");
        }

        if (file_exists($TmpFileName))
        {
            unlink($TmpFileName);
        }
    }

    /**
    * Check to see if there any eligibile resources missing a screenshot,
    *  take screenshots if so.
    */
    function TakeScreenshots($LastRunAt)
    {
        $DB = new Database();

        $Queue = $this->GetScreenshotQueue();

        if (count($Queue)>0)
        {
            $ResourceId = array_pop($Queue);
            $this->ScreenshotForResource($ResourceId);
        }
        else
        {
            $DB->Query("DELETE FROM AutoScreenshot_Failures "
                       ."WHERE FailureTime < NOW() - INTERVAL 1 WEEK");
        }

        return $this->ConfigSetting("MinutesBetweenChecks");
    }

    /**
    * Remove approval queue entries for resources which now have a manually
    * obtained screenshot, cleaning up the images as necessary.
    */
    function CleanDuplicatesOfManual()
    {
        $DB = new Database();

        $Schema = new MetadataSchema();

        $Field = $Schema->GetFieldByName("Screenshot");

        $DB->Query("SELECT * FROM AutoScreenshot_ApprovalQueue");
        $ApprovalQueue = $DB->FetchRows();

        foreach ($ApprovalQueue as $QueueEntry)
        {
            $ResourceId = $QueueEntry["ResourceId"];
            $Resource = new Resource($ResourceId);

            $ImageFiles = $Resource->Get($Field);
            if (count($ImageFiles)>0 || $this->IsSkipped($Resource) )
            {
                $ThisImage = new SPTImage($QueueEntry["ImageId"]);
                if (is_object($ThisImage) )
                {
                    $ThisImage->Delete();
                    $DB->Query("DELETE FROM AutoScreenshot_ApprovalQueue "
                               ."WHERE ResourceId=".intval($ResourceId));
                }
            }
        }
    }

    /**
    * Add information to the Full Record page about the AutoScreenshot status
    * of each resource.
    */
    function AppendHtmlToFieldDisplay($Field, $Resource, $Context, $Html)
    {
        $Schema = new MetadataSchema();
        $ScreenshotField = $Schema->GetFieldByName("Screenshot");

        if ($Field->Id() == $ScreenshotField->Id() &&
            $Context == "EDIT" &&
            count($Resource->Get($Field)) == 0)
        {
            $DB = new Database();

            $DB->Query("SELECT * FROM AutoScreenshot_ApprovalQueue "
                       ."WHERE ResourceId=".intval($Resource->Id()) );
            $ApprovalQueue = $DB->FetchRows();

            $DB->Query("SELECT * FROM AutoScreenshot_Failures "
                       ."WHERE ResourceId=".intval($Resource->Id()) );
            $Failures = $DB->FetchRows();

            $DB->Query("SELECT * FROM AutoScreenshot_Blacklist "
                       ."WHERE ResourceId=".intval($Resource->Id()) );
            $Blacklist = $DB->FetchRows();

            $Html .= '<div class="D_AutoScreenshot" style="float: right">';
            if ($this->IsSkipped($Resource))
            {
                $Html .= 'Automatic screenshot skips this resource because of flag settings.';
            }
            elseif (count($ApprovalQueue)>0)
            {
                $ImageToApprove = array_pop($ApprovalQueue);

                $TgtImage = new SPTImage($ImageToApprove["ImageId"]);

                $Html .= '<b><u>Automatic screenshot waiting for approval:</u></b><br>'
                    .'<img style="width: 400px;" src="'.$TgtImage->Url() . '"><br>'
                    .'<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .' onclick="$.get(\'index.php?P=P_AutoScreenshot_ApprovalQueueComplete&Id='
                    .$Resource->Id().'&A=Approve&Ajax=1\'); $(\'div.D_AutoScreenshot\')'
                    .'.replaceWith(\'<div style=\\\'float: right;\\\'>Screenshot Approved.</div>\');" >'
                    ."Approve</div>"
                    .'<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .'" onclick="$.get(\'index.php?P=P_AutoScreenshot_ApprovalQueueComplete&Id='
                    .$Resource->Id().'&A=Retry&Ajax=1\'); $(\'div.D_AutoScreenshot\').remove();" >'
                    ."Disapprove -- try again</div>"
                    .'<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .'" onclick="$.get(\'index.php?P=P_AutoScreenshot_ApprovalQueueComplete&Id='
                    .$Resource->Id().'&A=Blacklist&Ajax=1\'); $(\'div.D_AutoScreenshot\').remove();" >'
                    ."Disapprove -- Disable for this resource</div>";
            }
            elseif (count($Failures)>0)
            {
                $Html .= 'Automatic screenshot was attempted, but failed<br>'
                    .'<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .'" onclick="$.get(\'index.php?P=P_AutoScreenshot_ApprovalQueueComplete&Id='
                    .$Resource->Id().'&A=Retry&Ajax=1\'); $(\'div.D_AutoScreenshot\').remove();" >'
                    ."Retry</div>";
            }
            elseif (count($Blacklist)>0)
            {
                $Html .= 'Automatic screenshot is disabled for this resource<br>'
                    . '<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .'" onclick="$.get(\'index.php?P=P_AutoScreenshot_ApprovalQueueComplete&Id='
                    .$Resource->Id().'&A=Deblacklist&Ajax=1\'); $(\'div.D_AutoScreenshot\').remove();" >'
                    ."Re-enable</div>";
            }
            else
            {
                $Html .= 'Automatic screenshot not yet attempted<br>'
                    . '<div class="cw-button cw-button-constrained cw-button-elegant" '
                    .'" onclick="$.get(\'index.php?P=P_AutoScreenshot_Force&Id='
                    .$Resource->Id().'\'); $(\'div.D_AutoScreenshot\')'
                    .'.replaceWith(\'<div style=\\\'float: right;\\\'>'
                    .'Screenshot taken -- reload to approve.</div>\');" >'
                    ."Take now </div>";
            }

            $Html .= "</div>";
        }

        return array( "Field" => $Field,
                      "Resource" => $Resource,
                      "Context" => $Context,
                      "Html" => $Html );
    }

    /**
    * Determine if a resource is being ignored for automatic screenshots.
    *
    * @param Resource -- the resource object to check
    * @return TRUE when resource is being skipped, FALSE otherwise.
    */
    function IsSkipped($Resource)
    {
        $Schema = new MetadataSchema();

        # skip resources that don't use the default schema
        if ($Resource->SchemaId() != MetadataSchema::SCHEMAID_DEFAULT)
        {
            return TRUE;
        }

        $ShouldSkip = FALSE;
        if (count($this->ConfigSetting("SkipIfTrue")))
        {
            foreach ($this->ConfigSetting("SkipIfTrue") as $SkipField)
            {
                $Field = $Schema->GetField($SkipField);
                $ShouldSkip |= ($Resource->Get($Field) == TRUE);
            }
        }

        if (count($this->ConfigSetting("SkipIfFalse")))
        {
            foreach ($this->ConfigSetting("SkipIfFalse") as $SkipField)
            {
                $Field = $Schema->GetField($SkipField);
                $ShouldSkip |= ($Resource->Get($Field) == FALSE);
            }
        }

        return $ShouldSkip;
    }
}
?>
