diff -pruN 0.9.4-4/CHANGELOG.md 0.9.8-1/CHANGELOG.md
--- 0.9.4-4/CHANGELOG.md	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/CHANGELOG.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,272 +0,0 @@
-# Zoph Changelog #
-##Zoph 0.9.4##
-###18 Sept 2016###
-
-Zoph 0.9.4 is the new stable release. It is recommended for everyone to upgrade to this release
-###Features###
-* Geocoding: Zoph now also searches Wikipedia
-* [Issue#67](https://github.com/jeroenrnl/zoph/issues/67) Changed the colour scheme definition to use a nice interface to select the colour
-* [Issue#23](https://github.com/jeroenrnl/zoph/issues/23) An admin user can now define default prefences for new users
-* [Issue#24](https://github.com/jeroenrnl/zoph/issues/24) Added an option to automatically propagate permissions to newly created albums
-* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Removed Yahoo, Cloudmade mapping as they no longer offer their services to the public
-* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Removed Openlayers mapping, as Zophs implementation was buggy and did not work anymore.
-* [Issue#47](https://github.com/jeroenrnl/zoph/issues/47) Photos can now be deleted from disk (moved to a trash dir)
-* [Issue#67](https://github.com/jeroenrnl/zoph/issues/67) Added some new colour schemes
-
-###Bugs###
-* Fixed an issue with album pulldown when editing group access rights
-* Fixed an issue where the circles page would sometimes report $title not found
-* Fixed an issue with changing views on circle page
-* Fixed an issue that caused errors in Firefox when using the configuration page
-* fixed collapsable details for time and rating
-* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Fixed a case where an admin user was sometimes not allowed to see a person or a place
-
-###Other improvements###
-* [Issue#77](https://github.com/jeroenrnl/zoph/issues/77) Lots of fixes in the German translation by Thomas Weiland (@HonkXL)
-* Moved group display to template
-* Moved group delete (confirm) into template
-* Moved group edit to a template
-* [Issue#79](https://github.com/jeroenrnl/zoph/issues/79) Modify recursive creation of directories, so Zoph can function in an open_basedir enverironment.
-* [Issue#66](https://github.com/jeroenrnl/zoph/issues/66) Cleanup of CSS
-* Some modernization of the looks of Zoph
-* [Issue#85](https://github.com/jeroenrnl/zoph/issues/85) Modified import process to show clearer error message
-* [Issue#66](https://github.com/jeroenrnl/zoph/issues/66) Added a reset CSS
-* [Issue#81](https://github.com/jeroenrnl/zoph/issues/81) Documentation updates
-* Some fixes for UnitTests
-* Additional tests
-* Refactor of group_permissions class into permissions class
-* Refactor prefs class
-* Moved preferences page to template
-* Modified prefs template to use labels instead of definition lists
-
-##Zoph 0.9.3##
-###10 jun 2016###
-
-Zoph 0.9.3 is the new stable release. It is recommended for everyone to upgrade to this release
-
-###Features###
-* [Issue #72](https://github.com/jeroenrnl/zoph/issues/72) Zoph now has a new logon screen.
-The logon screen has background photos. Two of them are already included in Zoph. You can place your own backgrounds in ```templates/default/images/backgrounds```. Or, you can (on the config screen) define an album from which the images will be used as background images. Zoph will display a random image as background.
-* [Issue #76](https://github.com/jeroenrnl/zoph/issues/76) The logon screen now gives a message about the username and/or password being wrong instead of just returning to the same screen
-* [Issue #75](https://github.com/jeroenrnl/zoph/issues/75) Zoph now uses PHP's password hashing algorithm instead of MySQL's.
-This includes a random 'salt' added to each password. This will make it much, much harder to decrypt your passwords, if your database would ever fall into the wrong hands. The old hashes will be updated with the new ones as soon the the user logs in. Zoph will continue to support the old password hashes at least until v0.9.5.
-* [Issue #26](https://github.com/jeroenrnl/zoph/issues/26) It is now possible to define the cookie expirement time. In previous versions of Zoph, a user would be logged out when closing the browser. Is now possible to extend the time to 1 hour, 4 hours, 8 hours, 1 day, 1 week or 1 month. This means a user will not need to re-login for that period of time, even when the browser is closed in the mean time. This can be very convenient, but it could mean that a user leaves Zoph logged in on a public PC. Therefore, the default is still 'session', which means a user will be logged out when closing the browser.
-* "new" pages now show up in breadcrumbs
-* It is now possible to give a user "can see all photos" access rights. This means you can give a user access to all photos, without giving him/her admin rights and without having to update user rights whenever an album is added.
-* [Issue #22](https://github.com/jeroenrnl/zoph/issues/22) It is now possible to allow a user to create albums, categories, people, circles and places. The user automatically has access rights to place photos in the albums, categories, people, circles and places he or she has created.
-* [Issue #21](https://github.com/jeroenrnl/zoph/issues/21) It is now possible to allow a user to delete photos. The user will have to have "write" access to at least one album a photo is in.
-* Remove the rather ugly trailing space on the links on zoph.php
-
-###Bugs###
-* [Issue #73](https://github.com/jeroenrnl/zoph/issues/73) Fixed sharing feature
-* [Issue #74](https://github.com/jeroenrnl/zoph/issues/74) Fixed Canadian English, Dutch and German translation files
-
-###Other improvements###
-* Added a way to disable a setting on the configuration page depending on the state of another configuration item. (This was created because the photo album as a logon background relies on the sharing feature to be enabled).
-* Moved user page to template
-* Moved form into a separate class
-* Some cleanup of the places and categories pages
-* Refactor HTML for actionlinks
-* Modified createTestData script to only require password once
-* Rearranged order of unittests
-* Added translations for German, Canadian English and Dutch
-
-##Zoph 0.9.2##
-###1 apr 2016###
-
-Zoph 0.9.2 is the new stable release. I have decided to drop the separation between 'stable' and 'unstable' or 'feature' releases. This means that it is recommended for everyone to upgrade to this release.
-
-###Features###
-* [Issue #44](https://github.com/jeroenrnl/zoph/issues/44) : Added 'circles': a way to group people in Zoph. This is especially handy if you have a large amount of people in your Zoph, and the 'person' page is becoming confusing or cluttered.
-* [Issue #46](https://github.com/jeroenrnl/zoph/issues/46) A circle and it's members can be surpressed in the overview page, so you can, for example, hide people that you added only for a small set of photos.
-* [Issue #20](https://github.com/jeroenrnl/zoph/issues/20) Zoph has switched to the PDO classes for database access. This ensures compatibility with PHP in the future, because the old mysql libs will be dropped soon.
-* [Issue #32](https://github.com/jeroenrnl/zoph/issues/32) It is now possible to set more properties of a photo, including map zoom from the web import.
-* [Issue #60](https://github.com/jeroenrnl/zoph/issues/60) The link text for "next" and "previous" as well as page numbers has been increased in size for better usability esp. on mobile devices
-* Added a script for fixing filename case (by Jason Taylor [@JiCit] )
-* Access Google maps via https (Jason Taylor [@JiCiT])
-* As of this version, the language files are in the php dir, and no longer need to be copied or moved separately
-
-###Bugs###
-* [Issue #49](https://github.com/jeroenrnl/zoph/issues/49) Zoph now supports MySQL strict mode
-* [Issue #55](https://github.com/jeroenrnl/zoph/issues/55) Autocomplete not working for people
-* [Issue #58](https://github.com/jeroenrnl/zoph/issues/58) Sort order for albums and categories can not be changed
-* CLI: Fixed an issue where Zoph would try to import to the current directory when double spaces were present in CLI
-* Better handling of file not found problems during import
-* Fixed two bugs that caused maps not to display
-* Fixed an issue where breadcrumbs wouldn't be removed correctly in some cases
-* Changed erronous extension of Exception class
-* Fixed slow login times for non-admin users
-* Improved performance on people page
-* Fixed: zoom buttons are missing from Google Maps
-* Remove duplicate files from import (if you would specify the same file twice on CLI import, you would get an error, this is now filtered out)
-* Fixed an issue where the person pulldown on the add user page appeared to be empty
-* Remove a user from a group when a the user is deleted
-* Fixed a warning about unknown variable on places page
-* Allow apostropes in place names when creating map markers (Jason Taylor [@JiCiT])
-
-###Refactor###
-* A complete new query builder has been created
-* Many more parts of Zoph can be (and are being) tested automatically now, this should improve overall quality and reduce bugs
-* Many parts of Zoph have been cleaned up to modernize code to the current state of PHP - dropping PHP 5.3 and 5.4 compatibility
-* Dropped MSIE6/7 compatibility
-* Added documentation to many parts of Zoph's source code
-* Many changes to readability of source code, such as more consistent use of whitespace
-* Added some more debugging possibilities to easier troubleshoot in case of problems
-* Changed logging so less logging is displayed when set to log::NONE
-* Changed all self:: references into static:: references
-* Added function scope to many methods
-* Started using namespaces to better organize the classes
-* Updated version numbers in REQUIREMENTS readme. 
-* [Issue #8](https://github.com/jeroenrnl/zoph/issues/8) (partial) Changed several parts of Zoph to use templates 
-* Added improvements to templating system
-* Modified query for photo access rights to a view for performance reasons
-* Changed logging so SQL query log to file can be done without displaying 
-* Performance improvement on place page
-* Added a posibility to debug queries including parameters
-
-## Zoph 0.9.1 ##
-### 21 Feb 2014 ###
-Zoph 0.9.1 is the first feature release for Zoph 0.9, it shows a preview of some of the new features for Zoph 0.10. Most important change is the move of most configuration items from config.inc.php into the Web GUI.
-
-####Features####
-
-* [Issue #28](https://github.com/jeroenrnl/zoph/issues/28) Configuration through webinterface 
-* Removed display desc under thumbnail feature 
-* Removed MIXED_THUMBNAILS and THUMB_EXTENSION settings 
-* removed DEFAULT_SHOW_ALL setting 
-* Removed LANG_DIR configuration item 
-* Changed the looks of <input> fields a bit 
-* Removed alternative password validators 
-* Removed checks for PHP 5.1 
-* Adding CLI support for configuration 
-* [Issue #7](https://github.com/jeroenrnl/zoph/issues/7) Added a favicon 
-* [Issue #18](https://github.com/jeroenrnl/zoph/issues/18) Added "return" link on bulk edit page 
-* Added a script to migrate config to new db-based system 
-* [Issue #8](https://github.com/jeroenrnl/zoph/issues/8) Made template selectible from webinterface 
-* Removed MAX_CRUMBS 
-
-####Bugs####
-
-* Simplified CLI code & fixed bug in --autoadd
-* [Issue #34](https://github.com/jeroenrnl/zoph/issues/34) Rows and columns swapped on photos page
-* [Issue #36](https://github.com/jeroenrnl/zoph/issues/36) Webimporter does not import description
-* [Issue #37](https://github.com/jeroenrnl/zoph/issues/37) Can not add position on map using the mouse
-* Fixed a bug that caused EXIF information in some (rare) cases to report the aperture wrong.
-* Strict standards warning 
-* [Issue #45](https://github.com/jeroenrnl/zoph/issues/45) Pagebreak inside HTML tags causes browser to render incorrectly
-* [Issue #45](https://github.com/jeroenrnl/zoph/issues/45) Added selectArray cache to zophTable
-* [Issue #48](https://github.com/jeroenrnl/zoph/issues/48) Repair photo ratings during import
-* [Issue #50](https://github.com/jeroenrnl/zoph/issues/50) Geonames project has changed URL and requires username
-* [Issue #51](https://github.com/jeroenrnl/zoph/issues/51) Fixed depth in tree display when autocorrect is off
-* [Issue #39](https://github.com/jeroenrnl/zoph/issues/39) Added support for session.upload_progress as APC replacement (PHP 5.4 compatibility)
-* [Issue #38](https://github.com/jeroenrnl/zoph/issues/38) CLI tries to lookup previous argument's value when looking up photographer
-
-####Improvements####
-
-I have made quite a few improvements on the "inside" of Zoph. I have refactored many parts of Zoph
-to create cleaner, less duplicated and more robust code. I have introduced UnitTests (resulting in 
-about 20% of Zoph's sourcecode now tested fully automatic for bugs). As a help to that, I am now 
-using Sonar to automatically run these tests and also analyse Zoph code for other problems.
-
-* [Issue #29](https://github.com/jeroenrnl/zoph/issues/29) First step in creating unittests for Zoph 
-* Sonar Support 
-* Refactor of PHP part of Mapping implementation 
-* Move timezone-related global functions into class 
-* TimeZone object improvements 
-* Small change in way template is called on photo page (Full page templates are now "templates" and partial pages are "blocks") 
-* Refactor of htmlMimeMail.php 
-* Refactor of Mail_mimePart 
-* Refactor annotate photo, watermark photo, image.php 
-* Removed several global variables  
-* Finished refactor of MIME classes 
-* Refactor album, category, place, person, photo 
-* Refactor: getEditArray() + unittests 
-* Further refactor of photo, album, person, place, category  
-* Refactor: move ratings out of photo object  
-* Refactor: moved relations from photo object to new photoRelations object 
-* Refactor: photo object 
-* Got rid of adding session_id to URL 
-* Modified internal database references to static 
-* Removed brackets from require and include statements 
-* Replaceed a die() with exception 
-* Changed self-references in objects to use self:: 
-* Removed unused class smtp 
-* Made autoload a little more robust 
-* Changes to autoload so it works in unittests too. 
-* Removed unused RFC822 class 
-* Changed line-endings in mailMimePart.inc.php to unix-style 
-* Removed various unused variables 
-* Removed duplicate templates 
-* Removed unused $user from createPulldown() calls. 
-* [Issue #40](https://github.com/jeroenrnl/zoph/issues/40) Change documentation to Markdown        
-* Modified some queries to improve performance 
-
-## Zoph 0.9.0.1 ##
-### 18 oct 2012 ###
-
-Zoph 0.9.0.1 is the first maintenance release for Zoph 0.9. It adds compatibility with MySQL 5.4.4 and later and PHP 5.4 support. Several bugs were fixed.
-
-
-#### Bugs ####
-
-* [Issue #1](https://github.com/jeroenrnl/zoph/issues/1)  Changed TYPE=MyISAM to ENGINE=MyISAM for MySQL > 5.4.4 compatibility
-* [Issue #1](https://github.com/jeroenrnl/zoph/issues/1)  Fixed: PHP Notice: Array to string conversion
-* [Issue #2](https://github.com/jeroenrnl/zoph/issues/2)  Changed timestamp(14) into timestamp
-* [Issue #3](https://github.com/jeroenrnl/zoph/issues/3)  Removed pass-by-reference for PHP 5.4 compatibility
-* [Issue #6](https://github.com/jeroenrnl/zoph/issues/6)  Missing French translation
-* [Issue #30](https://github.com/jeroenrnl/zoph/issues/30) Remove warning about undefined variables
-* [Issue #31](https://github.com/jeroenrnl/zoph/issues/31) Fixed several errors in geotagging code
-* [Issue #33](https://github.com/jeroenrnl/zoph/issues/33) Fixed: no error message when rotate fails
-             Fixed a small layout issue on the prefs page
-
-## Zoph 0.9 ##
-### 23 jun 2012 ###
-
-Zoph 0.9 is a stable release. It's equal to v0.9pre2, except for an updated Italian translation.
-
-#### Translations ####
-Updated Italian translation, by Francesco Ciattaglia
-
-There are no known bugs in this version.
-
-## Zoph 0.9pre2 ##
-### 20 Feb 2012 ###
-
-Zoph 0.9pre2 is the second release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
-
-#### Bugs ####
-
-* Bug#3471099: Map not displaying when looking at photo in edit mode
-* Bug#3471100: On some pages, title contains PHP warning
-
-## Zoph 0.9pre1 ##
-### 26 Nov 2011 ###
-
-Zoph 0.9pre1 is the first release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
-
-#### Bugs ###
-
-* Bug#3420574: When using --autoadd, zoph CLI import sometimes tries to create new locations or photographers even though they already exist in the database.
-* Bug#3427517: Share this photo feature does not work
-* Bug#3427518: Not possible to remove and album or category from a photo
-* Bug#3433687: Not possible to remove album or category from photo (bulk)
-* Bug#3431130: Share this photo doesn't show links in photo edit mode
-* Bug#3433810: Popup for albums, categories, people and places doesn't always disappear when moving mouse away.
-* Removed a warning that in some cases caused images not to be displayed.
-
-#### Translations ####
-
-* Added a few missing strings, reported by Pekka Kutinlahti.
-* Updated Italian translation, by Francesco Ciattaglia
-* Updated Dutch, German, Canadian English and Finnish
-
-#### Other ####
-* Got rid of a lot of PHP warnings
-* Got rid of a lot of PHP strict messages
-* Cut down on the number of global variables
-* Removed support for magic_quotes
-* Removed (last traces of) PHP4 support
-* Bug#3435181: Variable inside quotes
-* Updated wikibooks documentation
-
-Older changes can be found in http://en.wikibooks.org/wiki/Zoph/Changelog/Archive and http://en.wikibooks.org/wiki/Zoph/Changelog/0.8-0.9
diff -pruN 0.9.4-4/cli/zoph 0.9.8-1/cli/zoph
--- 0.9.4-4/cli/zoph	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/cli/zoph	2018-03-02 20:49:58.000000000 +0000
@@ -31,7 +31,7 @@
 define("INI_FILE", "/etc/zoph.ini");
 
 define("CLI", true);
-define("CLI_API", 4);
+define("CLI_API", 5);
 
 
 define("EXIT_NO_PROBLEM", 0);
@@ -92,7 +92,7 @@ function init() {
     }
     
     try {
-        $cli=new cli($user, CLI_API, $argv);
+        $cli=new cli\cli($user, CLI_API, $argv);
         $cli->run();
     } catch (CliAPINotCompatibleException $e) {
         echo $e->getMessage() . "\n";
diff -pruN 0.9.4-4/contrib/hierplaces.sh 0.9.8-1/contrib/hierplaces.sh
--- 0.9.4-4/contrib/hierplaces.sh	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/hierplaces.sh	1970-01-01 00:00:00.000000000 +0000
@@ -1,146 +0,0 @@
-#!/bin/bash
-# Part of Zoph 0.5
-# This script attempts to make a hierarchical structure out of your 
-# places database. 
-# It is not supported and you should use it at your own risk. 
-# Make a backup before you start.
-
-db_name="zoph"
-db_user="zoph_rw"
-db_pass="pass"
-db_prefix="zoph_"
-db_host="localhost"
-
-#---
-mysql="mysql -u${db_user} -p${db_pass} -h${db_host} ${db_name} -Bs -e "
-mysql_user_output="mysql -u${db_user} -p${db_pass} -h${db_host} ${db_name} -e "
-
-function askcont {
-   echo "Do you want to continue?"
-   read answer
-   case $answer in
-   [yY])
-   ;;
-   *)
-       exit
-   ;;
-   esac
-}
-
-doubles=`$mysql "select title, count(*) as number from zoph_places group by title having number > 1"`
-
-if [ "$doubles" != "" ]
-then
-   echo "There seem to be some doubles in your location list:"
-   $mysql_user_output "select title, count(*) as number from zoph_places group by title having number > 1 order by title;"
-   askcont
-fi
-
-echo "Please check this list of countries for any unmeant doubles,"
-echo "such as \"Netherlands\" and \"NL\"."
-$mysql_user_output "select country, count(photo_id) as \"number of photos\" 
-   from ${db_prefix}places as pl join ${db_prefix}photos as ph 
-   on pl.place_id=ph.location_id group by country order by country;"
-askcont
-
-echo "Please check this list of states for any unmeant doubles,"
-echo "such as \"Alberta\" and \"AB\"."
-$mysql_user_output "select state, count(photo_id) as \"number of photos\" 
-   from ${db_prefix}places as pl join ${db_prefix}photos as ph 
-   on pl.place_id=ph.location_id group by state order by state;"
-askcont
-
-echo "Please check this list of cities for any unmeant doubles,"
-echo "such as \"New York\" and \"New York City\"."
-$mysql_user_output "select city, count(photo_id) as \"number of photos\" 
-   from ${db_prefix}places as pl join ${db_prefix}photos as ph 
-   on pl.place_id=ph.location_id group by city order by city;"
-askcont
-
-$mysql "select distinctrow country from "$db_prefix"places" | while read country
-do
-  if [ "x$country" != "xNULL" ] && [ "x$country" != "x" ]
-  then
-      echo "Found country: " $country
-      places=`$mysql "select place_id from ${db_prefix}places where country=\"$country\" and title!=\"$country\""`;
-      key=`$mysql "insert into "$db_prefix"places (parent_place_id, title, country) VALUES (1,\"$country\",\"$country\"); select last_insert_id()"`
-
-      for place in $places
-      do
-          $mysql "update "$db_prefix"places set parent_place_id="$key" where place_id="$place
-      done
-  fi
-done
-
-
-# Try to figure out states and provinces
-
-$mysql "select distinctrow parent_place_id from ${db_prefix}places" | while read parent
-do
-    $mysql "select distinctrow state from ${db_prefix}places where parent_place_id=\"$parent\"" | while read state
-    do
-        if [ "x$state" != "xNULL" ] && [ "x$state" != "x" ]
-        then
-            echo "  state: " $state
-            states=`$mysql "select place_id from ${db_prefix}places where parent_place_id=${parent} and state=\"$state\" and title != \"${state}\""`;
-            if [ "x$state" != "x" ]
-            then
-                statekey=`$mysql "select place_id from ${db_prefix}places
-                    where state=\"$state\" and parent_place_id=\"$state\"
-                    limit 1"`
- 
-                if [ "x$statekey" = "x" ]
-                then 
-                    country=`$mysql "SELECT country FROM ${db_prefix}places
-                        where place_id=$parent"`
-                    statekey=`$mysql "INSERT INTO "$db_prefix"places 
-                        (parent_place_id, title, state, country) 
-                        VALUES ($parent,\"$state\",\"$state\", \"$country\"); 
-                        SELECT last_insert_id()"`
-                fi
-                for place in $states
-                do
-                    $mysql "update "$db_prefix"places set parent_place_id="$statekey" where place_id="$place
-                done
-            fi
-        fi
-    done
-done
-
-
-$mysql "select distinctrow parent_place_id from ${db_prefix}places" | while read parent
-do
-    $mysql "select distinctrow city from ${db_prefix}places where parent_place_id=\"$parent\"" | while read city
-    do
-        if [ "x$city" != "xNULL" ] && [ "x$city" != "x" ]
-        then
-            echo "    city: " $city
-            cities=`$mysql "select place_id from ${db_prefix}places where parent_place_id=${parent} and city=\"$city\" and title != \"${city}\""`;
-            if [ "x$cities" != "x" ]
-            then
-                citykey=`$mysql "select place_id from ${db_prefix}places
-                    where city=\"$city\" and parent_place_id=\"$parent\" 
-                    and title=\"$city\"
-                    limit 1"`
- 
-                if [ "x$citykey" = "x" ]
-                then
-                    state=`$mysql "SELECT state FROM ${db_prefix}places
-                        where place_id=$parent"`
-                    country=`$mysql "SELECT country FROM ${db_prefix}places
-                        where place_id=$parent"`
-                    
-                    citykey=`$mysql "INSERT INTO "$db_prefix"places 
-                        (parent_place_id, title, city, state, country) 
-                        VALUES ($parent,\"$city\",\"$city\", 
-                        \"$state\", \"$country\"); 
-                        SELECT last_insert_id()"`
-                fi
-                for place in $cities
-                do
-                    $mysql "update "$db_prefix"places set parent_place_id="$citykey" where place_id=$place and place_id!=$citykey"
-                done
-            fi
-        fi
-    done
-done
diff -pruN 0.9.4-4/contrib/migrate_config.php 0.9.8-1/contrib/migrate_config.php
--- 0.9.4-4/contrib/migrate_config.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/migrate_config.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,158 +0,0 @@
-<?php
-/**
- * Migrate configuration from old constants-based config to new
- * database-based config.
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package ZophContrib
- * @author Jeroen Roos
- */
-
-require_once("include.inc.php");
-
-if (!isset($user)) {
-    $user=user::getCurrent();
-}
-
-if (!$user->is_admin()) {
-    die("Must be admin");
-}
-
-
-$convert = array(
-    "interface.title" => "ZOPH_TITLE",
-    "interface.width" => "DEFAULT_TABLE_WIDTH",
-    "interface.autocomplete" => "AUTOCOMPLETE",
-    "interface.language" => "DEFAULT_LANG",
-    "interface.user.default" => "DEFAULT_USER",
-    "interface.user.cli" => "CLI_USER",
-    "interface.max.days" => "MAX_DAYS_PAST",
-    "url.http" => "ZOPH_URL",
-    "url.https" => "ZOPH_SECURE_URL",
-    "path.images" => "IMAGE_DIR",
-    "path.upload" => "IMPORT_DIR",
-    "path.magic" => "MAGIC_FILE",
-    "path.unzip" => "UNZIP_CMD",
-    "path.untar" => "UNTAR_CMD",
-    "path.ungz" => "UNGZ_CMD",
-    "path.unbz" => "UNBZ_CMD",
-    "maps.provider" => "MAPS",
-    "maps.geocode" => "GEOCODE",
-    "maps.key.cloudmade" => "CLOUDMADE_KEY",
-    "import.enable" => "IMPORT",
-    "import.upload" => "UPLOAD",
-    "import.maxupload" => "MAX_UPLOAD",
-    "import.parallel" => "IMPORT_PARALLEL",
-    "import.rotate" => "IMPORT_AUTOROTATE",
-    "import.resize" => "IMPORT_RESIZE",
-    "import.dated" => "USE_DATED_DIRS",
-    "import.dated.hier" => "HIER_DATED_DIRS",
-    "watermark.enable" => "WATERMARKING",
-    "watermark.file" => "WATERMARK",
-    "watermark.pos.x" => "WM_POSX",
-    "watermark.pos.y" => "WM_POSY",
-    "watermark.transparency" => "WM_TRANS",
-    "rotate.enable" => "ALLOW_ROTATIONS",
-    "rotate.command" => "ROTATE_CMD",
-    "rotate.backup" => "BACKUP_ORIGINAL",
-    "rotate.backup.prefix" => "BACKUP_PREFIX",
-    "share.enable" => "SHARE",
-    "share.salt.full" => "SHARE_SALT_FULL",
-    "share.salt.mid" => "SHARE_SALT_MID",
-    "feature.download" => "DOWNLOAD",
-    "feature.comments" => "ALLOW_COMMENTS",
-    "feature.mail" => "EMAIL_PHOTOS",
-    "feature.mail.bcc" => "BCC_ADDRESS",
-    "feature.annotate" => "ANNOTATE_PHOTOS",
-    "feature.rating" => "ALLOW_RATINGS",
-    "date.tz" => "CAMERA_TZ",
-    "date.guesstz" => "GUESS_TZ",
-    "date.format" => "DATE_FORMAT",
-    "date.timeformat" => "TIME_FORMAT"
-);
-
-?>
-<h1>Migrate config</h1>
-
-<?php
-foreach ($convert as $newname => $oldname) {
-    $newconfig=conf::getItemByName($newname);
-    if (defined($oldname)) {
-        $oldconfig=constant($oldname);
-
-        if ($newconfig->checkValue($oldconfig)) {
-            $newconfig->setValue($oldconfig);
-            echo $oldname . " --> " . $newname . "<br>\n";
-        } else {
-            echo "<b>" . $oldname . " could not be converted, " . e($oldconfig) . " is not a valid value for " . $newname . "</b>, default (" . $newconfig->getDefault() . ") has been used<br>\n";
-        }
-    }
-    $newconfig->update();
-}
-
-// Some specials:
-
-$convert = array(
-    "import.filemode" => "FILE_MODE",
-    "import.dirmode" => "DIR_MODE"
-);
-
-foreach ($convert as $newname => $oldname) {
-    $newconfig=conf::getItemByName($newname);
-    if (defined($oldname)) {
-        $oldconfig="0" . decoct(constant($oldname));
-
-        if ($newconfig->checkValue($oldconfig)) {
-            $newconfig->setValue($oldconfig);
-            echo $oldname . " --> " . $newname . "<br>\n";
-        } else {
-            echo "<b>" . $oldname . " could not be converted, " . e($oldconfig) . " is not a valid value for " . $newname . "</b>, default (" . $newconfig->getDefault() . ") has been used<br>\n";
-        }
-    }
-    $newconfig->update();
-}
-
-$ssl_force=conf::getItemByName("ssl.force");
-if (defined(FORCE_SSL_LOGIN) && FORCE_SSL_LOGIN) {
-    $ssl_force->setValue("login");
-} else if (defined(FORCE_SSL) && FORCE_SSL) {
-    $ssl_force->setValue("always");
-} else {
-    $ssl_force->setValue("never");
-}
-$ssl_force->update();
-echo "FORCE_SSL / FORCE_SSL_LOGIN --> ssl.force<br>\n";
-
-$convert = array(
-    "interface.sort.order" => "DEFAULT_ORDER",
-    "interface.sort.dir" => "DEFAULT_DIRECTION"
-);
-
-foreach ($convert as $newname => $oldname) {
-    $oldconfig=$$oldname;
-    $newconfig=conf::getItemByName($newname);
-
-    if ($newconfig->checkValue($oldconfig)) {
-        $newconfig->setValue($oldconfig);
-        echo "$" . $oldname . " --> " . $newname . "<br>\n";
-    } else {
-        echo "<b>$" . $oldname . " could not be converted, " . e($oldconfig) . " is not a valid value for " . $newname . "</b>, default (" . $newconfig->getDefault() . ") has been used<br>\n";
-    }
-    $newconfig->update();
-}
-?>
-<p style="font-size: large"><b>You should delete <tt>migrate_config.php</tt> now.</b></p>
diff -pruN 0.9.4-4/contrib/user_to_group.php 0.9.8-1/contrib/user_to_group.php
--- 0.9.4-4/contrib/user_to_group.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/user_to_group.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,73 +0,0 @@
-<?php
-/*
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- */
-
-/* This script can be used to migrate from per-user authentication (Zoph
- * 0.7.3 and before) to per-group authentication (Zoph 0.7.4 and later).
- * This script creates a group for each user and migrates the user rights
- * from the user to the group.
- *
- * Make a backup of your database before using this script!
- * Copy the script to Zoph's directory on your webserver and call it from
- * your webbrowser (you must be logged on as admin user before, and did
- * I mention making a backup?) After the script has run, you can remove the
- * zoph_album_permissions from your database, as it is no longer used.
- */
-    require_once("include.inc.php");
-
-
-    if (!$user->is_admin()) {
-        header("Location: " . add_sid("zoph.php"));
-    }
-
-    echo "<h1>Migrating user permissions to group permissions</h1>\n";
-
-    echo "<h2>Getting list of users...</h2>\n";
-    $users=get_users();
-    echo "<h2>Creating a group for each user...</h2>\n";
-    echo "<ul>\n";
-    foreach ($users as $u) {
-        $user_name=$u->get("user_name");
-        echo "<li>" . $user_name . "</li>\n";
-        $user_id=$u->get("user_id");
-        $group=new group();
-        $group->set("group_name", $user_name);
-        $group->insert();
-        $group_id=$group->get("group_id");
-        $user_group[$user_id]=$group_id;
-        $group->add_member($user_id);
-    }
-    echo "</ul>\n";
-    echo "<h2>Migrating user permissions to group permissions...</h2>\n";
-    echo "<ul>\n";
-    foreach ($users as $u) {
-        $user_name=$u->get("user_name");
-        echo "<li>" . $user_name . "</li>\n";
-        $user_id=$u->get("user_id");
-        $sql="SELECT * from " . DB_PREFIX . "album_permissions " .
-            "WHERE user_id=" . escape_string($user_id);
-
-        $result=mysql_query($sql);
-        while ($row=mysql_fetch_array($result, MYSQL_ASSOC)) {
-            unset($row["user_id"]);
-            $row["group_id"]=$user_group[$user_id];
-            $gp=new group_permissions();
-            $gp->set_fields($row);
-            $gp->insert();
-        }
-    }
-?>
diff -pruN 0.9.4-4/contrib/zophClean 0.9.8-1/contrib/zophClean
--- 0.9.4-4/contrib/zophClean	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/zophClean	1970-01-01 00:00:00.000000000 +0000
@@ -1,106 +0,0 @@
-#!/usr/bin/perl -w
-
-#
-# Clean up discrepancies bewteen the db and the images.
-#
-# jgeiger, Jan 2003
-#
-
-use strict;
-use DBI;
-
-my $db_host = "localhost";
-my $db_name = "zoph";
-my $db_user = "zoph_rw";
-my $db_pass = "pass";
-my $db_prefix = "zoph_";
-
-my $image_dir = "/data/images";
-
-my $type = $ARGV[0];
-if (!$type or ($type ne "-file" and $type ne "-db")) {
-    die "zophClean: please specify -file or -db\n";
-}
-
-my $dbh = DBI->connect("DBI:mysql:$db_name:$db_host", $db_user, $db_pass) or die "$!";
-my $query = "select distinct(concat(concat(path, '/'), name)) from $db_prefix" . "photos order by path, name";
-my $sth = $dbh->prepare($query);
-$sth->execute() or die "$!";
-
-my @db_images;
-
-while (my ($image) = $sth->fetchrow_array()) {
-    #print "$image_dir/$image\n";
-    push @db_images, "$image_dir/$image";
-}
-
-$dbh->disconnect();
-
-open IMAGES, "find $image_dir -iname \"*.jpg\" | fgrep -v mid_ | fgrep -v thumb_ | sort | uniq |" or die "$!";
-
-my @file_images = <IMAGES>;
-close IMAGES;
-
-if ($type eq "-file") {
-    print "echo ";
-}
-elsif ($type eq "-db") {
-    print "# ";
-}
-
-print scalar @file_images, " files, ", scalar @db_images, " records\n";
-
-my $image = shift @file_images;
-my $db_image = shift @db_images;
-
-while (1) {
-    if (not $image or not $db_image) { last; }
-
-    chomp $image;
-
-    if ($image ne $db_image) {
-        if ($image lt $db_image) {
-            if ($type eq "-file") {
-                print "rm $image; ";
-                $image =~ s|/([^/]+)$|/mid/mid_$1|;
-                print "rm -f $image; ";
-                $image =~ s|mid|thumb|g;
-                print "rm -f $image\n";
-            }
-            $image = shift @file_images;
-        }
-        else {
-            if ($type eq "-db") {
-                my $image_name = $db_image;
-                $image_name =~ s/.*\/([^\/]+)/$1/;
-                my $image_path = $db_image;
-                $image_path =~ s/\Q$image_dir\E\/(.*)\/\Q$image_name/$1/;
-                print "delete from $db_prefix" . "photos where name = '$image_name' and path = '$image_path';\n";
-            }
-            $db_image = shift @db_images;
-        }
-    }
-    else {
-        $image = shift @file_images;
-        $db_image = shift @db_images;
-    }
-}
-
-if ($type eq "-file") {
-    while (my $image = shift @file_images) {
-        print "rm $image; ";
-        $image =~ s|/([^/]+)$|/mid/mid_$1|;
-        print "rm -f $image; ";
-        $image =~ s|mid|thumb|g;
-        print "rm -f $image\n";
-    }
-}
-elsif ($type eq "-db") {
-    while (my $image = shift @db_images) {
-        my $image_name = $db_image;
-        $image_name =~ s/.*\/([^\/]+)/$1/;
-        my $image_path = $db_image;
-        $image_path =~ s/\Q$image_dir\E\/(.*)\/\Q$image_name/$1/;
-        print "delete from $db_prefix" . "photos where name = '$image_name' and path = '$image_path';\n";
-    }
-}
diff -pruN 0.9.4-4/contrib/zophEdit 0.9.8-1/contrib/zophEdit
--- 0.9.4-4/contrib/zophEdit	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/zophEdit	1970-01-01 00:00:00.000000000 +0000
@@ -1,467 +0,0 @@
-#! /usr/bin/python
-
-banner = """
-#    zophEdit - edit photo metadata in a zoph database
-#
-#    Copyright (C) 2003 Nils Decker <ndecker@gmx.de>
-"""
-#    This program is free software; you can redistribute it and/or modify
-#    it under the terms of the GNU General Public License as published by
-#    the Free Software Foundation; either version 2 of the License, or
-#    (at your option) any later version.
-#
-#    This program is distributed in the hope that it will be useful,
-#    but WITHOUT ANY WARRANTY; without even the implied warranty of
-#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-#    GNU General Public License for more details.
-#
-#    You should have received a copy of the GNU General Public License
-#    along with this program; if not, write to the Free Software
-#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-
-# $Id: zophEdit,v 1.1 2004/10/26 06:14:12 jasongeiger Exp $
-
-# modifications to the zoph database:
-# - alter table albums add column done default 0
-
-import MySQLdb, os
-from string import join, split, strip
-import readline
-import re
-
-# TODO: doku
-
-# *************************************************************** 
-# configuration
-
-conffile = os.getenv('HOME') + '/.zophEdit'
-
-connargs = {
-	'unix_socket' : '/var/run/mysqld/mysqld.sock',
-	'db' : 'zoph',
-	'user' : 'zoph_rw',
-	'passwd' : 'pass' }
-
-db_prefix = 'zoph_'
-
-photo_cmd = "gnome-moz-remote http://localhost/zoph-dev/photo.php?photo_id=%i > /dev/null"
-album_cmd = "gnome-moz-remote http://localhost/zoph-dev/photos.php?album_id=%i > /dev/null"
-
-
-# *************************************************************** 
-
-# {{{ global constants
-photo_props = ['title', 'view', 'rating', 'description', 'date', 'time', 'comment']
-
-default_dict = {
-		'.' : 'next:1',
-		'q' : 'quit:1',
-		'next' : 'next:1',
-		'all'  : 'apply:1000',
-		'quit' : 'quit:1',
-		'list' : 'list:1',
-		'help' : 'help:1',
-		'done' : 'done:1',
-	}
-
-commands = [
-		('help',         'help'),
-		('setalbum',     'work on this album'),
-		('firstalbum',   'work on the first pending album'),
-		('done',         'mark this album as done'),
-		('next',         'jump to nth image'),
-		('apply',        'apply the rest of the line to the next n photos'),
-		('list',         'list shortcuts'),
-		('del',          'delete shortcut'),
-		('album',        'add photo to album'),
-		('category',     'add photo to category'),
-		('person',       'add person to photo'),
-		('place',        'set the place of the photo'),
-		('photographer', 'set photographer of the photo'),
-		(join(photo_props, ','), 'set attribute of the photo') ]
-
-# }}}
-
-# {{{ Exceptions
-
-class NotFoundError(Exception):
-	def __init__(self, type, key):
-		self.type = type
-		self.key = key
-
-	def printMsg(self):
-		print "Cannot find %s for %s" % ( self.type, self.key )
-
-class ManyFoundError(Exception):
-	def __init__(self, type, key, vals):
-		self.type = type
-		self.key = key
-		self.vals = vals
-
-	def printMsg(self):
-		print "More than one %s for %s found: " % (self.type, self.key)
-		for l in self.vals:
-			print "  %8i : %s" % l
-
-class CommandError(Exception):
-	def __init__(self, cmd):
-		self.cmd = cmd
-
-	def printMsg(self):
-		print "Unknown command: %s" % (self.cmd,)
-# }}}
-
-# {{{ Db
-class Db:
-	db = None
-	cur = None
-
-	def __init__(self):
-		self.db = apply(MySQLdb.Connect, (), connargs)
-		self.cur = self.db.cursor()
-
-	def get_album(self, id):
-		self.cur.execute("SELECT album_id, album FROM " + db_prefix + "albums WHERE album_id = %s", (id,))
-
-		res = self.cur.fetchone()
-
-		if res:
-			(album_id, album) = res
-
-			self.cur.execute("SELECT photo_id from " + db_prefix + "photo_albums where album_id = %s", (album_id,))
-			album_photos = map( (lambda x: x[0]), self.cur.fetchall())
-
-			return (album_id, album, album_photos)
-		else:
-			return (0, None, [])
-
-	def get_first_album(self):
-		self.cur.execute("SELECT album_id from " + db_prefix + "albums WHERE done = 0 ORDER BY album_id LIMIT 1")
-		(id,) = self.cur.fetchone()
-		return self.get_album(id)
-
-	def mark_album_done(self, id):
-		self.cur.execute("UPDATE " + db_prefix + "albums SET done = 1 WHERE album_id = '%s'", (id,))
-
-	def set_prop(self, p, prop, val):
-		if not prop in photo_props:
-			raise Exception("invalid property %s" % (prop,))
-
-		self.cur.execute("UPDATE " + db_prefix + "photos SET %s = '%s' WHERE photo_id = %s" % (prop, val, p))
-
-	def add_album(self, p, name):
-		album_id, title = self.find_album(name)
-		self.cur.execute("INSERT INGNORE INTO " + db_prefix + "photo_albums(photo_id, album_id) " +
-						 "VALUES( %s, %s )", (p, album_id))
-		return title
-
-
-	def find_album(self, name):
-		sql = "SELECT album_id, album FROM " + db_prefix + "albums WHERE "
-		if name.isdigit():
-			sql = sql + "album_id = %s"
-		else:
-			sql = sql + "album like '%s%%'"
-
-		self.cur.execute(sql % (name,))
-		data = self.cur.fetchall()
-
-		if len(data) == 0:
-			raise NotFoundError("album", name)
-	 	elif len(data) == 1:
-			return data[0][0], data[0][1]
-		else:
-			raise ManyFoundError("album", name, data)
-
-	def add_category(self, p, name):
-		category_id, title = self.find_category(name)
-		self.cur.execute("INSERT IGNORE INTO " + db_prefix + "photo_categories(photo_id, category_id) " +
-						 "VALUES( %s, %s )", (p, category_id))
-		return title
-
-
-	def find_category(self, name):
-		sql = "SELECT category_id, category FROM " + db_prefix + "categories WHERE "
-		if name.isdigit():
-			sql = sql + "category_id = %s"
-		else:
-			sql = sql + "category like '%s%%'"
-
-		self.cur.execute(sql % (name,))
-		data = self.cur.fetchall()
-
-		if len(data) == 0:
-			raise NotFoundError("category", name)
-	 	elif len(data) == 1:
-			return data[0][0], data[0][1]
-		else:
-			raise ManyFoundError("category", name, data)
-
-	def add_person(self, p, name):
-		person_id, fullname = self.find_person(name)
-		self.cur.execute(("INSERT IGNORE INTO " + db_prefix + "photo_people (photo_id, person_id ) " +
-						  "VALUES(%s, %s)") % (p, person_id))
-		return fullname
-		
-
-	def find_person(self, name):
-		names = split(name, ',', 1)
-		names.append("")
-
-		self.cur.execute(("SELECT person_id, first_name, last_name FROM " + db_prefix + "people " +
-						  "WHERE first_name LIKE '%s%%' AND last_name LIKE '%s%%'" )
-						 % (names[1], names[0]))
-
-		data = self.cur.fetchall()
-		data = map( (lambda x: (x[0], "%s, %s" % (x[2], x[1]))), data)
-
-		if len(data) == 0:
-			raise NotFoundError("person", name)
-	 	elif len(data) == 1:
-			return data[0][0], data[0][1]
-		else:
-			raise ManyFoundError("person", name, data)
-
-	def set_photographer(self, p, name):
-		person_id, fullname = self.find_person(name)
-		self.cur.execute("UPDATE " + db_prefix + "photos SET photographer_id = %s WHERE photo_id = %s"
-						 %( person_id, p))
-		return fullname
-
-
-	def set_place(self, p, name):
-		place_id, title = self.find_place(name)
-		self.cur.execute(("UPDATE " + db_prefix + "photos SET location_id = %s " +
-						  "WHERE photo_id = %s") % (place_id, p))
-		return title
-
-	def find_place(self, name):
-		sql = "SELECT place_id, title FROM " + db_prefix + "places WHERE "
-		if name.isdigit():
-			sql = sql + "place_id = %s"
-		else:
-			sql = sql + "title like '%s%%'"
-
-		self.cur.execute(sql % (name,))
-		data = self.cur.fetchall()
-
-		if len(data) == 0:
-			raise NotFoundError("place", name)
-	 	elif len(data) == 1:
-			return data[0][0], data[0][1]
-		else:
-			raise ManyFoundError("place", name, data)
-
-
-
-# }}}
-
-def help(dict):
-	print "Commands: "
-	for c in commands:
-		print "  %-15s : %s" % c
-	print
-	print "Predefined shortcuts:"
-	for k in default_dict.keys():
-		if dict[k] == default_dict[k]:
-			print "  %-5s : %s" % (k, default_dict[k])
-	print
-
-def main(): # {{{ main
-	dict = readConfig()
-	db = Db()
-	try:
-		res = {}
-
-		album_id, album = None, None
-		album_photos = []
-		photos_pos = -1
-
-		while not res.has_key('quit'):
-			if photos_pos != -1:
-				photos = [album_photos[photos_pos]]
-			else:
-				photos = []
-
-			prompt = ''
-			if album_id:
-				prompt = "%s" %(album,)
-				if photos_pos != -1:
-					prompt = prompt + "[%i]:%i" % (photos_pos, photos[0])
-			prompt = prompt + '> '
-
-
-			line = raw_input(prompt)
-
-			try:
-				(cmds, dict) = parse_line(line, dict)
-
-				# {{{ commands
-				for (cmd,val) in cmds:
-					if cmd == 'help':
-						help(dict)
-					elif cmd == 'setalbum':
-						album_id, album, album_photos = db.get_album(int(val))
-						photos_pos = -1
-
-						if album_id:
-							os.system(album_cmd % (album_id,))
-					elif cmd == 'firstalbum':
-						album_id, album, album_photos = db.get_first_album()
-						photos_pos = -1
-
-						if album_id:
-							os.system(album_cmd % (album_id,))
-					elif cmd == 'done':
-						db.mark_album_done(album_id)
-
-					elif cmd == 'next':
-						skip = int(val)
-						photos_pos = photos_pos + skip
-
-						if photos_pos < 0 or photos_pos >= len(album_photos):
-							photos_pos = -1
-							photos = []
-						else:
-							photos = [album_photos[photos_pos]]
-							os.system(photo_cmd % (photos[0],))
-					elif cmd == 'apply':
-						pos = 0
-						if photos_pos != -1:
-							pos = photos_pos
-						photos = album_photos[pos: pos + int(val)]
-						print "working on photos: %s" % (str(photos),)
-					elif cmd == 'list':
-						print "Aliases:"
-						for k in dict.keys():
-							if not default_dict.has_key(k):
-								print "%-8s = %s" % (k, dict[k])
-					elif cmd == 'album':
-						for p in photos:
-							x = db.add_album(p, val)
-							print "Added photo %i to album %s" % (p, x)
-					elif cmd == 'category':
-						for p in photos:
-							x = db.add_category(p, val)
-							print "Added photo %i to category %s" % (p, x)
-					elif cmd == 'person':
-						for p in photos:
-							x = db.add_person(p, val)
-							print "Added %s to photo %i" % (x, p)
-					elif cmd == 'place':
-						for p in photos:
-							x = db.set_place(p, val)
-							print "Photo %i is at place %s" % (p, x)
-					elif cmd == 'photographer':
-						for p in photos:
-							x = db.set_photographer(p, val)
-							print "Photographer for photo %i is %s" % (p, x)
-					elif cmd in photo_props:
-						for p in photos:
-							db.set_prop(p, cmd, val)
-							print "Photo %i %s set to %s" %(p, cmd, val)
-					elif cmd == 'del':
-						if dict.has_key(val):
-							del dict[val]
-					else:
-						raise CommandError(cmd)
-				# }}}
-
-			except ManyFoundError, e:
-				e.printMsg()
-			except NotFoundError, e:
-				e.printMsg()
-			except CommandError, e:
-				e.printMsg()
-
-	except EOFError:
-		print
-
-	writeConfig(dict)
-
-	# }}} main
-
-# {{{ line
-parse_eq = re.compile('^([A-Za-z0-9.]+)=(.*)$')
-parse_col = re.compile('^([A-Za-z0-9.]+):(.*)$')
-def parse_line(line, dict):
-	res = []
-
-	eq = parse_eq.match(line)
-	if eq:
-		dict[eq.group(1)] = eq.group(2)
-	else:
-		def split_line(line): # {{{ split_line
-			res = []
-			cur = ''
-			in_quote = None
-
-			while line:
-				if line[0] == ' ' and not in_quote:
-					if len(cur) > 0:
-						res.append(cur)
-					cur = ''
-				elif line[0] == '\"':
-					in_quote = not in_quote
-				else:
-					cur = cur + line[0]
-				line = line[1:]
-
-			if len(cur) > 0:
-				res.append(cur)
-
-			return res
-			#}}}
-
-		tokens = split_line(line)
-
-		while len(tokens) > 0:
-			token, tokens = tokens[0], tokens[1:]
-			col = parse_col.match(token)
-			if col:
-				key = col.group(1)
-				if dict.has_key(key) and dict[key].find(':') == -1:
-					key = dict[key]
-				res.append((key, col.group(2)))
-			else:
-				if dict.has_key(token):
-					for t in split_line(dict[token]):
-						tokens = [t] + tokens
-				else:
-					raise CommandError(token)
-
-	return (res, dict)
-
-#}}}
-
-# {{{ config
-
-def readConfig():
-	dict = default_dict.copy()
-
-	try:
-		f = open(conffile, 'r')
-		for l in f.readlines():
-			if l[0] == '#':
-				continue
-			dict = parse_line(l, dict)[1]
-		f.close()
-	except Exception, e:
-		pass
-
-	return dict
-
-def writeConfig(dict):
-	f = open(conffile, 'w')
-	for k in dict.keys():
-		if not default_dict.has_key(k) or default_dict[k] != dict[k]:
-			f.write("%s=%s\n"%(k, dict[k]))
-	f.close()
-		
-
-#}}}
-
-if __name__ == '__main__':
-	print join(filter(None, map((lambda x: strip(x[1:])), split(banner,'\n'))), '\n')
-	main()
-
diff -pruN 0.9.4-4/contrib/zophFixImageFileCase.sh 0.9.8-1/contrib/zophFixImageFileCase.sh
--- 0.9.4-4/contrib/zophFixImageFileCase.sh	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/zophFixImageFileCase.sh	1970-01-01 00:00:00.000000000 +0000
@@ -1,63 +0,0 @@
-#!/bin/sh
-
-# find files that are *not* in mid or thumbs
-# and do *not* start with orig_
-files="$(find . \( -iname '*.jpg' -o -iname '*.jpeg' \) -a ! -name 'orig_*' -a ! \( -path '/mid' -o -path '/thumb' \))";
-echo "$files" | while read filename_full; do
-  dirname=$(dirname "$filename_full");
-  filename=$(basename "$filename_full");
-#  extension="${filename##*.}";
-#  filename="${filename%.*}";
-
-  #
-  # thumb
-  #
-
-  # check if a same named file exists in thumb,
-  # but with a different case
-  dirname_thumb="${dirname}/thumb";
-  filename_thumb="thumb_${filename}";
-
-  if [ -d "${dirname_thumb}" ]; then
-    filename_full_thumb=$(find "${dirname_thumb}" -iname "${filename_thumb}");
-    filename_thumb=$(basename "$filename_full_thumb");
-
-    if [ "${filename_thumb}_NOTFOUND" != "_NOTFOUND" ]; then
-      # check if the two files have different cases
-      if [ "$filename_thumb" != "thumb_${filename}" ]; then
-         filename_full_thumb_new="${dirname_thumb}/thumb_${filename}";
-
-         echo "THUMB:";
-         echo Found \"$filename_full\" and \"$filename_full_thumb\";
-         echo Will rename \"$filename_full_thumb\" to \"$filename_full_thumb_new\";
-         mv "$filename_full_thumb" "$filename_full_thumb_new";
-      fi;
-    fi;
-  fi;
-
-  #
-  # mid
-  #
-
-  # check if a same named file exists in mid,
-  # but with a different case
-  dirname_mid="${dirname}/mid";
-  filename_mid="mid_${filename}";
-
-  if [ -d "${dirname}/mid" ]; then
-    filename_full_mid=$(find "${dirname_mid}" -iname "${filename_mid}");
-    filename_mid=$(basename "$filename_full_mid");
-
-    if [ "${filename_mid}_NOTFOUND" != "_NOTFOUND" ]; then
-      # check if the two files have different cases
-      if [ "$filename_mid" != "mid_${filename}" ]; then
-         filename_full_mid_new="${dirname_mid}/mid_$filename";
-
-         echo "MID:"
-         echo Found \"$filename_full\" and \"$filename_full_mid\";
-         echo Will rename \"$filename_full_mid\" to \"$filename_full_mid_new\";
-         mv "$filename_full_mid" "$filename_full_mid_new";
-      fi;
-    fi;
-  fi;
-done;
diff -pruN 0.9.4-4/contrib/zophImportMovie.sh 0.9.8-1/contrib/zophImportMovie.sh
--- 0.9.4-4/contrib/zophImportMovie.sh	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/contrib/zophImportMovie.sh	1970-01-01 00:00:00.000000000 +0000
@@ -1,130 +0,0 @@
-#!/bin/bash
-# (c) giles@chaletpomme.com April 2004
-# for oly c-750 videos
-
-movie=$1
-
-year=2005
-
-echo "Adding movie $movie to Zoph database"
-echo "Assuming year is $year - change in source"
-sleep 3
-
-# Work out dates, paths and filenames
-filename=$(basename $movie)
-
-month=$(echo $filename|sed "s/^[Pp]//"|head -c 1)
-day=$(echo $filename|sed "s/^[pP]//"|head -c 3|tail -c 2)
-case $month in
-    a)
-	month=10
-	;;
-    b)
-	month=11
-	;;
-    c)
-	month=12
-	;;
-esac
-month=$(printf %02d $month)
-
-
-# Paths:
-basepath=/maxtor/photographs
-path=olympus/$year.$month.$day
-midsize=$basepath/$path/mid/mid_${filename%.MOV}.jpg
-thumbsize=$basepath/$path/thumb/thumb_${filename%.MOV}.jpg
-
-
-echo month is $month, day is $day
-echo path is: $path
-echo Midsize is $midsize
-echo Thumbnail is $thumbsize
-
-# EXIF-type stuff
-frames=$(tcprobe -i $movie 2>/dev/null|tr ':,' '\n'|grep frames|tr ' ' '\n'|grep [0-9] ) >/dev/null
-exposure=" $[ $frames / 15 ] seconds"
-echo Video has $frames frames, i.e. $exposure seconds
-size=$(find $movie -printf "%s")
-
-has_audio=$(tcprobe -i $movie 2>/dev/null|grep "7875,8"|wc -l)
-if [ $has_audio -eq 1 ] ; then
-    comment="video with audio"
-else
-    comment="video"
-fi
-
-# 5 is the location_id for unknown video location
-
-mysql -v -u giles -e "USE zoph ; INSERT INTO photos (name,path,date,exposure,comment,compression,size,location_id) VALUES (\"$filename\",\"$path\",\"$year-$month-$day\",\"$exposure\",\"$comment\",\"MJPEG\",$size,5);"
-
-# Add to movie category.  This is category 11.
-#photo_id=$(mysql -s -u giles -e "USE zoph ; SELECT photo_id FROM photos WHERE name=\"$filename\";"|grep -v photo_id|head -1)
-
-
-# Ensure directory exists:
-if [ ! -d $basepath/$path ] ; then
-    echo Creating image directories
-    mkdir $basepath/$path
-    mkdir $basepath/$path/thumb
-    mkdir $basepath/$path/mid
-else
-    echo Directories exist
-fi
-
-
-
-rm /tmp/grab*.ppm
-
-step=$[ $frames / 25 ]
-echo therefore step is $step
-
-for grab in $(seq 0 24)
-  do
-  frame=$[ $grab * $step ]
-  echo -n "$frame($grab) "
-  transcode -i $movie -x auto,null -o /tmp/grab -y ppm -c $frame-$[ $frame + 1 ] 2>&1 > /dev/null 2>/dev/null || sleep 5
-  mv /tmp/grab000000.ppm /tmp/grab$grab.ppm
-  
-done
-echo
-
-montage -geometry 96x72+0+0 -tile 5x5 -borderwidth 0 $( ls -1v --color=none /tmp/grab*.ppm) $midsize
-
-#rm /tmp/grab*.ppm
-
-echo Midsize written to $midsize
-
-
-
-
-
-# Now making thumbnail
-# We can only show four frames in the 120x90 thumbnail
-step=$[ $frames / 4 ]
-echo therefore step is $step
-
-for grab in $(seq 0 3)
-  do
-  frame=$[ $grab * $step ]
-  echo -n "$frame($grab) "
-  transcode -i $movie -x auto,null -o /tmp/grab -y ppm -c $frame-$[ $frame + 1 ] 2>&1 > /dev/null 2>/dev/null || sleep 5
-  mv /tmp/grab000000.ppm /tmp/grab$grab.ppm
-  
-done
-echo
-
-montage -geometry 60x45+0+0 -tile 2x2 -borderwidth 0 $( ls -1v --color=none /tmp/grab[0123].ppm) $thumbsize
-
-mogrify -stroke yellow -pointsize 34 -draw "text 5,58 VIDEO" $thumbsize
-if [ $has_audio -eq 1 ] ; then
-    mogrify -stroke yellow -pointsize 20 -draw "text 50,70 audio" $thumbsize
-fi
-echo Thumbnail written to $thumbsize
-
-rm /tmp/grab*.ppm
-
-mv -v $movie $basepath/$path/
-
-echo
-echo
diff -pruN 0.9.4-4/debian/changelog 0.9.8-1/debian/changelog
--- 0.9.4-4/debian/changelog	2017-04-03 13:50:21.000000000 +0000
+++ 0.9.8-1/debian/changelog	2018-09-14 08:24:56.000000000 +0000
@@ -1,3 +1,16 @@
+zoph (0.9.8-1) unstable; urgency=medium
+
+  * New upstream version
+  * Russian debconf translation (Closes: #883288)
+  * Included dbconfig-common upgrade for database version from
+    0.9.4 to higher
+  * Include patch for upstream bug #111
+    - https://github.com/jeroenrnl/zoph/issues/111
+  * Update watch to look at gitlab, now locatation for zoph source
+    though the 0.9.8 release came from github.
+
+ -- John Lines <john@paladyn.org>  Fri, 14 Sep 2018 09:24:56 +0100
+
 zoph (0.9.4-4) unstable; urgency=medium
 
   * Portuguese translation (Closes: #859340)
diff -pruN 0.9.4-4/debian/patches/confDefault-image-path.patch 0.9.8-1/debian/patches/confDefault-image-path.patch
--- 0.9.4-4/debian/patches/confDefault-image-path.patch	2016-12-11 21:33:56.000000000 +0000
+++ 0.9.8-1/debian/patches/confDefault-image-path.patch	2018-06-20 11:25:56.000000000 +0000
@@ -1,11 +1,12 @@
-Description: Use FHS compliant path for Images
- Upstream uses /data/images as a sample path,
- use an FHS compliant path which can be created in
- the package, i.e. /var/lib/zoph
-Author: John Lines <john@paladyn.org> 
---- a/php/classes/confDefault.inc.php
-+++ b/php/classes/confDefault.inc.php
-@@ -263,7 +263,7 @@
+Description: Fix default image path for FHS compatibility
+ Upstream uses /data/images, package uses /var/lib/zoph
+Author: John Lines <jlines@debian.org>
+Last-Update: 2018-06-20
+---
+This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
+--- a/php/classes/conf/confDefault.inc.php
++++ b/php/classes/conf/confDefault.inc.php
+@@ -281,7 +281,7 @@
          $pathImages->setLabel("Images directory");
          $pathImages->setDesc("Location of the images on the filesystem. Absolute path, " .
              " thus starting with a /");
diff -pruN 0.9.4-4/debian/patches/EditAlbumOrCategory.patch 0.9.8-1/debian/patches/EditAlbumOrCategory.patch
--- 0.9.4-4/debian/patches/EditAlbumOrCategory.patch	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/debian/patches/EditAlbumOrCategory.patch	2018-06-20 18:04:43.000000000 +0000
@@ -0,0 +1,29 @@
+Description: Fix for upstream issue #111 - Not possible to edit album or category
+ Apply upstream patch pulled from GIT
+Origin: upstream, https://github.com/jeroenrnl/zoph/commit/70e9d6579321d2a7b2c2c2d0b8230c824317a0ac
+Bug: https://github.com/jeroenrnl/zoph/issues/111
+Applied-Upstream: commit, https://github.com/jeroenrnl/zoph/commit/70e9d6579321d2a7b2c2c2d0b8230c824317a0ac
+---
+This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
+--- a/php/albums.php
++++ b/php/albums.php
+@@ -98,7 +98,7 @@
+ if ($user->canEditOrganizers()) {
+     $actionlinks=array(
+         translate("new") => "album.php?_action=new&amp;parent_album_id=" . (int) $album->getId(),
+-        translate("edit") => "album.php?_action=edit&amp;parent_album_id=" . (int) $album->getId(),
++        translate("edit") => "album.php?_action=edit&amp;album_id=" . (int) $album->getId(),
+     );
+     if ($album->get("coverphoto")) {
+         $actionlinks["unset coverphoto"]="album.php?_action=update&amp;album_id=" . (int) $album->getId() .
+--- a/php/util.inc.php
++++ b/php/util.inc.php
+@@ -36,7 +36,7 @@
+     $html = "";
+     foreach ($fields as $key => $val) {
+         $html.=
+-            "<label for=\"$key\">$field[0]</label>\n" . $field[1] ."<br>";
++            "<label for=\"$key\">$val[0]</label>\n" . $val[1] ."<br>";
+     }
+     return $html;
+ }
diff -pruN 0.9.4-4/debian/patches/series 0.9.8-1/debian/patches/series
--- 0.9.4-4/debian/patches/series	2016-12-11 19:23:38.000000000 +0000
+++ 0.9.8-1/debian/patches/series	2018-06-20 17:48:14.000000000 +0000
@@ -1 +1,2 @@
 confDefault-image-path.patch
+EditAlbumOrCategory.patch
diff -pruN 0.9.4-4/debian/po/ru.po 0.9.8-1/debian/po/ru.po
--- 0.9.4-4/debian/po/ru.po	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/debian/po/ru.po	2018-06-21 09:41:35.000000000 +0000
@@ -0,0 +1,55 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the zoph package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: zoph\n"
+"Report-Msgid-Bugs-To: zoph@packages.debian.org\n"
+"POT-Creation-Date: 2016-12-15 12:45+0000\n"
+"PO-Revision-Date: 2017-12-02 00:19+0500\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 2.0.4\n"
+"Last-Translator: Lev Lamberov <dogsleg@debian.org>\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
+"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"Language: ru\n"
+
+#. Type: select
+#. Description
+#: ../zoph.templates:1001
+msgid "Remove image files (photos) you uploaded ?"
+msgstr "Удалить загруженные вами файлы изображений (фотографии)?"
+
+#. Type: select
+#. Description
+#: ../zoph.templates:1001
+msgid ""
+"Zoph imports files into, by default, /var/lib/zoph If you decide to remove "
+"the zoph package, but wish to keep the photos you uploaded answer yes. To "
+"have the files removed, answer no. To be asked at package removal time "
+"answer ask."
+msgstr ""
+"По умолчанию zoph импортирует файлы в /var/lib/zoph. Если вы решили удалить "
+"пакет zoph, но хотите сохранить загруженные фотографии, ответьте \"да"
+"\" (yes). Чтобы удалить файлы, ответьте \"нет\" (no). Для того, чтобы "
+"система спросила вас об этом во время удаления пакета, выберите \"спросить"
+"\" (ask)."
+
+#. Type: boolean
+#. Description
+#: ../zoph.templates:2001
+msgid "Keep uploaded image files after removal ?"
+msgstr "Сохранить загруженные файлы после удаления пакета?"
+
+#. Type: boolean
+#. Description
+#: ../zoph.templates:2001
+msgid ""
+"You have imported some photos into /var/lib/zoph, and are removing the zoph "
+"package."
+msgstr "Вы импортировали фотографии в /var/lib/zoph и удаляете пакет zoph."
diff -pruN 0.9.4-4/debian/README.Debian 0.9.8-1/debian/README.Debian
--- 0.9.4-4/debian/README.Debian	2016-12-12 15:14:35.000000000 +0000
+++ 0.9.8-1/debian/README.Debian	2018-06-20 11:18:18.000000000 +0000
@@ -25,6 +25,7 @@ zoph group.
 ##php.ini settings##
 
 Settings you may need to change in /etc/php/7.0/apache2/php.ini:
+
 ###max_input_time###
 This is the time Zoph is allowed by PHP to spend waiting for the file to
 be uploaded. Depending on the size of your files and the speed of your
diff -pruN 0.9.4-4/debian/README.source 0.9.8-1/debian/README.source
--- 0.9.4-4/debian/README.source	2016-11-29 09:34:23.000000000 +0000
+++ 0.9.8-1/debian/README.source	2018-06-20 17:46:54.000000000 +0000
@@ -1,10 +1,7 @@
 zoph for Debian
 --------------
 
-<this file describes information about the source package, see Debian policy
-manual section 4.14. You WILL either need to modify or delete this file>
+This version (0.9.8) of zoph was downloaded from  https://github.com/jeroenrnl/zoph/archive/v0.9.8.tar.gz 
 
-
-
- -- John Lines <john@paladyn.org>  Tue, 29 Nov 2016 09:32:23 +0000
+ -- John Lines <john@paladyn.org> Wed 20 Jun 18:46:09 BST 2018
 
diff -pruN 0.9.4-4/debian/rules 0.9.8-1/debian/rules
--- 0.9.4-4/debian/rules	2016-11-29 09:34:23.000000000 +0000
+++ 0.9.8-1/debian/rules	2018-09-12 16:57:23.000000000 +0000
@@ -19,7 +19,4 @@
 
 
 # dh_make generated override targets
-# This is example for Cmake (See https://bugs.debian.org/641051 )
-#override_dh_auto_configure:
-#	dh_auto_configure -- #	-DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH)
 
diff -pruN 0.9.4-4/debian/watch 0.9.8-1/debian/watch
--- 0.9.4-4/debian/watch	2016-12-10 17:01:04.000000000 +0000
+++ 0.9.8-1/debian/watch	2018-09-12 16:54:10.000000000 +0000
@@ -14,14 +14,16 @@ version=4
 #opts="filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%<project>-$1.tar.gz%" \
 #   https://github.com/<user>/zoph/tags \
 #   (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
-opts="filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%zoph-$1.tar.gz%" \
-   https://github.com/jeroenrnl/zoph/tags \
-   (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
-
-# Direct Git
-# opts="mode=git" http://git.example.com/zoph.git \
-#   refs/tags/v([\d\.]+) debian uupdate
 
+#opts="filenamemangle=s%(?:.*?)?v?(\d[\d.]*)\.tar\.gz%zoph-$1.tar.gz%" \
+#   https://github.com/jeroenrnl/zoph/tags \
+#   (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
+
+# Zoph has now moved to gitlab
+#  even though there is not a release there yet
+
+opts=filenamemangle=s/.*\/archive\/(\d\S+)\/zoph.*\.tar\.gz/zoph-$1\.tar\.gz/g \
+  https://gitlab.com/jeroenrnl/zoph/tags?sort=updated_desc .*/archive/(\d\S+)/.*\.tar\.gz.*
 
 
 
diff -pruN 0.9.4-4/debian/zoph.docs 0.9.8-1/debian/zoph.docs
--- 0.9.4-4/debian/zoph.docs	2016-12-03 19:30:35.000000000 +0000
+++ 0.9.8-1/debian/zoph.docs	2018-06-20 11:19:22.000000000 +0000
@@ -1,4 +1,10 @@
-README.md
-REQUIREMENTS.md
-FAQ.md
-UPGRADE.md
+docs/README.md
+docs/REQUIREMENTS.md
+docs/UPGRADE.md
+docs/WEBINTERFACE.md
+docs/CLI.md
+docs/CHANGELOG.md
+docs/CONFIGURATION.md
+docs/IMPORT-CLI.md
+docs/IMPORT-WEB.md
+
diff -pruN 0.9.4-4/debian/zoph.install 0.9.8-1/debian/zoph.install
--- 0.9.4-4/debian/zoph.install	2016-12-10 16:02:28.000000000 +0000
+++ 0.9.8-1/debian/zoph.install	2018-09-12 08:18:03.000000000 +0000
@@ -2,6 +2,7 @@
 php/ usr/share/zoph/www/
 cli/zoph usr/bin/
 sql/zoph.sql => /usr/share/dbconfig-common/data/zoph/install/mysql
+sql/zoph_update-0.9.6.sql => /usr/share/dbconfig-common/data/zoph/upgrade/mysql/0.9.6
 debian/apache.conf etc/zoph
 debian/createdb => /usr/share/dbconfig-common/data/zoph/install-admin/mysql 
 
diff -pruN 0.9.4-4/docs/CHANGELOG.md 0.9.8-1/docs/CHANGELOG.md
--- 0.9.4-4/docs/CHANGELOG.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/CHANGELOG.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,1328 @@
+# Zoph Changelog #
+## Zoph 0.9.8 ##
+### 02 march 2018 ###
+
+I have moved all Zoph's documentation to Github. About 12 years ago, Zoph's documentation was hand-crafted HTML that was an ordeal to keep updated. I moved it to Wikibooks so I could update the docs through their webinterface. Nowadays, Github offers 'markdown' which is a text format that is both readable in plain text and can be rendered to a more pleasing look on the website. I was already keeping some documentation in this format and it caused a lot of extra work, because I was essentially maintaining two sets of documentation. So, as of this release, all documentation is back in one place: [Zoph's repository on Github](http://github.com/jeroenrnl/zoph/)
+
+Furthermore, quite a few bugfixes have been been made. Most of them related to the rewrite of the search page in the previous version.
+
+#### Bugs ####
+* [issue#102](https://github.com/jeroenrnl/zoph/issues/102): Error instead of thumbnail for empty circles
+* [issue#103](https://github.com/jeroenrnl/zoph/issues/103): each() is deprecated as of PHP 7.2
+* [issue#104](https://github.com/jeroenrnl/zoph/issues/104): Search results for text-based 'LIKE' searches are reversed
+* [issue#105](https://github.com/jeroenrnl/zoph/issues/105), [issue#106](https://github.com/jeroenrnl/zoph/issues/106), [issue#108](https://github.com/jeroenrnl/zoph/issues/108): Several issues with the search results page
+* [issue#107](https://github.com/jeroenrnl/zoph/issues/107): ">", ">=", "<" and "<=" are pointless for text searches
+* [issue#109](https://github.com/jeroenrnl/zoph/issues/109): Autocomplete dropdown sometimes hidden behind map
+* [issue#110](https://github.com/jeroenrnl/zoph/issues/110): 'show all EXIF' button doesn't work
+
+#### Refactor ####
+* Removed unused functions in util.inc.php
+* Removing ancient scripts that are either redundant or no longer working
+* [issue#100](https://github.com/jeroenrnl/zoph/issues/100): Deprecate ssl.force, url.http and url.https
+As of **Zoph 0.9.9** these settings will be removed. Zoph warns you if you are using them as of **Zoph 0.9.8**. If you feel your Zoph installation can not do without these settings, please comment in this issue.
+* [issue#110](https://github.com/jeroenrnl/zoph/issues/110): Changed deprecated `read_exif_data()` to `exif_read_data()`
+
+## Zoph 0.9.7 ##
+### 19 jan 2018 ###
+I have had a very busy year and little time to spend on Zoph, but last december, I finally found time to finish what I had originally planned for 0.9.6: a complete rewrite of the search screen and the search engine. Most of the code in that part of Zoph was over 10 years old and had become quite messy over the years. The search engine is really the core of Zoph: if you open an album in Zoph, under the hood, Zoph really executes a search for all the photos in that album. This makes this code really important and I've made sure to cover all this by automated tests (UnitTests) before making any changes.
+
+
+* [issue#83](https://github.com/jeroenrnl/zoph/issues/83) Complete rewrite of the search page and the core functions of Zoph, including modernization of several other part of Zoph.
+* [issue#90](https://github.com/jeroenrnl/zoph/issues/90) Error displayed when adding a new place
+* [issue#99](https://github.com/jeroenrnl/zoph/issues/99) Geolocation doesn't work when using https
+* Documentation updates - not all files were correctly displayed using Github's Markdown interpreter
+
+## Zoph 0.9.6 ##
+### 14 apr 2017 ###
+Zoph 0.9.5 coincided with a significant change in MySQL, that caused a lot of bugs in Zoph and other open source projects. MySQL changed the way they process queries to handle them much more strictly. What makes things worse, is that MariaDB did not make this change, so at first I could not reproduce the issue. Because of the amount of work, I have decided to postpone the development that was planned for 0.9.6 and make this a bugfix-only release. In this release, I have included a few bugfixes by Pontus Fröding which is really great, thanks Pontus!
+
+
+### Bugs ###
+* [issue#86](https://github.com/jeroenrnl/zoph/issues/86) Fixed an omission in the upgrade instructions for 0.9.5
+* [issue#87](https://github.com/jeroenrnl/zoph/issues/87) error about class not found on add or edit
+* [issue#88](https://github.com/jeroenrnl/zoph/issues/88) Changes for MySQL 5.7 compatibility
+  * Give timestamp a default value
+  * Add field needed for MySQL 5.7 compatibility with SELECT DISTNCT .. ORDER BY
+  * Adding "ORDER BY" fields to autocover query
+  * More changes for MySQL 5.7 compatibility
+  * Updated SQL scripts
+  * Removed unused field from the database
+* [issue#91](https://github.com/jeroenrnl/zoph/issues/91) Changed PHPUnit classes to namespaced class naming
+* Fixed an issue in a UnitTest that caused a failed test
+* [Pull Request#94](https://github.com/jeroenrnl/zoph/pull/94) Add namespace to template showJSwarning in edit_person (by Pontus Fröding)
+* [Pull Request#95](https://github.com/jeroenrnl/zoph/pull/95) Add template namespace on two more places. (by Pontus Fröding)
+* [issue#92](https://github.com/jeroenrnl/zoph/issues/92) Fixed database connection to utf-8
+* [issue#93](https://github.com/jeroenrnl/zoph/issues/93) [Pull Request#95](https://github.com/jeroenrnl/zoph/pull/95) Fix for "Class pager not found" when using pagesets (by Pontus Fröding)
+
+### Refactor ###
+* Some modifications to backtrace printing, for easier debugging
+* Moved album view into template
+* [issue#89](https://github.com/jeroenrnl/zoph/issues/89) Changed look of next and previous buttons on photo page and increased size of actionlinks
+* Small style change
+
+## Zoph 0.9.5 ##
+### 4 feb 2017 ###
+
+Zoph 0.9.5 is the new stable release. It is recommended for everyone to upgrade to this release
+
+### Features ###
+* [Issue#68](https://github.com/jeroenrnl/zoph/issues/68) Changed from Mapstraction to Leaflet as mapping abstraction - with GoogleMaps, OpenStreetMap and MapBox (OpenStreetMap) support
+ The code for this was based on code provided by Jason (@JiCiT)
+* [Issue#80](https://github.com/jeroenrnl/zoph/issues/80) You can now edit permissions from the album screen, without the need to go to the group edit.
+* [Issue#82](https://github.com/jeroenrnl/zoph/issues/82) Zoph now gives a proper error message if a photo can not be found
+
+### Bugs ###
+* Fixed a bug where in some cases it was possible for an admin to unintentionally delete albums
+
+### Refactor ###
+* Lots of internal changes to move to an MVC-architecture
+* Several more parts of Zoph moved into templates
+* Added more unittests - to automatically test Zoph
+
+
+## Zoph 0.9.4 ##
+### 18 Sept 2016 ###
+
+Zoph 0.9.4 is the new stable release. It is recommended for everyone to upgrade to this release
+### Features ###
+* Geocoding: Zoph now also searches Wikipedia
+* [Issue#67](https://github.com/jeroenrnl/zoph/issues/67) Changed the colour scheme definition to use a nice interface to select the colour
+* [Issue#23](https://github.com/jeroenrnl/zoph/issues/23) An admin user can now define default prefences for new users
+* [Issue#24](https://github.com/jeroenrnl/zoph/issues/24) Added an option to automatically propagate permissions to newly created albums
+* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Removed Yahoo, Cloudmade mapping as they no longer offer their services to the public
+* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Removed Openlayers mapping, as Zophs implementation was buggy and did not work anymore.
+* [Issue#47](https://github.com/jeroenrnl/zoph/issues/47) Photos can now be deleted from disk (moved to a trash dir)
+* [Issue#67](https://github.com/jeroenrnl/zoph/issues/67) Added some new colour schemes
+
+### Bugs ###
+* Fixed an issue with album pulldown when editing group access rights
+* Fixed an issue where the circles page would sometimes report $title not found
+* Fixed an issue with changing views on circle page
+* Fixed an issue that caused errors in Firefox when using the configuration page
+* fixed collapsable details for time and rating
+* [Issue#78](https://github.com/jeroenrnl/zoph/issues/78) Fixed a case where an admin user was sometimes not allowed to see a person or a place
+
+### Other improvements ###
+* [Issue#77](https://github.com/jeroenrnl/zoph/issues/77) Lots of fixes in the German translation by Thomas Weiland (@HonkXL)
+* Moved group display to template
+* Moved group delete (confirm) into template
+* Moved group edit to a template
+* [Issue#79](https://github.com/jeroenrnl/zoph/issues/79) Modify recursive creation of directories, so Zoph can function in an open_basedir enverironment.
+* [Issue#66](https://github.com/jeroenrnl/zoph/issues/66) Cleanup of CSS
+* Some modernization of the looks of Zoph
+* [Issue#85](https://github.com/jeroenrnl/zoph/issues/85) Modified import process to show clearer error message
+* [Issue#66](https://github.com/jeroenrnl/zoph/issues/66) Added a reset CSS
+* [Issue#81](https://github.com/jeroenrnl/zoph/issues/81) Documentation updates
+* Some fixes for UnitTests
+* Additional tests
+* Refactor of group_permissions class into permissions class
+* Refactor prefs class
+* Moved preferences page to template
+* Modified prefs template to use labels instead of definition lists
+
+## Zoph 0.9.3 ##
+### 10 jun 2016 ###
+
+Zoph 0.9.3 is the new stable release. It is recommended for everyone to upgrade to this release
+
+### Features ###
+* [Issue #72](https://github.com/jeroenrnl/zoph/issues/72) Zoph now has a new logon screen.
+The logon screen has background photos. Two of them are already included in Zoph. You can place your own backgrounds in ```templates/default/images/backgrounds```. Or, you can (on the config screen) define an album from which the images will be used as background images. Zoph will display a random image as background.
+* [Issue #76](https://github.com/jeroenrnl/zoph/issues/76) The logon screen now gives a message about the username and/or password being wrong instead of just returning to the same screen
+* [Issue #75](https://github.com/jeroenrnl/zoph/issues/75) Zoph now uses PHP's password hashing algorithm instead of MySQL's.
+This includes a random 'salt' added to each password. This will make it much, much harder to decrypt your passwords, if your database would ever fall into the wrong hands. The old hashes will be updated with the new ones as soon the the user logs in. Zoph will continue to support the old password hashes at least until v0.9.5.
+* [Issue #26](https://github.com/jeroenrnl/zoph/issues/26) It is now possible to define the cookie expirement time. In previous versions of Zoph, a user would be logged out when closing the browser. Is now possible to extend the time to 1 hour, 4 hours, 8 hours, 1 day, 1 week or 1 month. This means a user will not need to re-login for that period of time, even when the browser is closed in the mean time. This can be very convenient, but it could mean that a user leaves Zoph logged in on a public PC. Therefore, the default is still 'session', which means a user will be logged out when closing the browser.
+* "new" pages now show up in breadcrumbs
+* It is now possible to give a user "can see all photos" access rights. This means you can give a user access to all photos, without giving him/her admin rights and without having to update user rights whenever an album is added.
+* [Issue #22](https://github.com/jeroenrnl/zoph/issues/22) It is now possible to allow a user to create albums, categories, people, circles and places. The user automatically has access rights to place photos in the albums, categories, people, circles and places he or she has created.
+* [Issue #21](https://github.com/jeroenrnl/zoph/issues/21) It is now possible to allow a user to delete photos. The user will have to have "write" access to at least one album a photo is in.
+* Remove the rather ugly trailing space on the links on zoph.php
+
+### Bugs ###
+* [Issue #73](https://github.com/jeroenrnl/zoph/issues/73) Fixed sharing feature
+* [Issue #74](https://github.com/jeroenrnl/zoph/issues/74) Fixed Canadian English, Dutch and German translation files
+
+### Other improvements ###
+* Added a way to disable a setting on the configuration page depending on the state of another configuration item. (This was created because the photo album as a logon background relies on the sharing feature to be enabled).
+* Moved user page to template
+* Moved form into a separate class
+* Some cleanup of the places and categories pages
+* Refactor HTML for actionlinks
+* Modified createTestData script to only require password once
+* Rearranged order of unittests
+* Added translations for German, Canadian English and Dutch
+
+## Zoph 0.9.2 ##
+### 1 apr 2016 ###
+
+Zoph 0.9.2 is the new stable release. I have decided to drop the separation between 'stable' and 'unstable' or 'feature' releases. This means that it is recommended for everyone to upgrade to this release.
+
+### Features ###
+* [Issue #44](https://github.com/jeroenrnl/zoph/issues/44) : Added 'circles': a way to group people in Zoph. This is especially handy if you have a large amount of people in your Zoph, and the 'person' page is becoming confusing or cluttered.
+* [Issue #46](https://github.com/jeroenrnl/zoph/issues/46) A circle and it's members can be surpressed in the overview page, so you can, for example, hide people that you added only for a small set of photos.
+* [Issue #20](https://github.com/jeroenrnl/zoph/issues/20) Zoph has switched to the PDO classes for database access. This ensures compatibility with PHP in the future, because the old mysql libs will be dropped soon.
+* [Issue #32](https://github.com/jeroenrnl/zoph/issues/32) It is now possible to set more properties of a photo, including map zoom from the web import.
+* [Issue #60](https://github.com/jeroenrnl/zoph/issues/60) The link text for "next" and "previous" as well as page numbers has been increased in size for better usability esp. on mobile devices
+* Added a script for fixing filename case (by Jason Taylor [@JiCit] )
+* Access Google maps via https (Jason Taylor [@JiCiT])
+* As of this version, the language files are in the php dir, and no longer need to be copied or moved separately
+
+### Bugs ###
+* [Issue #49](https://github.com/jeroenrnl/zoph/issues/49) Zoph now supports MySQL strict mode
+* [Issue #55](https://github.com/jeroenrnl/zoph/issues/55) Autocomplete not working for people
+* [Issue #58](https://github.com/jeroenrnl/zoph/issues/58) Sort order for albums and categories can not be changed
+* CLI: Fixed an issue where Zoph would try to import to the current directory when double spaces were present in CLI
+* Better handling of file not found problems during import
+* Fixed two bugs that caused maps not to display
+* Fixed an issue where breadcrumbs wouldn't be removed correctly in some cases
+* Changed erronous extension of Exception class
+* Fixed slow login times for non-admin users
+* Improved performance on people page
+* Fixed: zoom buttons are missing from Google Maps
+* Remove duplicate files from import (if you would specify the same file twice on CLI import, you would get an error, this is now filtered out)
+* Fixed an issue where the person pulldown on the add user page appeared to be empty
+* Remove a user from a group when a the user is deleted
+* Fixed a warning about unknown variable on places page
+* Allow apostropes in place names when creating map markers (Jason Taylor [@JiCiT])
+
+### Refactor ###
+* A complete new query builder has been created
+* Many more parts of Zoph can be (and are being) tested automatically now, this should improve overall quality and reduce bugs
+* Many parts of Zoph have been cleaned up to modernize code to the current state of PHP - dropping PHP 5.3 and 5.4 compatibility
+* Dropped MSIE6/7 compatibility
+* Added documentation to many parts of Zoph's source code
+* Many changes to readability of source code, such as more consistent use of whitespace
+* Added some more debugging possibilities to easier troubleshoot in case of problems
+* Changed logging so less logging is displayed when set to log::NONE
+* Changed all self:: references into static:: references
+* Added function scope to many methods
+* Started using namespaces to better organize the classes
+* Updated version numbers in REQUIREMENTS readme. 
+* [Issue #8](https://github.com/jeroenrnl/zoph/issues/8) (partial) Changed several parts of Zoph to use templates 
+* Added improvements to templating system
+* Modified query for photo access rights to a view for performance reasons
+* Changed logging so SQL query log to file can be done without displaying 
+* Performance improvement on place page
+* Added a posibility to debug queries including parameters
+
+## Zoph 0.9.1 ##
+### 21 Feb 2014 ###
+Zoph 0.9.1 is the first feature release for Zoph 0.9, it shows a preview of some of the new features for Zoph 0.10. Most important change is the move of most configuration items from config.inc.php into the Web GUI.
+
+#### Features ####
+
+* [Issue #28](https://github.com/jeroenrnl/zoph/issues/28) Configuration through webinterface 
+* Removed display desc under thumbnail feature 
+* Removed MIXED_THUMBNAILS and THUMB_EXTENSION settings 
+* removed DEFAULT_SHOW_ALL setting 
+* Removed LANG_DIR configuration item 
+* Changed the looks of <input> fields a bit 
+* Removed alternative password validators 
+* Removed checks for PHP 5.1 
+* Adding CLI support for configuration 
+* [Issue #7](https://github.com/jeroenrnl/zoph/issues/7) Added a favicon 
+* [Issue #18](https://github.com/jeroenrnl/zoph/issues/18) Added "return" link on bulk edit page 
+* Added a script to migrate config to new db-based system 
+* [Issue #8](https://github.com/jeroenrnl/zoph/issues/8) Made template selectible from webinterface 
+* Removed MAX_CRUMBS 
+
+#### Bugs ####
+
+* Simplified CLI code & fixed bug in --autoadd
+* [Issue #34](https://github.com/jeroenrnl/zoph/issues/34) Rows and columns swapped on photos page
+* [Issue #36](https://github.com/jeroenrnl/zoph/issues/36) Webimporter does not import description
+* [Issue #37](https://github.com/jeroenrnl/zoph/issues/37) Can not add position on map using the mouse
+* Fixed a bug that caused EXIF information in some (rare) cases to report the aperture wrong.
+* Strict standards warning 
+* [Issue #45](https://github.com/jeroenrnl/zoph/issues/45) Pagebreak inside HTML tags causes browser to render incorrectly
+* [Issue #45](https://github.com/jeroenrnl/zoph/issues/45) Added selectArray cache to zophTable
+* [Issue #48](https://github.com/jeroenrnl/zoph/issues/48) Repair photo ratings during import
+* [Issue #50](https://github.com/jeroenrnl/zoph/issues/50) Geonames project has changed URL and requires username
+* [Issue #51](https://github.com/jeroenrnl/zoph/issues/51) Fixed depth in tree display when autocorrect is off
+* [Issue #39](https://github.com/jeroenrnl/zoph/issues/39) Added support for session.upload_progress as APC replacement (PHP 5.4 compatibility)
+* [Issue #38](https://github.com/jeroenrnl/zoph/issues/38) CLI tries to lookup previous argument's value when looking up photographer
+
+#### Improvements ####
+
+I have made quite a few improvements on the "inside" of Zoph. I have refactored many parts of Zoph
+to create cleaner, less duplicated and more robust code. I have introduced UnitTests (resulting in 
+about 20% of Zoph's sourcecode now tested fully automatic for bugs). As a help to that, I am now 
+using Sonar to automatically run these tests and also analyse Zoph code for other problems.
+
+* [Issue #29](https://github.com/jeroenrnl/zoph/issues/29) First step in creating unittests for Zoph 
+* Sonar Support 
+* Refactor of PHP part of Mapping implementation 
+* Move timezone-related global functions into class 
+* TimeZone object improvements 
+* Small change in way template is called on photo page (Full page templates are now "templates" and partial pages are "blocks") 
+* Refactor of htmlMimeMail.php 
+* Refactor of Mail_mimePart 
+* Refactor annotate photo, watermark photo, image.php 
+* Removed several global variables  
+* Finished refactor of MIME classes 
+* Refactor album, category, place, person, photo 
+* Refactor: getEditArray() + unittests 
+* Further refactor of photo, album, person, place, category  
+* Refactor: move ratings out of photo object  
+* Refactor: moved relations from photo object to new photoRelations object 
+* Refactor: photo object 
+* Got rid of adding session_id to URL 
+* Modified internal database references to static 
+* Removed brackets from require and include statements 
+* Replaceed a die() with exception 
+* Changed self-references in objects to use self:: 
+* Removed unused class smtp 
+* Made autoload a little more robust 
+* Changes to autoload so it works in unittests too. 
+* Removed unused RFC822 class 
+* Changed line-endings in mailMimePart.inc.php to unix-style 
+* Removed various unused variables 
+* Removed duplicate templates 
+* Removed unused $user from createPulldown() calls. 
+* [Issue #40](https://github.com/jeroenrnl/zoph/issues/40) Change documentation to Markdown        
+* Modified some queries to improve performance 
+
+## Zoph 0.9.0.1 ##
+### 18 oct 2012 ###
+
+Zoph 0.9.0.1 is the first maintenance release for Zoph 0.9. It adds compatibility with MySQL 5.4.4 and later and PHP 5.4 support. Several bugs were fixed.
+
+
+#### Bugs ####
+
+* [Issue #1](https://github.com/jeroenrnl/zoph/issues/1)  Changed TYPE=MyISAM to ENGINE=MyISAM for MySQL > 5.4.4 compatibility
+* [Issue #1](https://github.com/jeroenrnl/zoph/issues/1)  Fixed: PHP Notice: Array to string conversion
+* [Issue #2](https://github.com/jeroenrnl/zoph/issues/2)  Changed timestamp(14) into timestamp
+* [Issue #3](https://github.com/jeroenrnl/zoph/issues/3)  Removed pass-by-reference for PHP 5.4 compatibility
+* [Issue #6](https://github.com/jeroenrnl/zoph/issues/6)  Missing French translation
+* [Issue #30](https://github.com/jeroenrnl/zoph/issues/30) Remove warning about undefined variables
+* [Issue #31](https://github.com/jeroenrnl/zoph/issues/31) Fixed several errors in geotagging code
+* [Issue #33](https://github.com/jeroenrnl/zoph/issues/33) Fixed: no error message when rotate fails
+             Fixed a small layout issue on the prefs page
+
+## Zoph 0.9 ##
+### 23 jun 2012 ###
+
+Zoph 0.9 is a stable release. It's equal to v0.9pre2, except for an updated Italian translation.
+
+#### Translations ####
+Updated Italian translation, by Francesco Ciattaglia
+
+There are no known bugs in this version.
+
+## Zoph 0.9pre2 ##
+### 20 Feb 2012 ###
+
+Zoph 0.9pre2 is the second release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
+
+#### Bugs ####
+
+* Bug#3471099: Map not displaying when looking at photo in edit mode
+* Bug#3471100: On some pages, title contains PHP warning
+
+## Zoph 0.9pre1 ##
+### 26 Nov 2011 ###
+
+Zoph 0.9pre1 is the first release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
+
+#### Bugs ###
+
+* Bug#3420574: When using --autoadd, zoph CLI import sometimes tries to create new locations or photographers even though they already exist in the database.
+* Bug#3427517: Share this photo feature does not work
+* Bug#3427518: Not possible to remove and album or category from a photo
+* Bug#3433687: Not possible to remove album or category from photo (bulk)
+* Bug#3431130: Share this photo doesn't show links in photo edit mode
+* Bug#3433810: Popup for albums, categories, people and places doesn't always disappear when moving mouse away.
+* Removed a warning that in some cases caused images not to be displayed.
+
+#### Translations ####
+
+* Added a few missing strings, reported by Pekka Kutinlahti.
+* Updated Italian translation, by Francesco Ciattaglia
+* Updated Dutch, German, Canadian English and Finnish
+
+#### Other ####
+* Got rid of a lot of PHP warnings
+* Got rid of a lot of PHP strict messages
+* Cut down on the number of global variables
+* Removed support for magic_quotes
+* Removed (last traces of) PHP4 support
+* Bug#3435181: Variable inside quotes
+* Updated wikibooks documentation
+
+## Zoph 0.9 ##
+### 23 jun 2012 ###
+
+Zoph 0.9 is a stable release. It's equal to v0.9pre2, except for an updated Italian translation.
+
+### Translations ###
+* Updated Italian translation, by Francesco Ciattaglia
+
+There are no known bugs in this version.
+
+## Zoph 0.9pre2 ##
+### 20 feb 2012 ###
+
+Zoph 0.9pre2 is the second release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
+
+### Bugs ###
+* Bug#3471099: Map not displaying when looking at photo in edit mode
+* Bug#3471100: On some pages, title contains PHP warning
+
+## Zoph 0.9pre1 ##
+### 26 nov 2011 ###
+
+Zoph 0.9pre1 is the first release candidate for Zoph 0.9. Zoph is now completely feature-frozen for the 0.9 release, only bugfixes will be made.
+
+### Bugs ###
+* Bug#3420574: When using --autoadd, zoph CLI import sometimes tries to create new locations or photographers even though they already exist in the database.
+* Bug#3427517: Share this photo feature does not work
+* Bug#3427518: Not possible to remove and album or category from a photo
+* Bug#3433687: Not possible to remove album or category from photo (bulk)
+* Bug#3431130: Share this photo doesn't show links in photo edit mode
+* Bug#3433810: Popup for albums, categories, people and places doesn't always disappear when moving mouse away.
+* Removed a warning that in some cases caused images not to be displayed.
+
+### Translations ###
+* Added a few missing strings, reported by Pekka Kutinlahti.
+* Updated Italian translation, by Francesco Ciattaglia
+* Updated Dutch, German, Canadian English and Finnish
+
+### Other ###
+* Got rid of a lot of PHP warnings
+* Got rid of a lot of PHP strict messages
+* Cut down on the number of global variables
+* Removed support for magic_quotes
+* Removed (last traces of) PHP4 support
+* Bug#3435181: Variable inside quotes
+* Updated wikibooks documentation
+
+## Zoph 0.8.4 ##
+### 9 Sept 2011 ###
+
+Zoph 0.8.4 is the final pre-release for Zoph 0.9.
+
+This version adds several feature improvements. More features have been added the new CLI import, which was introduced in v0.8.2. The 'bulk edit' page has been improved, both in features as in loading speed (100x faster in some cases!). The 'tree view' and 'thumb view' overview pages have been improved. Several coding style modernisation changes have been made.
+
+### Features ###
+* Req#1985439: Adding albums, categories, places and people via the CLI
+* Req#1985439: Automatically adding albums, categories, places and people via the CLI
+* Req#3042674: Recursive import of directories
+* Req#1985439: Setting album, category, person, photographer, path from import dir.
+* Req#1756507: photocount in tree view.
+* Req#1491208: Show more info in thumbnail overview
+* REQ#2813979: Added date & time fields to bulk edit page
+* Added autocomplete support to bulk edit page
+* Changed the photo edit page to automatically add new dropdowns to albums, categories and people.
+* Removed 'people_slots' functionality
+* Changed add people on bulk photo edit page to use multiple dropdowns
+* Add multiple albums, categories, persons on both single and bulk  photo edit. 
+* Req#2871210: Added 'share photo' feature.
+* Zoph now stores a hash of a photo in the database
+* zoph CLI: Added -D as shorthand for `--path`
+
+### Bugs ###
+* Bug#3312029: `MAGIC_FILE` cannot be empty
+* Fixed an issue that caused the 'search' button for geocoding on the edit location page to be misplaced.
+* Fixed a typo that caused the 'track' screen to no longer work
+
+### Translations ###
+* Updated translations
+* Added some previously forgotten translations
+
+### Refactoring ###
+Zoph has started it's life in the era of PHP3, while the current version of PHP is version 5.3. In between a lot has been changed in PHP. I have started to adopt PHP5-style programming some time ago for new development. I have now also started to refactor the other code to a new coding style. Currently, Zoph still has ''a lot'' of global functions and I am slowly moving almost all of them to static methods.
+* Made several changes to function names to accommodate new coding style
+* Refactored `photo->update_relations()` to merge with the similar `photo->updateRelations()` that the new import introduced.
+* Moved `get_root_...()` functions into static functions.
+* Refactor of `zoph_table` object (now called `zophTable`)
+* Renamed function `photo->get_image_href()` to `photo->getURL()`
+* Made some changes to the `delete()` methods so PHP strict standards are followed.
+
+### Other ###
+* Inline documentation improvements
+* Improved expand/collapse Javascript robustness 
+* Some eyecandy (esp expand/collapse)
+* Changed the date and time field to type 'date' and type 'time', which are new types for HTML5. Tested in Chromium.
+* Removed deprecated IMAGE_SERVICE setting. IMAGE_SERVICE is now always on.
+* Renamed image_service.php to image.php 
+* Improved loading speed of the 'tracks' page by using a different, better cachable SQL query
+
+## Zoph 0.8.3 ##
+### April 3, 2011 ###
+
+Zoph 0.8.3 is a pre-release for Zoph 0.9.
+
+This version adds several feature improvements, mostly related to mapping. The most important addition is the support for geotagging. This version also fixes several bugs.
+
+Zoph 0.8.3 is beta release, I tested it as well as possible on my system, but it should not be considered a "stable" version. I would, however, very much appreciate if people could test and give feedback on this release and the updated documentation, in this way I can make sure that the stable (v0.9) version will be as bug-free as possible.
+### Features ###
+* Geotagging support
+* Req#2974014: Search for location
+* Geocoding: finding lat/lon location from city, county.
+* Req#2974016: Additional mapping resources
+* Req#3077944: When adding a new place, or editting a place with no location (lat/lon) set, zoph will zoom the map to the parent location.  If a photo is editted, and the photo has no lat/lon, but it's location does, the map is zoomed to the location's lat/lon.
+
+### Bugs ###
+* Getting rid of a NOTICE regarding unset `DB_PREFIX` constant
+* Several small changes to decrease the number of NOTICE messages.
+* In photo edit mode, moved maps to bottom of page, to fix a bug with Openlayers maps
+* Better error handling when `UPLOAD_DIR` does not exist.
+* Zoph.ini: Added quotes around values, PHP fails if they contain special characters. As suggested by scantron.
+* Bug#3237112: Rating counts are incorrect with new import
+* Bug#3237012: There is no "next" link on the bulk edit page, although a "previous" link is present.
+
+### Other ###
+* Switched from Mapstraction 1.x to Mapstraction 2.0.15
+* Namespacing in mapping Javascript.
+* Some changes in templating system
+* Bug#3104632: Various changes for PHP 5.3 compatibility
+* Refactor of zophcode, tag, smiley and replace objects to new coding style, including added PHPdoc comments.
+* Added a copyright note to Openlayers maps
+* Refactor of the admin class & move admin page to a template.
+* Getting rid of some warning messages
+
+### Translations ###
+* Dutch and Canadian English have been updated and are completely up to date
+
+## Zoph 0.8.2.1 ##
+### November 20, 2010 ###
+
+Zoph 0.8.2.1 is a bugfix release for Zoph 0.8.2.
+
+Many changes were made in Zoph 0.8.2 and with so many changed lines of code, a few bugs is almost inevitable. This release fixes all known bugs in v0.8.2.
+
+### Bugs ###
+* Bug#3064940: HTML in dropdown menus. (This bug was previously fixed in Zoph 0.8.0.5, but the fix was not correctly ported to the development branch)
+* Bug#3094182: New CLI does not store location and photographer
+* Bug#3094198: New CLI does not always look up location name correctly.
+* Bug#3094201: New CLI does not exit when it encounters an error (album, category, ... not found)
+* Bug#3102078: Webimport of archives fails with no error
+* Bug#3102080: New CLI `--update` can not set location and photographer
+* Bug#3102148: New CLI `--field` gives an error
+* Fix for an issue that caused javascript errors when an apostroph would appear in a title of a place.
+* Bug#3108196: Translation not working in Zoph 0.8.2
+
+## Zoph 0.8.2 ##
+### October 20, 2010 ###
+
+Zoph 0.8.2 is the second pre-release for Zoph 0.9.
+
+Zoph 0.8.2 features a completely rewritten import system. The webinterface has been modernized. Error handling and user-friendliness have been improved. The CLI interface prior to v0.8.2 was written in Perl, because the rest of Zoph was written in PHP, a lot of duplicate work needed to be done whenever something needed to be changed in the import system. As of this version, the CLI interface has been rewritten in PHP as well.
+
+Zoph 0.8.2 is beta release, I tested it as well as possible on my system, but it should not be considered a "stable" version. I would, however, very much appreciate if people could test and give feedback on this release and the updated documentation, in this way I can make sure that the stable (v0.9) version will be as bug-free as possible.
+
+### Features ###
+* New webimport
+* New CLI-import
+
+### Bugs ###
+* Bugfixes from v0.8.0.5 have been included in this release.
+
+### Other changes ###
+* Configuration of database connection has been moved from `config.inc.php` (webinterface) and `.zophrc` (CLI interface) to `/etc/zoph.ini`, for both the webinterface and the CLI interface.
+* `bin` and `man` directories in release tarball have been combined into the `cli` directory
+* HTML documentation (`docs` directory) is no longer included in the release. Maintaining this documentation cost a lot of time. The scripts I wrote to convert the Wikibooks documentation into offline documentation could not handle images and the documentation I wrote for the new webimport contains a lot of pictures. 
+
+## Zoph 0.8.0.5 ##
+### October 20, 2010 ###
+
+Zoph 0.8.0.5 is a bugfix release that fixes a few bugs in Zoph 0.8.0.4
+
+### Bugs ###
+* Bug#3049203: Rating links on search page do not work.
+* Bug#3054562: HTML in rating dropdown on search page
+* Bug#3054566: Search for albums/categories/places/people/photographers is broken after 0.8.0.2 update.
+* Bug#3066174: Rotation not working in auto edit mode
+* Bug#3064937: SQL error when inserting a place with no timezone.
+* Bug#3064940: HTML in dropdown menu's.
+* Bug#3072586: Latitude is misspelled as "lattitude"
+
+## Zoph 0.8.1.2 ##
+### July 15, 2010 ###
+
+Zoph 0.8.1.2 is a bugfix release that fixes a few bugs in Zoph 0.8.1.1.
+
+### Bugs ###
+* A few cases of duplicate encoding, causing HTML code to appear instead of being interpreted by the browser
+* A bug that caused markers not to work correctly
+* A bug that caused Zoph to loose timezone information when using the 'assign timezone to children' functionality. 
+
+## Zoph 0.8.0.4 ##
+### July 15, 2010 ###
+
+Zoph 0.8.0.4 is a bugfix release that fixes a few bugs in Zoph 0.8.0.3.
+
+### Bugs ###
+* A few cases of duplicate encoding, causing HTML code to appear instead of being interpreted by the browser
+
+## Zoph 0.8.1.1 ##
+### July 1, 2010 ###
+
+Zoph 0.8.1.1 is a security release that fixes a number of Cross Site Scripting (XSS) issues of which most were found by [VUPEN Security](http://www.vupen.com). I would like to thank VUPEN for reporting these bugs.
+
+Zoph 0.8.1.1 does not fix any other bugs.
+
+### Bugs ###
+* Several XSS scripting issues found by VUPEN Security
+* Several XSS scripting issues found during fixing of the above bugs
+
+## Zoph 0.8.0.3 ##
+### July 1, 2010 ###
+
+Zoph 0.8.0.3 is a security release that fixes a number of Cross Site Scripting (XSS) issues of which most were found by [VUPEN Security](http://www.vupen.com). I would like to thank VUPEN for reporting these bugs.
+
+This release also fixes all the bugs found since the 0.8.0.2 release.
+
+### Bugs ###
+* Several XSS scripting issues found by VUPEN Security
+* Several XSS scripting issues found during fixing of the above bugs
+* Bug#2901852: Fatal error when a photo without a photographer is displayed on the map
+* Bug#2902011: zophImport.pl cannot find people with no last name.
+* Bug#2925030: Last modified time is not displayed correctly
+* Bug#2925498: NULL entries in the database change to 0.000 after rotating an image causing fake map entries to appear. Fix by Jason Taylor.
+* Bug#2925508: Thumbnail covers actionlinks on people page. Fix by Jason Taylor.
+* Bug#2925506: Count of places is wrong. Fix by Jason Taylor.
+* Bug#2982051: editting photo does not work when using "auto edit".
+* Bug#3002691: Next/prev links lost after update.
+
+
+## Zoph 0.8.1 ##
+### 3 Jan 2010 ###
+
+Zoph 0.8.1 is the first feature release for v0.9. This release introduces a new logging system, that should allow users and developers to control more granular which debugging messages Zoph displays. The other major change is that Zoph is now completely UTF-8 based, this should fix issues users had with international characters. This last change requires some manual changes to the MySQL database.
+
+Zoph 0.8.1 is beta release, I tested it as well as possible on my system, but especially the UTF-8 conversion is very dependent on specific situations on your system; therefore it should not be considered a "stable" version. I would, however, very much appreciate if people could test and give feedback on this release and the upgrade documentation, in this way I can make sure that the stable (v0.9) version will be as bug-free as possible.
+
+### Features ###
+* New logging/debugging system
+
+### Bugs ###
+* Bug#1985449: Zoph should be UTF-8
+* Bug#2901852: Fatal error when a photo without a photographer is displayed on the map
+* Bug#2902011: zophImport.pl cannot find people with no last name.
+* Bug#2925030: Last modified time is not displayed correctly
+* All the bugfixes from Zoph 0.8.0.1 and 0.8.0.2
+
+## Zoph 0.8.0.2 ##
+### 1 Nov 2009 ###
+
+Zoph 0.8.0.2 is a bugfix release for Zoph 0.8.
+
+### Bugs ###
+* Bug#2876282: Not possible to create new pages.
+* Bug#2873171: fatal error when autocomplete is switched off.
+* Bug#2873171: Javascript error in MSIE when trying to change the parent place using the autocomplete dropdown.
+* Bug#2873171: Timezone autocomplete does not work in MSIE
+* Bug#2881212: Not possible to unset timezone.
+* Bug#2889934: No icons in admin menu when using MSIE8
+* Bug#2888263: Unintuative working of bulk edit page could lead to dataloss
+* Bug#2890387: Saved search does not remember the "include sub-albums/categories/places" checkbox and the state of the "AND/OR" dropdown.
+
+### Translations ###
+* Added a Russion translation created by Sergey Chursin and Alexandr Bondarev
+
+### Various ###
+* Changed deprecated mysql_escape_string() into new mysql_real_escape_string().
+
+## Zoph 0.7.0.8 and Zoph 0.8.0.1 ##
+### 23 Sept 2009 ###
+
+Security fixes for 0.7 and 0.8.
+
+### Bugs ###
+* Fixes a security bug that caused a user to be able to execute admin-only pages.
+
+## Zoph 0.8 ##
+### 9 Sept 2009 ###
+
+Final 0.8 release. Only small changes compared to 0.8pre3:
+
+### Bugs ###
+* Fixed a bug that caused users of PHP 5.1.x get an error about non-existant DateTime class.
+
+### Documentation ###
+* Added a few long-existing but overlooked and therefore not documented configuration settings
+* Added a troubleshooting section ("Solving Problems")
+
+## Zoph 0.8pre3 ##
+### 28 August 2009 ###
+
+This is the third pre-release for 0.8, it fixes the bugs discovered since v0.8pre2, including the security bug. It also updates several translations.
+
+### Bugs ###
+* Bug#2841196: PHP error when logging in as non-admin user
+* zophImport.pl: Perl error due to missing quote and indentation fixes
+* Bug#2841296: Not possible to download 4.2GB ZIP files
+* Bug#2841357: Save search fails without an error in some cases
+* Bug#2841373: Saved search does not always work correctly when saving a photo collection that was not the result of a search action.
+* Fix for a cross site scripting bug (the same as the 0.7.0.7 release)
+* Bug#2845750: zophImport.pl fails when `--path` contains multiple dirs
+
+### Translations ###
+* Dutch, Danish, French, Italian, Norwegian Bokmål and Swedish chef have been updated and are fully up to date.
+
+### Documentation ###
+* Various updates
+* Removing very old changelog and upgrade instructions. They can still be read in the online (wikibooks) version.
+* Adding long existing but until now not documented options `DEFAULT_ORDER` and `DEFAULT_DIRECTION`
+* Completely rewritten requirements page
+
+## Zoph 0.7.0.7 ##
+### 24 Aug 2009 ###
+
+Zoph 0.7.0.7 is an update of the stable 0.7 branch and fixes a cross site scripting security bug.
+
+### Bugs ###
+* Fix for a cross site scripting bug that found during development of Zoph v0.8
+
+## Zoph 0.8pre2 ##
+### 8 July 2009 ###
+
+This is the second pre-release for 0.8, it fixes the bugs discovered since v0.8pre1, including the security bug.
+### Bugs ###
+* Bug#2813464: Date link on photo page links to the wrong year
+* Bug#2813467: '+' links to expand date/time, ratings and tree view do not work anymore after a Googlemaps update
+* Fix for a cross site scripting bug that was reported by "y3nh4ck3r".
+* Fix for a bug that caused manually entered dates with webimport not to be used
+
+## Zoph 0.7.0.6 ##
+### 2 July 2009 ###
+
+Zoph 0.7.0.6 is an update of the stable 0.7 branch and fixes a cross site scripting security bug.
+
+### Bugs ###
+* Fix for a cross site scripting bug that was reported by "y3nh4ck3r".
+
+## Zoph 0.8pre1 ##
+### 27 June 2009 ###
+
+Zoph 0.8pre1 is a prerelease (release candidate) for Zoph 0.8. It fixes a number of bugs from 0.7.5. 
+
+### Bugs ###
+* Fix for a bug that would give an error (or not execute without an error, depending on the situation) when an album is added to a photo. Bug found and fixed by Pekka Kutinlati.
+* Bug#2687577: Download link does not work in some cases
+* Bug#2720782: edit does not work after using back and forward buttons
+* Bug#2720807: Layout glitch on slideshow
+* Fixed two small issues in saved searches
+* Bug#2718812: Cannot assign someone as a father/mother/spouse when person does not yet appear on a photo.
+* Bug#2724768: Error in timezone code
+* Bug#2750454: Fatal error: Call to undefined function `get_photographer_search_array()` in person.inc.php
+* Bug#2775190: Dropdown menu with people is not sorted by name.
+* Bug#2718814: Not possible to unset a relationship between persons.
+* Fixed a bug where the average rating would become 0 when the last rating for that photo was removed
+* Bug#2794052: Syntax error in timezone.inc.php when using PHP4
+* Bug#2803133: Making a category/album or place it's own parent causes out of memory error.
+* Bug#2804335: Division by zero error when importing JPG with zeroes in some of the EXIF fields.
+* Fix for a bug where the map on the photo page did not show the location of the photo if it is set on the location and not on the photo itself.
+* Fix for a bug where the map on the photo page did not show if the user is not using the 'auto-edit' feature.
+* Fix for a bug that caused a javascript error when title or address of a place contained quotes.
+
+
+### Translations ###
+* German, Canadian English, Danish, Dutch, Italian and Swedish Chef have been updated.
+* Added Finnish translation by Pekka Kutinlati.
+* Removed some empty translations from outdated translations
+
+
+### Other ###
+* Removed `zoph-0.3.3.postgress.diff` from the contrib dir. It was too outdated to serve any purpose.
+
+## Zoph 0.7.5 ##
+### 14 March 2009 ###
+
+Zoph 0.7.5 is the last "feature release" before v0.8. This version introduces a few new features that will be present in the next "stable" version, 0.8. This release fixes a number of bugs from the earlier 0.7.x releases.
+
+### Bugs ###
+* Bug#2465009, wrong counters for rating
+* Fixed a bug where a translated version of Zoph would not make a breadcrumb for search results.
+* Fixed: Timezone calculations are using local timezone instead of configured `CAMERA_TZ`.
+* Bug#2671365 Can not leave comments
+* Fixed a bug in `zophImport.pl` where `--update` could in some cases move a photo to a wrong location.
+
+### Features ###
+* Added a feature where an admin user can check out the ratings a certain user has given, adds a graph similar to the one on the reports page to the user's page.
+* Admins can now see who has rated what per photo
+* Admins can delete ratings
+* IP address and date/time are now stored when rating
+* An admin can control wheter a user can rate photos or not.
+* Req#2126915: Allow a user to rate the same photo multiple times, but only once per IP addres, you can use this for the `DEFAULT_USER` or a user account that is shared among multiple people. 
+* Improved error handling on erronous time or date. (timezone calculations)
+* Move all MySQL calls into `database.inc.php`, making adjusting to other db's easier, partly resolves Req#2464455
+* Req#1480136: Save search results
+* A list of all comments by a user is now shown in user profile.
+
+### Known issues ###
+* The translations have not yet been updated
+* Not all documentation is up to date
+
+## Zoph 0.7.4 ##
+### 22 December 2008 ###
+
+Zoph 0.7.4 is a "feature release", that introduces a few new features that will be present in the next "stable" version, 0.8. This release fixes a number of bugs from the earlier 0.7.x releases and specifically deals with some performance issues.
+
+### Bugs ###
+* Bug#2044965: Assign timezone to all children only assigns timezone to direct children.
+* Bug#2044967: Better error handling for guess timezone functionality
+* Bug#1820234: Zoph shows places, categories and people for restricted users.
+* Bug#2059210: Overal bad performance: long loading times, autocomplete boxes taking forever, etc. This fix gives a giant improvement on zoph performance.
+  * Simplified several SQL queries
+  * Changed SQL queries so unused rows are now longer requested from the db
+  * Changed SQL queries so records are no longer sorted when it is not needed
+  * Changed autocomplete code so it was no longer necessary to load both autocomplete and legacy dropdowns (major improvement on loading the seachpage!)
+* Bug#2125858: table headers on user page swapped
+* Bug#2097894: Layout failure in bulk edit page when using MSIE
+* Bug#1706366: People slots feature is incompatible with autocomplete Also adds autocomplete support to several pages that did not have it before (only bulk edit page does not have autocomplete support yet)
+* Bug#2274989: When changing user, password is overwritten.
+* Bug#2275005: Photographers not in people list. People list not showing all people for admin users. 
+* Bug#2373633: Counter on zoph.php wrong for non-admin users
+* Bug#2373609: Tree view shows all albums/categories/places
+* Bug#2315870: Layout glitch when using non-standard size thumbnails.
+* Bug#2438062: Zoph does not pick a different coverphoto for people if the assigned one is not visible for the user.
+### Features ###
+* Req#2097906: Add "next" and "prev" links to edit photo page, when not using auto-edit feature
+* Req#1467095: Group access rights
+
+### Translation ###
+* Fixed some errors in translations (mostly extra spaces)
+* Updated Canadian English, Dutch and German translations
+
+### Various ###
+* Removed 'smart_pulldown' code that was not used in most of the cases anyway, especially since autocomplete was added.
+* Created a script to automatically migrate from user-rights to group-rights. To be used with 0.7.3 to 0.7.4 or 0.7 to 0.8 migrations.
+
+## Zoph 0.7.3 ##
+### 24 July 2008 ###
+
+Zoph 0.7.3 is a "feature release", that introduces a few new features that will be present in the next "stable" version, 0.8. It also fixes some bugs from 0.7.1 and 0.7.2 Finally, it includes the bugfixes from 0.7.0.5. This includes the security update.
+
+### Bugs ###
+* Bug#1985434: a-z index for people doesn't work anymore.
+* Bug#2006151: one of the '+' buttons on the search page does not work
+* Bug#1987338: ZIP downloading feature does not work with PHP4
+* Bug#2006154: Case insensitive search for description field doesn't work
+* Bug#1985432: two different meanings for 'home'
+* Bug#1986847: wrong charset for French translation
+* Bug#1983556: It is not possible to unset many attributes once they have been set. Fix by Charles Brunet.
+* Bug#2015802: SQL error when inserting a new place
+* Bug#2012300: Missing linefeed on places page.
+* Bug#2015312: Wrong layout for 'work' field on person page.
+* Bug#2015346: Home location does not display the title
+* Bug#2015340: Deleting a person does not delete all references
+* Bug#2015348: Deleting a place does not remove all references to it
+* Bug#2021272: Crash when changing the parent of the root album
+* Bug#2022777: [person] tag is missing from pages feature
+* Bug#2021272: Crash when changing the parent of the root album
+
+### Features ###
+* Req#1505552: Mapping support. You can now use maps to show the location of your photos, using the mapstraction api. There is support for Google, Yahoo and Openstreetmap maps.
+* Req#1586463: Time zone support. You can store information about the timezone where a photo was taken and have Zoph automatically compute the correct time for you.
+* Req#2006156: Increase length of title field for albums and categories
+* Req#2021275 Expand all button for tree view
+
+### Translations ###
+Translations for Dutch, French, German and Polish  have been updated
+
+## Zoph 0.7.0.5 ##
+### 20 July 2008 ###
+
+Zoph 0.7.0.5 is a security fix that repairs several SQL injections. Although most are not exploitable or only exploitable by an admin user, I recommend upgrading to 0.7.0.5. This release also includes a number of extra 'safety nets' that will make exploiting any future SQL injections a lot harder.
+
+It also fixes a number of bugs in the 0.7 release:
+* Bug#1813293: import is not compatible with PHP < 5
+* Bug#2006151: one of the '+' buttons on the search page does not work
+* Bug#2012300: Missing linefeed on places page.
+* Bug#2015312: Wrong layout for 'work' field on person page.
+* Bug#2015346: Home location does not display the title
+* Bug#2021272: Crash when changing the parent of the root album
+
+## Zoph 0.7.2.1 ##
+### 3 June 2008 ###
+
+Zoph 0.7.2.1 is a bugfix release for Zoph 0.7.2 it fixes the following issues:
+* Bug#1981910: Some files in the distribution for 0.7.2 are not the latest version
+* Bug#1820229: Some thumbs not displayed when user has no right to see them.
+* Bug#1813293: web import is not compatible with PHP < 5
+
+## Zoph 0.7.2 ##
+### 1 June 2008 ###
+
+Zoph 0.7.2 is a "feature release", that introduces a few new features that will be present in the next "stable" version, 0.8. It also fixes some bugs from 0.7.1. Finally, it includes the bugfixes from 0.7.0.3 and 0.7.0.4.
+
+### Bugs ###
+* Bug#1819755: User that cannot see all albums does not always see all the albums he *is* allowed to see.
+* Bug#1820225: Restricted user can see the list of people.
+* Bug#1820229: User does not see all thumbs if he has not the right to see the manually assigned thumb.
+* Sometimes not all albums were shown and sortorder was not always correct.
+
+### Features ###
+* Zophcode: Possibility to add markup and smileys to comments. Smileys were taken from PHPBB. (they are under GPL)
+* Patch#1923522 and Patch#1923525 Default language now configurable and logon screen translated. Thanks to Francisco Javier Félix for providing these patches.
+* Req#1928328: Use an alternating colour scheme to make it easier to keep the overview on the list of people. Thanks to Francisco Javier Félix for providing this.
+* Added Licence and some extra security to selection.inc.php (although there was no security isssue with this file, in case there will be one discovered in the future, it will be harder to exploit).
+* Moved the functionality from `pager.inc.php` to `util.inc.php`, so it is easier to re-use.
+* Added an admin page where administrator can manage settings. Replaced 'users' in the main menu with 'admin'.
+* Req#1506959: Zoph Pages feature that allows customization of the first page of an album/category/person/place.
+
+### Translations ###
+* Spanish was updated by Francisco Javier Félix
+* Canadian English, German and Dutch were updated
+
+## Zoph 0.7.0.4 ##
+### 26 May 2008 ###
+
+This is a bugfix release that fixes a few bugs in the 0.7 release.
+* Bug#1923507: pleasewait.gif missing
+* Bug#1926107 SQL error because of dashed line in zoph.sql
+* Bug#1923955: photo x of y is not correctly translated 
+* Bug#1928150: tree view shows a "+" even though the branch is already open
+* Bug#1928671: Notify mail doesn't work
+* Perl chokes when the .zophrc file ends with a negative assignment (" = 0"), adding "1;" to make sure it always ends "positively".
+* Bug#1964408 Garbled layout on prefs page.
+Very small new feature: the photo is now shown when asking for confirmation of deletion
+
+## Zoph 0.7.0.3 ##
+### 15 March 2008 ###
+
+This is a bugfix release that fixes a few bugs in the 0.7 release.
+
+* Bug#1856587: CSS fixes for MSIE rendering problems
+* Bug#1859100: `zophImport.pl` moves files to wrong dir when path is specified in filename
+* Bug#1840352: Ratings and Favourites do not always work correctly.
+
+## Zoph 0.7.1 ##
+### 21 Oct 2007 ###
+
+Zoph 0.7.1 is a "feature release", that introduces a few new features that will be present in the next "stable" version, 0.8. It also includes the bugfixes from 0.7.0.1 and 0.7.0.2.
+
+* It is now possible to define the position of the watermark. 
+* Req#1713938: Zoph can now be configured to move an imported image instead of copying it. This saves you from having to clean up later. Default is to move the photo.
+* Req#1504375 You can now download a set (album, category, search result, ..) of photos in a ZIP file. The size of the ZIP file and the number of photos are configurable.
+* Req#1500560:  For albums and categories, you can now set the desired sort order through preferences. (newest/oldest photo, first/last change, lowest/highest/avg rating, name, sortname). Sortname is a new field that you can use to sort on.
+* Req#1742672 Albums/Categories/Places now also have a thumbnail when the album itself does not have any photos, it picks a photo from one of it's subalbums/c/p
+* Info table now displays total size of photos in the most appropriate unit (KiB, MiB, GiB) instead of always in MiB
+
+
+## Zoph 0.7.0.2 ##
+### 25 July 2007 ###
+* Bug#1756660: Admin can not see details of places
+* Admin can not see details of people
+* Bug#1755325: Not possible to unset a coverphoto
+* Bug#1598437 A user can now only put photos into an album he has write permission to.
+* Bug#1760100: SQL script for new installations doesn't work.
+* Italian translation is now up to date
+
+## Zoph 0.7.0.1 ##
+### 14 July 2007 ###
+* Fix for a (non-exploitable) SQL injection error.
+
+## Zoph 0.7 ##
+### 1 July 2007 ###
+
+### Bugfixes ###
+* Bug#1745803: Layout problem on annotate photo page
+* Bug#1745795: Autocompletion navigation with keyboard did not handle "enter" right
+* Fixed a bug that caused auto thumbnail not to when user was not logged in as admin
+* Fixed a bug where a non-admin user would get the same thumbnail for ALL categories, regardless of whether this photo would actually be in that category.
+* Bug#1742676: Thumbnails show unexpected behaviour with insufficent rights.
+* Bug#1742674: An autocomplete field now advances to next field when "enter" is pressed.
+
+### Cleanup and various ###
+* Made several (small) changes to Dutch, German, Canadian English, French, Norwegian and Swedish Chef.
+* Updated Turkish and Danish
+
+
+## Zoph 0.7pre2 ##
+### 24 June 2007 ###
+
+### Bugfixes ###
+* Bug#1738931 View selection does not work for people
+* Capitalization error in `places.php`, `albums.php`, `categories.php` that caused translations not to work
+* Bug#1738592 Pressing enter in autocomplete field did not work
+* Bug#1738307: In some cases `zophImport.pl` would try to connect to the database before the db connection was made.
+* Fixed a layout-issue where in some cases the photo description would end up on an odd place on the page.
+
+### Cleanup and various ###
+* All languages have been updated. All duplicate and unused strings have been removed from the translation files. Dutch, German, Canadian English, French, Norwegian and even Swedish Chef (Bork! Bork! Bork!) are completely up to date now. Danish, Italian and Turkish are almost up to date.
+
+## Zoph 0.7pre1 ##
+### 02 June 2007 ###
+
+### New Features ###
+* Req#722617: read/display/handle more/full exif data
+* Req#1260584: Javascript-based autocompletion for select-boxes.
+* Req#1478748 Now possible to search albums/categories/photographers/people by text instead of selecting from list.
+* Req#1491208: In albums/categories/places each link now shows the number of photos in that album and the number of photos in the album and the ones below it.  
+* In albums and categories you now see the number of photos in the current album, as well as the number of photos in the current album and all albums below it (which was the only one shown up until now) - just like places has had since the previous version of Zoph
+* Req#1506959 (partly): Specify a coverphoto for albums, categories, people and places
+* Req#1511961: There are now 3 views for albums/categories/people/places: list (the "old" view), tree and thumbnail.
+* Automatically pick a coverphoto in thumbnail view for a/c/p/p when none has been picked.
+* Req#1709390: zophImport.pl: You can now set the defaults for dateddirs, copy, hierarchical and verbose through the .zophrc file. Thanks to Peter Farr for the patch.
+* Patch#1647439: zophImport.pl can now resolve symlinks before importing. Thanks to Peter Farr for the patch.
+
+### Bugfixes ###
+* Bug#1564548, Bug#1725811: Bugs with slideshows showing an error
+* Bug#1568418: Pager links do not work in bulk edit page when no search criteria are used.
+* Bug#1571227: Webimport of ZIP files not working
+* Bug#1571577: Cannot login with `DEBUG` set
+* Bug#1571682: extra '/' in URL after logon
+* Bug#1574205: No "return" from edit page
+* Bug#1574206: Removing crumbs when on edit page does not correctily return
+* in some cases the second page of a search would change ">=" or "<=" into "=".
+* urls for places could not be longer than 32 chars.
+* Fix for a bug that made search behave incorrectly when text-search for a person did not return any people.
+* Bugfix for layout problem - sometimes the main window on the people page was not large enough to display all
+* Bug#1713946 Missing localized strings
+* Bug#1592560 Import fails when "path" field is empty
+* Bug#1598437 Import does not check if user can write to the selected album.
+* Patch#1713924: EXIF date/time priority, patch by Antoine Delvaux.
+
+### Cleanup and various ###
+* Lots of cleanout of HTML and CSS code. Now all unnecessary tables have been replaced by semantic HTML/CSS combinations.
+* Removed duplicate spaces in translation files.
+* Cleanout and getting rid of lots of (but not yet all) PHP warning messages.
+* Updated info page with new mailadress for Zoph
+* Changed "view" to "display" on the people page for consistancy reasons and to remove a translation problem (the word "view" is also used on the photo page, and has a different meaning there)
+* Dutch, German and French translation updated
+* changed some SQL syntax for speedup
+
+## Zoph 0.6 ##
+### 21 September 2006 ###
+
+* Removed mailaddress of original Dutch translator on his request
+* Fixed: Rating links on reports page not working in translated Zoph version.
+* Updated Danish language file
+* Fixed: issues with LIKE searches (Bug#1541763)
+* Improved error handling in imports
+* Fixed an issue with imports not working on Windows systems (Bug#1527333)
+* Fixed: slideshow not working on search results (Bug#1562419)
+
+## Zoph 0.6pre2 ##
+### 13 July 2006 ###
+
+* Updated translations: Dutch, English, German, Danish and Canadian English should be completely up to date now. 
+* Fixed a layout glitch in the edit screen for places
+* Fixed missing translations in relation and selection features.
+* Fixed some incompatibilities with PHP4
+* Fixed an issue that caused guest users to be unable to logon.
+* Fixed an issue with trying to logon after a session timeout
+* Fixed an issue with search not working for translated Zoph versions
+* Fixed some issues in the SQL installation script, thanks to Ed P. for the patch.
+* Added partial Turkish translation, thanks to Mufit Eribol
+* Fixed and issue with auto-edit mode where you would not return to the correct photo after making a change.
+* Updated man pages for zophImport.pl and zophExport.pl
+* In the userlist, changed "view" to "display" for consistancy reasons and to remove a translation problem (the word "view" is also used on the photo page, and has a different meaning there)
+
+## Zoph 0.6pre1 ##
+### 4 June 2006 ###
+
+### New features ###
+* It is now possible to leave comments with photos
+* You can select a photo to do certain actions with that selection.
+* You can now create links between photos. (Req#778845 (partly), Req#828750) (for now, this is the only feature that makes use of "selections")
+* Using external links to Zoph will now go to the login page and then to the requested URL. (Req#1443574)
+* Image service is now on by default
+* Possibility to overide sort order of photos in album (Req#665237)
+* Possibility to overide sort order of photos in category (similar to Req#665237)
+* Possibility to call albums and categories by name in URL instead of id. (Req#778024)
+* Made a small change to the menu: when hovering a menu-option, the layout changes to emulate a "tab"-like display (let me know if you like this!)
+* It is now longer required to be in the image dir to import a photo. (Req#853091)
+* ZophImport.pl and zophExport.pl now use and external file to store the configuration (like the Debian version of Zoph).
+* Quick navigation through locations. (Req#1417305)
+* The search page now has a "no children" checkbox next to albums, categories and places. (Req#1416195)
+* Add URL to places, so a link to -for example- a map can be made. (Req#1466069)
+
+### Bugfixes ###
+* Include URL to Zoph in e-mails (Req#655957)
+* Tranlation fixes in `define_annotated_photo.php`, `edit_person.inc.php` and `edit_place.inc.php`
+* `zoph_table.inc.php`: small layout fix in debug code
+* Fixed: a string would not be correctly translated if it starts with a "special character".
+* Fixed a few html encoding issues. (Bug#1467146 and some not reported bugs)
+* Button text not correct when php.ini setting is short_open_tag = Off (Bug#1459175)
+* Ratings being truncated (Bug#1466551)
+* Fixed a bug where logging in without SSL would redirect you to the wrong page.
+* Next/prev buttons lost after editting/deleteing a photo when using 'auto-edit' mode. (Bug#1467143, Bug#1463947)
+* CSS style is not applied when mid prefix is changed in config.inc.php (Bug#1466068)
+* Added missing space in photo.inc.php
+* Specifying the `DEFAULT_TABLE_WIDTH` as a percentage doesn't work (Bug#1446202)
+* HTML tag missing for all pages.
+* MySQL >4.1 conversion doesn't work with default user feature. (Bug#1500325)
+* Object syntax in `comment.inc.php not` compatible with PHP4.(Bug#1500582)
+
+### Cleanup and various ###
+* Updated Danish, Italian, Dutch, German and Canadian English language files
+* Cleanup of all language files (removed no longer used strings)
+* Removed `zoph_update-0.4pre1.sql`
+* In photo.php, the actionlinks are now built using an array. To make life a bit easier for people using the auto-edit feature, the edit page now displays more links.
+* Cleaned out the code of the search page: Removed lots of messy and redundant code and added whitespace for readability. Functionality should be unchanged.
+* Fixed code layout in `util.inc.php`
+* Updated HTML for the edit page of places to use semantic HTML and not tables.
+
+          
+
+## Zoph 0.5.1 ##
+### 12 March 2006 ###
+
+* Updated Richard Heyes mailclass to newest version. Should partly solve Req#655957
+* Fixed: Quotes and apostrophes do not display correctly (Bug#1443235)
+* Fixed: Places are sorted by id instead of alphabetically. (Bug#1443427)
+* Fixed: Loosing context after editing (Bug#1333428)
+* Fixed: Clicking on the thumbnail of a randomly chosen photo would pick a new random photo instead of showing a larger version of the thumb (Bug#1443927)
+* Fixed: field with double quotes are truncated (Bug#1443235)
+* Fixed: photo.php: the `_rows`, `_cols` etc. fields are added to the url, instead of replaced, whenever they are changed. (did not cause any functionality issues)
+* Fixed: error at the end of a slideshow (Bug#1446200)
+* Removed extra space in `create_text_input`
+* Fixed installation SQL file: some missing changes needed for Zoph 0.5, (Bug#1447727)
+* Resolved duplicate subject header in mail sent from Zoph
+* Translation fixes in German translation, thanks to Ulrich Wiederhold
+* Added missing translation to Dutch and Canadian English and updated zoph_strings.txt
+* Fixed: search page does not show results when using a translated Zoph version (Bug#1448346)
+
+## Zoph 0.5 ##
+### 1 March 2006 ###
+
+* v0.5 is equal to v0.5-pre4
+
+## Zoph 0.5 pre4 ##
+### 18 February 2006 ###
+
+* Solved a bug that caused an error on the bulk edit page if you would add some people to a photo and consequently made another edit (Bug#1422741)
+* Fixed an issue where the pager links on the bulk edit page would cause errors after an edit has been made.
+* Additional anti-SQL injection code in the search page.
+* When updating user permissions with a high number of albums, a "URL too long" error occurred. (Bug#1434235)
+* Fixed a bug that caused some albums permissions not to be properly updated when making a change.
+
+## Zoph 0.5-pre3 ##
+### 30 January 2006 ###
+
+* Solved a typo in upgrade documentations
+* Solved a bug that caused an Admin user not to be able to browse people 
+* zophImport.pl: `--verbose` combined with `--path` would not correctly tell where the file was copied.
+* zophImport.pl: now exits with a non-0 status code when something goes wrong
+* updated man-pages for `zophImport.pl` and `zophExport.pl` (thanks to Edelhard Becker)
+* Solved a bug that caused the bulk-edit page not to work when called from search-results (Bug#1415457)
+* Added brackets to some queries to make the search page react better on "not in" queries.
+* Fixed a bug that caused some changes made on the bulk-edit page to be ignored.  
+* Added an extra Update button to the bulk edit paged (Req#1416184)
+* Made a change to the db lookup for the place dropdown that dramatically increases the performance of the bulk edit page.
+
+## Zoph 0.5-pre2 ##
+### 24 January 2006 ###
+
+* Logging on with non-admin user in Zoph-0.5pre1 does not work (Bug#1413557)
+* Rating links do not work in v0.5pre1 (Bug#1413244)
+
+## Zoph 0.5-pre1 ##
+### 21 January 2006 ###
+
+* Changed typos in `logon.php` and `credits.html`
+* Fixed php errors when user is not logged in (bug#1325547)
+* Added compatibility with MySQL=>4.1, and code to automatically convert passwords from MySQL pre-4.1 to 4.1 and later format.
+* Many updates to HTML and CSS, most to improve HTML semantics. (Less tables used for layout).
+* Resolved some inconsistencies in config.inc.php (some defines used quotes and some not)
+* `zoph_table.inc.php` now gives some more debug info when `DEBUG` is on.
+* Locations are now hierarchical. The necessary database updates for this are done by the SQL update script; an unsopported script is included in the contrib dir that will try to change your locations to a real hierarchical list. Use at your own risk!
+* Dated_dirs can now be made hierarchical (instead of a directory called `2006.01.20` you will have a directory-tree `2006/01/20`). Thanks to Oliver Seidel (Req#656472)
+* Immediate editting of color schemes and possibility to copy them (Req#715104)
+* Dated dirs in webimporter (Req#739557)
+* Imported tar and zip files can be removed automatically (Req#739267)
+* Change of error message in import.php to ease translation.
+* People without "browse people" rights can now no longer see people's names. (Req#749503)
+* Use the file date and time if there is no date in exif header. (Req#752404)
+* Option to open the fullsize image in a new window. (Req#1252457)
+* Watermarking for high quality images. (Req#1250028)
+* Forced SSL login, thanks to Aaron Parecki. (Patch#1253265)
+* Forced SSL usage
+* `zophImport.pl`: Now fails when album/location/category/person does not exist. (Can be turned off by setting `$ignoreerror`). Partly solves Debian bug #284539.
+* `zophImport.pl`: A friendly error is now displayed when a photo is added to an album/cat/person it is already in. (partly solves Debian bug #284539)
+* Changed default permissions in `config.inc.php` as requested in Debian bug#326649
+* `zophImport.pl`: Added `--copy` and `--verbose` options. Solves Debian bug#211312 and partly #218491.
+* Major improvements to the search page. Thanks to Roy Bonser. (Req#685269 and Patch#1395052).
+* Fixed some possible SQL-injection issues.
+* Adding multiple people to a photo at once, thanks to Neil McBride. (Patch#1406959)
+* Fixed Date Field set inconsistently when using files with no EXIF info. (Bug#1402492)
+* Updated Canadian English, German and Dutch translations.
+
+## Zoph 0.4 ##
+### 4 September 2005 ###
+
+* Removed "float" in CSS breadcrumb definition, this was a workaround for a very small layout issue in Firefox, but caused some ugly behaviour in Konqueror and Safari.
+* Fixed incorrect 'Next' URL after editing photos. (bug#1252455)
+* Moved edit button to right side in `edit_photo.php`
+* Updated Dutch, English, Canadian English and German translation
+* `zoph_strings.txt` (translation skeleton file) was updated for 0.4
+* The "root category" on the categories page is now translated
+* Fixed a layout issue when pressing pause during a slideshow
+* "Up" button now takes you to the last page you were looking at, instead of the first (bug#1259152)
+* Added a warning to check for maximum file size when uploading fails (bug#739546)
+* Added Polish translation (thanks Krzysztof Kajkowski)
+* Swedish translation was updated by Johan Linder
+* Increased `DEFAULT_WIDTH` to 600, for layout reasons
+
+## Zoph 0.4pre2 ##
+### 1 August 2005 ###
+
+* Changed layout to use CSS (thanks Jeroen Roos)
+* Added Traditional Chinese translation (thanks Mat Lee)
+* Fixed translation of update and submit buttons
+* Added a "Contrib" directory in which some user-contributed tools are distributed.
+* Contrib: Diff to use Postgres as database (for zoph 0.3.3) (thanks Chris Beauchamp)
+* Contrib: ZophEdit Python script to edit photo metadata in a zoph database (thanks Nils Decker)
+* Contrib: ZophClean Perl script to find and solve differences between database and files on disk.
+* Fixed a bug where only Admin users could rate photos and add photos to a lightbox album (pat#1179920) (thanks Jason Taylor)
+* Added a check to prevent album names, category names, location, people names, user names and color schemes to have empty names (bug#846417)
+* Added a fix for `zophImport.pl`, it failed in looking up people that have a name with multiple spaces (pat#830236) (thanks Hans Verbrugge)
+* Contrib: Added a script to add movies to Zoph (pat#1176317) (thanks Giles Morant)
+* Fixed bug: a deleted album could still be a lightbox album (bug#1193347)
+* Fixed an url-encoding bug in relation to breadcrumbs (bug#1194722)
+* Fixed a problem with deleting a photo: returning to the photos after the delete was inconsistent when auto-edit is on or off. (bug#772403)
+* Added an error message when file cannot be unzipped (#1193351)
+* Changed the licence from BSD to GPL.
+* Changed default width in `config.inc.php` to be slidely wider to solve a layout glitch
+
+## Zoph 0.4pre1 ##
+Never released
+
+* Created a validator class to allow different types of authentication
+* Added a function to `validator.inc.php` to allow htpasswd authentication (req#656449) (thanks Asheesh Laroia)
+* Added $host param to `zophImport.pl` (bug#656438)
+* Fixed it's vs its grammar (bug#656444)
+* Changed `<?=` to `<?php echo` for `short_open_tag = Off` compatibility (bug#670542)
+* Changed logout tab in `header.inc.php` to show "logon" for default users (req#656448)
+* Added `DB_PREFIX` in `config.inc.php` and updated sql to use (req#656450)
+* Fixed `DEFAULT_ORDERING` bug in `photos.php` and `photo_search.inc.php` (bug#667484)
+* Fixed bug with date ordering failing to imply time ordering in `photo_search.inc.php` (pat#675164) (thanks Ian Kerr)
+* Fixed bug in which `PHPSESSID` failed to be passed in image links when cookies were disabled (bug#663523)
+* Fixed a bug in which `update_query_string()` in `util.inc.php` failed to overwrite new parameters (bug#678491)
+* Updated exif flash handling in exif.inc.php (bug#671023)
+* Included an udpated language package with new German, Dutch and Canadian English translations
+* Added image rotation (req#666979)
+* Fixed a problem with double escaping (bug#656435)
+* Fixed a problem with slideshows with IE on Mac (bug#667480)
+* Fixed a bug where the last modified date and the date a photo was taken were mixed up in the calendar view (bug#667486)
+* Added a "default destination path", so the import no longer fails when the path is not specified (bug#670855)
+* Added an extra space on the categories page (bug#741736). (thanks Mark Cooper)
+* Added languages Swedish (thanks Mikael Magnusson), Afrikaans (thanks Neels Jordaan), Hebrew (thanks "Prince01"), Portuguese (thanks Joaquim Azevedo), Danish (thanks Jesper Skytte) languages
+* Fixed a typo in "Swedish Chef" translation
+* Added support for PNG and GIF in the webimported (thanks Patrick Lam)
+* Added validation using `PHP_AUTH_USER`/`PW` using `php_validate()` (thanks Samuel Keim)
+* Upgraded mail classes
+* Added email notification
+* Added registration of last login time and IP address per user
+* Added annotated photo emails (thanks Nixon P. Childs)
+* Added ratings by multiple users
+* Improved navigation by adding up & return links
+* Fixed a problem with next button in some specific cases (bug#782519) (thanks Curtis Rawls)
+* Added bulk editting mode that can change any photo page into a "power edit" page (req#667478)
+* Fixed a problem with photo editting (bug#782600) (thanks Curtis Rawls)
+* Fixed offset bug in slideshow
+* Fixed a bug with the pager on search results page.
+* Fixed a bug where some photos where counted twice (or more) on the reports page (pat#675172) (thanks Ian Kerr)
+
+## Zoph 0.3.3 ##
+### 13 Dec 2002 ###
+
+* Fixed a bug in `zophImport.pl` in which creating a thumbnail (or midsize) could fail when the original image was smaller than the thumbnail size (thanks Tetsuji Kyan)
+* Removed the +profile option to `convert()` in `zophImport.pl` since this was caused problems on some user's systems (a problem with expansion of the * ?)
+* Fixed a bug in `slideshow.php` which caused an error to be displayed when a slideshow was completed
+* Added a pref to allow descriptions to be displayed under thumbnails
+* Fixed a minor pager bug in photos.php
+* Updated `person.inc`, `person.php` and `photo_search.php` so that the person and photos pages accept "person=LastName,FirstName" in the url instead of just person_ids
+* Added a missing call to `getvar("type")` in `image_service.php` (thanks Ian Kerr)
+* Added photo counts to "photos of", "photos by" and "photo at" links in `person.php` and `place.php`
+* Added "photos of" and "photos by" links to `people.php`, "photos at" links to `places.php`
+* Split `WEB_IMPORT` config into `CLIENT_WEB_IMPORT` and `SERVER_WEB_IMPORT`
+* Updated `import.php` to handle uploads of zip and tar archives
+* Fixed a bug in `import.php` which caused server imports to fail when no destination path was set
+* Replaced `<?` with `<?php` so that `short_open_tag` need not be enabled in `php.ini`
+* Fixed a typo in mail.php which caused html mail to have broken images
+* Created zophExport.pl to create static html galleries of photos
+* Added a man page for zophImport.pl (thanks Mark Cooper)
+* Updated the tutorial renamed it as the manual
+* Added an updated language pack with Norwegian and Spanish translations (thanks Haavard Leonardo Lund and Alvaro González Crespo)
+
+## Zoph 0.3.2 ##
+### 17 Oct 2002 ###
+
+* Fixed a bug in edit_photo.inc.php in which the "show additional attributes" link did not work if register_globals was disabled
+* Updated photos.php so that the first and last pages are always shown in the pager (thanks Christian Hoenig)
+* Added a "delete" link to the edit photo page in edit_photo.inc.php
+* Fixed a bug in photo.php where the auto edit pref was ignored when using the search page
+* Added four new color schemes
+* Fixed spelling of aperture and metering in dropdown in util.inc.php (thanks Francesco Ciattaglia)
+* Added missing translation code to `categories.inc.php`, `albums.php`, `zoph.php`
+* Added `DEFAULT_SHOW_ALL` config parameter for `people.php` and `places.php`
+* Added missing footer include from `info.php` and `reports.php`
+* Replaced calls to `include_once` with calls to `require_once`
+* Added path to field pulldown in `search.php`
+* Add `or die` checks to `zophImport.pl` after file manipulations commands
+* Replaced `rename()` with calls to `copy()` + `unlink()` in `zophImport.pl` as rename fails when moving accross filesystems
+* Altered table structure in `person.php`
+* Added new language pack with new Italian translation (thanks Francesco Ciattaglia)
+
+## Zoph 0.3.1 ##
+### 30 Sep 2002 ###
+
+* Fixed a bug in `zophImport.pl` in which `thumb_extension` was applied even when `mixed_thumbnails` was set
+* Updated `zophImport.pl` so that a path need not be passed when doing `--update --thumbnails`
+* Fixed `user.inc.php` so that the "Offset 1 is invalid for MySQL result index" warning is not displayed when a non admin views a photo (this bug was only present in the Zoph 0.3 download for 2 or so hours on Sep 26)
+* Fixed state field size label in `edit_place.inc.php`
+* Updated French language module
+* Removed extra tables in `zoph.sql` included by accident in 0.3
+* Updated `image_service.php` to enable use of cached images (thanks Alan Shutko)
+* Fixed a bug in `user.inc.php` in which, if `register_globals` is disabled, revoking an album would cause all albums to be revoked for that user
+* Fixed `photos.php` so that an odd pager size no longer results in fractional page numbers
+* Fixed `zoph.php` so that the minimum random photo rating is used in the randomly chosen photo link
+* Modified `get_link()` in `place.inc.php` so that a city link can also be displayed
+* Updated `album_permissions.inc.php` so that revoking permissions on an album will also revoke permissions on descendant albums
+* Added a pref to bring up the edit screen whenever a photo is clicked
+* Added a pref to control whether the camera (exif) info is displayed
+* Added a lightbox feature to hold favorite photos
+
+## Zoph 0.3 ##
+### 25 September 2002 ###
+
+* Update `zophImport.pl` to look up photos by path as well as name when updating (thanks Francisco J. Montilla)
+* Fixed spelling of "Metering Mode" in `photo.inc.php` (thanks Francisco J. Montilla)
+* Updated `zophImport.pl` to generate jpeg thumbnails for all image types if desired
+* Updated `photo.inc.php` and `image_service.php` to handle the new thumbnails
+* Fixed the urlencoding of image names/paths in `photo.inc.php` and `util.inc.php` (thanks Francisco J. Montilla)
+* Increased size of name and path fields in photos table
+* Created a timestamp field in the photos table
+* Added recent photos taken/modified links (thanks David Moulton for the idea)
+* Fixed a change password bug in `password.php`
+* Added a (view all) photos tab to the header
+* Created `variables.inc.php` for PHP 4.2.x compatibility (thanks David Baldwin)
+* Modified `calendar.inc.php` to handle pre 1970 dates (thanks David Baldwin)
+* Zoph is now internationalized (thanks Eric Seigne for the code and French translation)
+* Added a web based importer (initial code from Jan Miczaika)
+* Added ability to order results
+* Other minor fixes and improvements
+* Updated documentation
+
+## Zoph 0.2.1 ##
+### 21 June 2002 ###
+
+* Added default, auto logged in user feature (disabled by default)
+* Fixed spelling of "aperture" in `zophImport.pl` (thanks Donald Gover)
+* Fixed greedy split match in `zophImport.pl` (thanks Donald Gover)
+* Quoted image name passed to `jhead` in `zophImport.pl`
+* Wrapped image name in `urlencode()` in `get_image_href` in `photo.inc.php`
+* Fixed remove photo links (thanks Matthew MacIntyre)
+* Added view all options to people and places templates
+* Added check for null in color scheme loading in `prefs.inc.php`
+* Fixed templates to display album and category descriptions, if present
+* Increased size of album and category description fields
+* Added `focus_dist`, `ccd_width` and `comment` photo fields
+* Increased size of `focal_length` photo field
+* Increased size of state field in places table
+* Added missing not null constraint to `detailed_people` field in `users` table
+
+## Zoph 0.2 ##
+### 24 April 2002 ###
+
+* Initial public release
+* Rewrite of Zoph 0.1 
+
+## Zoph 0.1 ##
+completed on 14 Sep 2000, never released
diff -pruN 0.9.4-4/docs/CLI.md 0.9.8-1/docs/CLI.md
--- 0.9.4-4/docs/CLI.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/CLI.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,378 @@
+# The `zoph` CLI tool #
+
+`zoph` is the commandline interface (CLI) of Zoph 0.8.2 and later. You can use the CLI to import photos in Zoph and make (bulk) changes to photos already in Zoph. 
+
+## Multiple Zoph installations ##
+
+### `--instance` ###
+You can have multiple Zoph installations on one system. For example a Zoph installation for yourself and one for a family member or friend, or if you are a Zoph developper, a *production* and a *development* version. The webinterface can determine which installation your are using by the URL you are using. The command line interface does not have an URL thus it needs a different way to find out which instance of Zoph is used.
+
+**Aliases:** `-i`
+
+**Default:** First instance in zoph.ini
+
+**Options:** Instance defined in zoph.ini
+
+**Example:** zoph --instance production photo.jpg
+
+## Commands ##
+You can only supply one "command" type option to Zoph, if you supply more, Zoph will take the last one.
+
+### `--import` ###
+The list of photos given will be imported in Zoph
+
+**Aliases:** `-I`
+
+**Default:** --import is the default command, it doesn't need to be given.
+
+**Options:** 
+
+**Example:** zoph --import photo.jpg
+
+### `--update` ###
+Zoph will try to find the given list of photos in the database and apply the options to those photos. You can either give a list of filenames or a list of id's, see [--useIds](#--useIds).
+
+**Aliases:** `-u`
+
+**Default:** `--import` is the default command
+
+**Example:** `zoph --update photo.jpg`
+
+### `--new` ###
+Create albums, categories, places and people from CLI
+
+**Aliases:** `-N`
+
+**Default:** 
+
+**Options:** Use `--album "new album"`, `--category "new category"`, `--person "new person"`, `--place "new location"`. The new object will be created directly under the root unless [--parent](#--parent) is specified. See [--person](#--person) for details on how Zoph determines what's the first and second name.
+
+**Example:** `zoph --new --parent "Holidays" --album "Summer 2011"`
+
+### `--version` ###
+Show the current Zoph version.
+
+**Aliases:** `-V`
+
+**Default:** `--import` is the default command
+
+**Options:** All other options will be ignored if `--version` is specified
+
+**Example:** `zoph --version`
+
+### `--help` ###
+Display help.
+
+**Aliases:** `-h`
+
+**Default:** --import is the default command
+
+**Options:** All other options will be ignored if `--help` is specfied
+
+**Example:** zoph --help
+
+## Organizers ##
+Organizers is what Zoph is all about, these are the ways you can organize your photos by.
+
+### `--album` ###
+Specify one or multiple albums Zoph should add the given list of photos to. You can specify `--album` multiple times.
+
+**Aliases:** `-a` `--albums`
+
+**Options:** The name of an album or multiple, separated by commas. The album must pre-exist in the database.
+
+**Example:** 
+````
+zoph --album "Summer, Holiday" photo.jpg
+zoph -a "Summer" -a "Holiday" photo.jpg
+````
+
+### `--category` ###
+Specify one or multiple categories Zoph should add the given list of photos to. You can specify `--category` multiple times.
+
+**Aliases:** `-c` `--categories`
+
+**Options:** The name of a category or multiple, separated by commas. The category must pre-exist in the database.
+
+**Example:** 
+````
+zoph --category "sun, water" photo.jpg
+zoph -c "sun" -c "water" photo.jpg
+````
+
+### `--person` ###
+Specify one or multiple persons that appear on the photos specified. You can specify `--person` multiple times.
+
+**Aliases:** `-p` `--persons` `--people`
+
+**Options:** The name of a person or a list of persons separated by commas. The person must pre-exist in the database. When using [--new](#--new) to add new persons to the database, Zoph will try to determine which parts of the name are first, middle and last. If a name is a single word ("John"), Zoph assumes this is the first name. If a name is two words ("John Doe"), Zoph will assume this is the first and last name. If a name is 3 or more words, Zoph will assume the first word is the first name, the second is a middle name and all remaining words are the last name. If this does not give the correct results, you can choose to separate by colon (":") instead of space. Zoph will then set the part before the first colon to first name, then middle, then last and finally 'called'.
+
+**Example:** 
+````
+zoph --person "Linus Torvalds, Mark Shuttleworth" photo.jpg
+zoph -p "Linus Torvalds" -p "Mark Shuttleworth" photo.jpg
+zoph --new --person "Linus Torvalds"
+zoph --new --person "John Fitzgerald Kennedy"
+zoph --new --person "Johnny B.::Goode"
+zoph --new --person "John::Doe:Average Joe"
+````
+
+### `--location` ###
+Specify the location where the photos specified were taken. You can specify `--location` only one time.
+
+**Aliases:** `-l` `--place`
+
+**Options:** The name of a place. The place must pre-exist in the database.
+
+**Example:** 
+````
+zoph --location "Rotterdam" photo.jpg
+zoph -l "Rotterdam" photo.jpg
+````
+
+### `--photographer` ###
+Specify the photographer of the photos specified. You can specify `--photographer` only one time.
+
+**Aliases:** `-P`
+
+**Options:** The name of a person. The person must pre-exist in the database.
+
+**Example:** 
+````
+zoph --photographer "Alan Cox" photo.jpg
+zoph -P "Alan Cox" photo.jpg
+````
+
+### `--fields` ###
+
+**Aliases:** `-f` `--field`
+Specify fields that should be filled for the photos specified. You can specify `--field` multiple times.
+
+**Options:** The following fields can be used: 
+* date
+* time
+* camera_make
+* camera_model
+* flash_used
+* focal_length
+* exposure
+* compression
+* aperture
+* iso_equiv
+* metering_mode
+* ccd_width
+* focus_dist
+* comment
+* lat
+* lon
+* rating
+* description
+* level
+* view
+* title
+
+**Example:** 
+````
+zoph --field "rating=10" photo.jpg
+zoph -f "description=self portrait" photo.jpg
+````
+
+## Options ##
+### `--thumbs` / `--no-thumbs`  ###
+Specify whether thumbnails should be created.
+
+**Aliases:** `-t` / `--nothumbs` `-n`
+
+**Default:** When importing ([--import](#--import)): create thumbs. When updating ([--update](#--update)): do not create thumbs.
+
+**Options:** Use these commands to overrule the defaults. If you want to recreate thumbs for already imported photos, use `--thumbs`. If you do not want to create thumbnails while importing, use `--no-thumbs`.
+
+**Example:** 
+````
+zoph --import --no-thumbs photo.jpg
+zoph --update -t photo.jpg
+````
+
+### `--exif` / `--no-exif`  ###
+Specify whether EXIF date should be read.
+
+**Aliases:** `--EXIF` / `--noexif` `--no-EXIF` `--noEXIF` 
+
+**Default:** When importing ([--import](#--import)): read EXIF data. When updating ([--update](#--update)): do not read EXIF data.
+
+**Options:** Use these commands to overrule the defaults. If you want to reread the EXIF date of already imported photos, use `--exif`. If you do not want to read EXIF data while importing, use `--no-exif`.
+
+**Example:** 
+````
+zoph --import --no-exif photo.jpg
+zoph --update --exif photo.jpg
+````
+
+### `--size` / `--no-size`  ###
+Specify whether Zoph should update the dimensions of the photo stored in the database.
+
+**Aliases:** *(none)* / `--nosize`
+
+**Default:** When importing ([--import](#--import)): update database with dimensions of the image. When updating ([--update](#--update)): do not update the size information.
+
+**Options:** Use these commands to overrule the defaults. If you want to update the information stored in the database when updating, use `--size`. If you do not want store size information while importing (although I see no real use for this), use `--no-size`.
+
+**Example:** 
+````
+zoph --import --no-size photo.jpg
+zoph --update --size photo.jpg
+````
+
+### `--useids`  ###
+When updating photos it can be useful to be able to specify database ids instead of filenames.
+
+**Aliases:** `--useIds` `--use-ids` `--useid` `--use-id`
+
+**Default:** Filenames are used. Using `--useids` implies `--update`
+
+**Options:** You can specify a list of ids instead of a list of filenames. You can either specify a single id or a range of ids. Keep in mind that the list of filenames or ids are the **last** options of the command and do not necessarily follow the `--useids` option.
+
+**Example:** 
+````
+zoph --update --useids 2 5 11-20 56
+zoph --update --useids --album "Summer" 15-60
+````
+
+### `--move` / `--copy` ###
+When importing photos, you can either import a copy of the photo or move the photo into the Zoph imagedirectory.
+
+**Default:** Files are moved.
+
+**Options:** If the file imported is a symlink, in case of `--move`, a copy of the file the symlink points to is imported and the symlink is deleted. In case of `--copy`, the symlink is not deleted.
+
+**Example:** 
+````
+zoph --move photo.jpg
+zoph --copy photo.jpg
+````
+### `--dateddirs` / `--no-dateddirs` ###
+
+With dated dirs, Zoph automatically creates directories based on the (EXIF-)date of a photo. For example a photo taken on March 15, 2010, will automatically be places in a directory called 2010.03.15
+
+**Aliases:** `--datedDirs` `--dated` `-d` / `--no-datedDirs` `--nodateddirs` `--nodatedDirs`
+
+**Default:** No dated dirs are used.
+
+**Options:** 
+
+**Example:** `zoph --dateddirs photo.jpg`
+
+### `--hierarchical` / `--no-hierarchical` ###
+Hierarchical dated dirs are similar to [--dateddirs](#--dateddirs----no-dateddirs), Zoph automatically creates directories based on the (EXIF-)date of a photo, the difference is that with hierarchical dated dirs, a separtate directory is create for year, month and day. For example a photo taken on March 15, 2010, will automatically be places in the directory tree `2010/03/15`.
+
+**Aliases:** `-H` `--hier` / `--no-hierarchical` `--no-hier` `--nohierarchical` `--nohier`
+
+**Default:** No hierarchical dated dirs are used.
+
+**Example:** `zoph --hierarchical photo.jpg`
+
+### `--hash` / `--no-hash` ###
+As of v0.8.4 Zoph stores a hash of each photo in the database. This is currently only used for the 'share photo' feature. In the future other features will use this, as it will allow Zoph to detect whether a photo has been changed.
+
+**Default:** Generate a hash or update the hash when `--update` is used.
+
+**Options:** 
+
+**Example:** `zoph --no-hash photo.jpg`
+
+### `--parent` ###
+
+**Default:** If you do not specify a parent, the new object will be placed directly under the root.
+When adding new objects to the database using the [--new](#--new) option, you can determine where in the tree an album, category or place will be placed by specifying `--parent`.
+
+**Options:** `--parent` **must precede** the actual album, category or place. The parent is only set for the next [--album](#--album), [--category](#--category) or [--place](#--place).
+
+**Example:** 
+
+Create a new album called 'summer 2011' under the root album:
+````
+zoph --new --album "Summer 2011"
+````
+
+
+Create new albums called 'Summer 2011' and 'Winter 2011' under the 'Holidays' album:
+````
+zoph --new --parent "Holidays" --album "Summer 2011, Winter 2011"
+````
+
+Create new albums called 'Summer 2011' and 'Winter 2011' under the 'Holidays' album and an album 'Trees' under the root album:
+````
+zoph --new --parent "Holidays" --album "Summer 2011, Winter 2011" --album "Trees"
+````
+
+Create new albums called 'Summer 2011' and 'Winter 2011' under the 'Holidays' album and an album "Trees" under the "Nature" album:
+````
+zoph --new --parent "Holidays" --album "Summer 2011, Winter 2011" --parent "Nature" --album "Trees"
+````
+
+Create a new album called 'Summer 2011' under the 'Holidays' album and a cateogory "Trees" under the "Nature" category:
+````
+zoph --new --parent "Holidays" --album "Summer 2011" --parent "Nature" --category "Trees"
+````
+
+### `--autoadd` ###
+
+You can use [--new](--new) to add albums, categories, places and people from CLI, with autoadd you can add them in the same run as you are importing photos. Zoph will add any album, category, etc. you have specified, but does not exist. However, to protect you from every typo to be automatically added to the database, only items preceded with [--parent](#--parent) will be added, unless you specify [--addalways](#--addalways). Of course this only works for albums, categories and locations, and not for persons and photographers.
+
+**Aliases:** `-A` `--auto-add`
+
+**Example:**
+````
+zoph --autoadd --album "Summer 2011" IMG_1234.JPG
+No parent album for "Summer 2011"
+````
+`zoph --autoadd --parent "Holidays" --album "Summer 2011" IMG_1234.JPG`
+
+### `--addalways` ###
+
+When using [--autoadd](#--autoadd), zoph protects you from every typo to be automatically added to the database by only adding albums, categories and location preceded with [--parent](--parent). To overrule this behaviour, use `--addalways`, which causes them to be added under the root album, category or location.
+
+**Aliases:** `-w` `--add-always`
+
+**Default:** Do not add albums, categories or locations unless a parent has been specified.
+
+**Example:** `zoph --autoadd --addalways --album "Summer 2011" IMG_1234.JPG`
+
+### `--recursive` ###
+
+With `--recursive`, Zoph will recursively go through directories added to the file list and import photos found in those dirs as well.
+
+**Aliases:** `-r`
+
+**Default:** Zoph will error if you try to import a directory.
+
+**Example:** 
+Import image IMG_1234.JPG and any photos in the directory 'Photos', or any directory below that.
+````
+zoph -r IMG_1234.JPG Photos/
+````
+
+### `--dirpattern` ###
+With `--dirpattern`, you can automatically assign albums, categories, people, photographer, location or path based on the directories the photos are in. You do this by specifying a pattern, based on which Zoph will use directory names to assign to correct organizer. This pattern consists of a list of letters, where each letter is a directory. This option makes no sense if you do not specify [--recursive](#--recursive) as well.
+
+**Default:** No default.
+
+**Options:** **a** (album), **c** (category), **l** (location), **p** (person), **P** (photographer) and **D** (path)
+
+**Example:** `zoph -r --dirpattern "Paccc" *`
+Import all files in the current directory **and** the directories below. For each path, assign the name of the first directory as photographer, the second as album, and the third, fourth and fifth as categories. For a more detailed example, see [Using dirpatterns](IMPORT-CLI.md#Using_dirpatterns) 
+
+### `--path` ###
+You may want to manually organize your photos in directories. You can use `--path` for that. The path is inserted between the image directory and (in case they are enabled) dated or hierarchical dated directories.
+
+**Aliases:** `-D` 
+
+**Default:** Photos are imported directly under the image dir.
+
+**Options:** Valid path, relative to image dir.
+
+**Example:** 
+````
+zoph --path "holiday" photo.jpg
+zoph --path "travel/business" --dateddirs photo.jpg
+````
diff -pruN 0.9.4-4/docs/CONFIGURATION.md 0.9.8-1/docs/CONFIGURATION.md
--- 0.9.4-4/docs/CONFIGURATION.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/CONFIGURATION.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,220 @@
+# CONFIGURATION #
+
+## Database connection ##
+Access to the database needs to be configured through `/etc/zoph.ini`.
+The `zoph.ini` files tells Zoph where it can find the database and it tells Zoph's CLI scripts where it can find your Zoph installation. Normally, `zoph.ini` will be placed in `/etc`. If you have no write access in `/etc` or have another reason to not put this file there, you should change the `INI_FILE` setting in `config.inc.php` and the 'zoph' CLI utility.
+
+**Never, _ever_, place it in the same directory as the Zoph PHP files. This will enable _everyone_ to download it and read your passwords.**
+
+An example `zoph.ini` file called **`zoph.ini.example`** is included in the `cli` dir of the Zoph tarball.
+
+### Contents of `zoph.ini` ###
+`zoph.ini` consists of one or more *sections*. A section starts with the name of the section between square brackets.
+`[zoph]`
+You should create a section for each Zoph installation on your system. The section name is a descriptive name that you can choose yourself. Each section must contain the following settings:
+
+`db_host`	
+The hostname of the system that is running your MySQL server, usually "`localhost`".
+
+`db_name`
+The name of the database. If you have followed the installation instructions closely, this will be `zoph`, but of course you are free to use any other name.
+
+`db_user`
+The user to connect to your Zoph database. If you have followed the installation instructions closely, this will be `zoph_rw`, but of course you are free to use any other name.
+
+`db_pass` 
+Password to connect to the database. This is what you have set while creating users for Zoph in MySQL.
+
+`db_prefix`
+Zoph can prefix all MySQL table names with a prefix string. This is especially useful for people who only have a single database to use and want to use multiple applications on, for example, a shared hosting environment. By default, this is "`zoph_`".
+
+`php_location`
+With the `php_location` setting, you define where the PHP-files for your Zoph installation are located. This is necessary for the Zoph CLI scripts to locate the rest of your Zoph installation.
+
+All values that contain non-alphanumeric characters must be enclosed in double quotes. It won't hurt to use quotes even if the values are purely alphanumeric.
+
+#### Examples ####
+##### Single installation ####
+Most Zoph users will have only one Zoph installation on their system. This is how a `zoph.ini` for a single installation looks:
+
+````
+[zoph]
+db_host = "localhost"
+db_name = "zoph"
+db_user = "zoph_rw"
+db_pass = "pass"
+db_prefix = "zoph_"
+php_location = "/var/www/html/zoph"
+````
+##### Multiple installations ####
+You can have multiple Zoph installations on one system. For example, one for yourself and one for a family member or friend; or, if you are a Zoph developper, a development and a productions environment. If you have more than one Zoph installation, simply create a section *per installation*. For example:
+
+````
+[production]
+db_host = "localhost"
+db_name = "zoph"
+db_user = "zoph_rw"
+db_pass = "pass"
+db_prefix = "zoph_"
+php_location = "/var/www/html/zoph"
+
+[development]
+db_host = "localhost"
+db_name = "zophdev"
+db_user = "zoph_rw"
+db_pass = "pass"
+db_prefix = "zoph_"
+php_location = "/var/www/html/zophdev"
+````
+
+The webinterface of Zoph will be able to determine which settings it should use with the `php_location` setting. The CLI scripts need the `--instance` parameter to determine that. If you omit the `--instance` parameter, it will use the first one in `zoph.ini`.
+
+## Web GUI ##
+Most of Zoph can be configured from the Web GUI. Log in as a user with admin rights. If you haven't created a user for yourself, you can login with the user `admin`. Go to "admin" in the top menu and then choose "config". The configuration items should be self-explanatory.
+
+When you first get started with Zoph, you should at least change the following:
+
+### Images path ###
+**Images directory** under **paths**. This is the directory where your photos are stored. It should be an _absolute path_ (that is: referenced from the root) and it should not be in your webroot. See the [installation documentation](INSTALLATION.md) for how to set the correct access rights for this directory.
+
+### Sharing Salt ###
+**Salt for sharing full size images** and **Salt for sharing mid size images** under **Sharing**. You should set these salts to unique values. You can do so by clicking the generate buttons. Even though you will not need these unless you enable **Sharing**, it is a good idea to make sure you have a unique salt set. (and Zoph will refuse to save your configuration if you don't).
+
+### Enable import and upload ###
+**Import through webinterface** and **Upload through webinterface** under **Import**. Unless you plan to use the CLI import exclusively, you should enable import through the web interface here.
+
+### Interface title ###
+**Title** under **Interface settings**. You probably want to change the name Zoph will show on the login page and in the title bar.
+
+## `config.inc.php` ##
+There are a few configuration settings that can only be changed in `config.inc.php`. Most users will never need to change anything here. 
+
+### `LOG_ALWAYS` ###
+**Description:**: This option controls how much debug information is showed. Zoph will show you the severity you configure and everything worse than that. This setting configures a log level that is always displayed, no matter which subject the message is in. By default this is set to `log::FATAL`, which means that any message that has a severity of FATAL or *worse* is displayed. Since `log::FATAL` is the worst kind of message, only `log::FATAL` messages will be displayed. If you configure `log::ERROR`, you will see `ERROR` and `FATAL` messages and if you configure `log::DEBUG`, you will see all messages. A special severity level has been added to suppreses *all* messages: **log::NONE**
+
+**Default:** `log::FATAL`
+
+**Options:** See [Log Severity](#log-severity) below
+
+**Example:** `define('LOG_ALWAYS', log::ERROR);`
+
+### `LOG_SEVERITY` ###
+**Description:**
+This setting works in the same way as the previous one, except that only messages for a specific severity will be displayed; it is used in combination with [`LOG_SUBJECT`](#log_subject) to achieve this. These two option enable you to have granular control over which messages are displayed. With `LOG_SEVERITY` you configure how much debug information is showed. The difference with [`LOG_ALWAYS`](#log_always) is, that the messages are only shown for the subject you have configured in [`LOG_SUBJECT`](#log_subject). Zoph will show you the severity you configure and everything worse than that. So if you configure `log::ERROR`, you will see `ERROR` and `FATAL` messages and if you configure `log::DEBUG`, you will see all messages.
+
+**Default:** `log::NONE`
+
+**Options:** See [Log Severity](#log-severity) below
+
+**Example:** `define('LOG_SEVERITY', log::NOTIFY);`
+
+
+### `LOG_SUBJECT` ###
+**Description:** With this setting you can control for which subjects you want to see the messages. There is a special subject to show all messages: `log::ALL`. You can also combine multiple subjects, using the | (or) sign and the ~ (not) sign. This option, together with [`LOG_SEVERITY`](#log_severity) enables you to have granular control over which messages are displayed. 
+
+**Default:** `log::NONE`
+
+**Options:** See [Log Subjects](#log-subjects) below
+
+**Example:**
+Display all messages which indicate an error or a fatal error, regarding the translation of Zoph or images:
+
+````php
+define('LOG_SEVERITY', log::ERROR);
+define('LOG_SUBJECT', log::LANG | log::IMG);
+````
+Display all messages, except debug-level messages, except those regarding SQL queries:
+
+````php
+define('LOG_SEVERITY', log::NOTIFY);
+define('LOG_SUBJECT', log::ALL | ~log::SQL);
+````
+
+Display all messages, except those regarding redirects or the database connection:
+
+````php
+define('LOG_SEVERITY', log::DEBUG);
+define('LOG_SUBJECT', log::ALL ~(log::REDIRECT | log::DB));
+````
+
+### Log Severity ###
+Severity      | Meaning
+--------------|---------------------
+log::DEBUG    |	Debugging messages, Zoph gives information about what it's doing.
+log::NOTIFY   |	Notification about something that is happening which is influencing Zoph's program flow
+log::WARN     |	Warning about something that is happening
+log::ERROR    | Error condition, something has gone wrong, but Zoph can recover
+log::FATAL    |	Fatal error, something has gone wrong and Zoph needs to stop execution of the current script.
+log::NONE     | Do not display any messages
+
+### Log Subjects ###
+Subject       | Type of messages in this subject
+--------------|---------------------
+log::ALL      | All messages
+log::VARS     | Messages regarding setting of variables
+log::LANG     | Messages regarding the translation of Zoph
+log::LOGIN    | Messages regarding the Login procedure
+log::REDIRECT | Messages regarding redirection
+log::DB       | Messages regarding the database connection
+log::SQ       | Messages regarding SQL Queries
+log::XML      | Messages regarding XML creation
+log::IMG      | Messages regarding image creation
+log::IMPORT   | Messages regarding the import functions
+log::GENERAL  | Other messages
+log::NONE     | No messages.
+
+## Resized image generation ##
+Zoph automatically creates thumbnails and medium-sized ('mid') images during import. To influence this process, you can edit the parameters below. It is not recommended to change these, especially not after you have imported some photos. In the near future there will be an option to change this in the webinterface.
+
+### `THUMB_SIZE` ###
+**Description:**
+Maximum width or height of thumbnails
+
+**Default:**
+`120`
+
+**Options:**
+Maximum width/height in pixels.
+
+**Example:**
+`define('THUMB_SIZE', 120);`
+
+### `MID_SIZE` ###
+**Description:**
+Maximum width or height of midsized images
+
+**Default:**
+`480`
+
+**Options:**
+Maximum width/height in pixels.
+
+**Example:**
+`define('MID_SIZE', 480);`
+
+### `THUMB_PREFIX` ###
+**Description:**
+Prefix for filenames of thumbnails
+
+**Default:**
+`thumb`
+
+**Options:**
+**Do not** make this string empty!
+
+**Example:**
+`define('THUMB_PREFIX', 'thumb');`
+
+### `MID_PREFIX` ###
+**Description:**
+Prefix for filenames of thumbnails
+
+**Default:**
+`mid`
+
+**Options:**
+**Do not** make this string empty!
+
+**Example:**
+`define('MID_PREFIX', 'mid');`
+
Binary files 0.9.4-4/docs/img/Config_import.png and 0.9.8-1/docs/img/Config_import.png differ
Binary files 0.9.4-4/docs/img/ZophComment.png and 0.9.8-1/docs/img/ZophComment.png differ
Binary files 0.9.4-4/docs/img/Zoph_enable_import.png and 0.9.8-1/docs/img/Zoph_enable_import.png differ
Binary files 0.9.4-4/docs/img/ZophImport001.png and 0.9.8-1/docs/img/ZophImport001.png differ
Binary files 0.9.4-4/docs/img/ZophImport002.png and 0.9.8-1/docs/img/ZophImport002.png differ
Binary files 0.9.4-4/docs/img/ZophImport003.png and 0.9.8-1/docs/img/ZophImport003.png differ
Binary files 0.9.4-4/docs/img/ZophImport004.png and 0.9.8-1/docs/img/ZophImport004.png differ
Binary files 0.9.4-4/docs/img/ZophImport005.png and 0.9.8-1/docs/img/ZophImport005.png differ
Binary files 0.9.4-4/docs/img/ZophImport006.png and 0.9.8-1/docs/img/ZophImport006.png differ
Binary files 0.9.4-4/docs/img/ZophImport007.png and 0.9.8-1/docs/img/ZophImport007.png differ
Binary files 0.9.4-4/docs/img/ZophImport008.png and 0.9.8-1/docs/img/ZophImport008.png differ
Binary files 0.9.4-4/docs/img/ZophImport009.png and 0.9.8-1/docs/img/ZophImport009.png differ
Binary files 0.9.4-4/docs/img/ZophImport010.png and 0.9.8-1/docs/img/ZophImport010.png differ
Binary files 0.9.4-4/docs/img/ZophImport011.png and 0.9.8-1/docs/img/ZophImport011.png differ
Binary files 0.9.4-4/docs/img/ZophImport012.png and 0.9.8-1/docs/img/ZophImport012.png differ
Binary files 0.9.4-4/docs/img/ZophImport013.png and 0.9.8-1/docs/img/ZophImport013.png differ
Binary files 0.9.4-4/docs/img/zoph-ssl-config.png and 0.9.8-1/docs/img/zoph-ssl-config.png differ
diff -pruN 0.9.4-4/docs/IMPORT-CLI.md 0.9.8-1/docs/IMPORT-CLI.md
--- 0.9.4-4/docs/IMPORT-CLI.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/IMPORT-CLI.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,222 @@
+# Import using the CLI #
+Many users will use the Zoph webinterface almost exclusively to work with Zoph. However, more advanced users may prefer the commandline interface (CLI) for some tasks. Zoph has a CLI client called `zoph` that can be used to import photos and make (bulk) changes to photos already in the database.
+
+A detailed overview of all the options can be found in [The Zoph CLI tool](CLI.md).
+
+## configuration ##
+### zoph.ini ###
+First of all, you will need a valid [zoph.ini](CONFIGURATION.md#contents-of-zophini) file to work with the CLI client. If you have multiple Zoph installations on your system, an important difference with the webinterface is the fact that the CLI cannot automatically determine which Zoph installation (instance) you are trying to import photos to. By default it will take the first instance, otherwise you need to specify the [--instance](CLI.md#--instance) CLI option. See the [CONFIGURATION](CONFIGURATION.md) documentation for some examples.
+
+### Webinterface ###
+![Screenshot: import settings on configuration page](img/Config_import.png)
+There are several option related to import in the configuration page (under admin). See the screenshot for an overview. The options should be self-explanatory.
+
+## Specifying which photos to import ##
+Of course you want to tell Zoph which photos it should import. The list of photos is **always** specified **last**. You can simply specify filenames, but you can also use your shell's "globbing" feature to specify multiple photos at once.
+### example ###
+Let's say you have a bunch of photos plus a text file in a directory:
+
+````
+zoph@zoph $ ls
+IMG_1203.JPG     IMG_1207.JPG     IMG_1211.JPG     IMG_1215.JPG     IMG_1219.JPG
+IMG_1204.JPG     IMG_1208.JPG     IMG_1212.JPG     IMG_1216.JPG     IMG_1220.JPG
+IMG_1205.JPG     IMG_1209.JPG     IMG_1213.JPG     IMG_1217.JPG     IMG_1221.JPG
+IMG_1206.JPG     IMG_1210.JPG     IMG_1214.JPG     IMG_1218.JPG     photos.txt
+````
+If you want to import all photos, you could do
+````
+zoph@zoph $ zoph *
+````
+However, this will cause an error when zoph tries to import a textfile, so it's better to do:
+````
+zoph@zoph $ zoph *.JPG
+````
+Or even
+````
+zoph@zoph $ zoph IMG_12*.JPG
+````
+But, what if you would like to import only some of these photos, for example, 1203 to 1205, 1210 to 1219 (except 1213), 1220 and 1221. You could of course specify every file individually:
+````
+zoph@zoph $ zoph IMG_1203.JPG IMG_1204.JPG IMG_1205.JPG IMG_1210.JPG IMG_1211.JPG IMG_1212.JPG IMG_1214.JPG
+              IMG_1215.JPG IMG_1216.JPG IMG_1217.JPG IMG_1218.JPG IMG_1219.JPG IMG_1220.JPG IMG_1221.JPG
+````
+Well, I don't know about you, but *I* certainly didn't buy a computer to do things by myself, so why not let the computer take care of that?
+````
+zoph@zoph $ zoph IMG_120[3-5].JPG  IMG_121[^3]*.JPG IMG_122*.JPG
+````
+That saved a lot of typing, didn't it? This is not a zoph feature, by the way, it is a feature of your shell (probably [Bash](http://www.gnu.org/software/bash/bash.html)). 
+
+## Organizing photos ##
+You could just import your photos like described above and then use the Zoph webinterface to organize them, but why not organize them right away? 
+### Albums, categories and people ###
+You can put your photo in one or more albums and one or more categories (well, that's actually zero or more, since you don't *have* to put them in an album or category). Use the [--album](CLI.md#--album) and [--category](CLI.md#--category) commandline option for this. If there are any people on the photo, you can add people to the photo using the [--person](CLI.md#--person) option. Remember that the list of photos *always* comes *after* the other options. It's important to realize that the album, category or person must already be in the database. 
+
+#### Examples ####
+Import `IMG_1300.JPG` and place it in the album **Summer** and category **Landscapes**:
+````
+zoph@zoph $ zoph --album "Summer" --category "Landscapes" IMG_1300.JPG
+````
+
+Import `john.jpg` and place it in the album **Family**, category **Portraits** and specify **John Doe** is in this picture:
+````
+zoph@zoph $ zoph --album "Family" --category "Portraits" --person "John Doe" john.jpg
+````
+Import `family.jpg` and place it in the albums **Family** and **Summer** and specify **John Doe**, **Johnny Doe** and **Jane Doe** are in this picture:
+````
+zoph@zoph $ zoph --album "Family" --album "Summer" --category "Portraits" --person "John Doe, Johnny Doe, Jane Doe" family.jpg
+````
+Import `guitarists.jpg` and place it in the categories **Music** and **Musicians** and specify **Hank Marvin**, **Jimi Hendrix** and **Brian May** are in this picture:
+````
+zoph@zoph $ zoph --category "Music, Musicians" --person "Hank Marvin" --person "Jimi Hendrix" --person "Brian May" guitarists.jpg
+````
+As you can see, you can add multiple albums, categories or people by repeating the [--album](CLI.md#--album), [--category](CLI.md#--category) or [--person](CLI.md#--person) option multiple times, or by specifying it only once and give it a list of albums, categories or people, separated by commas.
+
+### Photographer and location ###
+Of course, you also want to record *where* and *by whom* the your photos were taken. This works almost the same als albums, categories and people, *except* that you can only store *one of each*. Again, the person and place must be in the database prior to using it via the CLI. Specify the photographer using the [--photographer](CLI.md#--photographer) option and the location using the [--location](CLI.md#--location) option.
+
+#### Examples ####
+Import `IMG_1400.JPG` and set **John Doe** as the photographer:
+````
+zoph@zoph $ zoph --photographer "John Doe" IMG_1400.JPG
+````
+
+Import `IMG_1401.JPG` and set **Berlin** as the location where the photo was taken:
+````
+zoph@zoph $ zoph --location "Berlin" IMG_1401.JPG
+````
+
+### Other fields ###
+There are a lot more attributes Zoph can store about your photos. Many of them will be automatically read from the photo's EXIF information. You can also set these fields manually using the [--field](CLI.md#--field) option.
+#### Examples ####
+Import `IMG_1416.JPG` and set the title:
+````
+zoph@zoph $ zoph --field "Title=A nice photo" IMG_1416.JPG
+````
+
+## Import directory ##
+During the import, Zoph moves (or copies) your photos to a directory that you have set to be the `image_dir`. You set this in the configuration screen. Under this directory, Zoph can create subdirectories. This is controlled by the [--path](CLI.md#--path), [--dateddirs](CLI.md#--dateddirs) and [--hierarchical](CLI.md#--hierarchical) options.
+
+With [--path](CLI.md#--path), you can manually set a path that will be inserted between the `image_dir` and the filename. With [--dateddirs](CLI.md#--dateddirs) and [--hierarchical](CLI.md#--hierarchical), Zoph will create directories based on the (EXIF-)date of the photo. If you specify both a pathname and [--dateddirs](CLI.md#--dateddirs) or [--hierarchical](CLI.md#--hierarchical), the location will contain the path first and the dated directory second.
+
+### Examples ###
+Assume IMG_1480.JPG was taken on 5 May 2010 and IMG_1481.JPG was taken on 13 May 2010 and image_dir is set to `/data/photos`.
+````
+zoph@zoph $ zoph IMG_1480.JPG IMG_1481.JPG
+zoph@zoph $ ls /data/photos
+  mid
+  thumb
+  IMG_1480.JPG
+  IMG_1481.JPG
+````
+Ok, now let's add a `--path`:
+````
+zoph@zoph $ zoph --path "family" IMG_1480.JPG IMG_1481.JPG
+zoph@zoph $ ls /data/photos
+  family
+
+zoph@zoph $ ls /data/photos/family
+  mid
+  thumb
+  IMG_1480.JPG
+  IMG_1481.JPG
+````
+And `--dateddirs`:
+````
+zoph@zoph $ zoph --dateddirs IMG_1480.JPG IMG_1481.JPG
+zoph@zoph $ ls /data/photos
+  2010.05.05
+  2010.05.13
+zoph@zoph $ ls /data/photos/2010.05.05
+  mid
+  thumb
+  IMG_1480.JPG
+````
+This is of course nice if you only have a few photos, but when your collection grows and you have taken photos spread over several years, you will end up with hundreds of dated directories. For this reason, there is hierarchical dated directories:
+````
+zoph@zoph $ zoph --hierarchical IMG_1480.JPG IMG_1481.JPG
+zoph@zoph $ ls /data/photos
+  2010
+zoph@zoph $ ls /data/photos/2010
+  05
+zoph@zoph $ ls /data/photos/2010/05
+  05
+  13
+zoph@zoph $ ls /data/photos/2010/05/05
+  mid
+  thumb
+  IMG_1480.JPG
+````
+You could, of course, also use both a path and dated directories:
+````
+zoph@zoph $ zoph --path "family" --hierarchical IMG_1480.JPG
+zoph@zoph $ zoph --path "family" --dateddirs IMG_1481.JPG
+zoph@zoph $ ls /data/photos
+  family
+zoph@zoph $ ls /data/photos/family
+  2010
+  2010.05.13
+````
+Although mixing `--dateddirs` and `--hierarchical` is probably not a good idea if you want to keep your collection organized and also, at least somewhat accessible directly from the OS (as opposed to from Zoph).
+(By the way, when specifying both `--dateddirs` *and* `--hierarchical`, hierarchical will take precedence).
+
+## Using dirpattern ##
+With the [--dirpattern](CLI.md#--dirpattern) CLI option, you can automatically assign albums, categories, people, photographer, location or path based on the directories the photos are in. You do this by specifying a pattern, based on which Zoph will use directory names to assign to correct organizer. This pattern consists of a list of letters, where each letter is a directory. The letters you can use are: **a** (album), **c** (category), **l** (location), **p** (person), **P** (photographer) and **D** (path).
+
+Let's say you have the following directory structure:
+````
+ |- John Doe
+ |   |- Walk in the park
+ |   |   |- Trees
+ |   |   |   |- IMG_2001.JPG
+ |   |   |   |- IMG_2002.JPG
+ |   |   |   |- Flowers
+ |   |   |        |- IMG_2003.JPG
+ |   |   |- Flowers
+ |   |       |- IMG_2004.JPG
+ |   |- A day in the forest
+ |   |   |- Trees
+ |   |   |   |- IMG_2005.JPG
+ |   |   |   |- IMG_2006.JPG
+ |   |   |   |- Birds
+ |   |   |       |- IMG_2007.JPG
+ |   |   |- Animals
+ |   |       |- IMG_2008.JPG
+ |   |- Summer Holiday
+ |       |- IMG_2009.JPG
+ |       |- IMG_2010.JPG
+ |- Jane Doe
+     |- A day in the forest
+     |   |- Trees
+     |   |   |- DSC_1000.JPG
+     |   |   |- DSC_1001.JPG
+     |   |   |- Birds
+     |   |       |- DSC_1002.JPG
+     |   |- Animals
+     |       |- DSC_1003.JPG
+     |- Summer Holiday
+         |- DSC_1004.JPG
+         |- DSC_1005.JPG
+````
+Now, you can go into the top directory and run a Zoph import with the `--dirpattern` option, to automatically assign a photographer, an album and a few categories to each photo:
+````
+zoph --import -r --dirpattern "Pacc" *
+````
+Zoph will now import the entire directory structure, using the first level directory name to assign the photographer (the **P** in the dirpattern), the second level to assign an album (**a**) and the third and fourth to assign categories (**cc**). 
+In this example, `IMG_2001.JPG` to `IMG_2010.JPG` will be stored with "John Doe" as photographer and the photos `DSC_1000.JPG` to `DSC_1005.JPG` will be stored with "Jane Doe" as photographer. 
+`IMG_2001.JPG` to `IMG_2004.JPG` will have album "A walk in the park".
+
+`IMG_2005.JPG` to `IMG_2008.JPG` as well as `DSC_1000.JPG` to `IMG_1003.JPG` will be in the album "A day in the forest".
+
+`IMG_2009.JPG`, `IMG_2010.JPG`, `DSC_1004.JPG` and `DSC_1005.JPG` will be in the album "Summer Holiday".
+
+`IMG_2001.JPG`, `IMG_2002.JPG`, `IMG_2003.JPG`, `IMG_2005.JPG` to `IMG_2007.JPG` and `DSC_1000.JPG` to `IMG_1002.JPG` will be in the category Trees.
+
+`IMG_2003.JPG` and `IMG_2004.JPG` will be in the category Flowers. Which means that `IMG_2003.JPG` will be assigned to *both* Trees and Flowers. In the same way, `DSC_1002.JPG` will be assigned to both Trees and Birds.
+
+`IMG_2008.JPG` and `DSC_1003.JPG` will be in the category Animals.
+
+Finally, the photos in the "Summer Holiday" album, will not have any categories assigned.
+
+## Controlling the way `zoph` works ##
+The Zoph CLI client has several options that control how it works, an example of `--dateddirs` and `--hierarchical` has been given above. More settings can be found in [The Zoph CLI tool](CLI.md).
+
diff -pruN 0.9.4-4/docs/IMPORT-WEB.md 0.9.8-1/docs/IMPORT-WEB.md
--- 0.9.4-4/docs/IMPORT-WEB.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/IMPORT-WEB.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,84 @@
+# Importing photos - through the web interface #
+A photo album is of little use without photos. This page describes how to import photos into Zoph with the webinterface. You could also use the [Zoph/Using the commandline tools](CLI.md). 
+
+The import process in Zoph consists of 2 steps: **uploading** and **importing**. If you have access to the filesystem of the server, you can skip the uploading and manually move the files you wish to import into the directory specified as `upload dir` on the [configuration screen](CONFIGURATION.md).  You can then use the webinterface to import them into Zoph. If you don't have access to the server's filesystem or do not wish to use it, you can upload photos to the `upload dir` and then use the same process. You can even mix the two: copy some photos directly into the `upload dir` and upload others and then continue as if it is one set of photos (which it actually is now).
+
+## Configuration ##
+![Enabling import and upload in the configuration screen](img/Zoph_enable_import.png)
+There are several configuration options that are related to importing and uploading photos.
+At least, make sure `Import through webinterface` is enabled and if you also want to enable uploading photos through the webinterface, also enable `Upload through webinterface`. Furthermore, make sure that the `upload dir` under `paths` is set correctly. On some systems it might be needed to set the `magic file` in order for Zoph to be able to figure out the filetypes of the imported photos.
+
+## Uploading ##
+To import photos in Zoph, first click on "Import" in the main menu. If you do not have that option, your useraccount is not permitted to perform imports or importing is disabled. Once you have clicked on the option, you will be taken to the Import page.
+
+![Import page](img/ZophImport001.png)
+
+Next, click on "Browse..." to select an image on your local disk. Due to browser/operating system restrictions, you can only select one image at a time. Once you have selected an image, click "Import" to start the upload. Your browser will now upload the photo to the webserver that is running your Zoph installation and place it in the upload directory you have specified in the configuration.
+
+Once you have clicked "Import", Zoph will display a progress bar indicating how the import is progressing. At the same time, it will create a new upload form, enabling you to start another upload. There is no limit on the number of uploads you can do simultaneously (although of course each additional upload will slow down all the other uploads and use resources on both the uploading system and the server running Zoph).
+
+![A few busy uploads](img/ZophImport002.png)
+
+As soon as the first upload is finished, Zoph will create a new 'window' on the page, "Uploaded Photos" and will place the newly uploaded photo in this window. At first, the photo will be displayed using an icon, but at the same time, Zoph will create a thumbnail for this image. Each additional finished upload will be placed in this window and thumbnails will be created. Zoph will show you with an icon which image is being resized and which images are waiting. By default, Zoph will resize one image at a time. You can use the `resize parallel` setting (on the configuration page) to enable multiple parallel resize jobs.
+
+![Image resize in progress](img/ZophImport003.png)
+
+Once the thumbnail is created, the icon will be replaced by a thumbnail image. If you hold your mouse still over the thumbnail, you will see a "midsize" image, which will enable you to take a closer look to the photos.
+
+![Zoph showing thumbnails of uploaded photos](img/ZophImport004.png)
+
+## Setting properties of photos ##
+So, now the photos are on the server, we can import them into Zoph. We could just hit the "Import" button at the bottom of the screen and add the photos to albums, categories, etc. later, but it's probably easier to do it now. Keep in mind that all categories, albums, etc. will have to exist before you can use them on the import screen, it is (currently) not possible to add them from the import screen. Once all uploads have finished, it is no problem to leave the import page and go create albums, categories, etc. The photos are all stored on the server, so when you return to the import page, the photos will still be there.
+
+### Album ###
+The photos I have just uploaded were all taken during a summer vacation in Canada, so I have created an album "Canada" under the "Summer vacation" album. To assign this album to imported photos, I click on the white box next to "albums" to display a dropdown menu that shows all the albums I have created. When I click on "Canada", the white box will display "Canada" and a new, empty field is created in case I want to add more.
+
+![Choosing an album](img/ZophImport005.png) ![Chosen an album](img/ZophImport006.png)
+
+### Photographer and Location ###
+All photos are taken by me, so I'm adding myself as the photographer and all photos were taken in Canada, so I set the location to "Canada" (In a real-world situation, you'd probably want to be a bit more precise about the location where your photos were taken, but for this example this will do). Note that, since a photo can only have one location and one photographer, no empty field is added.
+
+### Categories ###
+Now, I would like to add categories. I use categories to describe what is on the photo. But, in this case, there are many different subjects on these photos, how are we going to do that? Let's start with flowers. First, I select the category "flowers" and then I 'tick' each photo with flowers.
+
+![The photos to be imported have been 'ticked'](img/ZophImport007.png)
+
+## Importing ##
+Finally, click "Import" and Zoph starts importing the photos into the database. Have a moment of patience and the 3 selected photos will disappear from the "Uploaded photos" window because they are no longer in the "upload" directory. At the same time a "details" window is added, showing you any messages the import process generated. If something goes wrong, the error message will also be displayed. In that case, the photos will not disappear from the "Uploaded photos" window, so you can easily try again after you have resolved the problem.
+
+![Photos have been imported](img/ZophImport008.png)
+
+### Using autocomplete ###
+So, let's import a few more photos. As you can see, the album, category, location and photographer we have chosen before, are still there, so we only need to change the category. The next picture shows a plane and no flowers at all, so click the little red "x" next to "Flowers" to remove that category and then click the empty field next to categories. The list of categories is quite long, so why search the list yourself, if you can let the computer do that for you? We are looking for a category named "planes", so we'll type a "p", the list will now be significantly shorter, only showing categories starting with "p". We select "planes" by clicking on it. Finally, we'll tick the photo we want to import and then hit "Import" to start the import.
+
+![Using autocomplete on a dropdown box](img/ZophImport009.png)
+
+### Multiple categories ###
+Now we only have 4 photos left. They all feature 'mountains', so let's remove the category "Planes" and add "Mountains". The first two mostly feature mountains while the others also feature other subjects. We'll just tick the first two, click import, and wait for the photo's to be imported. This time, we don't click the little red "x" next to "Mountains", but instead, add a second category. "Roads", for the first one. Again, we tick the photo we want to import, click "Import" and wait for the photo to be imported. 
+The last photo features "Mountains" and "Snow", so, we want to remove "Roads" and add "Snow". Instead of using the red "x", you can also simply re-open the dropdown box and choose "Snow" (or type "snow" or a part of that). Tick the last photo and wait for the "Uploaded photos" window to disappear, since all photos have now been imported.
+
+
+## Uploading an archive ##
+Uploading photos one-by-one can take a lot of time. Even worse, once you're uploading 5 or 10 photos simultaneously, your browser could get a bit slow. Wouldn't it be great if you could upload a bunch of photos in one go? Well, you can! Just put them in a ZIP or TAR archive and upload that!
+
+For this exercise, I have put a few of my photos from a vacation on Cuba in a zipfile called cuba.zip. First, I am uploading the file to the server. Just click on "Browse...", find the ZIP-file on my local disk and then "Import".
+
+![Uploading a ZIP file](img/ZophImport010.png)
+
+### Correcting problems ###
+Depending on the speed of the connection to the server, the upload may take a long time, since ZIPing a photo usually doesn't make it any smaller. We use the ZIP file only to be able to upload multiple files in one go. Once the upload is finished, it will appear in the "Uploaded photos" window:
+
+![Oops!](img/ZophImport011.png)
+
+Oops! Something went wrong there! I forgot to configure `unzip command` on the configuration screen. After I have changed that (in a separate browser window), I simply click on "retry" to give it another go. I don't need to re-upload the file.
+
+As you can see, Zoph now starts unpacking the archive (notice the 'unpack' icon). The error from the previous action is still there, that could be confusing; fortunately you can remove it by clicking "clear" in the "Details" window.
+
+![Problem solved](img/ZophImport012.png)
+
+After Zoph has finished decompressing the ZIP file, the photos in it will appear in the same way as if they were uploaded one-by-one. If Zoph encounters another archive inside the archive, it will unpack it as well. If you upload a .tar.gz file, this is exactly what happens: Zoph un-gzips it, resulting in a .tar file, which will be unTARred. Of course, you must have defined `ungzip command` and `untar command` in the configuration screen for this to work. Besides ZIP, TAR and GZ, Zoph supports BZIP files as well. Define `unbzip command` to enable that. (But remember, it makes little sense to Gzip or Bzip a bunch of photos, it will not make the file any smaller!)
+
+![Archive unpacked](img/ZophImport013.png)
+
+## Importing local files ##
+In many cases, the server running Zoph, may be the same machine that you are working on, or it may be sitting next to that machine. In those cases, uploading photos via the webinterface may be an unnecessary inconvenience. In that case, you may simple move or copy the files into the upload directory and then visit the webinterface. Zoph will find the photos and start resizing them. After that, you can proceed with importing just like the two previous cases. 
diff -pruN 0.9.4-4/docs/INSTALL.md 0.9.8-1/docs/INSTALL.md
--- 0.9.4-4/docs/INSTALL.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/INSTALL.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,159 @@
+Zoph Installation
+=================
+
+Requirements
+------------
+
+See the [requirements](REQUIREMENTS.md) document.
+
+Creating the database
+---------------------
+
+### Create a database and import the tables ###
+
+```
+$ mysql -u root -p -e "CREATE DATABASE zoph CHARACTER SET utf8 COLLATE utf8_general_ci"
+$ mysql -u root -p zoph < sql/zoph.sql
+```
+
+### Create users for zoph ###
+
+I created two users: ```zoph_rw``` is used by the application and ```zoph_admin``` is used when I work directly in mysql so I don't
+have to use root.
+
+```
+$ mysql -u root -p
+mysql> grant select, insert, update, delete on zoph.* to zoph_rw@localhost identified by 'PASSWORD';
+mysql> grant all on zoph.* to zoph_admin identified by 'PASSWORD';
+```
+
+Create zoph.ini
+---------------
+In Zoph 0.8.2 and later, you need to create a zoph.ini file, usually in 
+/etc. zoph.ini is where you define database settings. A simple example:
+
+```
+[zoph]
+db_host = "localhost"
+db_name = "zoph"
+db_user = "zoph_rw"
+db_pass = "pass"
+db_prefix = "zoph_"
+
+php_location = /var/www/html/zoph
+```
+
+An example zoph.ini file, called zoph.ini.example is included in the cli directory.
+See the man page for zoph.ini(5) or the [documentation](docs/) for more details
+
+Install the templates
+---------------------
+
+### Pick a location to put Zoph ###
+
+Create a zoph/ directory off the doc root of your web server, or create a Virtual Host with a new doc root.
+
+```
+$ mkdir /var/www/html/zoph
+```
+
+### Copy the templates ###
+```
+$ cp -r php/* /var/www/html/zoph/
+```
+### Set accessrights ###
+
+For better security, you probably want to set accessrights on your Zoph files. (You may want to do this after testing whether Zoph works, in that case you know what caused it when it seizes working after this change)
+
+First, you need to figure out which user Apache is running under. Usually this is apache for both user and group. To determine this, check httpd.conf or use
+
+```
+ps -ef | grep httpd
+```
+
+You should probably make all files owned by the user apache and the group apache. You can do than with
+
+```
+chown -R apache:apache /var/www/html/zoph 
+```
+You can either make them only readable by this user/group (more security): *440*, readable by all users: *444*, or readable and writable by all users: *666*. The last case means that you don't need root access to edit config.inc.php or to make changes to the other php files (such as upgrades to a new version). Keep in mind that giving write access to the .php files effectively gives control over Zoph. If you have other users on your system, you should choose the first option. Also, your mysql password is in `/etc/zoph.ini`, so if you've users on your system that are not allowed to know it, you should protect it against reading as well. The directories should have execute rights: *550* for max security or *777* for access for all users.
+
+To do this, first go to the directory directly above your Zoph directory, in this example /var/www/html
+
+```
+cd /var/www/html
+chmod [dir] zoph
+cd zoph
+find -type f | xargs chmod [file]
+find -type d | xargs chmod [dir]
+```
+replace [dir] with the accesspattern you've chosen for directories above and replace [file] with the one for files.
+
+> :exclamation: Warning :exclamation:
+> Double check whether you are using the correct directory and if you have typed it correctly, if you would 
+> accidently type `/[space]var/www/html/zoph` or something, you would change all files on your entire system to 
+> apache/apache as owner - not good).
+
+
+### Access rights for your photos ###
+In many cases you can simply leave the access rights on you photo directories on default.
+However, if you use both the CLI and the webinterface to access your photos, you may want to change to a more advanced way of managing accessrights, using the [setgid](https://en.wikipedia.org/wiki/Setgid#setgid_on_directories]) feature in Linux and most other POSIX Operating Systems.
+
+* Create a new Unix group (in example "photo")
+````
+groupadd photo
+````
+* Add all users that use the CLI and/or are allowed to modify the photos on disk to this group (in this example, the user is called 'jeroen')
+````
+useradd -g photo jeroen
+````
+* Additionally, the apache user is added to this group, on my system, this user is called 'apache', but 'www-data' is also often used.
+````
+useradd -g photo apache
+````
+* Change the ownership of the photo directory to your user and the group photo
+````
+chown jeroen:photo /data/images
+````
+* Set the permissions on this directory as you wish, for example *775* (full rights for user and group, read rights for other) or *770* (full rights for user and group, no access for others).
+````
+chmod 775 /data/images
+````
+* Now set 'setgid' on the dir, this causes new files and directories to be created with the group 'photo'.
+````
+chmod g+s /data/images
+````
+
+
+Configure the PHP templates
+---------------------------
+
+Some configuration options can be set in `php/config.inc.php file`. Usually you will not have to change anything there. Most configuration can be done from the web interface of Zoph. For more information, see the [Configuration documentation](CONFIGURATION.md).
+
+Install the CLI scripts
+-----------------------
+
+### Check the path to PHP ###
+
+The CLI script points to `/usr/bin/php`.  If your PHP installation is in a different place, edit the first line of the script.
+
+### Copy cli/zoph to /bin ###
+Or some other directory in your `PATH`.
+
+### Install the man page ###
+Man pages for zoph and `zoph.ini` are in the `cli`/ directory. Copy these to the `man1` and `man5` directoies in your manpath, `/usr/local/man/man1` and `/usr/local/man/man5` for example.
+
+Test it
+-------
+Try hitting http://localhost/zoph/logon.php.  You should be presented with the logon screen.
+
+You can log in with admin / admin. It is recommended to change this.
+
+If you get a 404 error...
+make sure the zoph/ folder and templates can be seen by the web server.
+
+If you see a bunch of code...
+make sure Apache is configured to handle PHP (see the [requirements file](REQUIREMENTS.md) file)
+
+If you see a MySQL access denied error...
+make sure the `db_user` you specified in `zoph.ini` actually has access to the database.  If your database is not on localhost, you will need to grant permissions to `zoph_rw@hostname` for that host.
diff -pruN 0.9.4-4/docs/README.md 0.9.8-1/docs/README.md
--- 0.9.4-4/docs/README.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/README.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,12 @@
+# Zoph Documentation #
+http://www.zoph.org
+
+1. [Requirements](REQUIREMENTS.md)
+2. [Installation guide](INSTALL.md) 
+3. [Configuration Instructions](CONFIGURATION.md)
+4. [Using the web interface](WEBINTERFACE.md)
+5. [Importing photos through the web interface](IMPORT-WEB.md)
+6. [Importing photos using the CLI interface](IMPORT-CLI.md)
+7. [Using the CLI tool](CLI.md)
+8. [Upgrade Instructions](UPGRADE.md)
+9. [Changelog](CHANGELOG.md)
diff -pruN 0.9.4-4/docs/REQUIREMENTS.md 0.9.8-1/docs/REQUIREMENTS.md
--- 0.9.4-4/docs/REQUIREMENTS.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/REQUIREMENTS.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,61 @@
+# REQUIREMENTS #
+
+Zoph is being developed on Linux, but it should be able to run on any OS that can run Apache, MySQL and PHP. Users have reported succesful installations on MacOSX, several BSD flavours and even Windows. 
+
+Zoph requires the following:
+* Apache 2.2 or 2.4
+* PHP 7.1 or 7.2
+* MariaDB 10.1 MySQL 5.6 or 5.7
+* ImageMagick 6.9
+
+Other versions may work as well, see below for more details. How to install these applications and get them to work together is depending on your OS and distribution. Check the documentation of the application and/or your distribution for details.
+
+## Apache ##
+* Current versions of Zoph are developed on Apache 2.4.x
+
+## PHP ##
+Current versions of Zoph are developed on PHP 7.2
+* PHP 5.5 and older are no longer supported
+* PHP 5.6 and 7.0 should still work, but it is recommended to update to 7.1 or 7.2
+
+### Required features ###
+The following features (extensions) to PHP are required for Zoph. Not all distributions automatically install all of them.
+* session
+* pcre
+* gd2
+* exif
+* xml
+* pear (if you want to use the e-mail features)
+* FileInfo
+
+## php.ini settings ##
+
+Settings you may need to change in php.ini:
+### max_input_time ###
+This is the time Zoph is allowed by PHP to spend waiting for the file to be uploaded. Depending on the size of your files and the speed of your server's connection, 30 seconds (the default) is usually enough to process single images, if you are uploading zip or tar files, you may want to increase this to 60 or 120 seconds.
+
+### max_execution_time ###
+This is the time Zoph is allowed by PHP to run. Depending on the speed of your webserver, Zoph could spend quite a lot of time resizing an image. 30 seconds may not be enough, especially if you have a camera with a lot of megapixels.
+
+### memory_limit ###
+This is the amount of memory PHP allows Zoph to use. Especially if you have large images, the default (8 or 16 Megabyte) may not be enough. If you have sufficient memory in your server, setting it to 128M is perfectly safe.
+* If you are using the web importer you may need to increase the `max_execution_time`, `upload_max_filesize`, `post_max_size` and `max_input_time`  defined in php.ini.
+* If you are using the watermarking feature, you probably need to increase the `memory_limit` setting. Please note that enabling this function uses a rather large amount of memory on the webserver. PHP by default allows a script to use a maximum of 8MB memory. You should probably increase this by changing `memory_limit` in php.ini. A rough estimation of how much memory it will use is 6 times the number of megapixels in your camera. For example, if you have a 5 megapixel camera, change the line in php.ini to `memory_limit=30M`
+* The e-mail photo feature may require increasing the `memory_limit` setting. Since Zoph needs to convert the photo into Base64 encoding for mail, it requires quite a large amount of memory if you try to send full size images and you may need to adjust `memory_limit` in php.ini, you should give it at least about 4 times the size of your largest image.
+
+## MySQL ##
+* Current versions are developed with MariaDB 10.x
+* MySQL or MariaDB 5.6 or 5.7 should also work
+* MySQL 5.0 to 5.5 may still work but are no longer supported.
+
+## ImageMagick ##
+* Current Zoph versions have been tested against ImageMagick 6.9.x
+
+## Browser ##
+In order to be able to use Zoph, you will need a browser.
+* Zoph is being developed and thoroughly tested with a recent Firefox build
+* Zoph should work with all recent browser versions
+    * Please report a bug if it doesn't.
+* Older versions usually work, but layout may not be 100% ok.
+* Some features require Javascript support
+    * Most of Zoph should work when Javascript is turned off in the browser, but this is decreasing, Javascript is required for more and more functions!
diff -pruN 0.9.4-4/docs/UPGRADE.md 0.9.8-1/docs/UPGRADE.md
--- 0.9.4-4/docs/UPGRADE.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/UPGRADE.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,119 @@
+# UPGRADE INSTRUCTIONS #
+## Zoph 0.9.6 or 0.9.7 to 0.9.8 ##
+
+* *If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.6. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.*
+
+### Copy files ###
+Copy the contents of the `php` directory, including all subdirs, into your webroot. 
+```
+    cp -a php/* /var/www/html/zoph
+```
+If you use the CLI client, you should copy it to a path that's in your `$PATH`
+```
+    cp cli/zoph /usr/bin
+```
+
+### Database changes ###
+* There are no database changes in v0.9.7 and v0.9.8
+
+### Deprecated configuration ###
+I will be removing the `ssl.force`, `url.http` and `url.https` configuration option in v0.9.9. As of v0.9.8, Zoph will show a warning. If your setup requires setting these functions, please comment on [issue#100](http://github.com/jeroenrnl/zoph/issues/100)
+
+![screenshot of the deprecated options](img/zoph-ssl-config.png)
+
+## Zoph 0.9.5 to 0.9.6 ##
+* *If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.5. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.*
+
+### Copy files ###
+Copy the contents of the `php` directory, including all subdirs, into your webroot.
+```
+    cp -a php/* /var/www/html/zoph
+```
+If you use the CLI client, you should copy it to a path that's in your `$PATH`
+```
+    cp cli/zoph /usr/bin
+```
+### Database changes ###
+Execute zoph-update-0.9.6.sql:
+```
+     mysql -u zoph_admin -p zoph < sql/zoph_update-0.9.6.sql
+```
+Changes this script makes:
+
+* Give several timestamp fields a default value, because as of MySQL 5.7.4 "0000-00-00 00:00:00" is no longer a valid date in the default configuration (this was reverted in MySQL 5.7.8)
+* Set `person_id` in the `zoph_users` table to have a default of `NULL` instead of `"0"`
+* Drop the `column contact_type` from `zoph_places`, as it was not used as of Zoph 0.3.3 (!)
+
+## Zoph 0.9.4 to 0.9.5 ##
+* *If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.4. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.*
+
+### Copy files ###
+Copy the contents of the `php` directory, including all subdirs, into your webroot.
+```
+    cp -a php/* /var/www/html/zoph
+```
+If you use the CLI client, you should copy it to a path that's in your `$PATH`
+```
+    cp cli/zoph /usr/bin
+```
+### Database changes ###
+There are no database changes for 0.9.5
+
+## Zoph 0.9.3 to 0.9.4 ##
+* *If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.3. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.*
+
+### Copy files ###
+Copy the contents of the `php` directory, including all subdirs, into your webroot.
+```
+    cp -a php/* /var/www/html/zoph
+```
+
+### Database changes ###
+* Execute zoph-update-0.9.4.sql:
+```
+    mysql -u zoph_admin -p zoph < sql/zoph_update-0.9.4.sql
+```
+Changes this script makes:
+
+* Add a field that stores whether or not new subalbums should be automatically granted permission
+* Add new colour schemes
+
+## Zoph 0.9.2 to 0.9.3 ##
+* If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.2. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.
+
+### Copy files ###
+
+Copy the contents of the php directory, including all subdirs, into your webroot.
+```
+    cp -a php/* /var/www/html/zoph
+```
+## Database changes ##
+* Execute zoph-update-0.9.3.sql:
+```
+    mysql -u zoph_admin -p zoph < sql/zoph_update-0.9.3.sql
+```
+Changes this script makes:
+
+* Resize the `password` field to allow store bigger hashes
+* Add fields to the `user` table to allow for new access rights
+* Add `created_by` fields to the albums, categories, places, people and circles tables
+
+## Zoph 0.9.1 to 0.9.2 ##
+* *If you want to upgrade from an older version, first follow the instructions to upgrade to 0.9.1. It is not necessary to install older versions first, you can just install the current version and follow the upgrade instructions below.*
+
+### Copy files ###
+Copy the contents of the `php` directory, including all subdirs, into your webroot. 
+```
+     cp -a php/* /var/www/html/zoph
+```
+### Database changes ###
+* Execute zoph-update-0.9.2.sql:
+```
+    mysql -u zoph_admin -p zoph < sql/zoph_update-0.9.2.sql
+```
+Changes this script makes:
+
+* Add previously missing 'random' sortorder to preferences
+* Resize Last IP address field so IPv6 addresses can be stored
+* Database changes for 'circles' feature
+* Create a VIEW on the database to speed up queries for non-admin users
diff -pruN 0.9.4-4/docs/WEBINTERFACE.md 0.9.8-1/docs/WEBINTERFACE.md
--- 0.9.4-4/docs/WEBINTERFACE.md	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/docs/WEBINTERFACE.md	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,358 @@
+# Using the Web Interface #
+This manual will describe how to start using Zoph. It assumes you have successfully installed all the components and are presented the logon screen when you visit http://localhost/zoph/logon.php (if you are not running your web browser on the same machine as where you are running Zoph, you will need to replace `localhost` with the hostname or IP address of that machine.
+
+## Logging In For The First Time ##
+An admin user was created when you loaded the database. You should be able to login as `admin` using the password `admin`. You will be presented with Zoph's home page where you will be greeted as 'Unknown Person'. There will be a blank square where a random photo would normally appear.
+
+The first thing you should do is change the admin's password:
+
+1. Click on the 'prefs' tab.
+2. Click the 'change password' link.
+3. Enter a new password and click 'submit'.
+
+Next, create a 'person' record for yourself:
+
+4. Click on the 'people' tab.
+5. Click on the [ new ] link on the right.
+6. Fill in your first name, last name and whatever other fields you wish.
+7. Click the 'Insert' button.
+
+Now create a user for yourself:
+
+8. Click on the 'admin' tab.
+9. Click on the 'users' icon.
+10. Click on [ new ].
+11. Pick a username and password.
+12. Select yourself from the person pulldown.
+13. Change your class to Admin.
+14. Click the 'Insert' button.
+
+An admin has permission to do anything so you don't need to grant the specific 'browse' or 'view details' permissions, nor grant permissions to individual albums.
+
+Now that you have created a user:
+
+15. Click on the 'logout' tab.
+16. Login as your user.
+
+## Creating An Album, Category and Place ##
+
+Before importing some images we'll create an album to put them in. Note that you do not need to assign photos to an album if you don't want to.
+
+To create an album:
+
+1. Click on the 'albums' tab.
+2. Click on [ new ].
+3. Pick an album name. I'm using "Zoph Tutorial" for this example.
+4. Add an album description if you wish. This can be left blank.
+5. Click the 'Insert' button.
+
+To create a category, click on the 'category' tab and do exactly as above.
+
+To create a place:
+
+6. Click on the 'places' tab.
+7. Click on [ new ].
+8. Enter a title at minimum.
+9. Click the 'Insert' button.
+
+Albums, categories and places are hierarchical in nature (each entry can have children entries as well). When you view photos in an album, category or place within Zoph you can choose whether you see just the photo in that album, category or place or also automatically see photos placed in their children.
+
+Once you are creating more albums, categories and places, you can change the hierarchy by choosing the 'parent' album, category or place.
+
+## Importing photos ##
+
+Once you have your Zoph installation up and running, the next step is to add photos to it. There are 3 ways to import photos in Zoph:
+
+1. Uploading and importing them using the webinterface
+2. Placing the photos in the upload directory and importing them using the webinterface
+3. Importing the photos using the Command Line Interface (CLI) tool.
+
+The first 2 are described in [Using the webinterface to import photos](IMPORT-WEB.md), the last one is described in [using the CLI](CLI.md).
+
+## Viewing Photos ##
+
+Now that there are some photos in the database, I'll go over some features of the web interface. The UI is fairly self-explanatory so you probably don't really have to read this section.
+
+### Finding Photos ###
+
+There are numerous ways to find the photos you just entered, a few of which are:
+
+- Browse to the album you created and click [ view photos ].
+- Browse to yourself in 'people' and click [ photos by ].
+- Browse to the place you took of photo and click [ photos at ].
+- Use the search form. Specifying no criteria will bring up all photos.
+- Use the 'photos' tab and you can browse every photo in Zoph.
+
+### The Results Page ###
+
+By default the results page will show thumbnails of up to 12 photos (you can change this from the 'prefs' page). On the prefs page you can also choose whether or not to display descriptions under the thumbnails.
+
+From this page you can:
+
+- Click on a thumbnail to go to the photo page.
+- Click on [ Prev ] or [ Next ], or use the pager, to page through the photos (if your results span more than one page).
+- Click on [ Slideshow ] to enter slideshow mode.
+- Reorder the photos by choosing a field from the pulldown.
+- Change the direction of ordering by clicking on one of the triangles. The white triangle/arrow represents the current direction.
+- Change the number of rows and columns displayed.
+
+### The Photo Page ###
+Once you click on a thumbnail, you will go to a page for that photo. Here you'll see a mid sized version of the photo plus all the information about the photo available.
+
+From this page you can:
+
+- Click the name of the file or the image itself to bring up the full sized photo.
+- Click on a person, place, album, category or date to take you to the corresponding pages.
+- Click on [ Prev ] or [ Next ] to move through the photos as they appeared on the results page.
+- Click on [ email ] to email the photo (If this function is enabled in the configuration).
+- Click on [ lightbox ] to add the photo to your [lightbox](#lightboxes) (if you have one)
+- Click on [ edit ] or [ delete ]. (if you are an admin or have been given write permission on the album in which the photo appears).
+
+If you set the auto edit pref, you will automatically be presented with fields to edit the info about a photo whenever you click on a thumbnail. Clicking [ return ] takes you back to the regular view.
+
+### Slideshows ###
+
+The slideshow mode (entered from the results page) will cycle through the current set of photos by refreshing the browser window. The interval between refreshes can be set on the 'prefs' page.
+
+From the slideshow page you can:
+
+- Click on [ pause ] to pause the slideshow.
+- Click on [ stop ] to return to the results page.
+- Click on [ open ] to view the current photo on the normal photo page.
+
+### The Search Page ###
+
+You can search for photos using multiple criteria on the search page. Note that when you select an album or category, all descendant albums or categories will also automatically be chosen.
+
+By default all criteria are joined by "and". You can choose "or" instead from the pulldowns in the left most column. Since you can not specify groupings of the conditions, you might not get the results you want if you try to construct complex queries using different conjunctions.
+
+Note that for non-admin users, the album, category, location, person and photographer menus are pruned so that only those entries that actually appear in a photo that the user has access to are shown.
+
+### Random Photos ###
+
+The thumbnail that appears on the 'home' page is randomly selected. I tend to like this photo to be somewhat good so I created a setting on the 'prefs' page that lets you specify the minimum rating to be used when selecting a random photo.
+
+If you want to create a never ending slideshow of random photos, you can do this by clicking the "randomly chosen photo" link on the home page. If you then click the [ Slideshow ] link on the results page a new random photo will be chosen for each slide.
+
+### Lightboxes ###
+
+Lightboxes give someone a place to gather and share their favorite photos. In Zoph, lightboxes are simply albums. When editing a user, an admin can declare a certain album to be the lightbox for that user. The admin must grant the user permission to view the lightbox album as well.
+
+IMPORTANT: Don't give a user write permission on their lightbox album. The user could then add any photo to their lightbox and be able to edit it.
+
+Whenever the user views a photo they can click a 'lightbox' link which will add that photo to their lightbox. When a user is viewing their lightbox, each photo will have an 'x' below it. Clicking this will remove that photo from the lightbox.
+
+Lightboxes allow users to gather photos of interest to them without requiring the ability to edit a photo to place it in an album.
+
+Lightboxes are only activated for a user by specifying a lightbox album. The setup I used was to create a "Lightbox" album, under which I created separate albums for each user's lightbox (e.g. "Jason's Lightbox").
+
+To share a lightbox an admin must grant permission to that album to other users.
+
+## Managing Users, Groups & Restricting Access ##
+
+One of the features of Zoph is its system of access privileges. You have already created an admin class user for yourself. If you desire, you can create other users as well.
+
+### Users ###
+Creating a new user account
+
+1. Log on with an admin account.
+2. Click "admin" in the menubar.
+3. Click "users".
+4. Click "new" in the right top of the screen.
+5. Specify a user name (e.g. first initial + last name).
+6. If the user is also represented by a "person" in your database (for example because he or she has taken photos or appears on them), specify a person, otherwise leave it on "Unknown Person".
+7. Specify a password
+8. Select a user class: an Admin can do anything. A User has restrictions. In that case, you can specify what a user can see, besides photos:
+- **browse people**: the user can view the 'people' page in which all the people stored in the database can be seen.
+- **browse places**: the user can view the 'places' page in which all the places stored in the database can be seen.
+- **details of people**: if "Yes" the user can see all information about any person. If "No" the user will only see a person's name.
+- **details of places**: if "Yes" the user can see all information about any place. If "No" the user will only see a place's title and city.
+- **import**: if "Yes" the user can import photos
+- **download zipfiles**: if "Yes", this user can download a set of photos (album, category, search result, etc.) as a zip file.
+- **leave comments**: if "Yes", this user can leave comments with photos.
+- **rate photos**: if "Yes", this user can rate photos.
+- **rate photos multiple times**: if "Yes", this user can rate the same photo more than once. Use this if you create an account that is used by multiple people. Each photo can be rated once per IP address.
+        lightbox: the user's lightbox album
+9. Click "insert"
+10. Add the user to one or more groups. See [groups](#groups) how to do that.
+
+### Modifying or deleting a user account ###
+
+1. Log on with an admin account.
+2. Click "admin" in the menubar.
+3. Click "users".
+4. Click the user you want to modify
+5. Click "edit" or "delete"
+6. Make the desired changes or confirm in case of a deletion.
+For a description of the fields, see creating a new user account.
+
+### Groups ###
+In order to give users access to photos, you must create one or more groups, you can then give accessrights to albums to those groups, you could for example create a group 'family', a group 'colleagues' and a group 'friends'. A user can be member of multiple groups and Zoph will combine the accessrights for all the groups.
+
+#### Creating a group ####
+1. Log on with an admin account.
+2. Click "admin" in the menubar.
+3. Click "groups".
+4. Enter a group name
+5. If desired, enter a description.
+6. Click "insert"
+You will automatically be taken to the edit group screen. See [Modifying a group](#modifying-a-group) for an explanation.
+
+#### Modifying a group ####
+1. Log on with an admin account.
+2. Click "admin" in the menubar.
+3. Click "groups".
+4. Click on "display" next to the group name you wish to edit
+5. Click on "edit"
+6. You can change the group name or description and add or remove a member. 
+  - If you modify the group name, all the members will automatically change to the new group.
+  - Select a user from the list to add them to the group
+  - To remove a member from the group, tick the box in front of the username
+7. Click "update".
+  - This screen has two "update" buttons, be sure to click the top one for these modifications.
+8. To add albums to the list this user can view, use the "grant access to all existing albums" or add a specific album. For a more detailed explanation, see [albums](#albums).
+9. Click "update".
+  - This screen has two "update" buttons, be sure to click the bottom one for these modifications.  Be sure to modify either the top or the bottom half of this screen and not both.
+
+### Albums ###
+
+For non-Admin users, permissions to view photos are granted on a per album basis. For each album you grant a group access to, you must specify:
+
+- An access level
+- A Watermark level (if you have enabled watermarking in the configuration)
+- Whether that album will be writable
+
+Zoph determines whether a user has access to a photo checking two things:
+1. Is this photo in an album that at least one of the groups the user is member of has access to?
+2. If so, is the group's access level for that album greater than or equal to the level of the photo?
+
+If both of these conditions are met, the user has access to the photo.
+
+If you have enabled watermarking, a photo with an watermark level lower than the photo's level, will see full-sized photos with a watermark on them.
+
+If a photo appears in more than one album, a user needs only to have permission on one of them to see the photo. If the user has access to multiple albums in which a photo is found, or the user has multiple groups that give access to the photo, the least restrictive (highest access level) permission is used.
+
+The level of a photo (and therefore of access levels) can range from 0 to 10. The default access level is 5.
+
+Zoph is designed so that these privileges should work transparently. It should appear to any user that they have access to all photos. There should be no reference to any album or photo (including photo counts) to which a user does not have access.
+
+If an album is marked writable, members of the group may edit the photos that they have access to in that album.
+
+If you want to grant a group access to all albums you can do this all at once and the access level and writable flag will apply to every album. This is handy if you want to let someone see everything but don't want them to change anything.
+
+Note that when you create new albums you will have to grant permissions to any non-Admins before they will be able to see those album.
+
+## Comments ##
+
+If enabled in the configuration and allowed under the user's profile, a user can leave comments with photos. Limited markup is possible, the following markup is supported: [u]underline[/u], [i]italics[/i] and [b]bold[/b], the various possible smileys are displayed with the comment input form: 
+
+![Comments](img/ZophComment.png)
+
+## Scenarios ##
+### Access levels ###
+
+Say you create an account for your parents but you don't want them to see the photos in the "New Years Party" album. Simply don't grant them permission to that album.
+
+However, say the photos in this album are mostly harmless, except for a few that you would like to keep hidden. In this case, change the level of those photos to 6 (or higher) and grant the user permission to the album with an access level of 5 (or less).
+
+### Watermarks ###
+
+You have taken a couple of brilliant landscape images. You really want to show them to one of your regular customers, but you're affraid they will simply take your image and publish it, without paying for it. You put the photos in an album "Landscape" and set their level to 3.
+
+After that, you give your customers account access level 5 and watermark level 2. They will now be able to watch the photo, but when they look at the fullsize image, a large copyright message will be superimposed over the photo. In this way, they will be able to judge the quality of the image, but it will be unusable to use in their productions.
+
+After they have payed for the photo, you can put the image in an album that does allow them to see the photo without a watermark, change the level of the photo or change the watermark level. (keep in mind that the latter two will also influence other accounts or photos, respectively).
+
+### Defining a Default User ###
+
+A default user can be defined. This user is automatically logged in when a person first opens Zoph in their browser.  You can use this feature to create a guest account with limited permissions.
+
+To define a default user, create a user, grant that user whatever permissions you want and set the user's preferences. Whichever user is defined as the default user is unable to modify their preferences while they are the default.  Then, in the configuration screen, under `Interface settings` select the `Default user`. Admin users can not be default users and you should not grant any write permissions to the guest user.
+
+That's it. Now when you hit /zoph/zoph.php for the first time you'll be logged in as the guest user (if you are already logged in as someone you'll first have to log out). You can also log out when you are the guest user to be able to log back in as someone else.
+
+What if a guest hits logout and wants to get back in but doesn't know the guest account info? Hitting submit on the logon page without specifying a user name or password will log you in as the default user.
+
+## Pages, Pagesets and ZophCode ##
+
+By default, each album, category, place and person has a page that will show the basic data about this object. If you want, you can customize it using the ZophPages feature.
+
+### ZophCode ###
+
+A ZophPage is written in ZophCode. ZophCode is very similar to bbCode or html and consist of tags in square brackets. Currently supported tags are:
+
+ZophCode Tag                | Meaning                   | Example
+----------------------------|---------------------------|------------------------------------------
+[b]...[/b]                  | Bold                      | You can make text [b]bold[/b].
+[i]...[/i]                  | Italics                   | To [i]emphasize[/i] a word...
+[u]...[/u]                  | Underline                 | [u]Underline[/u] a word.
+[h1]...[/h1]                | Level 1 (chapter) header  | [h1]My holiday[/h1]
+[h2]...[/h2]                | Level 2 (paragraph) header| [h2]Second day[/h2]
+[h3]...[/h3]                | Level 3 (sub-prgr) header | [h3]Afternoon[/h3]
+[color=<color>]...[/color]  | Text colour.              | Words in [color=blue]blue[/color] and [color=#ff0000]red[/color].
+[font=<font>]...[/font]     | Text font                 | Mixing [font=times]fonts[/font] can make your [font=courier]page[/font] look professional (or messy).
+[br]                        | Line break                | Best regards,[br]Jeroen
+[background=<color>]...[/background] | Background colour| [background=blue]Blue background[/background]
+[photo=<id>]...[/photo]     | Link to a photo           | [photo=123]See this photo![/photo]
+[album=<id>]...[/album]     | Link to an album          | [album=123]See this album![/album]
+[person=<id>]...[/person]   | Link to a person          | [person=123]See this person![/person]
+[cat=<id>]...[/cat]         | Link to a category        | [cat=123]See this category![/cat]
+[place=<id>]...[/place]     | Link to a place           | [place=123]See this place![/place]
+[link=<url>]...[/link]      | Link to a webpage         | [link=http://www.zoph.org]Zoph[/link]
+[thumb=<id>]                | Thumbnail of a photo      | [thumb=123]
+[mid=<id>]                  | Mid-size image of a photo | [mid=123]
+
+It is possible to nest tags. For example:
+
+````
+[h1]Holiday in [b]Spain[b][/h1]
+[i]more [u]emphasis[/u] by [b]combining[/b] tags[/i].
+Click on this thumbnail to see the photo: [photo=123][thumb=123][/photo]
+
+Just make sure you open and close the tags in the right order:
+
+[b]This [i]will[/b] not[/i] work!
+````
+Finally, you can use smileys. See [Comments](#comments) for an overview.
+
+## Pages ##
+
+You can create a new page by going to the admin page and clicking on `Pages`. Just type your text and tags and save. If you would like to spread the content over multiple pages, just save this one and create another page.
+
+## PageSets ##
+
+The next step is to combine 1 or more pages into a pageset. Keep in mind that if you have only one page, you still have to put it into a pageset. Create a pageset from the admin page and add the pages you would like to have in this pageset to it. You can also define the order in which the pages appear in the pageset by clicking on `move up` and `move down`.
+
+You also have to decide what you would like to do with the original page Zoph automatically creates for each album, category, place and person. You can choose to never display it, to display it on the first page, the last page or on each page. You can also choose whether you would like to see you own page first or the auto-generated. Keep in mind that if you choose to never display the original page, you could restrict navigation for your users. For example, if you have an album with a few sub-albums, your users will be unable to navigate to these subalbums unless you provide links in your page to these subalbums (and add new links every time you have added a new sub-album).
+
+### Assigning a PageSet to an object ###
+
+The final step in enabling the ZophPages feature, is assigning the pageset to and album, category, place or person. You can do that by editing the specific object and choose the pageset.
+
+## Preferences ##
+
+A user can customize Zoph using the preferences page. Below are descriptions of a few of the prefs.
+### Breadcrumbs ###
+Between the tabs and the title bar you'll see a list of breadcrumbs by default. On the prefs page You can specify the number of breadcrumbs to show or you can choose to disable the breadcrumbs completely.
+
+One note about breadcrumbs: clicking on the small x to the right of the breadcrumbs will clear the list of crumbs.
+
+### Choosing a Language ###
+The default value is 'Browser Default'. This means that Zoph will try to present itself in the language you can specify in your browser's settings. If no translations are present for any of the languages in your browser's list, English will be displayed by default.
+
+If you specify a language on the prefs page instead of 'Browser Default' your setting will override whatever you have your browser set to.
+
+The language files are stored in php/lang. Creating a new translation involves creating a file in this directory with the language's two letter code in lowercase. Use one of the existing (non English) language files as an example.
+
+### Other Prefs ###
+
+Here are descriptions of a few of the other prefs:
+
+**days past for recent photos links:** The home page has links to view photos taken or modified in the past X days, this pref sets the number of days.
+
+**display camera info:** If set to No, when viewing the details of a photo, fields like camera make and model, focal length, exposure, etc. are not displayed.
+
+**automatically edit photos:** If set to Yes, when an admin or a user with write permission views the details of a photo, they are immediately taken to the edit screen. This is handy for editing one photo after another.
+
+**color scheme:** the color scheme to use. Admins can click on "color scheme" to add, edit and delete color schemes.
+
diff -pruN 0.9.4-4/FAQ.md 0.9.8-1/FAQ.md
--- 0.9.4-4/FAQ.md	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/FAQ.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,130 +0,0 @@
-# Zoph FAQ #
-### 15 - 07 - 2013 ###
-
-## Documentation ##
-
-Up to date documentation can be found on http://en.wikibooks.org/wiki/zoph
-
-## Troubleshooting ##
-
-### MySQL not installed ###
-
-After logging in I get the following error: 
-
-    Fatal error: Call to undefined function: mysql_pconnect(). 
-
-What's wrong? 
-
-You may need to install the php-mysql module (rpms and debs are
-available).
-
-### session.autostart ###
-
-I can log in but when I try to view any other page I get: 
- 
-    Fatal error:
-    The script tried to execute a method or access a property of an incomplete
-    object. Please ensure that the class definition user of the object you are
-    rying to operate on was loaded _before_ the session was started in
-    /var/www/zoph/auth.inc.php on line 64".
-
-This can happen when a session is automatically started at the
-beginning of a request. You can fix this by disabling
-session.auto_start in your php.ini or by inserting a call to
-session_write_close() before the call to session_start() in
-auth.inc.php.
-
-### GD library missing ###
-
-I'm trying to use the importer from the web but I get this error: 
-
-    Fatal error: Call to undefined function: imagecreatefromjpeg()
-
-To use the importer you need the GD 2 library for image creation
-support in PHP. See the REQUIREMENTS doc for more info.
-
-### Moving photos on disk ###
-I moved my photos around after I loaded them and now I see broken images.
-How can I fix them?
-
-If you move images to a different directory you'll start seeing broken
-images in Zoph unless you also update the 'path' field in the
-database.
-
-If you edit a photo, at the bottom of the page you'll see a 'show
-additional attributes' link. That will let you edit the path for a
-photo.
-
-If you're moving a bunch of photos, you may want to just create a list
-of their names as you are relocating them and then change all the
-paths at once from within MySQL:
-
-    mysql> update photos set path = 'new_path' where name in
-    ('photo1.jpg', 'photo2.jpg');
-
-### Cookies ###
-Can I use Zoph without having to enable cookies?
-
-Zoph will work without cookies but you have to enable
-session.use_trans_sid in your php.ini file so that url rewriting will
-work. Starting with PHP 4.2.0 this parameter is disabled by default.
-
-### Missing translations ###
-
-Why do I see some English phrases when I'm using a translation:
-
-    [vo] that have been categorized
-
-Some language files are missing a few translations. Many, but not all,
-are shown in italics and preceded by [vo]. To fix this simply open the
-correct language file in the lang/ directory and add a transltions of
-the missing string (the English string should already be present in
-the file). Please share your changes, through an issue or fork + pull 
-request.
-
-### Why do I see a bunch of code when I try to access Zoph? ###
-
-First, check to make sure that you have an AddType line for php files
-in your httpd.conf file. This is described in the INSTALL document.
-
-## Customization ##
-
-### Change width of Zoph display ###
-
-Can I get Zoph to take up my whole browser window rather than that little
-rectangle?
-
-Try setting *Screen width* in the configuration screen (*admin* -> *configuration*) to "100%".
-
-### Can I customize the name/title used in the interface? ###
-
-Change *Title* in the configuration screen (*admin* -> *configuration*)
-This is what appears on the logon page, on the home page, and in the title of every
-page.
-
-### Changing text ###
-I don't like your welcome screen, your instructions on the import page,
-or your use of English in general.
-
-You could edit the templates so that Zoph says just what you want. A
-better alternative is perhaps to create your own custom translation.
-Create a file in the lang/ directory that maps English to English and
-tweak whatever phrases you want. For example:
-
- Welcome %s. %s currently contains=Go away %s. %s isn't for you.
-
-## Miscellaneous ##
-
-### How do you pronounce Zoph? ###
-
-I say Zoph with an O like in photos, some say Zoph like software 
-("Zophtware"), but you can pronounce it however you like.
-
-### What license is Zoph released under? ###
-
-Zoph used to be licensed under the modified BSD license. As of version 0.4
-this has been changed to the GPL license. We have done our best to make sure
-all the code in Zoph could be changed to this license. If you feel your
-copyright has been violated with this change, please contact us a.s.a.p.
-Some included files have their own license because the license doen not
-allow us to change it to GPL.
diff -pruN 0.9.4-4/INSTALL.md 0.9.8-1/INSTALL.md
--- 0.9.4-4/INSTALL.md	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/INSTALL.md	1970-01-01 00:00:00.000000000 +0000
@@ -1,138 +0,0 @@
-#Zoph Installation#
-
-##Requirements##
-
-See the REQUIREMENTS.md document.
-
-##Creating the database##
-
-###Create a database and import the tables###
-
-```
-$ mysql -u root -p -e "CREATE DATABASE zoph CHARACTER SET utf8 COLLATE utf8_general_ci"
-$ mysql -u root -p zoph < sql/zoph.sql
-```
-
-###Create users for zoph###
-
-I created two users: zoph_rw is used by the application and zoph_admin is used when I work directly in mysql so I don't
-have to use root.
-
-```
-$ mysql -u root -p
-mysql> grant select, insert, update, delete on zoph.* to zoph_rw@localhost identified by 'PASSWORD';
-mysql> grant all on zoph.* to zoph_admin identified by 'PASSWORD';
-```
-
-##Create zoph.ini##
-In Zoph 0.8.2 and later, you need to create a zoph.ini file, usually in 
-/etc. zoph.ini is where you define database settings. A simple example:
-
-```
-[zoph]
-db_host = "localhost"
-db_name = "zoph"
-db_user = "zoph_rw"
-db_pass = "pass"
-db_prefix = "zoph_"
-
-php_location = /var/www/html/zoph
-```
-
-An example zoph.ini file, called zoph.ini.example is included in the cli directory.
-See the man page for zoph.ini(5) or the Wikibooks documentation http://en.wikibooks.org/wiki/Zoph/Configuration for more details
-
-##Install the templates##
-
-###Pick a location to put Zoph###
-
-Create a zoph/ directory off the doc root of your web server, or create a Virtual Host with a new doc root.
-
-```
-$ mkdir /var/www/html/zoph
-```
-
-###Copy the templates###
-```
-$ cp -r php/* /var/www/html/zoph/
-```
-###Set accessrights###
-
-For better security, you probably want to set accessrights on your Zoph files. (You may want to do this after testing whether Zoph works, in that case you know what caused it when it seizes working after this change)
-
-First, you need to figure out which user Apache is running under. Usually this is apache for both user and group. To determine this, check httpd.conf or use
-
-```
-ps -ef | grep httpd
-```
-
-You should probably make all files owned by the user apache and the group apache. You can do than with
-
-```
-chown -R apache:apache /var/www/html/zoph 
-```
-You can either make them only readable by this user/group (more security): *440*, readable by all users: *444*, or readable and writable by all users: *666*. The last case means that you don't need root access to edit config.inc.php or to make changes to the other php files (such as upgrades to a new version). Keep in mind that giving write access to the .php files effectively gives control over Zoph. If you have other users on your system, you should choose the first option. Also, your mysql password is in `/etc/zoph.ini`, so if you've users on your system that are not allowed to know it, you should protect it against reading as well. The directories should have execute rights: *550* for max security or *777* for access for all users.
-
-To do this, first go to the directory directly above your Zoph directory, in this example /var/www/html
-
-```
-cd /var/www/html
-chmod [dir] zoph
-cd zoph
-find -type f | xargs chmod [file]
-find -type d | xargs chmod [dir]
-```
-replace [dir] with the accesspattern you've chosen for directories above and replace [file] with the one for files.
-
-> #:exclamation: Warning :exclamation:
-> Double check whether you are using the correct directory and if you have typed it correctly, if you would 
-> accidently type `/[space]var/www/html/zoph` or something, you would change all files on your entire system to 
-> apache/apache as owner - not good).
-
-
-###Access rights for your photos###
-In many cases you can simply leave the access rights on you photo directories on default.
-However, if you use both the CLI and the webinterface to access your photos, you may want to change to a more advanced way of managing accessrights, using the [setgid](https://en.wikipedia.org/wiki/Setgid#setgid_on_directories]) feature in Linux and most other POSIX Operating Systems.
-
-* Create a new Unix group (in example "photo")
-    groupadd photo
-* Add all users that use the CLI and/or are allowed to modify the photos on disk to this group (in this example, the user is called 'jeroen')
-    useradd -g photo jeroen
-* Additionally, the apache user is added to this group, on my system, this user is called 'apache', but 'www-data' is also often used.
-    useradd -g photo apache
-* Change the ownership of the photo directory to your user and the group photo
-    chown jeroen:photo /data/images
-* Set the permissions on this directory as you wish, for example *775* (full rights for user and group, read rights for other) or *770* (full rights for user and group, no access for others).
-    chmod 775 /data/images
-* Now set 'setgid' on the dir, this causes new files and directories to be created with the group 'photo'.
-    chmod g+s /data/images
-
-
-##Configure the PHP templates##
-Some configuration options can be set in php/config.inc.php file. Usually you will not have to change anything there. Most configuration can be done from the web interface of Zoph. For more information, see http://en.wikibooks.org/wiki/Zoph/Configuration.
-
-##Install the CLI scripts##
-
-###Check the path to PHP###
-
-The CLI script points to /usr/bin/php.  If your PHP installation is in a different place, edit the first line of the script.
-
-###Copy cli/zoph to /bin###
-Or some other directory in your PATH.
-
-###Install the man page###
-Man pages for zoph and zoph.ini is in the cli/ directory. Copy these to the man1 and man5 directoies in your manpath, /usr/local/man/man1 and /usr/local/man/man5 for example.
-
-##Test it##
-Try hitting http://localhost/zoph/logon.php.  You should be presented with the logon screen.
-
-You can log in with admin / admin. It is recommended to change this.
-
-If you get a 404 error...
-make sure the zoph/ folder and templates can be seen by the web server.
-
-If you see a bunch of code...
-make sure Apache is configured to handle PHP (see the REQUIREMENTS file)
-
-If you see a MySQL access denied error...
-make sure the db_user you specified in zoph.ini actually has access to the database.  If your database is not on localhost, you will need to grant permissions to zoph_rw@hostname for that host.
diff -pruN 0.9.4-4/php/admin.php 0.9.8-1/php/admin.php
--- 0.9.4-4/php/admin.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/admin.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @author Jeroen Roos
  */
 
+use template\template;
+
 require_once "include.inc.php";
 
 if (!$user->isAdmin()) {
diff -pruN 0.9.4-4/php/album.php 0.9.8-1/php/album.php
--- 0.9.4-4/php/album.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/album.php	2018-03-02 20:49:58.000000000 +0000
@@ -20,6 +20,9 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+
+use template\block;
+
 require_once "include.inc.php";
 
 if (!$user->canEditOrganizers()) {
@@ -78,10 +81,12 @@ if ($action == "confirm") {
       <h1>
         <ul class="actionlink">
           <li><a href="albums.php"><?php echo translate("return") ?></a></li>
-          <li><a href="album.php?_action=delete&amp;album_id=<?php
-            echo $album->get("album_id") ?>">
-            <?php echo translate("delete") ?>
-          </a></li>
+            <?php if ($action != "insert"): ?>
+              <li><a href="album.php?_action=delete&amp;album_id=<?php
+                echo $album->get("album_id") ?>">
+                <?php echo translate("delete") ?>
+              </a></li>
+            <?php endif ?>
         </ul>
         <?php echo translate("album") ?>
       </h1>
@@ -93,6 +98,19 @@ if ($action == "confirm") {
           <input type="submit" value="<?php echo translate($action, 0) ?>">
 
         </form>
+        <?php
+            if ($user->isAdmin()) {
+                if ($_action == "new") {
+                    echo new block("message", array(
+                        "class" => "info",
+                        "text" => translate("After this album has been created, groups can be given access to it."
+                    )));
+                } else {
+                    $view=new permissions\view\edit($album);
+                    echo $view->view();
+                }
+            }
+        ?>
       </div>
     <?php
 }
diff -pruN 0.9.4-4/php/albums.php 0.9.8-1/php/albums.php
--- 0.9.4-4/php/albums.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/albums.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+use template\block;
+use template\template;
 
 require_once "include.inc.php";
 
@@ -40,18 +42,30 @@ if (!$parent_album_id) {
     $album = new album($parent_album_id);
 }
 
+try {
+    $selection=new selection($_SESSION, array(
+        "coverphoto"    => "album.php?_action=update&amp;album_id=" . $album->getId() . "&amp;coverphoto=",
+        "return"        => "_return=albums.php&amp;_qs=parent_album_id=" . $album->getId()
+    ));
+} catch (PhotoNoSelectionException $e) {
+    $selection=null;
+}
+
 $pagenum = getvar("_pageset_page");
 
 $album->lookup();
 $obj=&$album;
-$ancestors = $album->get_ancestors();
-$order = $user->prefs->get("child_sortorder");
-$children = $album->getChildren($order);
-$totalPhotoCount = $album->getTotalPhotoCount();
-$photoCount = $album->getPhotoCount();
+$ancestors = $album->getAncestors();
 
 $title = $album->get("parent_album_id") ? $album->get("album") : translate("Albums");
 
+$ancLinks=array();
+if ($ancestors) {
+    while ($parent = array_pop($ancestors)) {
+        $ancLinks[$parent->getName()] = $parent->getURL();
+    }
+}
+
 require_once "header.inc.php";
 
 try {
@@ -63,142 +77,60 @@ try {
     $page=null;
 }
 
-?>
-<h1>
-<?php
+$tpl=new template("organizer", array(
+    "page"          => $page,
+    "pageTop"       => $album->showPageOnTop(),
+    "pageBottom"    => $album->showPageOnBottom(),
+    "showMain"      => $showOrig,
+    "title"         => $title,
+    "ancLinks"      => $ancLinks,
+    "selection"     => $selection,
+    "coverphoto"    => $album->displayCoverPhoto(),
+    "description"   => $album->get("description"),
+    "view"          => $_view,
+    "view_name"     => "Album view",
+    "view_hidden"   => null,
+    "autothumb"     => $_autothumb
+));
+
+$actionlinks=array();
+
 if ($user->canEditOrganizers()) {
-    ?>
-      <ul class="actionlink">
-        <li>
-            <a href="album.php?_action=new&amp;parent_album_id=<?php
-                echo $album->get("album_id") ?>"><?php echo translate("new") ?>
-            </a>
-        </li>
-        <li>
-            <a href="album.php?_action=edit&amp;album_id=<?php
-                echo $album->get("album_id") ?>">
-                <?php echo translate("edit") ?>
-          </a>
-        </li>
-        <?php if ($album->get("coverphoto")): ?>
-        <li>
-            <a href="album.php?_action=update&amp;album_id=<?php
-                echo $album->get("album_id") ?>&amp;coverphoto=NULL">
-                <?php echo translate("unset coverphoto") ?>
-            </a>
-        </li>
-        <?php endif; ?>
-      </ul>
-    <?php
-}
-?>
-    <?php echo $title . "\n" ?>
-</h1>
-<?php
-if ($user->isAdmin()) {
-    include "selection.inc.php";
-}
-if ($album->showPageOnTop()) {
-    echo $page;
-}
-if ($showOrig) {
-    ?>
-    <div class="main">
-      <form class="viewsettings" method="get" action="albums.php">
-        <?php echo create_form($request_vars, array ("_view", "_autothumb", "_button")) ?>
-        <?php echo translate("Album view", 0) . "\n" ?>
-        <?php echo template::createViewPulldown("_view", $_view, true) ?>
-        <?php echo translate("Automatic thumbnail", 0) . "\n" ?>
-        <?php echo template::createAutothumbPulldown("_autothumb", $_autothumb, true) ?>
-      </form>
-      <br>
-      <h2>
-    <?php
-    if ($ancestors) {
-        while ($parent = array_pop($ancestors)) {
-            echo $parent->getLink() . " &gt; ";
-        }
-    }
-    echo $title . "\n";
-    ?>
-    </h2>
-    <?php
-    echo $album->displayCoverPhoto();
-    ?>
-    </p>
-    <?php
-    if ($album->get("album_description")) {
-        ?>
-        <div class="description">
-            <?php echo $album->get("album_description") ?>
-        </div>
-        <?php
-    }
-    $fragment = translate("in this album");
-    $sortorder = $album->get("sortorder");
-    $sort="";
-    if ($sortorder) {
-        $sort = "&amp;_order=" . $sortorder;
-    }
-    if ($totalPhotoCount > 0) {
-        if ($totalPhotoCount > $photoCount && $children) {
-            ?>
-            <ul class="actionlink">
-                <li><a href="photos.php?album_id=<?php
-                    echo $album->getBranchIds() . $sort ?>">
-                  <?php echo translate("view photos") ?>
-                </a></li>
-            </ul>
-            <?php
-            $fragment .= " " . translate("or its children");
-            if ($totalPhotoCount>1) {
-                echo sprintf(translate("There are %s photos"), $totalPhotoCount);
-                echo " $fragment.<br>\n";
-            } else {
-                echo sprintf(translate("There is %s photo"), $totalPhotoCount);
-                echo " $fragment.<br>\n";
-            }
-            $fragment = translate("in this album");
-            if (!$album->get("parent_album_id")) { // root album
-                $fragment = translate("available");
-            }
-        }
-    }
-    if ($photoCount > 0) {
-        ?>
-          <ul class="actionlink">
-            <li><a href="photos.php?album_id=<?php
-                echo $album->getId() . $sort ?>">
-              <?php echo translate("view photos")?>
-            </a></li>
-          </ul>
-        <?php
-        if ($photoCount > 1) {
-            echo sprintf(translate("There are %s photos"), $photoCount);
-            echo " $fragment.\n";
-        } else {
-            echo sprintf(translate("There is %s photo"), $photoCount);
-            echo " $fragment.\n";
-        }
-    }
-    if ($children) {
-        $tpl=new block("view_" . $_view, array(
-            "id" => $_view . "view",
-            "items" => $children,
-            "autothumb" => $_autothumb,
-            "topnode" => true,
-            "links" => array(
-                translate("view photos") => "photos.php?album_id="
-            )
-        ));
-        echo $tpl;
+    $actionlinks=array(
+        translate("new") => "album.php?_action=new&amp;parent_album_id=" . (int) $album->getId(),
+        translate("edit") => "album.php?_action=edit&amp;parent_album_id=" . (int) $album->getId(),
+    );
+    if ($album->get("coverphoto")) {
+        $actionlinks["unset coverphoto"]="album.php?_action=update&amp;album_id=" . (int) $album->getId() .
+            "&amp;coverphoto=NULL";
     }
-    ?>
-    </div>
-    <?php
-} // if show_orig
-if ($album->showPageOnBottom()) {
-    echo $page;
 }
+
+$tpl->addActionlinks($actionlinks);
+
+$sortorder = $album->get("sortorder");
+$sort = $sortorder ? $sortorder : "";
+
+$tpl->addBlock(new block("photoCount", array(
+    "tpc"       => $album->getTotalPhotoCount(),
+    "totalUrl"  => "photos.php?album_id=" . $album->getBranchIds() . $sort,
+    "pc"        => $album->getPhotoCount(),
+    "url"       => "photos.php?album_id=" . $album->getId() . $sort
+)));
+
+$order = $user->prefs->get("child_sortorder");
+$children = $album->getChildren($order);
+if ($children) {
+    $tpl->addBlock(new block("view_" . $_view, array(
+        "id" => $_view . "view",
+        "items" => $children,
+        "autothumb" => $_autothumb,
+        "topnode" => true,
+        "links" => array(
+            translate("view photos") => "photos.php?album_id="
+        )
+    )));
+}
+echo $tpl;
 require_once "footer.inc.php";
 ?>
diff -pruN 0.9.4-4/php/auth.inc.php 0.9.8-1/php/auth.inc.php
--- 0.9.4-4/php/auth.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/auth.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -27,6 +27,9 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+
+use conf\conf;
+
 $_action="display";
 $error="";
 if (!defined("CLI")) {
diff -pruN 0.9.4-4/php/autoload.inc.php 0.9.8-1/php/autoload.inc.php
--- 0.9.4-4/php/autoload.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/autoload.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -23,7 +23,8 @@
  */
 
 /**
- * Autoload classes
+ * Autoload file
+ * @param string filename to load
  */
 function zophAutoload($file) {
     if (is_readable(settings::$php_loc . "/" . $file)) {
@@ -34,16 +35,28 @@ function zophAutoload($file) {
 
 }
 
+/**
+ * Autoload a class
+ * @param string name of class to load
+ */
 function zophAutoloadClass($class) {
     $file="classes/" . str_replace("\\", "/", $class) . ".inc.php";
     return zophAutoload($file);
 }
 
+/**
+ * Autoload an interface
+ * @param string name of interface to load
+ */
 function zophAutoloadInterface($interface) {
     $file="interfaces/" . $interface . ".inc.php";
     return zophAutoload($file);
 }
 
+/**
+ * Autoload a trait
+ * @param string name of trait to load
+ */
 function zophAutoloadTrait($trait) {
     $file="traits/" . $trait . ".inc.php";
     return zophAutoload($file);
diff -pruN 0.9.4-4/php/calendar.php 0.9.8-1/php/calendar.php
--- 0.9.4-4/php/calendar.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/calendar.php	2018-03-02 20:49:58.000000000 +0000
@@ -1,5 +1,7 @@
 <?php
-/*
+/**
+ * Display a calendar with links to all the days
+ *
  * This file is part of Zoph.
  *
  * Zoph is free software; you can redistribute it and/or modify
@@ -14,7 +16,14 @@
  * You should have received a copy of the GNU General Public License
  * along with Zoph; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jason Geiger
+ * @author Jeroen Roos
  */
+
+use template\template;
+
 require_once "include.inc.php";
 
 $date = getvar("date");
diff -pruN 0.9.4-4/php/categories.php 0.9.8-1/php/categories.php
--- 0.9.4-4/php/categories.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/categories.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,9 @@
  * @author Jeroen Roos
  *
  */
+use template\block;
+use template\template;
+
 require_once "include.inc.php";
 
 $_view=getvar("_view");
@@ -41,7 +44,7 @@ if (!$parent_category_id) {
 }
 $category->lookup();
 $obj=&$category;
-$ancestors = $category->get_ancestors();
+$ancestors = $category->getAncestors();
 $order = $user->prefs->get("child_sortorder");
 $children = $category->getChildren($order);
 
diff -pruN 0.9.4-4/php/category.php 0.9.8-1/php/category.php
--- 0.9.4-4/php/category.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/category.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,9 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+
+use template\template;
+
 require_once "include.inc.php";
 
 if (!$user->canEditOrganizers()) {
diff -pruN 0.9.4-4/php/circle.php 0.9.8-1/php/circle.php
--- 0.9.4-4/php/circle.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/circle.php	2018-03-02 20:49:58.000000000 +0000
@@ -20,6 +20,10 @@
  * @package Zoph
  * @author Jeroen Roos
  */
+use template\block;
+use template\form;
+use template\template;
+
 require_once "include.inc.php";
 
 if (!$user->canEditOrganizers()) {
diff -pruN 0.9.4-4/php/classes/admin.inc.php 0.9.8-1/php/classes/admin.inc.php
--- 0.9.4-4/php/classes/admin.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/admin.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @author Jeroen Roos
  */
 
+use template\template;
+
 /**
  * This is a class to generate the admin page
  *
diff -pruN 0.9.4-4/php/classes/album.inc.php 0.9.8-1/php/classes/album.inc.php
--- 0.9.4-4/php/classes/album.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/album.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -29,6 +29,8 @@ use db\delete;
 use db\db;
 use db\clause;
 use db\selectHelper;
+use conf\conf;
+use template\template;
 
 /**
  * Photo album
@@ -165,6 +167,7 @@ class album extends zophTreeTable implem
         $users = user::getRecords("user_id", array("lightbox_id" => $this->get("album_id")));
         if ($users) {
             foreach ($users as $user) {
+                $user->lookup();
                 $user->setFields(array("lightbox_id" => null));
                 $user->update();
             }
@@ -381,6 +384,28 @@ class album extends zophTreeTable implem
     }
 
     /**
+     * Create an array describing permissions for all groups
+     * for display or edit
+     * @param bool Return array of groups instead of array of permissions
+     * @return array permissions
+     */
+    public function getPermissionArray($getGroup=false) {
+        $groups = group::getAll();
+        $perms=array();
+        foreach ($groups as $group) {
+            $permissions = $group->getGroupPermissions($this);
+            if ($permissions) {
+                if ($getGroup) {
+                    $perms[]=$group;
+                } else {
+                    $perms[]=$permissions;
+                }
+            }
+        }
+        return $perms;
+    }
+
+    /**
      * Get a link to this album
      * @return link to this album
      * @todo returns HTML, should be phased out in favour of getURL()
@@ -443,6 +468,7 @@ class album extends zophTreeTable implem
     /**
      * Lookup album by name
      * @param string name
+     * @param bool do a "LIKE" comparison instead of "equals"
      * @todo This function is almost equal to category::getByName(), should be merged
      */
     public static function getByName($name, $like=false) {
@@ -493,12 +519,14 @@ class album extends zophTreeTable implem
     }
     /**
      * Return all albums
+     * @return array array of albums
      */
     public static function getAll() {
         $user=user::getCurrent();
 
         $qry=new select(array("a" => "albums"));
         $qry->addFields(array("album_id"), true);
+        $qry->addFields(array("album"));
         $qry->addOrder("album");
 
         if (!$user->canSeeAllPhotos()) {
@@ -508,13 +536,13 @@ class album extends zophTreeTable implem
             $qry->addParam(new param(":userid", $user->getId(), PDO::PARAM_INT));
         }
 
-
         return static::getRecordsFromQuery($qry);
     }
     /**
      * Get albums newer than a certain date
      * @param user get albums for this user
      * @param string date
+     * @return array array of albums
      */
     public static function getNewer(user $user, $date) {
         $qry=new select(array("a" => "albums"));
@@ -534,6 +562,7 @@ class album extends zophTreeTable implem
 
     /**
      * Get number of albums for the currently logged on user
+     * @return int count
      */
     public static function getCount() {
         $user=user::getCurrent();
@@ -550,19 +579,6 @@ class album extends zophTreeTable implem
         return $qry->getCount();
     }
 
-    /**
-     * Get an array of id => name to build a non-hierarchical array
-     * this function always returns ALL albums and does NOT check user permissions
-     * @retrun array albums
-     */
-    public static function getSelectArray() {
-        $albums=static::getRecords();
-        $selectArray=array(null => "");
-        foreach ($albums as $album) {
-            $selectArray[(string) $album->getId()] = $album->getName();
-        }
-        return $selectArray;
-    }
 }
 
 ?>
diff -pruN 0.9.4-4/php/classes/annotatedPhoto.inc.php 0.9.8-1/php/classes/annotatedPhoto.inc.php
--- 0.9.4-4/php/classes/annotatedPhoto.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/annotatedPhoto.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -24,6 +24,8 @@
  * @author Richard P. Childs
  */
 
+use conf\conf;
+
 /**
  * A class representing an annotated photo
  * An annotated photo is a photo with information about the
@@ -178,8 +180,7 @@ class annotatedPhoto extends photo {
      * @param array vars to import into $this->vars;
      */
     public function setVars(array $vars) {
-        reset($vars);
-        while (list($key, $val) = each($vars)) {
+        foreach ($vars as $key => $val) {
 
             // ignore empty keys or values
             if (empty($key) || $val == "") { continue; }
diff -pruN 0.9.4-4/php/classes/block.inc.php 0.9.8-1/php/classes/block.inc.php
--- 0.9.4-4/php/classes/block.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/block.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,51 +0,0 @@
-<?php
-/**
- * Class that takes care of displaying blocks
- *  A block is a template for a part of the screen,
- *  while a template is a full page.
- * @todo this separation is still ongoing and won't be finished
- *       until all HTML has moved into templates (and blocks).
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * This class takes care of displaying blocks
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-class block extends template {
-    /**
-     * Create block object
-     *
-     * @param string Name of template (without path or extension)
-     * @param array Array of variables that can be used in the template
-     * @return template
-     */
-    public function __construct($template, $vars=null) {
-        $this->vars=$vars;
-        if (!preg_match("/^[A-Za-z0-9_]+$/", $template)) {
-            log::msg("Illegal characters in template", log::FATAL, log::GENERAL);
-        } else {
-            $this->template="templates/default/blocks/" . $template . ".tpl.php";
-        }
-    }
-
-}
diff -pruN 0.9.4-4/php/classes/breadcrumb.inc.php 0.9.8-1/php/classes/breadcrumb.inc.php
--- 0.9.4-4/php/classes/breadcrumb.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/breadcrumb.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,8 @@
  * @author Jeroen Roos
  */
 
+use template\block;
+
 class breadcrumb {
 
     /** @var string title of the crumb */
diff -pruN 0.9.4-4/php/classes/calendar.inc.php 0.9.8-1/php/classes/calendar.inc.php
--- 0.9.4-4/php/classes/calendar.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/calendar.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -40,6 +40,8 @@
  * @package Zoph
  */
 
+use template\block;
+
 class Calendar {
     /**
      * @var int The start day of the week. This is the day that appears in the first column
diff -pruN 0.9.4-4/php/classes/category.inc.php 0.9.8-1/php/classes/category.inc.php
--- 0.9.4-4/php/classes/category.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/category.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -30,6 +30,10 @@ use db\db;
 use db\clause;
 use db\selectHelper;
 
+use conf\conf;
+
+use template\template;
+
 /**
  * A category class corresponding to the category table.
  *
diff -pruN 0.9.4-4/php/classes/circle.inc.php 0.9.8-1/php/classes/circle.inc.php
--- 0.9.4-4/php/classes/circle.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/circle.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -29,6 +29,8 @@ use db\delete;
 use db\param;
 use db\clause;
 
+use template\template;
+
 /**
  * A class representing a group of people
  *
@@ -144,6 +146,9 @@ class circle extends zophTable {
         $people->addParam(new param(":circleid", (int) $this->getId(), PDO::PARAM_INT));
 
         $peopleIds=$people->toArray();
+        if (empty($peopleIds)) {
+            return;
+        }
         $param=new param(":personIds", (array) $peopleIds, PDO::PARAM_INT);
 
         $qry=new select(array("p" => "photos"));
@@ -268,38 +273,6 @@ class circle extends zophTable {
     }
 
     /**
-     * getPhotocount for members
-     * @return int count
-     */
-    public function getPhotocount() {
-        $user=user::getCurrent();
-
-        $allPhotos=array();
-        foreach ($this->getMembers() as $member) {
-
-            $photos=array();
-            $vars=array(
-                "person_id" => $member->getId()
-            );
-            get_photos($vars, 0, 99999999999, $photos, $user);
-            foreach ($photos as $photo) {
-                $allPhotos[$photo->getId()]=true;
-            }
-        }
-        return sizeOf($allPhotos);
-    }
-
-    /**
-     * getTotalPhotocount for members
-     * There is no such thing as subpersons, so photoCount() and totalPhotoCount() are always
-     * equal.
-     * @return int count
-     */
-    public function getTotalPhotocount() {
-        return $this->getPhotocount();
-    }
-
-    /**
      * Add a member to a circle
      * @param person Person to add
      */
diff -pruN 0.9.4-4/php/classes/cli/arguments.inc.php 0.9.8-1/php/classes/cli/arguments.inc.php
--- 0.9.4-4/php/classes/cli/arguments.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/cli/arguments.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,517 @@
+<?php
+/**
+ * This file reads and interpretes the CLI arguments
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace cli;
+
+use log;
+use album;
+use category;
+use person;
+use place;
+use conf\conf;
+
+/**
+ * This class reads and interpretes CLI arguments
+ */
+class arguments {
+    /** Contains the non-interpreted arguments */
+    private $arguments = array();
+    /** Contains the interpreted arguments, before lookup */
+    private $processed = array();
+    /** Contains the interpreted arguments, after lookup */
+    private $vars = array();
+
+    /** Default command */
+    public static $command="import";
+
+    /**
+     * Create a new instance of the class.
+     * This construct also takes care of interpreting and looking up of the
+     * values
+     * @param array CLI arguments
+     */
+    public function __construct(array $argv) {
+        // We don't care about the name of the script
+        array_shift($argv);
+        $this->arguments=$argv;
+        if (count($this->arguments)===0) {
+            $this->arguments[]="--help";
+        }
+        $this->process();
+        $this->lookup();
+    }
+
+    /**
+     * Process the arguments
+     * @todo This function contains a list of all arguments Zoph can understand
+     * this really doesn't belong here and should be moved into a controller
+     * part of the app.
+     */
+    private function process() {
+        $argv=$this->arguments;
+        $args=&$this->processed;
+
+        $args["albums"]=array();
+        $args["categories"]=array();
+        $args["files"]=array();
+        $args["people"]=array();
+        $args["photographer"]=array();
+        $args["location"]=array();
+        $args["instance"]="";
+        $args["fields"]=array();
+        $args["path"]="";
+        $args["dirpattern"]="";
+
+        /* For new albums, categories, places, people */
+
+        $parent=0;
+        $args["palbum"]=array();
+        $args["pcat"]=array();
+        $args["pplace"]=array();
+
+        /*
+          Used short arguments: A C D H I N P V a c d f h i l n p r t u v w
+        */
+        $size=sizeof($argv);
+        for ($i=0; $i<$size; $i++) {
+            switch($argv[$i]) {
+            case "":
+                break;
+            case "--instance":
+            case "-i":
+                $args["instance"]=$argv[++$i];
+                break;
+
+
+            case "--albums":
+            case "--album":
+            case "-a":
+                $albums=explode(",",$argv[++$i]);
+                foreach ($albums as $album) {
+                    $args["albums"][]=trim($album);
+                    if (isset($parent)) {
+                        $args["palbum"][]=trim($parent);
+                    }
+                }
+                $parent=0;
+                break;
+
+            case "--category":
+            case "--categories":
+            case "-c":
+                $cats=explode(",",$argv[++$i]);
+                foreach ($cats as $cat) {
+                    $args["categories"][]=trim($cat);
+                    if (isset($parent)) {
+                        $args["pcat"][]=trim($parent);
+                    }
+                }
+                $parent=0;
+                break;
+
+            case "--config":
+            case "-C":
+                static::$command="config";
+                $args["_configitem"]=$argv[++$i];
+                if (isset($argv[$i+1])) {
+                    $args["_configvalue"]=$argv[++$i];
+                } else {
+                    $args["_configdefault"]=true;
+                }
+                break;
+            case "--dumpconfig":
+                static::$command="dumpconfig";
+                break;
+            case "--fields":
+            case "--field":
+            case "-f":
+                $args["fields"][]=$argv[++$i];
+                break;
+            case "--import":
+                static::$command="import";
+                break;
+            case "--place":
+            case "--location":
+            case "-l":
+                // Multiple locations are possible when using --new
+                $locs=explode(",",$argv[++$i]);
+                foreach ($locs as $loc) {
+                    $args["location"][]=trim($loc);
+                    if (isset($parent)) {
+                        $args["pplace"][]=trim($parent);
+                    }
+                }
+                $parent=0;
+                break;
+            case "--people":
+            case "--persons":
+            case "--person":
+            case "-p":
+                $people=explode(",",$argv[++$i]);
+                foreach ($people as $person) {
+                    $args["people"][]=trim($person);
+                }
+                break;
+            case "--photographer":
+            case "-P":
+                $args["photographer"]=$argv[++$i];
+                break;
+
+            case "--parent":
+                $parent=$argv[++$i];
+                break;
+
+            case "--thumbs":
+            case "-t":
+                conf::set("import.cli.thumbs", true);
+                break;
+            case "--nothumbs":
+            case "--no-thumbs":
+            case "-n":
+                conf::set("import.cli.thumbs", false);
+                break;
+            case "--exif":
+            case "--EXIF":
+                conf::set("import.cli.exif", true);
+                break;
+            case "--no-exif":
+            case "--noEXIF":
+            case "--noexif":
+            case "--no-EXIF":
+                conf::set("import.cli.exif", false);
+                break;
+            case "--size":
+                conf::set("import.cli.size", true);
+                break;
+            case "--nosize":
+            case "--no-size":
+                conf::set("import.cli.size", false);
+                break;
+            case "--hash":
+                conf::set("import.cli.hash", true);
+                break;
+            case "--no-hash":
+                conf::set("import.cli.hash", false);
+                break;
+
+
+            case "--update":
+            case "-u":
+                static::$command="update";
+                break;
+            case "--import":
+            case "-I":
+                static::$command="import";
+                break;
+            case "--new":
+            case "-N":
+                static::$command="new";
+                break;
+
+            case "--useIds":
+            case "--useids":
+            case "--use-ids":
+            case "--useid":
+            case "--use-id":
+                conf::set("import.cli.useids", true);
+                break;
+
+            case "--copy":
+                conf::set("import.cli.copy", true);
+                break;
+            case "--move":
+                conf::set("import.cli.copy", false);
+                break;
+
+            case "-A":
+            case "--autoadd":
+            case "--auto-add":
+                conf::set("import.cli.add.auto", true);
+                break;
+
+            case "-w":
+            case "--add-always":
+            case "--addalways":
+                conf::set("import.cli.add.always", true);
+                break;
+
+            case "-r":
+            case "--recursive":
+                conf::set("import.cli.recursive", true);
+                break;
+
+
+            case "--dateddirs":
+            case "--datedDirs":
+            case "--dated":
+            case "-d":
+                conf::set("import.dated", true);
+                break;
+            case "--hierarchical":
+            case "--hier":
+            case "-H":
+                conf::set("import.dated", true);
+                conf::set("import.dated.hier", true);
+                break;
+            case "--no-dateddirs":
+            case "--no-datedDirs":
+            case "--no-dated":
+            case "--nodateddirs":
+            case "--nodatedDirs":
+            case "--nodated":
+                conf::set("import.dated", false);
+                break;
+            case "--no-hierarchical":
+            case "--no-hier":
+            case "--nohierarchical":
+            case "--nohier":
+                conf::set("import.dated.hier", false);
+                break;
+            case "-D":
+            case "--path":
+                $args["path"]=$argv[++$i];
+                break;
+
+            case "--dirpattern":
+                $args["dirpattern"]=$argv[++$i];
+                break;
+
+            case "-V":
+            case "--version":
+                static::$command="version";
+                break;
+            case "-h":
+            case "--help":
+                static::$command="help";
+                break;
+            case "-v":
+            case "--verbose":
+                $verbose=conf::get("import.cli.verbose");
+                conf::set("import.cli.verbose", ++$verbose);
+                break;
+            default:
+                if (substr($argv[$i],0,1)=="-") {
+                    echo "unknown argument: " . $argv[$i] . "\n";
+                    exit(1);
+                } else {
+                    $args["files"][]=$argv[$i];
+                }
+                break;
+            }
+        }
+        if (isset($args["fields"])) {
+            $newfields=array();
+            foreach ($args["fields"] as $f) {
+                $field=explode("=", $f);
+                $newfields[$field[0]]=$field[1];
+            }
+            $args["fields"]=$newfields;
+        }
+
+        if (conf::get("import.cli.useids")==true && static::$command=="import") {
+            static::$command="update";
+        }
+    }
+    /**
+     * Looks up the given parameters in the database and gives back ids
+     */
+    private function lookup() {
+        $args=$this->processed;
+        $vars=&$this->vars;
+        foreach ($args as $type=>$arg) {
+            if (empty($arg) || empty($type)) {
+                continue;
+            }
+
+            log::msg($type . "\t->\t" . implode(",", (array) $arg), log::DEBUG, log::IMPORT);
+            switch($type) {
+            case "albums":
+                foreach ($arg as $name) {
+                    if (static::$command=="new" ||
+                      (conf::get("import.cli.add.auto") && !album::getByName($name))) {
+                        $parent=array_shift($args["palbum"]);
+                        // this is a string comparison because the trim() in process() changes
+                        // everything into a string...
+                        if ($parent==="0") {
+                            if (conf::get("import.cli.add.always")) {
+                                $parent_id=album::getRoot()->getId();
+                            } else {
+                                throw new \CliNoParentException("No parent for album " . $name);
+                            }
+                        } else {
+                            $palbum=album::getByName($parent);
+                            if ($palbum) {
+                                $parent_id=$palbum[0]->getId();
+                            } else {
+                                throw new \AlbumNotFoundException("Album not found: $parent");
+                            }
+                        }
+                        $vars["_new_album"][]=array("parent" => $parent_id, "name" => $name);
+                    } else {
+                        $album=album::getByName($name);
+                        if ($album) {
+                            $album_id=$album[0]->getId();
+                            $vars["_album_id"][]=$album_id;
+                        } else {
+                            throw new \AlbumNotFoundException("Album not found: $name");
+                        }
+                    }
+                }
+                break;
+            case "categories":
+                foreach ($arg as $name) {
+                    if (static::$command=="new" ||
+                      (conf::get("import.cli.add.auto") && !category::getByName($name))) {
+                        $parent=array_shift($args["pcat"]);
+                        // this is a string comparison because the trim() in process() changes
+                        // everything into a string...
+                        if ($parent==="0") {
+                            if (conf::get("import.cli.add.always")) {
+                                $parent_id=category::getRoot()->getId();
+                            } else {
+                                throw new \CliNoParentException("No parent for category " . $name);
+                            }
+                        } else {
+                            $pcat=category::getByName($parent);
+                            if ($pcat) {
+                                $parent_id=$pcat[0]->getId();
+                            } else {
+                                throw new \CategoryNotFoundException("Category not found: $parent");
+                            }
+                        }
+                        $vars["_new_cat"][]=array("parent" => $parent_id, "name" => $name);
+                    } else {
+                        $cat=category::getByName($name);
+                        if ($cat) {
+                            $cat_id=$cat[0]->getId();
+                            $vars["_category_id"][]=$cat_id;
+                        } else {
+                            throw new \CategoryNotFoundException("Category not found: $name");
+                        }
+                    }
+                }
+                break;
+            case "people":
+                foreach ($arg as $name) {
+                    if (static::$command=="new" || (conf::get("import.cli.add.auto") &&
+                      !person::getByName($name))) {
+                        $vars["_new_person"][]=$name;
+                    } else {
+                        $person=person::getByName($name);
+                        if ($person) {
+                            $person_id=$person[0]->getId();
+                            $vars["_person_id"][]=$person_id;
+                        } else {
+                            throw new \PersonNotFoundException("Person not found: $name");
+                        }
+                    }
+                }
+                break;
+            case "photographer":
+                $name=$arg;
+                if (static::$command=="new" ||
+                  (conf::get("import.cli.add.auto") && !person::getByName($name))) {
+                    $vars["_new_photographer"][]=$name;
+                } else {
+                    $person=person::getByName($name);
+                    if ($person) {
+                        $person_id=$person[0]->getId();
+                        $vars["photographer_id"]=$person_id;
+                    } else {
+                        throw new \PersonNotFoundException("Person not found: $name");
+                    }
+                }
+                break;
+            case "location":
+                foreach ($arg as $name) {
+                    if (static::$command=="new" || (conf::get("import.cli.add.auto") &&
+                      !place::getByName($name))) {
+                        $parent=array_shift($args["pplace"]);
+                        // this is a string comparison because the trim() in process() changes
+                        // everything into a string...
+                        if ($parent==="0") {
+                            if (conf::get("import.cli.add.always")) {
+                                $parent_id=place::getRoot()->getId();
+                            } else {
+                                throw new \CliNoParentException("No parent for location " . $name);
+                            }
+                        } else {
+                            $pplace=place::getByName($parent);
+                            if ($pplace) {
+                                $parent_id=$pplace[0]->getId();
+                            } else {
+                                throw new \PlaceNotFoundException("Location not found: $parent");
+                            }
+                        }
+                        $vars["_new_place"][]=array("parent" => $parent_id, "name" => $name);
+                    } else {
+                        $name=$arg[0];
+                        $place=place::getByName($name);
+                        if ($place) {
+                            $place_id=$place[0]->getId();
+                            $vars["location_id"]=$place_id;
+                        } else {
+                            throw new \PlaceNotFoundException("Location not found: $name");
+                        }
+                    }
+                }
+                break;
+            case "path":
+                $vars["_path"]=$arg;
+                break;
+            case "dirpattern":
+                if (!preg_match("/^[aclpDP]+$/", $arg)) {
+                    throw new \CliIllegalDirpatternException("Illegal characters in " .
+                        "--dirpattern, allowed are: aclpDP");
+                } else {
+                    $vars["_dirpattern"]=$arg;
+                }
+                break;
+
+            case "fields":
+                foreach ($arg as $field=>$value) {
+                    $vars[$field]=$value;
+                }
+                break;
+            case "_configitem":
+            case "_configvalue":
+            case "_configdefault":
+                $vars[$type]=$arg;
+                break;
+            }
+        }
+    }
+    /**
+     * Returns the list of files
+     */
+    public function getFiles() {
+        return $this->processed["files"];
+    }
+
+    /**
+     * Returns an array of variables, with keys.
+     */
+    public function getVars() {
+        return $this->vars;
+    }
+
+}
+?>
diff -pruN 0.9.4-4/php/classes/cli/cli.inc.php 0.9.8-1/php/classes/cli/cli.inc.php
--- 0.9.4-4/php/classes/cli/cli.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/cli/cli.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,532 @@
+<?php
+/**
+ * Controller for the CLI
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace cli;
+
+use file;
+use photo;
+use album;
+use category;
+use place;
+use person;
+use user;
+use conf\conf;
+
+/**
+ * Controller class for the CLI
+ */
+class cli {
+    /**
+     * Defines the API version between the /bin/zoph binary and the files in the webroot
+     * these must be equal.
+     */
+    const API=5;
+
+    /**
+     * @var The user that is doing the import
+     */
+    private $user;
+    /**
+     * @var Commandline arguments
+     */
+    private $args;
+    /**
+     * List of files to be imported
+     */
+    private $files=array();
+    private $photos=array();
+
+    /**
+     * Create cli object
+     * @param User user doing the import
+     * @param int API version of the executable script. This is used to check if the executable
+     *            script is compatible with the scripts in php directory
+     * @param $args array of CLI arguments
+     */
+    public function __construct(user $user, $api, array $args) {
+        if ($api != static::API) {
+            throw new \CliAPINotCompatibleException("This Zoph installation is not compatible " .
+                "with the Zoph executable you are running.");
+        }
+        $this->user=$user;
+
+        if (!$user->isAdmin()) {
+            throw new \CliUserNotAdminException("CLI_USER must be an admin user");
+        }
+        $user->prefs->load();
+        $user->loadLanguage();
+        $this->args=new arguments($args);
+    }
+
+    /**
+     * Run the CLI
+     */
+    public function run() {
+        $this->processFiles();
+        switch(arguments::$command) {
+        case "import":
+            $this->doImport();
+            break;
+        case "update":
+            $this->doUpdate();
+            break;
+        case "new":
+            $this->addNew();
+            break;
+        case "config":
+            $this->doConfig();
+            break;
+        case "dumpconfig":
+            $this->doDumpCondig();
+            break;
+        case "version":
+            static::showVersion();
+            break;
+        case "help":
+            static::showHelp();
+            break;
+        default:
+            throw new \CliUnknownErrorException("Unknown command, please file a bug");
+        }
+
+    }
+
+    /**
+     * Check list of files
+     */
+    private function processFiles() {
+        $files=$this->args->getFiles();
+        foreach ($files as $filename) {
+            try {
+                if (arguments::$command=="import") {
+
+                    $file=new file($filename);
+                    $file->check();
+
+                    $file->getMime();
+                    if ($file->type=="directory" && conf::get("import.cli.recursive")) {
+                        $this->files=array_merge($this->files, file::getFromDir($file, true));
+                    } else if ($file->type!="image") {
+                        throw new \ImportFileNotImportableException("$file is not an image\n");
+                    } else {
+                        $this->files[]=$file;
+                    }
+                } else {
+                    if (conf::get("import.cli.useids")) {
+                        $file=$filename;
+                        if (is_numeric($file)) {
+                            $this->photos[]=$this->lookupFileById($file);
+                        } else if (preg_match("/^[0-9]+-[0-9]+$/", $file)) {
+                            list($start, $end) = explode("-",$file);
+                            foreach (range($start, $end) as $id) {
+                                try {
+                                    $this->photos[]=$this->lookupFileById($id);
+                                } catch (\ImportException $e) {
+                                    echo $e->getMessage();
+                                }
+                             }
+                        } else {
+                            throw new \ImportIdIsNotNumericException(
+                                "$file is not numeric, but --useids is set.\n");
+                        }
+                    } else {
+                        $this->photos[]=$this->lookupFile($filename);
+                    }
+                }
+            } catch (\Exception $e) {
+                echo $e->getMessage();
+            }
+        }
+    }
+
+    /**
+     * Looks up a photo by photo_id
+     */
+    private function lookupFileById($id) {
+        $photo=new photo((int) $id);
+        $count=$photo->lookup();
+        if ($count==1) {
+            return $photo;
+        } else if ($count==0) {
+            throw new \ImportFileNotFoundException("No photo with id $id was found\n");
+        } else {
+            throw new \ImportMultipleMatchesException(
+                "Multiple photos with id $id were found. This is probably a bug");
+        }
+    }
+
+    /**
+     * Looks up a file by filename
+     * @todo Maybe this should be moved into the file object?
+     */
+    private function lookupFile($file) {
+        $filename=basename($file);
+        $path=dirname($file);
+        if ($path==".") {
+            // No path given
+            //unset($path);
+            $path="./";
+        }
+
+        if (substr($path,0,2)=="./") {
+            // Path relative to the current dir given, change into absolute path
+            $path="/" . file::cleanupPath(getcwd() . "/" . $path);
+        }
+        if ($path[0]=="/") {
+            // absolute path given
+
+            $path="/" . file::cleanupPath($path) . "/";
+
+            // check if path is in conf::get("path.images")
+            if (substr($path, 0, strlen(conf::get("path.images")))!=conf::get("path.images")) {
+                throw new \ImportFileNotInPathException($file ." is not in the images path (" .
+                    conf::get("path.images") . "), skipping.\n");
+            } else {
+                $path=substr($path, strlen(conf::get("path.images")));
+                if ($path[0]=="/") {
+                    // conf::get("path.images") didn't end in '/', let's cut it off
+                    $path=substr($path, 1);
+                }
+            }
+        } else {
+            $path=file::cleanupPath($path);
+        }
+        $photos=photo::getByName($filename, $path);
+        if (sizeof($photos)==0) {
+            throw new \ImportFileNotFoundException($file ." not found.\n");
+        } else if (sizeof($photos)==1) {
+            return $photos[0];
+        } else {
+            throw new \ImportMultipleMatchesException("Multiple files named " . $file ." found.\n");
+        }
+    }
+
+    /**
+     * Process --import
+     */
+    private function doImport() {
+        $vars=$this->args->getVars();
+        if (conf::get("import.cli.add.auto")) {
+            $vars=$this->addNew();
+        }
+        if (is_array($this->files) && sizeof($this->files)>0) {
+            if (!isset($vars["_dirpattern"])) {
+                $photos=array();
+                foreach (array_unique($this->files) as $file) {
+                    $photo=new photo();
+                    $photo->file["orig"]=$file;
+                    $photos[]=$photo;
+                }
+            } else {
+                $photos=$this->processDirpattern();
+            }
+            \import\cli::photos($photos, $vars);
+        } else {
+            throw new \CliNoFilesException("Nothing to do, exiting");
+        }
+    }
+
+    /**
+     * Process --update
+     */
+    private function doUpdate() {
+        if (is_array($this->photos) && sizeof($this->photos)>0) {
+            $total=sizeof($this->photos);
+            $cur=0;
+            foreach ($this->photos as $photo) {
+                cliimport::progress($cur, $total);
+                $cur++;
+                $photo->lookup();
+                $photo->setFields($this->args->getVars());
+                $photo->update();
+                $photo->updateRelations($this->args->getVars(), "_id");
+                if (conf::get("import.cli.thumbs")===true) {
+                    $photo->thumbnail(true);
+                }
+                if (conf::get("import.cli.exif")===true) {
+                    $photo->updateEXIF();
+                }
+                if (conf::get("import.cli.size")===true) {
+                    $photo->updateSize();
+                }
+                if (conf::get("import.cli.hash")===true) {
+                    $photo->getHash();
+                }
+            }
+        } else {
+            throw new \CliNoFilesException("Nothing to do, exiting");
+        }
+    }
+    /**
+     * Add albums, categories, places, people that should be added because of --new or --autoadd
+     * if $vars is given,
+     */
+    public function addNew() {
+        $vars=$this->args->getVars();
+        $newvars=array();
+        $return_vars=array();
+
+        foreach ($vars as $var=>$array) {
+            switch($var) {
+            case "_new_album":
+                $newvars["_album_id"]=array();
+                foreach ($array as $new) {
+                    $album=new album();
+                    $album->set("album", $new["name"]);
+                    $album->set("parent_album_id", (int) $new["parent"]);
+                    $album->insert();
+                    $newvars["_album_id"][]=$album->getId();
+                }
+                break;
+            case "_new_cat":
+                $newvars["_category_id"]=array();
+                foreach ($array as $new) {
+                    $cat=new category();
+                    $cat->set("category", $new["name"]);
+                    $cat->set("parent_category_id", (int) $new["parent"]);
+                    $cat->insert();
+                    $newvars["_category_id"][]=$cat->getId();
+                }
+                break;
+            case "_new_place":
+                foreach ($array as $new) {
+                    $place=new place();
+                    $place->set("title", $new["name"]);
+                    $place->set("parent_place_id", (int) $new["parent"]);
+                    $place->insert();
+                    $newvars["location_id"]=$place->getId();
+                }
+
+                break;
+            case "_new_person":
+                $newvars["_person_id"]=array();
+                foreach ($array as $new) {
+                    $person=new person();
+                    $person->setName($new);
+                    $person->insert();
+                    $newvars["_person_id"][]=$person->getId();
+                }
+                break;
+            case "_new_photographer":
+                foreach ($array as $new) {
+                    $person=new person();
+                    $person->setName($new);
+                    $person->insert();
+                    $newvars["photographer_id"]=$person->getId();
+                }
+            default:
+                $return_vars[$var]=$array;
+            }
+        }
+        foreach ($newvars as $name=>$array) {
+            if (array_key_exists($name, $return_vars) && is_array($return_vars[$name])) {
+                $return_vars[$name]=array_merge($return_vars[$name], $array);
+            }
+            $return_vars[$name]=$array;
+        }
+        return($return_vars);
+    }
+
+    /**
+     * Process --config
+     */
+    private function doConfig() {
+        $vars=$this->args->getVars();
+        $name=$vars["_configitem"];
+        $default=isset($vars["_configdefault"]);
+        $item=conf::getItemByName($name);
+
+        if ($default) {
+            $value=$item->getDefault();
+        } else {
+            $value=$vars["_configvalue"];
+        }
+
+        if (conf::get("import.cli.verbose") > 0) {
+            echo "Setting config \"$name\" to \"$value\""  .
+                ($default ? " (default)" : "") . "\n";
+        }
+
+
+        $item->setValue($value);
+        $item->update();
+    }
+
+    /**
+     * Process --dump-config
+     */
+    private function doDumpConfig() {
+        $conf=conf::getAll();
+        foreach ($conf as $item) {
+            foreach ($item as $citem) {
+                if ($citem instanceof \confItemBool) {
+                    $value=($citem->getValue() ? "true": "false");
+                } else {
+                    $value=$citem->getValue();
+                }
+                echo $citem->getName() . ": " . $value . "\n";
+            }
+        }
+    }
+
+    /**
+     * Process the --dirpattern setting
+     */
+    public function processDirpattern() {
+        $vars=$this->args->getVars();
+
+        $patt=str_split($vars["_dirpattern"]);
+
+        $cur=getcwd();
+        $curlen=strlen($cur);
+        foreach ($this->files as $file) {
+            if (substr($file, 0, $curlen) != $cur) {
+                throw new \CliNotInCWDException("Sorry, --dirpattern can only be used when " .
+                    "importing files under the current dir. i.e. do not use absolute paths " .
+                    "or '../' when specifying --dirpattern.");
+            }
+            $filename=substr($file, $curlen + 1);
+            $dirs=explode("/", $filename);
+            array_pop($dirs);
+
+            $photo=new photo();
+            $photo->file["orig"]=$file;
+
+            $counter=0;
+            foreach ($dirs as $dir) {
+                if (isset($patt[$counter])) {
+                    switch($patt[$counter]) {
+                    case "a":
+                        // album
+                        $album=album::getByName($dir);
+                        if ($album[0] instanceof \album) {
+                            if (!is_array($photo->_album_id)) {
+                                $photo->_album_id=array();
+                            }
+                            $photo->_album_id[]=$album[0]->getId();
+                        } else {
+                            throw new \AlbumNotFoundException("Album not found: " . $dir);
+                        }
+                        break;
+                    case "c":
+                        // category
+                        $cat=category::getByName($dir);
+                        if ($cat[0] instanceof \category) {
+                            if (!is_array($photo->_category_id)) {
+                                $photo->_category_id=array();
+                            }
+                            $photo->_category_id[]=$cat[0]->getId();
+                        } else {
+                            throw new \CategoryNotFoundException("Category not found: " . $dir);
+                        }
+                        break;
+                    case "l":
+                        // location
+                        $place=place::getByName($dir);
+                        if ($place[0] instanceof \place) {
+                            $photo->set("location_id", $place[0]->getId());
+                        } else {
+                            throw new \PlaceNotFoundException("Place not found: " . $dir);
+                        }
+                        break;
+                    case "p":
+                        // person
+                        $person=person::getByName($dir);
+                        if ($person[0] instanceof \person) {
+                            if (!is_array($photo->_person_id)) {
+                                $photo->_person_id=array();
+                            }
+                            $photo->_person_id[]=$person[0]->getId();
+                        } else {
+                            throw new \PersonNotFoundException("Person not found: " . $dir);
+                        }
+                        break;
+                    case "D":
+                        // dir / path
+                        $path=$photo->_path;
+                        if (!empty($path)) {
+                            $path .= "/";
+                        }
+                        $photo->_path=$path . $dir;
+                        break;
+                    case "P":
+                        // photographer
+                        $person=person::getByName($dir);
+                        if ($person[0] instanceof \person) {
+                            $photo->set("photographer_id", $person[0]->getId());
+                        } else {
+                            throw new \PersonNotFoundException("Person not found: " . $dir);
+                        }
+                        break;
+                    default:
+                        // should never happen...
+                        throw new \CliUnknownErrorException("Unknown error");
+                    }
+                }
+                $counter++;
+            }
+            $photos[]=$photo;
+        }
+        return $photos;
+    }
+    /**
+     * Show help
+     */
+    private static function showHelp() {
+        echo "zoph " . VERSION . "\n";
+        echo <<<END
+Usage: zoph [OPTIONS] [IMAGE ...]
+OPTIONS:
+    --instance "INSTANCE"
+
+    --import
+    --update
+    --version
+    --help
+
+    --album "ALBUM"
+    --category "CATEGORY"
+    --photographer "FIRST_NAME LAST_NAME"
+    --location "PLACE"
+    --person "FIRST_NAME LAST_NAME"
+    --field "FIELD=VALUE"
+
+    --[no-]thumbs
+    --[no-]exif
+    --[no-]size
+    --useids
+    --move
+    --copy
+    --[no-]dateddirs
+    --[no-]hierarchical
+    --path
+
+END;
+    }
+
+    /**
+     * Tells user which Zoph version is being used
+     */
+    private static function showVersion() {
+        echo "Zoph v" . VERSION . ", released " . RELEASEDATE . "\n";
+    }
+}
+?>
diff -pruN 0.9.4-4/php/classes/comment.inc.php 0.9.8-1/php/classes/comment.inc.php
--- 0.9.4-4/php/classes/comment.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/comment.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -27,6 +27,8 @@ use db\select;
 use db\insert;
 use db\param;
 
+use template\block;
+
 /**
  * A class corresponding to the comments table.
  *
@@ -142,6 +144,7 @@ class comment extends zophTable {
 
     /**
      * Add this comment to a photo
+     * @param photo photo to add comment to
      */
     public function addToPhoto(photo $photo) {
         $qry=new insert(array("photo_comments"));
diff -pruN 0.9.4-4/php/classes/conf/collection.inc.php 0.9.8-1/php/classes/conf/collection.inc.php
--- 0.9.4-4/php/classes/conf/collection.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/collection.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,47 @@
+<?php
+/**
+ * A conf\collection is a collection of configuration items (@see conf\item).
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf;
+
+use conf\item\item;
+use template\block;
+
+/**
+ * Group of @see conf\Item objects
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class collection extends \generic\collection {
+    /**
+     * Add item
+     * For ArrayAccess interface
+     * @param string offset
+     * @param string value
+     */
+    public function offsetSet($off, $value) {
+        if (is_null($off) && ($value instanceof item)) {
+            $off=$value->getName();
+        }
+        parent::offsetSet($off, $value);
+    }
+}
diff -pruN 0.9.4-4/php/classes/conf/confDefault.inc.php 0.9.8-1/php/classes/conf/confDefault.inc.php
--- 0.9.4-4/php/classes/conf/confDefault.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/confDefault.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,936 @@
+<?php
+/**
+ * This class defines the configuration options Zoph has and their default settings
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf;
+
+use conf\item\text;
+use conf\item\checkbox;
+use conf\item\number;
+use conf\item\select;
+use conf\item\salt;
+
+use template\template;
+
+use language;
+use photo;
+use album;
+use user;
+use TimeZone;
+
+/**
+ * confDefault is the class that defines config options & their defaults
+ * in the database
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class confDefault extends conf {
+    /**
+     * Get default config
+     * @todo Ugly!
+     * @todo naming is wrong, because config is not being 'get', it's stored in the object
+     */
+    protected static function getConfig() {
+        static::getConfigInterface();
+        static::getConfigSSL();
+        static::getConfigURL();
+        static::getConfigPath();
+        static::getConfigMaps();
+        static::getConfigImport();
+        static::getConfigWatermark();
+        static::getConfigRotate();
+        static::getConfigShare();
+        static::getConfigFeature();
+        static::getConfigDate();
+    }
+
+    /**
+     * Get config collection for interface settings
+     */
+    private static function getConfigInterface() {
+        $interface = new collection();
+
+        $intTitle = new text();
+        $intTitle->setName("interface.title");
+        $intTitle->setLabel("Title");
+        $intTitle->setDesc("The title for the application. This is what appears " .
+            "on the home page and in the browser's title bar.");
+        $intTitle->setDefault("Zoph");
+        $intTitle->setRegex("^.*$");
+        $interface[]=$intTitle;
+
+        $intWidth = new text();
+        $intWidth->setName("interface.width");
+        $intWidth->setLabel("Screen width");
+        $intWidth->setDesc("A number in pixels (\"px\") or percent (\"%\"), the latter " .
+            "is a percentage of the user's browser window width.");
+        $intWidth->setDefault("800px");
+        $intWidth->setRegex("^[0-9]+(px|%)$");
+        $interface[]=$intWidth;
+
+        $intTpl = new select();
+        $intTpl->setName("interface.template");
+        $intTpl->setLabel("Template");
+        $intTpl->setDesc("The template Zoph uses");
+        $intTpl->addOptions(template::getAll());
+        $intTpl->setDefault("default");
+        $interface[]=$intTpl;
+
+        $intAutoc = new checkbox();
+        $intAutoc->setName("interface.autocomplete");
+        $intAutoc->setLabel("Autocomplete");
+        $intAutoc->setDesc("Use autocompletion for selection of albums, categories, " .
+            "places and people instead of standard HTML selectboxes. Can be individually " .
+            "switched off from user preferences.");
+        $intAutoc->setDefault(true);
+        $interface[]=$intAutoc;
+
+        $intLang = new select();
+        $intLang->setName("interface.language");
+        $intLang->setLabel("Default language");
+        $intLang->setDesc("Set the language used when neither the user or the browser " .
+            "specifies a preference");
+        $langs=language::getAll();
+        foreach ($langs as $iso => $lang) {
+            $intLang->addOption($iso, $lang->name);
+        }
+        $intLang->setDefault("en");
+        $interface[]=$intLang;
+
+        $intMaxDays = new number();
+        $intMaxDays->setName("interface.max.days");
+        $intMaxDays->setLabel("Maximum days");
+        $intMaxDays->setDesc("The maximum days Zoph displays in a dropdown box for 'photos " .
+            "changed / made in the past ... days' on the search screen");
+        $intMaxDays->setDefault("30");
+        $intMaxDays->setRegex("^[1-9][0-9]{0,2}$");
+        $intMaxDays->setBounds(0, 365, 1);
+        $interface[]=$intMaxDays;
+
+        $intSortOrder = new select();
+        $intSortOrder->setName("interface.sort.order");
+        $intSortOrder->setLabel("Default sort order");
+        $intSortOrder->setDesc("Default sort order of photos");
+        $intSortOrder->addOptions(photo::getFields());
+        $intSortOrder->setDefault("date");
+        $interface[]=$intSortOrder;
+
+        $intSortDir = new select();
+        $intSortDir->setName("interface.sort.dir");
+        $intSortDir->setLabel("Default sort direction");
+        $intSortDir->setDesc("Default sort order of photos, ascending or descending");
+        $intSortDir->addOption("asc", "Ascending");
+        $intSortDir->addOption("desc", "Descending");
+        $intSortDir->setDefault("asc");
+        $interface[]=$intSortDir;
+
+        $intLogonBgAlbum = new select();
+        $intLogonBgAlbum->setName("interface.logon.background.album");
+        $intLogonBgAlbum->setLabel("Logon screen background album");
+        $intLogonBgAlbum->setDesc("Select an album from which a random photo is chosen as a " .
+            "background for the logon screen");
+        $intLogonBgAlbum->addOptions(album::getSelectArray());
+        $intLogonBgAlbum->setOptionsTranslate(false);
+        $intLogonBgAlbum->setDefault(null);
+        $intLogonBgAlbum->requiresEnabled(new checkbox("share.enable"));
+
+        $interface[]=$intLogonBgAlbum;
+
+        $intCookieExpire = new select();
+        $intCookieExpire->setName("interface.cookie.expire");
+        $intCookieExpire->setLabel("Cookie Expiry Time");
+        $intCookieExpire->setDesc("Set the time after which a cookie will expire, that is, " .
+            "when a user will need to re-login. \"session\" (default) means: until user " .
+            "closes the browser");
+        $intCookieExpire->addOptions(array(
+            0       => "session",
+            3600    => "1 hour",
+            14400   => "4 hours",
+            28800   => "8 hours",
+            86400   => "1 day",
+            604800  => "1 week",
+            2592300 => "1 month"
+        ));
+        $intCookieExpire->setDefault(0);
+        $interface[]=$intCookieExpire;
+
+        $users=user::getAll();
+
+        $intUserDefault = new select();
+        $intUserDefault->setName("interface.user.default");
+        $intUserDefault->setLabel("Default user");
+        $intUserDefault->setDesc("Automatically log on as this user when not logged " .
+            "on. Can be used to give people access without a username and password. " .
+            "This user should be a non-admin user and should not have any change " .
+            "permissions.");
+        $intUserDefault->addOption(0, "Disabled");
+        foreach ($users as $usr) {
+            if (!$usr->isAdmin()) {
+                $intUserDefault->addOption($usr->getId(), $usr->getName());
+            }
+        }
+        $intUserDefault->setDefault(0);
+        $interface[]=$intUserDefault;
+
+        $intUserCli = new select();
+        $intUserCli->setName("interface.user.cli");
+        $intUserCli->setLabel("CLI user");
+        $intUserCli->setDesc("This is the Zoph user that is used when using the CLI " .
+            "interface when interacting with Zoph. This user must be an admin user. " .
+            "You can also set it to \"autodetect\", which means Zoph will lookup the " .
+            "name of the Unix user starting the CLI client and tries to find that user's " .
+            "name in the Zoph database.");
+        $intUserCli->addOption(0, "Autodetect");
+        foreach ($users as $usr) {
+            if ($usr->isAdmin()) {
+                $intUserCli->addOption($usr->getId(), $usr->getName());
+            }
+        }
+        $intUserCli->setDefault(0);
+        $interface[]=$intUserCli;
+
+        conf::addGroup($interface, "interface", "Interface settings",
+            "Settings that define how Zoph looks");
+
+    }
+
+    /**
+     * Get config collection for SSL settings
+     */
+    private static function getConfigSSL() {
+        $ssl = new collection();
+
+        $sslForce = new select();
+        $sslForce->setName("ssl.force");
+        $sslForce->setLabel("Force SSL");
+        $sslForce->setDesc("Force users to use https when using Zoph. When connecting " .
+            "to Zoph using http, the user will automatically be redirected to the same " .
+            "URL, but with https. When choosing \"login only\", the user will be " .
+            "redirected back to http after logging in. If your https-site is hosted on " .
+            "a different URL, you will need to define the correct url below.");
+        $sslForce->addOption("never", "Never");
+        $sslForce->addOption("always", "Always");
+        $sslForce->addOption("login", "Login only");
+        $sslForce->setDefault("never");
+        $sslForce->setDeprecated();
+        $ssl[]=$sslForce;
+
+        conf::addGroup($ssl, "ssl", "SSL", "Protect your site against eavesdropping by " .
+            "using https. You will need to configure this in your webserver as well.");
+    }
+
+    /**
+     * Get config collection for url settings
+     */
+    private static function getConfigURL() {
+        $url = new collection();
+
+        $urlHttp = new text();
+        $urlHttp->setName("url.http");
+        $urlHttp->setLabel("Zoph's URL");
+        $urlHttp->setDesc("Override autodetection of Zoph's URL, for example if you " .
+            "use a domainname to access Zoph but get redirected to a different URL.");
+        $urlHttp->setDefault("");
+        // This regex was stolen from http://mathiasbynens.be/demo/url-regex, @stephenhay
+        $urlHttp->setRegex("(^$|^https?:\/\/[^\s\/$.?#].[^\s]*$)");
+        $urlHttp->setDeprecated();
+        $url[]=$urlHttp;
+
+        $urlHttps = new text();
+        $urlHttps->setName("url.https");
+        $urlHttps->setLabel("Zoph's Secure URL");
+        $urlHttps->setDesc("Override autodetection of Zoph's Secure URL (https).");
+        $urlHttps->setDefault("");
+        // This regex was stolen from http://mathiasbynens.be/demo/url-regex, @stephenhay
+        $urlHttps->setRegex("(^$|^https:\/\/[^\s\/$.?#].[^\s]*$)");
+        $urlHttps->setDeprecated();
+        $url[]=$urlHttps;
+
+        conf::addGroup($url, "url", "URLs", "Define the URLs that are used to access " .
+            "Zoph. Only configure this if Zoph cannot determine it automatically.");
+    }
+
+    /**
+     * Get config collection for Path settings
+     */
+    private static function getConfigPath() {
+        $path = new collection();
+
+        $pathImages = new text();
+        $pathImages->setName("path.images");
+        $pathImages->setLabel("Images directory");
+        $pathImages->setDesc("Location of the images on the filesystem. Absolute path, " .
+            " thus starting with a /");
+        $pathImages->setDefault("/data/images");
+        $pathImages->setRegex("^\/[A-Za-z0-9_.\/]+$");
+        $pathImages->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), and underscore (_). Must start with a /");
+        $pathImages->setRequired();
+        $path[]=$pathImages;
+
+        $pathUpload = new text();
+        $pathUpload->setName("path.upload");
+        $pathUpload->setLabel("Upload dir");
+        $pathUpload->setDesc("Directory where uploaded files are stored and from where " .
+            "files are imported in Zoph. This is a directory under the images directory " .
+            "(above). For example, if the images directory is set to /data/images and " .
+            "this is set to upload, photos will be uploaded to /data/images/upload.");
+        $pathUpload->setDefault("upload");
+        $pathUpload->setRegex("^[A-Za-z0-9_]+[A-Za-z0-9_.\/]*$");
+        $pathUpload->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), and underscore (_). Can not start with a dot or a slash");
+        $path[]=$pathUpload;
+
+        $pathTrash = new text();
+        $pathTrash->setName("path.trash");
+        $pathTrash->setLabel("Trash dir");
+        $pathTrash->setDesc("Directory where photos are moved when they are " .
+            "deleted. If left blank, files will remain where they were. This is a directory " .
+            "under the images directory (above). For example, if the images directory is set to " .
+            "/data/images and this is set to trash, photos will be moved to /data/images/trash.");
+        $pathTrash->setDefault("");
+        $pathTrash->setRegex("^[A-Za-z0-9_]*[A-Za-z0-9_.\/]*$");
+        $pathTrash->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), and underscore (_). Can not start with a dot or a slash");
+        $path[]=$pathTrash;
+
+        $pathMagic = new text();
+        $pathMagic->setName("path.magic");
+        $pathMagic->setLabel("Magic file");
+        $pathMagic->setDesc("Zoph needs a MIME Magic file to be able to determine the " .
+            "filetype of an uploaded file. This is an important security measure, since " .
+            "it prevents users from uploading files other than images and archives. If " .
+            "left empty, PHP will use the built-in Magic file, if for some reason this " .
+            "does not work, you can specify the location of the MIME magic file. Where " .
+            "this file is located, depends on your distribution, " .
+            "/usr/share/misc/magic.mgc, /usr/share/misc/file/magic.mgc, " .
+            "/usr/share/file/magic are often used.");
+        $pathMagic->setDefault("");
+        $pathMagic->setRegex("^\/[A-Za-z0-9_.\/]+$");
+        $pathMagic->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), and underscore (_). Must start with a /. Can be " .
+            "empty for PHP builtin magic file.");
+        $path[]=$pathMagic;
+
+        $pathUnzip = new text();
+        $pathUnzip->setName("path.unzip");
+        $pathUnzip->setLabel("Unzip command");
+        $pathUnzip->setDesc("The command to use to unzip gzip files. Leave empty to " .
+            "disable uploading .gz files. On most systems \"unzip\" will work.");
+        $pathUnzip->setDefault("");
+        $pathUnzip->setRegex("^([A-Za-z0-9_.\/ -]+|)$");
+        $pathUnzip->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
+        $path[]=$pathUnzip;
+
+        $pathUntar = new text();
+        $pathUntar->setName("path.untar");
+        $pathUntar->setLabel("Untar command");
+        $pathUntar->setDesc("The command to use to untar tar files. Leave empty to disable " .
+            "uploading .tar files. On most systems \"tar xvf\" will work.");
+        $pathUntar->setDefault("");
+        $pathUntar->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
+        $pathUntar->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
+        $path[]=$pathUntar;
+
+        $pathUngz = new text();
+        $pathUngz->setName("path.ungz");
+        $pathUngz->setLabel("Ungzip command");
+        $pathUngz->setDesc("The command to use to unzip gzip files. Leave empty to disable " .
+            "uploading .gz files. On most systems \"gunzip\" will work.");
+        $pathUngz->setDefault("");
+        $pathUngz->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
+        $pathUngz->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
+        $path[]=$pathUngz;
+
+        $pathUnbz = new text();
+        $pathUnbz->setName("path.unbz");
+        $pathUnbz->setLabel("Unbzip command");
+        $pathUnbz->setDesc("The command to use to unzip bzip files. Leave empty to disable " .
+            "uploading .bz files. On most systems \"bunzip2\" will work.");
+        $pathUnbz->setDefault("");
+        $pathUnbz->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
+        $pathUnbz->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
+            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
+        $path[]=$pathUnbz;
+
+        conf::addGroup($path, "path", "Paths", "File and directory locations");
+    }
+
+    /**
+     * Get config collection for maps settings
+     */
+    private static function getConfigMaps() {
+        $maps = new collection();
+
+        $mapsProvider = new select();
+        $mapsProvider->setName("maps.provider");
+        $mapsProvider->setDesc("Enable or disable mapping support and choose the " .
+            "mapping provider");
+        $mapsProvider->setLabel("Mapping provider");
+        $mapsProvider->addOption("", "Disabled");
+        $mapsProvider->addOption("googlev3", "Google Maps v3");
+        $mapsProvider->addOption("mapbox", "Mapbox (OpenStreetMap)");
+        $mapsProvider->addOption("osm", "OpenStreetMap");
+        $mapsProvider->setDefault("");
+        $maps[]=$mapsProvider;
+
+        $mapsMapBoxAPIKey = new text();
+        $mapsMapBoxAPIKey->setName("maps.mapbox.apikey");
+        $mapsMapBoxAPIKey->setDesc("API key to use to access MapBox. The default is Zoph's API key, please do not use it in other projects. If you are setting up a high-volume site, please consider requesting your own key");
+        $mapsMapBoxAPIKey->setDefault("pk.eyJ1IjoiamVyb2Vucm5sIiwiYSI6ImNpdmh6dnlsazAwYWUydXBrbG50cHhlbmMifQ.0pSkJxO6ycD2Wg5GL4yYyw");
+        $mapsMapBoxAPIKey->setRegex("^[0-9a-zA-Z\.]+$");
+        $maps[]=$mapsMapBoxAPIKey;
+
+        $mapsGeocode = new select();
+        $mapsGeocode->setName("maps.geocode");
+        $mapsGeocode->setLabel("Geocode provider");
+        $mapsGeocode->setDesc("With geocoding you can lookup the location of a " .
+            "place from it's name. Here you can select the provider. Currently " .
+            "the only one available is 'geonames'");
+        $mapsGeocode->addOption("", "Disabled");
+        $mapsGeocode->addOption("geonames", "GeoNames");
+        $mapsGeocode->setDefault("");
+        $maps[]=$mapsGeocode;
+
+        conf::addGroup($maps, "maps", "Mapping support",
+            "Add maps to Zoph using various different mapping providers.");
+    }
+
+    /**
+     * Get config collection for import settings
+     */
+    private static function getConfigImport() {
+        $import = new collection();
+
+        $importEnable = new checkbox();
+        $importEnable->setName("import.enable");
+        $importEnable->setLabel("Import through webinterface");
+        $importEnable->setDesc("Use this option to enable or disable importing using " .
+            "the webbrowser. With this option enabled, an admin user, or a user with " .
+            "import rights, can import files placed in the import directory (below) " .
+            "into Zoph. If you want users to be able to upload as well, you need to " .
+            "enable uploading as well.");
+        $importEnable->setDefault(false);
+        $import[]=$importEnable;
+
+        $importUpload = new checkbox();
+        $importUpload->setName("import.upload");
+        $importUpload->setLabel("Upload through webinterface");
+        $importUpload->setDesc("Use this option to enable or disable uploading files. " .
+            "With this option enabled, an admin user, or a user with import rights, " .
+            "can upload files to the server running Zoph, they will be placed in the " .
+            "import directory (below). This option requires \"import through web " .
+            "interface\" (above) enabled.");
+        $importUpload->setDefault(false);
+        $import[]=$importUpload;
+
+        $importMaxupload = new number();
+        $importMaxupload->setName("import.maxupload");
+        $importMaxupload->setLabel("Maximum filesize");
+        $importMaxupload->setDesc("Maximum size of uploaded file in bytes. You might " .
+            "also need to change upload_max_filesize, post_max_size and possibly" .
+            "max_execution_time and max_input_time in php.ini.");
+        $importMaxupload->setRegex("^[0-9]+$");
+        $importMaxupload->setDefault("10000000");
+        $importMaxupload->setBounds(0, 1000000000, 1); // max = 1GB
+        $import[]=$importMaxupload;
+
+        $importParallel = new number();
+        $importParallel->setName("import.parallel");
+        $importParallel->setLabel("Resize parallel");
+        $importParallel->setDesc("Photos will be resized to thumbnail and midsize " .
+            "images during import, this setting determines how many resize actions run " .
+            "in parallel. Can be set to any number. If you have a fast server with " .
+            "multiple CPU's or cores, you can increase this for faster response on " .
+            "the import page.");
+        $importParallel->setRegex("^[0-9]+$");
+        $importParallel->setBounds(1, 99, 1);
+        $importParallel->setDefault("1");
+        $import[]=$importParallel;
+
+        $importRotate = new checkbox();
+        $importRotate->setName("import.rotate");
+        $importRotate->setLabel("Rotate images");
+        $importRotate->setDesc("Automatically rotate imported images, requires jhead");
+        $importRotate->setDefault(false);
+        $import[]=$importRotate;
+
+        $importResize = new select();
+        $importResize->setName("import.resize");
+        $importResize->setLabel("Resize method");
+        $importResize->setDesc("Determines how to resize an image during import. " .
+            "Resize can be about 3 times faster than resample, but the resized image " .
+            "has a lower quality.");
+        $importResize->addOption("resize", "Resize (lower quality / low CPU / fast)");
+        $importResize->addOption("resample", "Resample (high quality / high CPU / slow)");
+        $importResize->setDefault("resample");
+        $import[]=$importResize;
+
+        $importDated = new checkbox();
+        $importDated->setName("import.dated");
+        $importDated->setLabel("Dated dirs");
+        $importDated->setDesc("Automatically place photos in dated dirs " .
+            "(\"2012.10.16/\") during import");
+        $importDated->setDefault(false);
+        $import[]=$importDated;
+
+        $importDatedHier = new checkbox();
+        $importDatedHier->setName("import.dated.hier");
+        $importDatedHier->setLabel("Hierarchical dated dirs");
+        $importDatedHier->setDesc("Automatically place photos in a dated directory " .
+            "tree (\"2012/10/16/\") during import. Ignored unless \"Dated dirs\" is " .
+            "also enabled");
+        $importDatedHier->setDefault(false);
+        $import[]=$importDatedHier;
+
+        /**
+         * @todo This requires octdec to be run before using it so use
+         * octdec(conf::get("import.filemode")) or you will get "funny" results
+         */
+        $importFilemode = new select();
+        $importFilemode->setName("import.filemode");
+        $importFilemode->setLabel("File mode");
+        $importFilemode->setDesc("File mode for the files that are imported in Zoph. " .
+            "Determines who can read or write the files. (RW: Read/Write, RO: Read Only)");
+        $importFilemode->addOptions(array(
+            "0644" => "RW for user, RO for others (0644)",
+            "0664" => "RW for user/collection, RO for others (0664)",
+            "0666" => "RW for everyone (0666)",
+            "0660" => "RW for user/collection, not readable for others (0660)",
+            "0640" => "RW for user, RO for collection, not readable for others (0640)",
+            "0600" => "RW for user, not readable for others (0600)"
+        ));
+        $importFilemode->setDefault("0644");
+        $import[]=$importFilemode;
+
+        /**
+         * @todo This requires octdec to be run before using it so use
+         * octdec(conf::get("import.dirmode")) or you will get "funny" results
+         */
+        $importDirmode = new select();
+        $importDirmode->setName("import.dirmode");
+        $importDirmode->setLabel("dir mode");
+        $importDirmode->setDesc("Mode for directories that are created by Zoph. " .
+            "Determines who can read or write the files. (RW: Read/Write, RO: Read Only)");
+        $importDirmode->addOptions(array(
+            "0755" => "RW for user, RO for others (0755)",
+            "0775" => "RW for user/collection, RO for others (0775)",
+            "0777" => "RW for everyone (0777)",
+            "0770" => "RW for user/collection, not readable for others (0770)",
+            "0750" => "RW for user, RO for collection, not readable for others (0750)",
+            "0700" => "RW for user, not readable for others (0700)"
+        ));
+        $importDirmode->setDefault("0755");
+        $import[]=$importDirmode;
+
+        $importCliVerbose=new number();
+        $importCliVerbose->setName("import.cli.verbose");
+        $importCliVerbose->setLabel("CLI verbose");
+        $importCliVerbose->setDesc("Set CLI verbosity, can be overriden with --verbose");
+        $importCliVerbose->setDefault("0");
+        $importCliVerbose->setBounds(1,99,1);
+        $importCliVerbose->setInternal();
+        $import[]=$importCliVerbose;
+
+        $importCliThumbs=new checkbox();
+        $importCliThumbs->setName("import.cli.thumbs");
+        $importCliThumbs->setLabel("CLI: generate thumbnails");
+        $importCliThumbs->setDesc("Generate thumbnails when importing via CLI. Can be " .
+            "overridden with --thumbs (-t) and --no-thumbs (-n).");
+        $importCliThumbs->setDefault(true);
+        $import[]=$importCliThumbs;
+
+        $importCliExif=new checkbox();
+        $importCliExif->setName("import.cli.exif");
+        $importCliExif->setLabel("CLI: read EXIF data");
+        $importCliExif->setDesc("Read EXIF data when importing via CLI. The default " .
+            "behaviour can be overridden with --exif and --no-exif.");
+        $importCliExif->setDefault(true);
+        $import[]=$importCliExif;
+
+        $importCliSize=new checkbox();
+        $importCliSize->setName("import.cli.size");
+        $importCliSize->setLabel("CLI: size of image");
+        $importCliSize->setDesc("Update image dimensions in database when importing " .
+            "via CLI. The default behaviour can be overridden with --size and --no-size.");
+        $importCliSize->setDefault(true);
+        $import[]=$importCliSize;
+
+        $importCliHash=new checkbox();
+        $importCliHash->setName("import.cli.hash");
+        $importCliHash->setLabel("CLI: calculate hash");
+        $importCliHash->setDesc("Calculate a hash when importing or updating a photo " .
+            "using the CLI. Can be overridden with --hash and --no-hash.");
+        $importCliHash->setDefault(true);
+        $import[]=$importCliHash;
+
+        $importCliCopy=new checkbox();
+        $importCliCopy->setName("import.cli.copy");
+        $importCliCopy->setDefault(false);
+        $importCliCopy->setLabel("CLI: copy on import");
+        $importCliCopy->setDesc("Make a copy of a photo that is imported using the " .
+            "CLI. Can be overridden with --copy and --move.");
+        $import[]=$importCliCopy;
+
+        $importCliUseids=new checkbox();
+        $importCliUseids->setName("import.cli.useids");
+        $importCliUseids->setLabel("CLI: Use Ids");
+        $importCliUseids->setDesc("Use ids instead of filenames when referencing photos.");
+        $importCliUseids->setDefault(false);
+        $importCliUseids->setInternal();
+        $import[]=$importCliUseids;
+
+        $importCliAddAuto=new checkbox();
+        $importCliAddAuto->setName("import.cli.add.auto");
+        $importCliAddAuto->setLabel("CLI: Auto add");
+        $importCliAddAuto->setDesc("Add non-existent albums, categories, places and " .
+            "people, when a parent is defined.");
+        $importCliAddAuto->setDefault(false);
+        $importCliAddAuto->setInternal();
+        $import[]=$importCliAddAuto;
+
+        $importCliAddAlways=new checkbox();
+        $importCliAddAlways->setName("import.cli.add.always");
+        $importCliAddAlways->setLabel("CLI: Auto add always");
+        $importCliAddAlways->setDesc("Add non-existent albums, categories, places " .
+            "and people, regardsless of whether a parent is defined.");
+        $importCliAddAlways->setDefault(false);
+        $importCliAddAlways->setInternal();
+        $import[]=$importCliAddAlways;
+
+        $importCliRecursive=new checkbox();
+        $importCliRecursive->setName("import.cli.recursive");
+        $importCliRecursive->setLabel("CLI: Recursive");
+        $importCliRecursive->setDesc("Recursively import directories when importing " .
+            "using the CLI.");
+        $importCliRecursive->setDefault(false);
+        $importCliRecursive->setInternal();
+        $import[]=$importCliRecursive;
+
+        conf::addGroup($import, "import", "Import", "Importing and uploading photos");
+    }
+
+    /**
+     * Get config collection for watermark settings
+     */
+    private static function getConfigWatermark() {
+        $watermark = new collection();
+
+        $watermarkEnable = new checkbox();
+        $watermarkEnable->setName("watermark.enable");
+        $watermarkEnable->setLabel("Enable Watermarking");
+        $watermarkEnable->setDesc("Watermarking only works if the watermark file below is set " .
+            "to an existing GIF image. Please note that enabling this function uses a " .
+            "rather large amount of memory on the webserver. PHP by default allows a " .
+            "script to use a maximum of 8MB memory. You should probably increase this " .
+            "by changing memory_limit in php.ini. A rough estimation of how much memory " .
+            "it will use is 6 times the number of megapixels in your camera. For " .
+            "example, if you have a 5 megapixel camera, change memory_limit in php.ini to 30M");
+        $watermarkEnable->setDefault(false);
+        $watermark[]=$watermarkEnable;
+
+        /** @todo: should allow .png too */
+        $watermarkFile = new text();
+        $watermarkFile->setName("watermark.file");
+        $watermarkFile->setLabel("Watermark file");
+        $watermarkFile->setDesc("If watermarking is used, this should be set to the name of the " .
+            "file that will be used as the watermark. It should be a GIF file, for best " .
+            "results, use contrasting colours and transparency. In the Contrib directory, " .
+            "3 example files are included. The filename is relative to the image directory, " .
+            "defined above.");
+        $watermarkFile->setDefault("");
+        $watermarkFile->setRegex("(^$|^[A-Za-z0-9_]+[A-Za-z0-9_.\/]*\.gif$)");
+        $watermarkFile->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward slash (/), " .
+            "dot (.), and underscore (_). Can not start with a dot or a slash");
+        $watermark[]=$watermarkFile;
+
+        $watermarkPosX = new select();
+        $watermarkPosX->setName("watermark.pos.x");
+        $watermarkPosX->setLabel("Horizontal position");
+        $watermarkPosX->setDesc("Define where the watermark will be placed horizontally.");
+        $watermarkPosX->addOptions(array(
+            "left" => "Left",
+            "center" => "Center",
+            "right" => "Right"
+        ));
+        $watermarkPosX->setDefault("center");
+        $watermark[]=$watermarkPosX;
+
+        $watermarkPosY = new select();
+        $watermarkPosY->setName("watermark.pos.y");
+        $watermarkPosY->setLabel("Vertical position");
+        $watermarkPosY->setDesc("Define where the watermark will be placed vertically.");
+        $watermarkPosY->addOptions(array(
+            "top" => "Top",
+            "center" => "Center",
+            "bottom" => "Bottom"
+        ));
+        $watermarkPosY->setDefault("center");
+        $watermark[]=$watermarkPosY;
+
+        $watermarkTrans = new number();
+        $watermarkTrans->setName("watermark.transparency");
+        $watermarkTrans->setLabel("Watermark transparency");
+        $watermarkTrans->setDesc("Define the transparency of a watermark. 0: fully " .
+            "transparent (invisible, don't use this, it's pointless and eats " .
+            "up a lot of resources, better turn off the watermark feature " .
+            "altogether) to 100: no transparency.");
+        $watermarkTrans->setDefault("50");
+        $watermarkTrans->setRegex("^(100|[0-9]{1,2})$");
+        $watermarkTrans->setBounds(0, 100, 1);
+        $watermark[]=$watermarkTrans;
+
+        conf::addGroup($watermark, "watermark", "Watermarking",
+            "Watermarking can display a (copyright) watermark over your full-size images.");
+    }
+
+    /**
+     * Get config collection for rotation settings
+     */
+    private static function getConfigRotate() {
+        $rotate = new collection();
+
+        $rotateEnable = new checkbox();
+        $rotateEnable->setName("rotate.enable");
+        $rotateEnable->setLabel("Rotation");
+        $rotateEnable->setDesc("Allow users (admins or with write access) to rotate images");
+        $rotateEnable->setDefault(false);
+        $rotate[]=$rotateEnable;
+
+        $rotateCommand = new select();
+        $rotateCommand->setName("rotate.command");
+        $rotateCommand->setLabel("Rotate command");
+        $rotateCommand->setDesc("Determine which command is used to rotate the image. " .
+            "This command must be available on your system. Convert is a lossy " .
+            "rotate function, which means it will lower the image quality of your " .
+            "photo. JPEGtran, on the other hand, only works on JPEG images, but " .
+            "is lossless.");
+        $rotateCommand->addOptions(array(
+            "convert" => "convert",
+            "jpegtran" => "jpegtran"
+        ));
+        $rotateCommand->setDefault("convert");
+        $rotate[]=$rotateCommand;
+
+        $rotateBackup = new checkbox();
+        $rotateBackup->setName("rotate.backup");
+        $rotateBackup->setLabel("Backup");
+        $rotateBackup->setDesc("Keep a backup image when rotating an image.");
+        $rotateBackup->setDefault(true);
+        $rotate[]=$rotateBackup;
+
+        $rotateBackupPrefix = new text();
+        $rotateBackupPrefix->setName("rotate.backup.prefix");
+        $rotateBackupPrefix->setLabel("Backup prefix");
+        $rotateBackupPrefix->setDesc("Prepend backup file for rotation backups with this.");
+        $rotateBackupPrefix->setDefault("orig_");
+        $rotateBackupPrefix->setRegex("^[a-zA-Z0-9_\-]+$");
+        $rotateBackupPrefix->setRequired();
+        $rotate[]=$rotateBackupPrefix;
+
+        conf::addGroup($rotate, "rotate", "Rotation", "Rotate images");
+    }
+
+    /**
+     * Get config collection for share settings
+     */
+    private static function getConfigShare() {
+        $share = new collection();
+
+        $shareEnable = new checkbox();
+        $shareEnable->setName("share.enable");
+        $shareEnable->setLabel("Sharing");
+        $shareEnable->setDesc("Sometimes, you may wish to share an image in Zoph " .
+            "without creating a user account for those who will be watching them. " .
+            "For example, in order to post a link to an image on a forum or website. " .
+            "When this option is enabled, you will see a 'share' tab next to a photo, " .
+            "where you will find a few ways to share a photo, such as a url and a " .
+            "HTML &lt;img&gt; tag. With this special url, it is possible to open a " .
+            "photo without logging in to Zoph. You can determine per user whether " .
+            "or not this user will see the tab and therefore the urls.");
+        $shareEnable->setDefault(false);
+        $share[]=$shareEnable;
+
+        $shareSaltFull = new salt();
+        $shareSaltFull->setName("share.salt.full");
+        $shareSaltFull->setLabel("Salt for sharing full size images");
+        $shareSaltFull->setDesc("When using the sharing feature, Zoph uses a hash " .
+            "to identify a photo. Because you do not want people who have access to " .
+            "you full size photos (via Zoph or otherwise) to be able to generate " .
+            "these hashes, you should give Zoph a secret salt so only authorized " .
+            "users of your Zoph installation can generate them. The salt for full " .
+            "size images (this one) must be different from the salt of mid size " .
+            "images (below), because this allows Zoph to distinguish between them. " .
+            "If a link to your Zoph installation is being abused (for example " .
+            "because someone whom you mailed a link has published it on a forum), " .
+            "you can modify the salt to make all hash-based links to your Zoph invalid.");
+        $shareSaltFull->setDefault("Change this");
+        $shareSaltFull->setRequired();
+        $share[]=$shareSaltFull;
+
+        $shareSaltMid = new salt();
+        $shareSaltMid->setName("share.salt.mid");
+        $shareSaltMid->setLabel("Salt for sharing mid size images");
+        $shareSaltMid->setDesc("The salt for mid size images (this one) must be " .
+            "different from the salt of full images (above), because this allows " .
+            "Zoph to distinguish between them. If a link to your Zoph installation " .
+            "is being abused (for example because someone whom you mailed a link " .
+            "has published it on a forum), you can modify the salt to make all " .
+            "hash-based links to your Zoph invalid.");
+        $shareSaltMid->setDefault("Modify this");
+        $shareSaltMid->setRequired();
+        $share[]=$shareSaltMid;
+
+        conf::addGroup($share, "share", "Sharing", "Sharing photos with non-logged on users");
+    }
+
+    /**
+     * Get config collection for feature settings
+     */
+    private static function getConfigFeature() {
+        $feature = new collection();
+
+        $featureDownload = new checkbox();
+        $featureDownload->setName("feature.download");
+        $featureDownload->setLabel("Downloading");
+        $featureDownload->setDesc("With this feature you can use download a set of " .
+            "photos (Albums, Categories, Places, People or a search result) in " .
+            "one or more ZIP files. Important! The photos in the ZIP file will " .
+            "NOT be watermarked. You must also grant each non-admin user you " .
+            "want to give these rights permission by changing \"can download " .
+            "zipfiles\" in the user's profile.");
+        $featureDownload->setDefault(false);
+        $feature[]=$featureDownload;
+
+        $featureComments = new checkbox();
+        $featureComments->setName("feature.comments");
+        $featureComments->setLabel("Comments");
+        $featureComments->setDesc("Enable comments. Before a user can actually leave " .
+            "comments, you should also give the user these rights through the edit " .
+            "user screen.");
+        $featureComments->setDefault(false);
+        $feature[]=$featureComments;
+
+        $featureMail = new checkbox();
+        $featureMail->setName("feature.mail");
+        $featureMail->setLabel("Mail photos");
+        $featureMail->setDesc("You can enable or disable the \"mail this photo feature\" " .
+            "using this option. Since Zoph needs to convert the photo into Base64 " .
+            "encoding for mail, it requires quite a large amount of memory if you " .
+            "try to send full size images and you may need to adjust memory_limit " .
+            "in php.ini, you should give it at least about 4 times the size of your " .
+            "largest image.");
+        $featureMail->setDefault(false);
+        $feature[]=$featureMail;
+
+        $featureMailBcc = new text();
+        $featureMailBcc->setName("feature.mail.bcc");
+        $featureMailBcc->setLabel("BCC address");
+        $featureMailBcc->setDesc("Automatically Blind Carbon Copy this mailaddress when " .
+            "a mail from Zoph is sent");
+        $featureMailBcc->setDefault("");
+        // not sure how long the "new" TLD's are going to be,
+        // 10 should be enough for most, feel free to report
+        // a bug if your TLD is longer.
+        $featureMailBcc->setRegex("^([0-9a-zA-Z_\-%\.]+@([0-9a-zA-Z\-]+\.)+[a-zA-Z]{2,10})?$");
+        $feature[]=$featureMailBcc;
+
+        $featureAnnotate = new checkbox();
+        $featureAnnotate->setName("feature.annotate");
+        $featureAnnotate->setLabel("Annotate photos");
+        $featureAnnotate->setDesc("A user can use the annotate photo function to e-mail a " .
+            "photo with a textual annotation. Can only be used in combination with the " .
+            "\"Mail photos\" feature above.");
+        $featureAnnotate->setDefault(false);
+        $feature[]=$featureAnnotate;
+
+        $featureRating = new checkbox();
+        $featureRating->setName("feature.rating");
+        $featureRating->setLabel("Photo rating");
+        $featureRating->setDesc("Allow users to rate photos. Before a non-admin user can " .
+            "actually rate, you should also give the user these rights through the " .
+            "edit user screen.");
+        $featureRating->setDefault(true);
+        $feature[]=$featureRating;
+
+        conf::addGroup($feature, "feature", "Features", "Various features");
+    }
+
+    /**
+     * Get config collection for date settings
+     */
+    private static function getConfigDate() {
+        $date = new collection();
+
+        $dateTz = new select();
+        $dateTz->setName("date.tz");
+        $dateTz->setLabel("Timezone");
+        $dateTz->setDesc("This setting determines the timezone to which your camera " .
+            "is set. Leave empty if you do not want to use this feature and always set " .
+            "your camera to the local timezone");
+
+        $dateTz->addOptions(TimeZone::getTzArray());
+        $dateTz->setDefault("");
+
+        $date[]=$dateTz;
+
+        $dateGuesstz = new checkbox();
+        $dateGuesstz->setName("date.guesstz");
+        $dateGuesstz->setLabel("Guess timezone");
+        $dateGuesstz->setDesc("If you have defined the precise location of a place " .
+            "(using the mapping feature), Zoph can 'guess' the timezone based on this " .
+            "location. It uses the Geonames project for this. This will, however, send " .
+            "information to their webserver, do not enable this feature if you're not " .
+            "comfortable with that.");
+        $dateGuesstz->setDefault(false);
+        $date[]=$dateGuesstz;
+
+        $dateFormat = new text();
+        $dateFormat->setName("date.format");
+        $dateFormat->setLabel("Date format");
+        $dateFormat->setDesc("This determines how Zoph displays dates. You can use the " .
+            "following characters: dDjlNSwzWFmMntLoYy (for explanation, see " .
+            "http://php.net/manual/en/function.date.php) and /, space, -, (, ), :, \",\" and .");
+        $dateFormat->setDefault("d-m-Y");
+        $dateFormat->setRegex("^[dDjlNSwzWFmMntLoYy\/ \-():,.]+$");
+        $dateFormat->setRequired();
+        $date[]=$dateFormat;
+
+        $dateTimeFormat = new text();
+        $dateTimeFormat->setName("date.timeformat");
+        $dateTimeFormat->setLabel("Time format");
+        $dateTimeFormat->setDesc("This determines how Zoph displays times. You can use the " .
+            "following characters: aABgGhHisueIOPTZcrU (for explanation, see " .
+            "http://php.net/manual/en/function.date.php) and /, space, -, (, ), :, \",\" and .");
+        $dateTimeFormat->setDefault("H:i:s T");
+        $dateTimeFormat->setRegex("^[aABgGhHisueIOPTZcrU\/ \-():,.]+$");
+        $dateTimeFormat->setRequired();
+        $date[]=$dateTimeFormat;
+
+        conf::addGroup($date, "date", "Date and time", "Date and time related settings");
+    }
+}
+
diff -pruN 0.9.4-4/php/classes/conf/conf.inc.php 0.9.8-1/php/classes/conf/conf.inc.php
--- 0.9.4-4/php/classes/conf/conf.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/conf.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,222 @@
+<?php
+/**
+ * Via this class Zoph can read configurations from the database
+ * the configurations themselves are stored in conf\item objects
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf;
+
+use PDO;
+
+use db\select;
+use db\param;
+use db\delete;
+use db\db;
+use db\clause;
+use log;
+
+/**
+ * conf is the main object for access to Zoph's configuration
+ * in the database
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class conf {
+
+    /**
+     * @var array Groups are one or more configuration objects that
+     *            belong together;
+     */
+    private static $groups=array();
+
+    /** @var bool whether or not the configuration has been loaded from the db */
+    private static $loaded=false;
+
+    /** @var array During loading from database this will be filled with warnings (if any)
+                   These can later be displayed through conf::getWarnings() */
+    private static $warnings=array();
+
+    /**
+     * Get the Id of the conf item
+     */
+    public function  getId() {
+        return $this->get("conf_id");
+    }
+
+    /**
+     * Read configuration from database
+     */
+    public static function loadFromDB() {
+        confDefault::getConfig();
+        $qry=new select(array("co" => "conf"));
+        $qry->addFields(array("conf_id", "value"));
+
+        try {
+            $result=db::query($qry);
+        } catch (\PDOException $e) {
+            log::msg("Cannot load configuration from database", log::FATAL, log::CONFIG | log::DB);
+        }
+
+        while ($row = $result->fetch(PDO::FETCH_NUM)) {
+            $key=$row[0];
+            $value=$row[1];
+            try {
+                $item=static::getItemByName($key);
+                try {
+                    $item->setValue($value);
+                    if ($item->isDeprecated() && $value != $item->getDefault()) {
+                        static::$warnings[]="Deprecated configuration item <b>" . $key . "</b> is used!";
+                    }
+                } catch (\ConfigurationException $e) {
+                    /* An illegal value is automatically set to the default */
+                    log::msg($e->getMessage(), log::ERROR, log::CONF);
+                }
+            } catch (\ConfigurationException $e) {
+                /* An unknown item will automatically be deleted from the
+                   database, so we can remove items without leaving a mess */
+                log::msg($e->getMessage(), log::NOTIFY, log::CONF);
+                $qry=new delete(array("co" => "conf"));
+                $qry->where(new clause("conf_id=:confid"));
+                $qry->addParam(new param(":confid", $key, PDO::PARAM_STR));
+                $qry->execute();
+            }
+
+        }
+        static::$loaded=true;
+
+    }
+
+    /**
+     * Read configuration from submitted form
+     * @param array of $_GET or $_POST variables
+     */
+    public static function loadFromRequestVars(array $vars) {
+        confDefault::getConfig();
+        foreach ($vars as $key=>$value) {
+            if (substr($key,0,1) == "_") {
+                if (substr($key,0,7) == "_reset_") {
+                    $key=substr(str_replace("_", ".", $key),7);
+                    $item=static::getItemByName($key);
+                    $item->delete();
+                }
+                continue;
+            }
+            $key=str_replace("_", ".", $key);
+            try {
+                if (!isset($vars["_reset_" . $key])) {
+                    $item=static::getItemByName($key);
+                    $item->setValue($value);
+                    $item->update();
+                }
+            } catch(\ConfigurationException $e) {
+                log::msg("Configuration cannot be updated: " .
+                    $e->getMessage(), log::ERROR, log::CONFIG);
+            }
+        }
+        static::$loaded=true;
+    }
+
+    /**
+     * Get a configuration item by name
+     * @param string Name of item to return
+     * @return conf\item Configuration item
+     * @throws \ConfigurationException
+     */
+    public static function getItemByName($name) {
+        $nameArr=explode(".", $name);
+        $group=array_shift($nameArr);
+        if (isset(static::$groups[$group])) {
+            $items=static::$groups[$group]->getItems();
+            if (isset($items[$name])) {
+                return $items[$name];
+            }
+        }
+        throw new \ConfigurationException("Unknown configuration item " . $name);
+    }
+
+    /**
+     * Get the value of a configuration item
+     * @param string Name of item to return
+     * @return string Value of parameter
+     */
+    public static function get($key) {
+        if (!static::$loaded) {
+            static::loadFromDB();
+        }
+        $item=static::getItemByName($key);
+        return $item->getValue();
+
+    }
+
+    /**
+     * Set the value of a configuration item
+     * Does not store this value in the database as this is mainly
+     * used for runtime-overriding a stored value. This function returns
+     * the object so the calling function can do a $item->update() if
+     * it should be stored in the db.
+     * @param string Name of item to change
+     * @param string Value to set
+     * @return conf\item the item that has been updated
+     */
+    public static function set($key, $value) {
+        $item=static::getItemByName($key);
+        $item->setValue($value);
+        return $item;
+    }
+
+    /**
+     * Get all configuration items (in groups)
+     * @return array Array of group objects
+     */
+    public static function getAll() {
+        if (!static::$loaded) {
+            static::loadFromDB();
+        }
+        return static::$groups;
+    }
+
+    /**
+     * Create a new conf\group and add it to the list
+     * @param collection collection to add as group
+     * @param string name
+     * @param string label
+     * @param string description
+     */
+    public static function addGroup(collection $collection, $name, $label, $desc = "") {
+        $group = new group($collection);
+
+        $group->setName($name);
+        $group->setLabel($label);
+        $group->setDesc($desc);
+
+
+        static::$groups[$name]=$group;
+        return $group;
+    }
+
+    /**
+     * Return warnings generated while loading configuration
+     */
+    public static function getWarnings() {
+        return static::$warnings;
+    }
+}
diff -pruN 0.9.4-4/php/classes/conf/group.inc.php 0.9.8-1/php/classes/conf/group.inc.php
--- 0.9.4-4/php/classes/conf/group.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/group.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,111 @@
+<?php
+/**
+ * A conf\group groups several configuration items (@see conf\item) together.
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf;
+
+use conf\item\item;
+use template\block;
+
+/**
+ * Group of @see conf\Item objects
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class group {
+    /** @var string Name of group */
+    private $name;
+    /** @var string Label */
+    private $label;
+    /** @var string Description */
+    private $desc;
+    /** @var collection Holds collection of configuration items */
+    private $collection;
+
+    public function __construct(collection $collection) {
+        $this->collection=$collection;
+    }
+
+    /**
+     * Set the name of the group
+     * @param string Name
+     */
+    public function setName($name) {
+        $this->name=$name;
+    }
+
+    /**
+     * Set the description of the group
+     * @param string Description
+     */
+    public function setDesc($desc) {
+        $this->desc=$desc;
+    }
+
+    /**
+     * Set the label of the group
+     * @param string Label
+     */
+    public function setLabel($label) {
+        $this->label=$label;
+    }
+
+    /**
+     * Get name
+     * @return string Name
+     */
+    public function getName() {
+        return $this->name;
+    }
+
+    /**
+     * Get description
+     * @return string Description
+     */
+    public function getDesc() {
+        return $this->desc;
+    }
+
+    /**
+     * Get label
+     * @return string Label
+     */
+    public function getLabel() {
+        return $this->label;
+    }
+
+    public function getItems() {
+        return $this->collection;
+    }
+
+    /**
+     * Display group
+     * @return block template block
+     */
+    public function display() {
+        return new block("confGroup", array(
+            "title" => translate($this->getLabel(), 0),
+            "desc"  => translate($this->getDesc(), 0),
+            "items" => $this->getItems()
+        ));
+    }
+}
diff -pruN 0.9.4-4/php/classes/conf/item/checkbox.inc.php 0.9.8-1/php/classes/conf/item/checkbox.inc.php
--- 0.9.4-4/php/classes/conf/item/checkbox.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/checkbox.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,70 @@
+<?php
+/**
+ * A checkbox configuration item defines a configuration item that can be true or false
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf\item;
+
+use template\block;
+
+/**
+ * A checkbox configuration item defines a configuration item that can be true or false
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class checkbox extends item {
+
+    /**
+     * Set value
+     * @param bool value
+     */
+    public function setValue($value) {
+        parent::setValue((bool) $value);
+    }
+
+    /**
+     * Check value
+     * check if a specific value is legal for this option
+     * @param string value
+     * @return bool
+     */
+    public function checkValue($value) {
+        return ((bool) $value == $value);
+    }
+
+    /**
+     * Display this option through template
+     * @return block template block
+     */
+    public function display() {
+        if ($this->internal) {
+            return;
+        }
+        $tpl=new block("confItemCheckbox", array(
+            "label" => e(translate($this->getLabel(),0)),
+            "name" => e($this->getName()),
+            "checked" => $this->getValue() ? "checked" : "",
+            "desc" => e(translate($this->getDesc(),0)),
+            "hint" => e(translate($this->getHint(),0)),
+        ));
+        return $tpl;
+     }
+}
diff -pruN 0.9.4-4/php/classes/conf/item/item.inc.php 0.9.8-1/php/classes/conf/item/item.inc.php
--- 0.9.4-4/php/classes/conf/item/item.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/item.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,307 @@
+<?php
+/**
+ * A confItem defines a configuration item
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+namespace conf\item;
+
+use PDO;
+
+use db\select;
+use db\param;
+use db\clause;
+
+use zophTable;
+use template\block;
+
+/**
+ * Configuration item
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+abstract class item extends zophTable {
+    /** @var string The name of the database table */
+    protected static $tableName="conf";
+    /** @var array List of primary keys */
+    protected static $primaryKeys=array("conf_id");
+    /** @var array Fields that may not be empty */
+    protected static $notNull=array();
+    /** @var bool keep keys with insert. In most cases the keys are set by
+                  the db with auto_increment */
+    protected static $keepKeys = true;
+    /** @var string URL for this class */
+    protected static $url="config.php#";
+
+    /** @var string Label to display */
+    protected $label;
+    /** @var string Longer description of item */
+    protected $desc;
+    /** @var string Default value */
+    protected $default;
+    /** @var string Input hint (format to use) */
+    protected $hint;
+    /** @var bool required, whether or not field may be empty*/
+    protected $required=false;
+    /** @var bool indicate that this configuration should no longer be used */
+    protected $deprecated=false;
+    /** @var bool internal, internal settings can not be changed from webinterface */
+    protected $internal=false;
+    /** @var array list of items that MUST be enabled for this item to be enabled */
+    protected $requiresEnabled=array();
+    /** @var array list of unmet requirements */
+    protected $unmet=array();
+
+    /**
+     * Create conf\item object
+     * @param string id, to fetch object from database.
+     * @retrun conf\item new object
+     */
+    public function __construct($id = 0) {
+        if ($id === 0 || preg_match("/^[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/", $id)) {
+            $this->set("conf_id", $id);
+        } else {
+            log::msg("Illegal configuration id", log::FATAL, log::VARS);
+        }
+
+    }
+
+    /**
+     * Update or insert configuration item
+     * checks if item already exists in db
+     * and updates it if it does or inserts
+     * if it does not.
+     */
+    final public function update() {
+        if ($this->checkValue($this->get("value"))) {
+            $qry=new select(array("co" => "conf"));
+            $qry->addFunction(array("count" => "COUNT(conf_id)"));
+            $qry->where(new clause("conf_id=:confid"));
+            $qry->addParam(new param(":confid", $this->fields["conf_id"], PDO::PARAM_STR));
+
+            if ($qry->getCount() > 0) {
+                parent::update();
+            } else {
+                parent::insert();
+            }
+        }
+    }
+
+    /**
+     * Get name of item
+     * @return string name
+     */
+    final public function getName() {
+        return $this->fields["conf_id"];
+    }
+
+
+    /**
+     * Get label for item
+     * @return string label
+     */
+
+    final public function getLabel() {
+        return $this->label;
+    }
+
+    /**
+     * Get description for item
+     * @return string description
+     */
+    final public function getDesc() {
+        return $this->desc;
+    }
+
+    /**
+     * Get value of item
+     * if value is not set, get default
+     * @return string value
+     */
+    final public function getValue() {
+        if (!isset($this->fields["value"]) || $this->fields["value"]===null || !$this->requirementsMet()) {
+            return $this->getDefault();
+        } else {
+            return $this->fields["value"];
+        }
+    }
+
+    /**
+     * Set value of item
+     * @param string value
+     * @throws ConfigurationException
+     */
+    public function setValue($value) {
+        if ($this->checkValue($value)) {
+            $this->fields["value"]=$value;
+        } else {
+            throw new \ConfigurationException("Configuration value for " .
+                $this->getName() . " is illegal");
+        }
+    }
+
+    /**
+     * Get default value of item
+     * @return string default value
+     */
+    final public function getDefault() {
+        return $this->default;
+    }
+
+    /**
+     * Get hint for item
+     * @return string hint
+     */
+    final public function getHint() {
+        return $this->hint;
+    }
+
+    /**
+     * Set name (id) of item
+     * @param string name
+     */
+    final public function setName($name) {
+        $this->fields["conf_id"]=$name;
+    }
+
+    /**
+     * Set label for item
+     * @param string label
+     */
+    final public function setLabel($label) {
+        $this->label=$label;
+    }
+
+    /**
+     * Set label for item
+     * @param string label
+     */
+    final public function setDesc($desc) {
+        $this->desc=$desc;
+    }
+
+    /**
+     * Set hint for item
+     * @param string hint
+     */
+    final public function setHint($hint) {
+        $this->hint=$hint;
+    }
+
+    /**
+     * Set whether or not a field is required
+     * @param bool
+     */
+    final public function setRequired($req=true) {
+        $this->required=(bool) $req;
+    }
+
+    /**
+     * Set whether or not a field is deprecated
+     * @param bool
+     */
+    final public function setDeprecated($dep=true) {
+        $this->deprecated=(bool) $dep;
+    }
+
+    /**
+     * Get whether or not a field is deprecated
+     * @param bool
+     */
+    final public function isDeprecated() {
+        return (bool) $this->deprecated;
+    }
+
+    /**
+     * Set whether or not a field is internal
+     * an internal field is not exposed in the webinterface
+     * and (at this moment) not stored in the database, although this is not enforced
+     * as there may be a future use-case where this will change.
+     * @param bool
+     */
+    final public function setInternal($int=true) {
+        $this->internal=(bool) $int;
+    }
+
+    /**
+     * Set default value for item
+     * @param string default
+     */
+    final public function setDefault($default) {
+        $this->default=$default;
+    }
+
+    /**
+     * This item requires another item to be enabled
+     * @param checkbox configuration item checkbox that must be enabled to use this parameter
+     */
+    final public function requiresEnabled(checkbox $item) {
+        $this->requiresEnabled[]=$item;
+    }
+
+    /**
+     * Are all requirements met?
+     */
+    final protected function requirementsMet() {
+        $met=true;
+        foreach ($this->requiresEnabled as $req) {
+            $req->lookup();
+            if ((bool) $req->getValue() === false) {
+                $met=false;
+                $this->unmet[$req->getName()]="enabled";
+            }
+        }
+        return $met;
+    }
+
+    /**
+     * Return a template block to show the item is deprecated
+     * @return block deprecation warning
+     */
+    final protected function displayDeprecationWarning() {
+        if ($this->isDeprecated()) {
+            return new block("confDeprecated");
+        }
+    }
+
+    /**
+     * Return a template block to show the unmet requirements for this
+     * confItem.
+     * @return block overview of unmet items
+     */
+    final protected function displayUnmetRequirements() {
+        if (!$this->requirementsMet()) {
+            return new block("confUnmetRequirements", array(
+                "unmet" => $this->unmet
+            ));
+        }
+    }
+
+    /**
+     * Display the item
+     */
+    abstract public function display();
+
+    /**
+     * Check whether value is legal
+     * @param string value
+     */
+    abstract public function checkValue($value);
+
+}
diff -pruN 0.9.4-4/php/classes/conf/item/number.inc.php 0.9.8-1/php/classes/conf/item/number.inc.php
--- 0.9.4-4/php/classes/conf/item/number.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/number.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,84 @@
+<?php
+/**
+ * A confItemNumber defines a configuration item that is defined using a user-specified number
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf\item;
+
+use template\block;
+
+/**
+ * A confItemNumber defines a configuration item that is defined using a user-specified number
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class number extends text {
+
+    protected $regex="[0-9]+";
+    protected $min=0;
+    protected $max=99;
+    protected $step=1;
+
+    public function display() {
+        if ($this->internal) {
+            return;
+        }
+        $tpl=new block("confItemNumber", array(
+            "label" => e(translate($this->getLabel(),0)),
+            "name" => e($this->getName()),
+            "value" => e($this->getValue()),
+            "desc" => e(translate($this->getDesc(),0)),
+            "hint" => e(translate($this->getHint(),0)),
+            "regex" => e($this->regex),
+            "size" => (int) $this->size,
+            "min" => (float) $this->min,
+            "max" => (float) $this->max,
+            "step" => (float) $this->step,
+            "req" => ($this->required ? "required" : "")
+        ));
+        return $tpl;
+    }
+
+    public function checkValue($value) {
+        if ($this->required && $value=="") {
+            return false;
+        }
+
+        if ((isset($this->min) && ($value < $this->min)) ||
+           (isset($this->max) && ($value > $this->max)) ||
+           (isset($this->step) && ($value % $this->step !== 0))) {
+            return false;
+        } else if (isset($this->regex)) {
+            return preg_match("/" . $this->regex ."/", $value);
+        } else {
+            return true;
+        }
+    }
+
+    public function setBounds($min, $max, $step=1) {
+        $this->min=$min;
+        $this->max=$max;
+        $this->step=$step;
+    }
+
+
+}
diff -pruN 0.9.4-4/php/classes/conf/item/salt.inc.php 0.9.8-1/php/classes/conf/item/salt.inc.php
--- 0.9.4-4/php/classes/conf/item/salt.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/salt.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,58 @@
+<?php
+/**
+ * A conf\item\salt is a special kind of conf\item\text, which allows auto generation
+ * of a secure salt string.
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf\item;
+
+use template\block;
+
+/**
+ * A conf\item\salt is a special kind of conf\item\text, which allows auto generation
+ * of a secure salt string.
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class salt extends text {
+
+    protected $regex="[a-zA-Z0-9]{10,40}";
+    protected $size=40;
+
+    public function display() {
+        if ($this->internal) {
+            return;
+        }
+        $id=str_replace(".", "_", $this->getName());
+        $tpl=new block("confItemSalt", array(
+            "label" => e(translate($this->getLabel(),0)),
+            "name" => e($this->getName()),
+            "id" => e($id),
+            "value" => e($this->getValue()),
+            "desc" => e(translate($this->getDesc(),0)),
+            "hint" => e(translate($this->getHint(),0)),
+            "regex" => e($this->regex),
+            "size" => (int) $this->size,
+            "req" => ($this->required ? "required" : "")
+        ));
+        return $tpl;
+    }
+}
diff -pruN 0.9.4-4/php/classes/conf/item/select.inc.php 0.9.8-1/php/classes/conf/item/select.inc.php
--- 0.9.4-4/php/classes/conf/item/select.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/select.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,112 @@
+<?php
+/**
+ * A confItemSelect defines a configuration item that is defined using a selectbox
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf\item;
+
+use template\block;
+
+/**
+ * A confItemSelect defines a configuration item that is defined using a selectbox
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class select extends item {
+    /** @var array list of options */
+    private $options=array();
+
+    /** @var bool translate options */
+    private $translate=true;
+
+    /**
+     * Add an option
+     * @param string key
+     * @param string description
+     */
+    public function addOption($key, $desc) {
+        $this->options[$key]=$desc;
+    }
+
+    /**
+     * Add multiple options
+     * @param array array of options
+     */
+    public function addOptions(array $options) {
+        foreach ($options as $key=>$desc) {
+            $this->addOption($key, $desc);
+        }
+    }
+
+    /**
+     * Get array of options
+     * @return array options
+     */
+    public function getOptions() {
+        return $this->options;
+    }
+
+    /**
+     * Set whether or not the options must be translated
+     * @param bool translate yes/no
+     */
+    public function setOptionsTranslate($translate) {
+        $this->translate=$translate;
+    }
+
+    /**
+     * Check value
+     * check if a specific value is legal for this option
+     * @param string value
+     * @return bool
+     */
+    public function checkValue($value) {
+        return array_key_exists($value, $this->options);
+    }
+
+    /**
+     * Display this option through template
+     * @return block template block
+     */
+    public function display() {
+        if ($this->internal) {
+            return;
+        }
+        $params=array(
+            "label"     => e(translate($this->getLabel(), 0)),
+            "name"      => e($this->getName()),
+            "value"     => e($this->getValue()),
+            "desc"      => e(translate($this->getDesc(), 0)) .
+                            $this->displayUnmetRequirements() .
+                            $this->displayDeprecationWarning(),
+            "hint"      => e(translate($this->getHint(), 0)),
+            "enabled"   => (bool) $this->requirementsMet()
+        );
+        if ($this->translate) {
+            $params["options"] = translate($this->getOptions(), 0);
+        } else {
+            $params["options"] = $this->getOptions();
+        }
+        $tpl=new block("confItemSelect", $params);
+        return $tpl;
+     }
+}
diff -pruN 0.9.4-4/php/classes/conf/item/text.inc.php 0.9.8-1/php/classes/conf/item/text.inc.php
--- 0.9.4-4/php/classes/conf/item/text.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/conf/item/text.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,79 @@
+<?php
+/**
+ * A text configuration item defines a configuration item that is defined using a
+ * user-specified string
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace conf\item;
+
+use template\block;
+
+/**
+ * A text configuration item defines a configuration item that is defined using a
+ * user-specified string
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class text extends item {
+
+    protected $regex=".+";
+    protected $size=30;
+
+    public function display() {
+        if ($this->internal) {
+            return;
+        }
+        $tpl=new block("confItemText", array(
+            "label" => e(translate($this->getLabel(),0)),
+            "name" => e($this->getName()),
+            "value" => e($this->getValue()),
+            "desc" => e(translate($this->getDesc(),0)) . $this->displayUnmetRequirements() . $this->displayDeprecationWarning(),
+            "hint" => e($this->getHint()),
+            "regex" => e($this->regex),
+            "size" => (int) $this->size,
+            "req" => ($this->required ? "required" : "")
+        ));
+        return $tpl;
+    }
+
+    public function setRegex($regex) {
+        $this->regex=$regex;
+    }
+
+    public function checkValue($value) {
+        if ($this->required && $value=="") {
+            return false;
+        }
+
+        if (isset($this->regex)) {
+            return preg_match("/" . $this->regex ."/", $value);
+        } else {
+            return true;
+        }
+    }
+
+    public function setSize($size) {
+        $this->size=(int) $size;
+    }
+
+
+}
diff -pruN 0.9.4-4/php/classes/confDefault.inc.php 0.9.8-1/php/classes/confDefault.inc.php
--- 0.9.4-4/php/classes/confDefault.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confDefault.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,907 +0,0 @@
-<?php
-/**
- * This class defines the configuration options Zoph has and their default settings
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * confDefault is the class that defines config options & their defaults
- * in the database
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-class confDefault extends conf {
-
-    protected static function getConfig() {
-        $interface=static::getConfigInterface();
-        static::getConfigInterfaceUser($interface);
-        static::getConfigSSL();
-        static::getConfigURL();
-        static::getConfigPath();
-        static::getConfigMaps();
-        $import=static::getConfigImport();
-        $import=static::getConfigImportFileMode($import);
-        static::getConfigImportCLI($import);
-        static::getConfigWatermark();
-        static::getConfigRotate();
-        static::getConfigShare();
-        static::getConfigFeature();
-        static::getConfigDate();
-    }
-
-    /**
-     * Get config group for interface settings
-     */
-    private static function getConfigInterface() {
-        $interface = conf::addGroup("interface", "Interface settings",
-            "Settings that define how Zoph looks");
-
-        $intTitle = new confItemString();
-        $intTitle->setName("interface.title");
-        $intTitle->setLabel("Title");
-        $intTitle->setDesc("The title for the application. This is what appears " .
-            "on the home page and in the browser's title bar.");
-        $intTitle->setDefault("Zoph");
-        $intTitle->setRegex("^.*$");
-        $interface[]=$intTitle;
-
-        $intWidth = new confItemString();
-        $intWidth->setName("interface.width");
-        $intWidth->setLabel("Screen width");
-        $intWidth->setDesc("A number in pixels (\"px\") or percent (\"%\"), the latter " .
-            "is a percentage of the user's browser window width.");
-        $intWidth->setDefault("800px");
-        $intWidth->setRegex("^[0-9]+(px|%)$");
-        $interface[]=$intWidth;
-
-        $intTpl = new confItemSelect();
-        $intTpl->setName("interface.template");
-        $intTpl->setLabel("Template");
-        $intTpl->setDesc("The template Zoph uses");
-        $intTpl->addOptions(template::getAll());
-        $intTpl->setDefault("default");
-        $interface[]=$intTpl;
-
-        $intAutoc = new confItemBool();
-        $intAutoc->setName("interface.autocomplete");
-        $intAutoc->setLabel("Autocomplete");
-        $intAutoc->setDesc("Use autocompletion for selection of albums, categories, " .
-            "places and people instead of standard HTML selectboxes. Can be individually " .
-            "switched off from user preferences.");
-        $intAutoc->setDefault(true);
-        $interface[]=$intAutoc;
-
-        $intLang = new confItemSelect();
-        $intLang->setName("interface.language");
-        $intLang->setLabel("Default language");
-        $intLang->setDesc("Set the language used when neither the user or the browser " .
-            "specifies a preference");
-        $langs=language::getAll();
-        foreach ($langs as $iso => $lang) {
-            $intLang->addOption($iso, $lang->name);
-        }
-        $intLang->setDefault("en");
-        $interface[]=$intLang;
-
-        $intMaxDays = new confItemNumber();
-        $intMaxDays->setName("interface.max.days");
-        $intMaxDays->setLabel("Maximum days");
-        $intMaxDays->setDesc("The maximum days Zoph displays in a dropdown box for 'photos " .
-            "changed / made in the past ... days' on the search screen");
-        $intMaxDays->setDefault("30");
-        $intMaxDays->setRegex("^[1-9][0-9]{0,2}$");
-        $intMaxDays->setBounds(0, 365, 1);
-        $interface[]=$intMaxDays;
-
-        $intSortOrder = new confItemSelect();
-        $intSortOrder->setName("interface.sort.order");
-        $intSortOrder->setLabel("Default sort order");
-        $intSortOrder->setDesc("Default sort order of photos");
-        $intSortOrder->addOptions(photo::getFields());
-        $intSortOrder->setDefault("date");
-        $interface[]=$intSortOrder;
-
-        $intSortDir = new confItemSelect();
-        $intSortDir->setName("interface.sort.dir");
-        $intSortDir->setLabel("Default sort direction");
-        $intSortDir->setDesc("Default sort order of photos, ascending or descending");
-        $intSortDir->addOption("asc", "Ascending");
-        $intSortDir->addOption("desc", "Descending");
-        $intSortDir->setDefault("asc");
-        $interface[]=$intSortDir;
-
-        $intLogonBgAlbum = new confItemSelect();
-        $intLogonBgAlbum->setName("interface.logon.background.album");
-        $intLogonBgAlbum->setLabel("Logon screen background album");
-        $intLogonBgAlbum->setDesc("Select an album from which a random photo is chosen as a " .
-            "background for the logon screen");
-        $intLogonBgAlbum->addOptions(album::getSelectArray());
-        $intLogonBgAlbum->setOptionsTranslate(false);
-        $intLogonBgAlbum->setDefault(null);
-        $intLogonBgAlbum->requiresEnabled(new confItemBool("share.enable"));
-
-        $interface[]=$intLogonBgAlbum;
-
-        $intCookieExpire = new confItemSelect();
-        $intCookieExpire->setName("interface.cookie.expire");
-        $intCookieExpire->setLabel("Cookie Expiry Time");
-        $intCookieExpire->setDesc("Set the time after which a cookie will expire, that is, " .
-            "when a user will need to re-login. \"session\" (default) means: until user " .
-            "closes the browser");
-        $intCookieExpire->addOptions(array(
-            0       => "session",
-            3600    => "1 hour",
-            14400   => "4 hours",
-            28800   => "8 hours",
-            86400   => "1 day",
-            604800  => "1 week",
-            2592300 => "1 month"
-        ));
-        $intCookieExpire->setDefault(0);
-        $interface[]=$intCookieExpire;
-
-
-        return $interface;
-    }
-
-    /**
-     * Get config group for interface.user settings
-     */
-    private static function getConfigInterfaceUser(confGroup $interface) {
-        $users=user::getAll();
-
-        $intUserDefault = new confItemSelect();
-        $intUserDefault->setName("interface.user.default");
-        $intUserDefault->setLabel("Default user");
-        $intUserDefault->setDesc("Automatically log on as this user when not logged " .
-            "on. Can be used to give people access without a username and password. " .
-            "This user should be a non-admin user and should not have any change " .
-            "permissions.");
-        $intUserDefault->addOption(0, "Disabled");
-        foreach ($users as $usr) {
-            if (!$usr->isAdmin()) {
-                $intUserDefault->addOption($usr->getId(), $usr->getName());
-            }
-        }
-        $intUserDefault->setDefault(0);
-        $interface[]=$intUserDefault;
-
-        $intUserCli = new confItemSelect();
-        $intUserCli->setName("interface.user.cli");
-        $intUserCli->setLabel("CLI user");
-        $intUserCli->setDesc("This is the Zoph user that is used when using the CLI " .
-            "interface when interacting with Zoph. This user must be an admin user. " .
-            "You can also set it to \"autodetect\", which means Zoph will lookup the " .
-            "name of the Unix user starting the CLI client and tries to find that user's " .
-            "name in the Zoph database.");
-        $intUserCli->addOption(0, "Autodetect");
-        foreach ($users as $usr) {
-            if ($usr->isAdmin()) {
-                $intUserCli->addOption($usr->getId(), $usr->getName());
-            }
-        }
-        $intUserCli->setDefault(0);
-        $interface[]=$intUserCli;
-    }
-
-    /**
-     * Get config group for SSL settings
-     */
-    private static function getConfigSSL() {
-        $ssl = conf::addGroup("ssl", "SSL", "Protect your site against eavesdropping by " .
-            "using https. You will need to configure this in your webserver as well.");
-
-        $sslForce = new confItemSelect();
-        $sslForce->setName("ssl.force");
-        $sslForce->setLabel("Force SSL");
-        $sslForce->setDesc("Force users to use https when using Zoph. When connecting " .
-            "to Zoph using http, the user will automatically be redirected to the same " .
-            "URL, but with https. When choosing \"login only\", the user will be " .
-            "redirected back to http after logging in. If your https-site is hosted on " .
-            "a different URL, you will need to define the correct url below.");
-        $sslForce->addOption("never", "Never");
-        $sslForce->addOption("always", "Always");
-        $sslForce->addOption("login", "Login only");
-        $sslForce->setDefault("never");
-        $ssl[]=$sslForce;
-    }
-
-    /**
-     * Get config group for url settings
-     */
-    private static function getConfigURL() {
-        $url = conf::addGroup("url", "URLs", "Define the URLs that are used to access " .
-            "Zoph. Only configure this if Zoph cannot determine it automatically.");
-
-        $urlHttp = new confItemString();
-        $urlHttp->setName("url.http");
-        $urlHttp->setLabel("Zoph's URL");
-        $urlHttp->setDesc("Override autodetection of Zoph's URL, for example if you " .
-            "use a domainname to access Zoph but get redirected to a different URL.");
-        $urlHttp->setDefault("");
-        // This regex was stolen from http://mathiasbynens.be/demo/url-regex, @stephenhay
-        $urlHttp->setRegex("(^$|^https?:\/\/[^\s\/$.?#].[^\s]*$)");
-        $url[]=$urlHttp;
-
-        $urlHttps = new confItemString();
-        $urlHttps->setName("url.https");
-        $urlHttps->setLabel("Zoph's Secure URL");
-        $urlHttps->setDesc("Override autodetection of Zoph's Secure URL (https).");
-        $urlHttps->setDefault("");
-        // This regex was stolen from http://mathiasbynens.be/demo/url-regex, @stephenhay
-        $urlHttps->setRegex("(^$|^https:\/\/[^\s\/$.?#].[^\s]*$)");
-        $url[]=$urlHttps;
-    }
-
-    /**
-     * Get config group for Path settings
-     */
-    private static function getConfigPath() {
-        $path = conf::addGroup("path", "Paths", "File and directory locations");
-
-
-        $pathImages = new confItemString();
-        $pathImages->setName("path.images");
-        $pathImages->setLabel("Images directory");
-        $pathImages->setDesc("Location of the images on the filesystem. Absolute path, " .
-            " thus starting with a /");
-        $pathImages->setDefault("/data/images");
-        $pathImages->setRegex("^\/[A-Za-z0-9_.\/]+$");
-        $pathImages->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), and underscore (_). Must start with a /");
-        $pathImages->setRequired();
-        $path[]=$pathImages;
-
-        $pathUpload = new confItemString();
-        $pathUpload->setName("path.upload");
-        $pathUpload->setLabel("Upload dir");
-        $pathUpload->setDesc("Directory where uploaded files are stored and from where " .
-            "files are imported in Zoph. This is a directory under the images directory " .
-            "(above). For example, if the images directory is set to /data/images and " .
-            "this is set to upload, photos will be uploaded to /data/images/upload.");
-        $pathUpload->setDefault("upload");
-        $pathUpload->setRegex("^[A-Za-z0-9_]+[A-Za-z0-9_.\/]*$");
-        $pathUpload->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), and underscore (_). Can not start with a dot or a slash");
-        $path[]=$pathUpload;
-
-        $pathTrash = new confItemString();
-        $pathTrash->setName("path.trash");
-        $pathTrash->setLabel("Trash dir");
-        $pathTrash->setDesc("Directory where photos are moved when they are " .
-            "deleted. If left blank, files will remain where they were. This is a directory " .
-            "under the images directory (above). For example, if the images directory is set to " .
-            "/data/images and this is set to trash, photos will be moved to /data/images/trash.");
-        $pathTrash->setDefault("");
-        $pathTrash->setRegex("^[A-Za-z0-9_]*[A-Za-z0-9_.\/]*$");
-        $pathTrash->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), and underscore (_). Can not start with a dot or a slash");
-        $path[]=$pathTrash;
-
-        $pathMagic = new confItemString();
-        $pathMagic->setName("path.magic");
-        $pathMagic->setLabel("Magic file");
-        $pathMagic->setDesc("Zoph needs a MIME Magic file to be able to determine the " .
-            "filetype of an uploaded file. This is an important security measure, since " .
-            "it prevents users from uploading files other than images and archives. If " .
-            "left empty, PHP will use the built-in Magic file, if for some reason this " .
-            "does not work, you can specify the location of the MIME magic file. Where " .
-            "this file is located, depends on your distribution, " .
-            "/usr/share/misc/magic.mgc, /usr/share/misc/file/magic.mgc, " .
-            "/usr/share/file/magic are often used.");
-        $pathMagic->setDefault("");
-        $pathMagic->setRegex("^\/[A-Za-z0-9_.\/]+$");
-        $pathMagic->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), and underscore (_). Must start with a /. Can be " .
-            "empty for PHP builtin magic file.");
-        $path[]=$pathMagic;
-
-        $pathUnzip = new confItemString();
-        $pathUnzip->setName("path.unzip");
-        $pathUnzip->setLabel("Unzip command");
-        $pathUnzip->setDesc("The command to use to unzip gzip files. Leave empty to " .
-            "disable uploading .gz files. On most systems \"unzip\" will work.");
-        $pathUnzip->setDefault("");
-        $pathUnzip->setRegex("^([A-Za-z0-9_.\/ -]+|)$");
-        $pathUnzip->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
-        $path[]=$pathUnzip;
-
-        $pathUntar = new confItemString();
-        $pathUntar->setName("path.untar");
-        $pathUntar->setLabel("Untar command");
-        $pathUntar->setDesc("The command to use to untar tar files. Leave empty to disable " .
-            "uploading .tar files. On most systems \"tar xvf\" will work.");
-        $pathUntar->setDefault("");
-        $pathUntar->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
-        $pathUntar->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
-        $path[]=$pathUntar;
-
-        $pathUngz = new confItemString();
-        $pathUngz->setName("path.ungz");
-        $pathUngz->setLabel("Ungzip command");
-        $pathUngz->setDesc("The command to use to unzip gzip files. Leave empty to disable " .
-            "uploading .gz files. On most systems \"gunzip\" will work.");
-        $pathUngz->setDefault("");
-        $pathUngz->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
-        $pathUngz->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
-        $path[]=$pathUngz;
-
-        $pathUnbz = new confItemString();
-        $pathUnbz->setName("path.unbz");
-        $pathUnbz->setLabel("Unbzip command");
-        $pathUnbz->setDesc("The command to use to unzip bzip files. Leave empty to disable " .
-            "uploading .bz files. On most systems \"bunzip2\" will work.");
-        $pathUnbz->setDefault("");
-        $pathUnbz->setRegex("^([A-Za-z0-9_.\/ ]+|)$");
-        $pathUnbz->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward " .
-            "slash (/), dot (.), underscore (_), dash (-) and space. Can be empty to disable");
-        $path[]=$pathUnbz;
-    }
-
-    /**
-     * Get config group for maps settings
-     */
-    private static function getConfigMaps() {
-        $maps = conf::addGroup("maps", "Mapping support",
-            "Add maps to Zoph using various different mapping providers.");
-
-        $mapsProvider = new confItemSelect();
-        $mapsProvider->setName("maps.provider");
-        $mapsProvider->setDesc("Enable or disable mapping support and choose the " .
-            "mapping provider");
-        $mapsProvider->setLabel("Mapping provider");
-        $mapsProvider->addOption("", "Disabled");
-        $mapsProvider->addOption("googlev3", "Google Maps v3");
-        $mapsProvider->setDefault("");
-        $maps[]=$mapsProvider;
-
-        $mapsGeocode = new confItemSelect();
-        $mapsGeocode->setName("maps.geocode");
-        $mapsGeocode->setLabel("Geocode provider");
-        $mapsGeocode->setDesc("With geocoding you can lookup the location of a " .
-            "place from it's name. Here you can select the provider. Currently " .
-            "the only one available is 'geonames'");
-        $mapsGeocode->addOption("", "Disabled");
-        $mapsGeocode->addOption("geonames", "GeoNames");
-        $mapsGeocode->setDefault("");
-        $maps[]=$mapsGeocode;
-    }
-
-    /**
-     * Get config group for import settings
-     */
-    private static function getConfigImport() {
-        $import = conf::addGroup("import", "Import", "Importing and uploading photos");
-
-        $importEnable = new confItemBool();
-        $importEnable->setName("import.enable");
-        $importEnable->setLabel("Import through webinterface");
-        $importEnable->setDesc("Use this option to enable or disable importing using " .
-            "the webbrowser. With this option enabled, an admin user, or a user with " .
-            "import rights, can import files placed in the import directory (below) " .
-            "into Zoph. If you want users to be able to upload as well, you need to " .
-            "enable uploading as well.");
-        $importEnable->setDefault(false);
-        $import[]=$importEnable;
-
-        $importUpload = new confItemBool();
-        $importUpload->setName("import.upload");
-        $importUpload->setLabel("Upload through webinterface");
-        $importUpload->setDesc("Use this option to enable or disable uploading files. " .
-            "With this option enabled, an admin user, or a user with import rights, " .
-            "can upload files to the server running Zoph, they will be placed in the " .
-            "import directory (below). This option requires \"import through web " .
-            "interface\" (above) enabled.");
-        $importUpload->setDefault(false);
-        $import[]=$importUpload;
-
-        $importMaxupload = new confItemNumber();
-        $importMaxupload->setName("import.maxupload");
-        $importMaxupload->setLabel("Maximum filesize");
-        $importMaxupload->setDesc("Maximum size of uploaded file in bytes. You might " .
-            "also need to change upload_max_filesize, post_max_size and possibly" .
-            "max_execution_time and max_input_time in php.ini.");
-        $importMaxupload->setRegex("^[0-9]+$");
-        $importMaxupload->setDefault("10000000");
-        $importMaxupload->setBounds(0, 1000000000, 1); // max = 1GB
-        $import[]=$importMaxupload;
-
-        $importParallel = new confItemNumber();
-        $importParallel->setName("import.parallel");
-        $importParallel->setLabel("Resize parallel");
-        $importParallel->setDesc("Photos will be resized to thumbnail and midsize " .
-            "images during import, this setting determines how many resize actions run " .
-            "in parallel. Can be set to any number. If you have a fast server with " .
-            "multiple CPU's or cores, you can increase this for faster response on " .
-            "the import page.");
-        $importParallel->setRegex("^[0-9]+$");
-        $importParallel->setBounds(1, 99, 1);
-        $importParallel->setDefault("1");
-        $import[]=$importParallel;
-
-        $importRotate = new confItemBool();
-        $importRotate->setName("import.rotate");
-        $importRotate->setLabel("Rotate images");
-        $importRotate->setDesc("Automatically rotate imported images, requires jhead");
-        $importRotate->setDefault(false);
-        $import[]=$importRotate;
-
-        $importResize = new confItemSelect();
-        $importResize->setName("import.resize");
-        $importResize->setLabel("Resize method");
-        $importResize->setDesc("Determines how to resize an image during import. " .
-            "Resize can be about 3 times faster than resample, but the resized image " .
-            "has a lower quality.");
-        $importResize->addOption("resize", "Resize (lower quality / low CPU / fast)");
-        $importResize->addOption("resample", "Resample (high quality / high CPU / slow)");
-        $importResize->setDefault("resample");
-        $import[]=$importResize;
-
-        $importDated = new confItemBool();
-        $importDated->setName("import.dated");
-        $importDated->setLabel("Dated dirs");
-        $importDated->setDesc("Automatically place photos in dated dirs " .
-            "(\"2012.10.16/\") during import");
-        $importDated->setDefault(false);
-        $import[]=$importDated;
-
-        $importDatedHier = new confItemBool();
-        $importDatedHier->setName("import.dated.hier");
-        $importDatedHier->setLabel("Hierarchical dated dirs");
-        $importDatedHier->setDesc("Automatically place photos in a dated directory " .
-            "tree (\"2012/10/16/\") during import. Ignored unless \"Dated dirs\" is " .
-            "also enabled");
-        $importDatedHier->setDefault(false);
-        $import[]=$importDatedHier;
-        return $import;
-    }
-
-    /**
-     * Get config group for import file/dir mode settings
-     */
-    private static function getConfigImportFileMode(confGroup $import) {
-
-        /**
-         * @todo This requires octdec to be run before using it so use
-         * octdec(conf::get("import.filemode")) or you will get "funny" results
-         */
-        $importFilemode = new confItemSelect();
-        $importFilemode->setName("import.filemode");
-        $importFilemode->setLabel("File mode");
-        $importFilemode->setDesc("File mode for the files that are imported in Zoph. " .
-            "Determines who can read or write the files. (RW: Read/Write, RO: Read Only)");
-        $importFilemode->addOptions(array(
-            "0644" => "RW for user, RO for others (0644)",
-            "0664" => "RW for user/group, RO for others (0664)",
-            "0666" => "RW for everyone (0666)",
-            "0660" => "RW for user/group, not readable for others (0660)",
-            "0640" => "RW for user, RO for group, not readable for others (0640)",
-            "0600" => "RW for user, not readable for others (0600)"
-        ));
-        $importFilemode->setDefault("0644");
-        $import[]=$importFilemode;
-
-        /**
-         * @todo This requires octdec to be run before using it so use
-         * octdec(conf::get("import.dirmode")) or you will get "funny" results
-         */
-        $importDirmode = new confItemSelect();
-        $importDirmode->setName("import.dirmode");
-        $importDirmode->setLabel("dir mode");
-        $importDirmode->setDesc("Mode for directories that are created by Zoph. " .
-            "Determines who can read or write the files. (RW: Read/Write, RO: Read Only)");
-        $importDirmode->addOptions(array(
-            "0755" => "RW for user, RO for others (0755)",
-            "0775" => "RW for user/group, RO for others (0775)",
-            "0777" => "RW for everyone (0777)",
-            "0770" => "RW for user/group, not readable for others (0770)",
-            "0750" => "RW for user, RO for group, not readable for others (0750)",
-            "0700" => "RW for user, not readable for others (0700)"
-        ));
-        $importDirmode->setDefault("0755");
-        $import[]=$importDirmode;
-        return $import;
-    }
-
-    /**
-     * Get config group for import CLI settings
-     */
-    private static function getConfigImportCLI(confGroup $import) {
-        $importCliVerbose=new confItemNumber();
-        $importCliVerbose->setName("import.cli.verbose");
-        $importCliVerbose->setLabel("CLI verbose");
-        $importCliVerbose->setDesc("Set CLI verbosity, can be overriden with --verbose");
-        $importCliVerbose->setDefault("0");
-        $importCliVerbose->setBounds(1,99,1);
-        $importCliVerbose->setInternal();
-        $import[]=$importCliVerbose;
-
-        $importCliThumbs=new confItemBool();
-        $importCliThumbs->setName("import.cli.thumbs");
-        $importCliThumbs->setLabel("CLI: generate thumbnails");
-        $importCliThumbs->setDesc("Generate thumbnails when importing via CLI. Can be " .
-            "overridden with --thumbs (-t) and --no-thumbs (-n).");
-        $importCliThumbs->setDefault(true);
-        $import[]=$importCliThumbs;
-
-        $importCliExif=new confItemBool();
-        $importCliExif->setName("import.cli.exif");
-        $importCliExif->setLabel("CLI: read EXIF data");
-        $importCliExif->setDesc("Read EXIF data when importing via CLI. The default " .
-            "behaviour can be overridden with --exif and --no-exif.");
-        $importCliExif->setDefault(true);
-        $import[]=$importCliExif;
-
-        $importCliSize=new confItemBool();
-        $importCliSize->setName("import.cli.size");
-        $importCliSize->setLabel("CLI: size of image");
-        $importCliSize->setDesc("Update image dimensions in database when importing " .
-            "via CLI. The default behaviour can be overridden with --size and --no-size.");
-        $importCliSize->setDefault(true);
-        $import[]=$importCliSize;
-
-        $importCliHash=new confItemBool();
-        $importCliHash->setName("import.cli.hash");
-        $importCliHash->setLabel("CLI: calculate hash");
-        $importCliHash->setDesc("Calculate a hash when importing or updating a photo " .
-            "using the CLI. Can be overridden with --hash and --no-hash.");
-        $importCliHash->setDefault(true);
-        $import[]=$importCliHash;
-
-        $importCliCopy=new confItemBool();
-        $importCliCopy->setName("import.cli.copy");
-        $importCliCopy->setDefault(false);
-        $importCliCopy->setLabel("CLI: copy on import");
-        $importCliCopy->setDesc("Make a copy of a photo that is imported using the " .
-            "CLI. Can be overridden with --copy and --move.");
-        $import[]=$importCliCopy;
-
-        $importCliUseids=new confItemBool();
-        $importCliUseids->setName("import.cli.useids");
-        $importCliUseids->setLabel("CLI: Use Ids");
-        $importCliUseids->setDesc("Use ids instead of filenames when referencing photos.");
-        $importCliUseids->setDefault(false);
-        $importCliUseids->setInternal();
-        $import[]=$importCliUseids;
-
-        $importCliAddAuto=new confItemBool();
-        $importCliAddAuto->setName("import.cli.add.auto");
-        $importCliAddAuto->setLabel("CLI: Auto add");
-        $importCliAddAuto->setDesc("Add non-existent albums, categories, places and " .
-            "people, when a parent is defined.");
-        $importCliAddAuto->setDefault(false);
-        $importCliAddAuto->setInternal();
-        $import[]=$importCliAddAuto;
-
-        $importCliAddAlways=new confItemBool();
-        $importCliAddAlways->setName("import.cli.add.always");
-        $importCliAddAlways->setLabel("CLI: Auto add always");
-        $importCliAddAlways->setDesc("Add non-existent albums, categories, places " .
-            "and people, regardsless of whether a parent is defined.");
-        $importCliAddAlways->setDefault(false);
-        $importCliAddAlways->setInternal();
-        $import[]=$importCliAddAlways;
-
-        $importCliRecursive=new confItemBool();
-        $importCliRecursive->setName("import.cli.recursive");
-        $importCliRecursive->setLabel("CLI: Recursive");
-        $importCliRecursive->setDesc("Recursively import directories when importing " .
-            "using the CLI.");
-        $importCliRecursive->setDefault(false);
-        $importCliRecursive->setInternal();
-        $import[]=$importCliRecursive;
-    }
-
-    /**
-     * Get config group for watermark settings
-     */
-    private static function getConfigWatermark() {
-        $watermark = conf::addGroup("watermark", "Watermarking",
-            "Watermarking can display a (copyright) watermark over your full-size images.");
-
-        $watermarkEnable = new confItemBool();
-        $watermarkEnable->setName("watermark.enable");
-        $watermarkEnable->setLabel("Enable Watermarking");
-        $watermarkEnable->setDesc("Watermarking only works if the watermark file below is set " .
-            "to an existing GIF image. Please note that enabling this function uses a " .
-            "rather large amount of memory on the webserver. PHP by default allows a " .
-            "script to use a maximum of 8MB memory. You should probably increase this " .
-            "by changing memory_limit in php.ini. A rough estimation of how much memory " .
-            "it will use is 6 times the number of megapixels in your camera. For " .
-            "example, if you have a 5 megapixel camera, change memory_limit in php.ini to 30M");
-        $watermarkEnable->setDefault(false);
-        $watermark[]=$watermarkEnable;
-
-        /** @todo: should allow .png too */
-        $watermarkFile = new confItemString();
-        $watermarkFile->setName("watermark.file");
-        $watermarkFile->setLabel("Watermark file");
-        $watermarkFile->setDesc("If watermarking is used, this should be set to the name of the " .
-            "file that will be used as the watermark. It should be a GIF file, for best " .
-            "results, use contrasting colours and transparency. In the Contrib directory, " .
-            "3 example files are included. The filename is relative to the image directory, " .
-            "defined above.");
-        $watermarkFile->setDefault("");
-        $watermarkFile->setRegex("(^$|^[A-Za-z0-9_]+[A-Za-z0-9_.\/]*\.gif$)");
-        $watermarkFile->setHint("Alphanumeric characters (A-Z, a-z and 0-9), forward slash (/), " .
-            "dot (.), and underscore (_). Can not start with a dot or a slash");
-        $watermark[]=$watermarkFile;
-
-        $watermarkPosX = new confItemSelect();
-        $watermarkPosX->setName("watermark.pos.x");
-        $watermarkPosX->setLabel("Horizontal position");
-        $watermarkPosX->setDesc("Define where the watermark will be placed horizontally.");
-        $watermarkPosX->addOptions(array(
-            "left" => "Left",
-            "center" => "Center",
-            "right" => "Right"
-        ));
-        $watermarkPosX->setDefault("center");
-        $watermark[]=$watermarkPosX;
-
-        $watermarkPosY = new confItemSelect();
-        $watermarkPosY->setName("watermark.pos.y");
-        $watermarkPosY->setLabel("Vertical position");
-        $watermarkPosY->setDesc("Define where the watermark will be placed vertically.");
-        $watermarkPosY->addOptions(array(
-            "top" => "Top",
-            "center" => "Center",
-            "bottom" => "Bottom"
-        ));
-        $watermarkPosY->setDefault("center");
-        $watermark[]=$watermarkPosY;
-
-        $watermarkTrans = new confItemNumber();
-        $watermarkTrans->setName("watermark.transparency");
-        $watermarkTrans->setLabel("Watermark transparency");
-        $watermarkTrans->setDesc("Define the transparency of a watermark. 0: fully " .
-            "transparent (invisible, don't use this, it's pointless and eats " .
-            "up a lot of resources, better turn off the watermark feature " .
-            "altogether) to 100: no transparency.");
-        $watermarkTrans->setDefault("50");
-        $watermarkTrans->setRegex("^(100|[0-9]{1,2})$");
-        $watermarkTrans->setBounds(0, 100, 1);
-        $watermark[]=$watermarkTrans;
-    }
-
-    /**
-     * Get config group for rotation settings
-     */
-    private static function getConfigRotate() {
-        $rotate = conf::addGroup("rotate", "Rotation", "Rotate images");
-
-        $rotateEnable = new confItemBool();
-        $rotateEnable->setName("rotate.enable");
-        $rotateEnable->setLabel("Rotation");
-        $rotateEnable->setDesc("Allow users (admins or with write access) to rotate images");
-        $rotateEnable->setDefault(false);
-        $rotate[]=$rotateEnable;
-
-        $rotateCommand = new confItemSelect();
-        $rotateCommand->setName("rotate.command");
-        $rotateCommand->setLabel("Rotate command");
-        $rotateCommand->setDesc("Determine which command is used to rotate the image. " .
-            "This command must be available on your system. Convert is a lossy " .
-            "rotate function, which means it will lower the image quality of your " .
-            "photo. JPEGtran, on the other hand, only works on JPEG images, but " .
-            "is lossless.");
-        $rotateCommand->addOptions(array(
-            "convert" => "convert",
-            "jpegtran" => "jpegtran"
-        ));
-        $rotateCommand->setDefault("convert");
-        $rotate[]=$rotateCommand;
-
-        $rotateBackup = new confItemBool();
-        $rotateBackup->setName("rotate.backup");
-        $rotateBackup->setLabel("Backup");
-        $rotateBackup->setDesc("Keep a backup image when rotating an image.");
-        $rotateBackup->setDefault(true);
-        $rotate[]=$rotateBackup;
-
-        $rotateBackupPrefix = new confItemString();
-        $rotateBackupPrefix->setName("rotate.backup.prefix");
-        $rotateBackupPrefix->setLabel("Backup prefix");
-        $rotateBackupPrefix->setDesc("Prepend backup file for rotation backups with this.");
-        $rotateBackupPrefix->setDefault("orig_");
-        $rotateBackupPrefix->setRegex("^[a-zA-Z0-9_\-]+$");
-        $rotateBackupPrefix->setRequired();
-        $rotate[]=$rotateBackupPrefix;
-    }
-
-    /**
-     * Get config group for share settings
-     */
-    private static function getConfigShare() {
-        $share = conf::addGroup("share", "Sharing", "Sharing photos with non-logged on users");
-
-        $shareEnable = new confItemBool();
-        $shareEnable->setName("share.enable");
-        $shareEnable->setLabel("Sharing");
-        $shareEnable->setDesc("Sometimes, you may wish to share an image in Zoph " .
-            "without creating a user account for those who will be watching them. " .
-            "For example, in order to post a link to an image on a forum or website. " .
-            "When this option is enabled, you will see a 'share' tab next to a photo, " .
-            "where you will find a few ways to share a photo, such as a url and a " .
-            "HTML &lt;img&gt; tag. With this special url, it is possible to open a " .
-            "photo without logging in to Zoph. You can determine per user whether " .
-            "or not this user will see the tab and therefore the urls.");
-        $shareEnable->setDefault(false);
-        $share[]=$shareEnable;
-
-        $shareSaltFull = new confItemSalt();
-        $shareSaltFull->setName("share.salt.full");
-        $shareSaltFull->setLabel("Salt for sharing full size images");
-        $shareSaltFull->setDesc("When using the sharing feature, Zoph uses a hash " .
-            "to identify a photo. Because you do not want people who have access to " .
-            "you full size photos (via Zoph or otherwise) to be able to generate " .
-            "these hashes, you should give Zoph a secret salt so only authorized " .
-            "users of your Zoph installation can generate them. The salt for full " .
-            "size images (this one) must be different from the salt of mid size " .
-            "images (below), because this allows Zoph to distinguish between them. " .
-            "If a link to your Zoph installation is being abused (for example " .
-            "because someone whom you mailed a link has published it on a forum), " .
-            "you can modify the salt to make all hash-based links to your Zoph invalid.");
-        $shareSaltFull->setDefault("Change this");
-        $shareSaltFull->setRequired();
-        $share[]=$shareSaltFull;
-
-        $shareSaltMid = new confItemSalt();
-        $shareSaltMid->setName("share.salt.mid");
-        $shareSaltMid->setLabel("Salt for sharing mid size images");
-        $shareSaltMid->setDesc("The salt for mid size images (this one) must be " .
-            "different from the salt of full images (above), because this allows " .
-            "Zoph to distinguish between them. If a link to your Zoph installation " .
-            "is being abused (for example because someone whom you mailed a link " .
-            "has published it on a forum), you can modify the salt to make all " .
-            "hash-based links to your Zoph invalid.");
-        $shareSaltMid->setDefault("Modify this");
-        $shareSaltMid->setRequired();
-        $share[]=$shareSaltMid;
-    }
-
-    /**
-     * Get config group for feature settings
-     */
-    private static function getConfigFeature() {
-        $feature = conf::addGroup("feature", "Features", "Various features");
-
-        $featureDownload = new confItemBool();
-        $featureDownload->setName("feature.download");
-        $featureDownload->setLabel("Downloading");
-        $featureDownload->setDesc("With this feature you can use download a set of " .
-            "photos (Albums, Categories, Places, People or a search result) in " .
-            "one or more ZIP files. Important! The photos in the ZIP file will " .
-            "NOT be watermarked. You must also grant each non-admin user you " .
-            "want to give these rights permission by changing \"can download " .
-            "zipfiles\" in the user's profile.");
-        $featureDownload->setDefault(false);
-        $feature[]=$featureDownload;
-
-        $featureComments = new confItemBool();
-        $featureComments->setName("feature.comments");
-        $featureComments->setLabel("Comments");
-        $featureComments->setDesc("Enable comments. Before a user can actually leave " .
-            "comments, you should also give the user these rights through the edit " .
-            "user screen.");
-        $featureComments->setDefault(false);
-        $feature[]=$featureComments;
-
-        $featureMail = new confItemBool();
-        $featureMail->setName("feature.mail");
-        $featureMail->setLabel("Mail photos");
-        $featureMail->setDesc("You can enable or disable the \"mail this photo feature\" " .
-            "using this option. Since Zoph needs to convert the photo into Base64 " .
-            "encoding for mail, it requires quite a large amount of memory if you " .
-            "try to send full size images and you may need to adjust memory_limit " .
-            "in php.ini, you should give it at least about 4 times the size of your " .
-            "largest image.");
-        $featureMail->setDefault(false);
-        $feature[]=$featureMail;
-
-        $featureMailBcc = new confItemString();
-        $featureMailBcc->setName("feature.mail.bcc");
-        $featureMailBcc->setLabel("BCC address");
-        $featureMailBcc->setDesc("Automatically Blind Carbon Copy this mailaddress when " .
-            "a mail from Zoph is sent");
-        $featureMailBcc->setDefault("");
-        // not sure how long the "new" TLD's are going to be,
-        // 10 should be enough for most, feel free to report
-        // a bug if your TLD is longer.
-        $featureMailBcc->setRegex("^([0-9a-zA-Z_\-%\.]+@([0-9a-zA-Z\-]+\.)+[a-zA-Z]{2,10})?$");
-        $feature[]=$featureMailBcc;
-
-        $featureAnnotate = new confItemBool();
-        $featureAnnotate->setName("feature.annotate");
-        $featureAnnotate->setLabel("Annotate photos");
-        $featureAnnotate->setDesc("A user can use the annotate photo function to e-mail a " .
-            "photo with a textual annotation. Can only be used in combination with the " .
-            "\"Mail photos\" feature above.");
-        $featureAnnotate->setDefault(false);
-        $feature[]=$featureAnnotate;
-
-        $featureRating = new confItemBool();
-        $featureRating->setName("feature.rating");
-        $featureRating->setLabel("Photo rating");
-        $featureRating->setDesc("Allow users to rate photos. Before a non-admin user can " .
-            "actually rate, you should also give the user these rights through the " .
-            "edit user screen.");
-        $featureRating->setDefault(true);
-        $feature[]=$featureRating;
-    }
-
-    /**
-     * Get config group for date settings
-     */
-    private static function getConfigDate() {
-        $date = conf::addGroup("date", "Date and time", "Date and time related settings");
-
-        $dateTz = new confItemSelect();
-        $dateTz->setName("date.tz");
-        $dateTz->setLabel("Timezone");
-        $dateTz->setDesc("This setting determines the timezone to which your camera " .
-            "is set. Leave empty if you do not want to use this feature and always set " .
-            "your camera to the local timezone");
-
-        $dateTz->addOptions(TimeZone::getTzArray());
-        $dateTz->setDefault("");
-
-        $date[]=$dateTz;
-
-        $dateGuesstz = new confItemBool();
-        $dateGuesstz->setName("date.guesstz");
-        $dateGuesstz->setLabel("Guess timezone");
-        $dateGuesstz->setDesc("If you have defined the precise location of a place " .
-            "(using the mapping feature), Zoph can 'guess' the timezone based on this " .
-            "location. It uses the Geonames project for this. This will, however, send " .
-            "information to their webserver, do not enable this feature if you're not " .
-            "comfortable with that.");
-        $dateGuesstz->setDefault(false);
-        $date[]=$dateGuesstz;
-
-        $dateFormat = new confItemString();
-        $dateFormat->setName("date.format");
-        $dateFormat->setLabel("Date format");
-        $dateFormat->setDesc("This determines how Zoph displays dates. You can use the " .
-            "following characters: dDjlNSwzWFmMntLoYy (for explanation, see " .
-            "http://php.net/manual/en/function.date.php) and /, space, -, (, ), :, \",\" and .");
-        $dateFormat->setDefault("d-m-Y");
-        $dateFormat->setRegex("^[dDjlNSwzWFmMntLoYy\/ \-():,.]+$");
-        $dateFormat->setRequired();
-        $date[]=$dateFormat;
-
-        $dateTimeFormat = new confItemString();
-        $dateTimeFormat->setName("date.timeformat");
-        $dateTimeFormat->setLabel("Time format");
-        $dateTimeFormat->setDesc("This determines how Zoph displays times. You can use the " .
-            "following characters: aABgGhHisueIOPTZcrU (for explanation, see " .
-            "http://php.net/manual/en/function.date.php) and /, space, -, (, ), :, \",\" and .");
-        $dateTimeFormat->setDefault("H:i:s T");
-        $dateTimeFormat->setRegex("^[aABgGhHisueIOPTZcrU\/ \-():,.]+$");
-        $dateTimeFormat->setRequired();
-        $date[]=$dateTimeFormat;
-
-    }
-}
-
diff -pruN 0.9.4-4/php/classes/confGroup.inc.php 0.9.8-1/php/classes/confGroup.inc.php
--- 0.9.4-4/php/classes/confGroup.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confGroup.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,157 +0,0 @@
-<?php
-/**
- * A confGroup groups several configuration items (@see confItem) together.
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * Group of @see confItem objects
- * @package Zoph
- * @author Jeroen Roos
- */
-class confGroup implements ArrayAccess, IteratorAggregate {
-    /** @var string Name of group */
-    private $name;
-    /** @var string Label */
-    private $label;
-    /** @var string Description */
-    private $desc;
-    /** @var array confItem objects */
-    private $items=array();
-
-    /**
-     * Set the name of the group
-     * @param string Name
-     */
-    public function setName($name) {
-        $this->name=$name;
-    }
-
-    /**
-     * Set the description of the group
-     * @param string Description
-     */
-    public function setDesc($desc) {
-        $this->desc=$desc;
-    }
-
-    /**
-     * Set the label of the group
-     * @param string Label
-     */
-    public function setLabel($label) {
-        $this->label=$label;
-    }
-
-    /**
-     * Get name
-     * @return string Name
-     */
-    public function getName() {
-        return $this->name;
-    }
-
-    /**
-     * Get description
-     * @return string Description
-     */
-    public function getDesc() {
-        return $this->desc;
-    }
-
-    /**
-     * Get label
-     * @return string Label
-     */
-    public function getLabel() {
-        return $this->label;
-    }
-
-    /**
-     * Check if item exists
-     * For ArrayAccess interface
-     * @param string offset
-     * @return bool whether or not key $off exists in items array
-     */
-    public function offsetExists($off) {
-        return isset($this->items[$off]);
-    }
-
-    /**
-     * Return item
-     * For ArrayAccess interface
-     * @param string offset
-     * @return confItem
-     */
-    public function offsetGet($off) {
-        return $this->items[$off];
-    }
-
-    /**
-     * Add item
-     * For ArrayAccess interface
-     * @param string offset
-     * @param string value
-     */
-    public function offsetSet($off, $value) {
-        if (is_null($off)) {
-            if ($value instanceof confItem) {
-                $off=$value->getName();
-            }
-        }
-        if (!is_null($off)) {
-            $this->items[$off]=$value;
-        } else {
-            $this->items[]=$value;
-        }
-    }
-
-    /**
-     * Unset item (remove)
-     * For ArrayAccess interface
-     * @param string offset
-     */
-    public function offsetUnset($off) {
-        unset($this->items[$off]);
-    }
-
-    /**
-     * For IteratorAggregate interface
-     * allow us to do foreach () on this object
-     */
-    public function getIterator() {
-        return new ArrayIterator($this->items);
-    }
-
-    /**
-     * Display group
-     * @return block template block
-     */
-    public function display() {
-        $tpl=new block("confGroup", array(
-            "title" => translate($this->getLabel(),0),
-            "desc"  => translate($this->getDesc(),0),
-            "items" => $this->items
-        ));
-        return $tpl;
-    }
-
-
-}
diff -pruN 0.9.4-4/php/classes/conf.inc.php 0.9.8-1/php/classes/conf.inc.php
--- 0.9.4-4/php/classes/conf.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/conf.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,201 +0,0 @@
-<?php
-/**
- * Via this class Zoph can read configurations from the database
- * the configurations themselves are stored in confItem objects
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-use db\select;
-use db\param;
-use db\delete;
-use db\db;
-use db\clause;
-
-/**
- * conf is the main object for access to Zoph's configuration
- * in the database
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-class conf {
-
-    /**
-     * @var array Groups are one or more configuration objects that
-     *            belong together;
-     */
-    private static $groups=array();
-
-    /** @var bool whether or not the configuration has been loaded from the db */
-    private static $loaded=false;
-
-    /**
-     * Get the Id of the conf item
-     */
-    public function  getId() {
-        return $this->get("conf_id");
-    }
-
-    /**
-     * Read configuration from database
-     */
-    public static function loadFromDB() {
-        confDefault::getConfig();
-        $qry=new select(array("co" => "conf"));
-        $qry->addFields(array("conf_id", "value"));
-
-        try {
-            $result=db::query($qry);
-        } catch (PDOException $e) {
-            log::msg("Cannot load configuration from database", log::FATAL, log::CONFIG | log::DB);
-        }
-
-        while ($row = $result->fetch(PDO::FETCH_NUM)) {
-            $key=$row[0];
-            $value=$row[1];
-            try {
-                $item=static::getItemByName($key);
-                try {
-                    $item->setValue($value);
-                } catch (ConfigurationException $e) {
-                    /* An illegal value is automatically set to the default */
-                    log::msg($e->getMessage(), log::ERROR, log::CONF);
-                }
-            } catch (ConfigurationException $e) {
-                /* An unknown item will automatically be deleted from the
-                   database, so we can remove items without leaving a mess */
-                log::msg($e->getMessage(), log::NOTIFY, log::CONF);
-                $qry=new delete(array("co" => "conf"));
-                $qry->where(new clause("conf_id=:confid"));
-                $qry->addParam(new param(":confid", $key, PDO::PARAM_STR));
-                $qry->execute();
-            }
-
-        }
-        static::$loaded=true;
-
-    }
-
-    /**
-     * Read configuration from submitted form
-     * @param array of $_GET or $_POST variables
-     */
-    public static function loadFromRequestVars(array $vars) {
-        confDefault::getConfig();
-        foreach ($vars as $key=>$value) {
-            if (substr($key,0,1) == "_") {
-                if (substr($key,0,7) == "_reset_") {
-                    $key=substr(str_replace("_", ".", $key),7);
-                    $item=static::getItemByName($key);
-                    $item->delete();
-                }
-                continue;
-            }
-            $key=str_replace("_", ".", $key);
-            try {
-                if (!isset($vars["_reset_" . $key])) {
-                    $item=static::getItemByName($key);
-                    $item->setValue($value);
-                    $item->update();
-                }
-            } catch(ConfigurationException $e) {
-                log::msg("Configuration cannot be updated: " .
-                    $e->getMessage(), log::ERROR, log::CONFIG);
-            }
-        }
-        static::$loaded=true;
-    }
-
-
-    /**
-     * Get a configuration item by name
-     * @param string Name of item to return
-     * @return confItem Configuration item
-     * @throws ConfigurationException
-     */
-    public static function getItemByName($name) {
-        $nameArr=explode(".", $name);
-        $group=array_shift($nameArr);
-        if (isset(static::$groups[$group]) && isset(static::$groups[$group][$name])) {
-            return static::$groups[$group][$name];
-        } else {
-            throw new ConfigurationException("Unknown configuration item " . $name);
-        }
-    }
-
-    /**
-     * Get the value of a configuration item
-     * @param string Name of item to return
-     * @return string Value of parameter
-     */
-    public static function get($key) {
-        if (!static::$loaded) {
-            static::loadFromDB();
-        }
-        $item=static::getItemByName($key);
-        return $item->getValue();
-
-    }
-
-    /**
-     * Set the value of a configuration item
-     * Does not store this value in the database as this is mainly
-     * used for runtime-overriding a stored value. This function returns
-     * the object so the calling function can do a $item->update() if
-     * it should be stored in the db.
-     * @param string Name of item to change
-     * @param string Value to set
-     * @return confItem the item that has been updated
-     */
-    public static function set($key, $value) {
-        $item=static::getItemByName($key);
-        $item->setValue($value);
-        return $item;
-    }
-
-    /**
-     * Get all configuration items (in groups)
-     * @return array Array of group objects
-     */
-    public static function getAll() {
-        if (!static::$loaded) {
-            static::loadFromDB();
-        }
-        return static::$groups;
-    }
-
-    /**
-     * Create a new confGroup and add it to the list
-     * @param string name
-     * @param string label
-     * @param string description
-     */
-    public static function addGroup($name, $label, $desc = "") {
-        $group = new confGroup();
-
-        $group->setName($name);
-        $group->setLabel($label);
-        $group->setDesc($desc);
-
-
-        static::$groups[$name]=$group;
-        return $group;
-    }
-}
diff -pruN 0.9.4-4/php/classes/confItemBool.inc.php 0.9.8-1/php/classes/confItemBool.inc.php
--- 0.9.4-4/php/classes/confItemBool.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItemBool.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,66 +0,0 @@
-<?php
-/**
- * A confItemBool defines a configuration item that can be true or false
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * A confItemBool defines a configuration item that can be true or false
- * @package Zoph
- * @author Jeroen Roos
- */
-class confItemBool extends confItem {
-
-    /**
-     * Set value
-     * @param bool value
-     */
-    public function setValue($value) {
-        parent::setValue((bool) $value);
-    }
-
-    /**
-     * Check value
-     * check if a specific value is legal for this option
-     * @param string value
-     * @return bool
-     */
-    public function checkValue($value) {
-        return ((bool) $value == $value);
-    }
-
-    /**
-     * Display this option through template
-     * @return block template block
-     */
-    public function display() {
-        if ($this->internal) {
-            return;
-        }
-        $tpl=new block("confItemBool", array(
-            "label" => e(translate($this->getLabel(),0)),
-            "name" => e($this->getName()),
-            "checked" => $this->getValue() ? "checked" : "",
-            "desc" => e(translate($this->getDesc(),0)),
-            "hint" => e(translate($this->getHint(),0)),
-        ));
-        return $tpl;
-     }
-}
diff -pruN 0.9.4-4/php/classes/confItem.inc.php 0.9.8-1/php/classes/confItem.inc.php
--- 0.9.4-4/php/classes/confItem.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItem.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,273 +0,0 @@
-<?php
-/**
- * A confItem defines a configuration item
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-use db\select;
-use db\param;
-use db\clause;
-
-/**
- * Configuration item
- * @package Zoph
- * @author Jeroen Roos
- */
-abstract class confItem extends zophTable {
-    /** @var string The name of the database table */
-    protected static $tableName="conf";
-    /** @var array List of primary keys */
-    protected static $primaryKeys=array("conf_id");
-    /** @var array Fields that may not be empty */
-    protected static $notNull=array();
-    /** @var bool keep keys with insert. In most cases the keys are set by
-                  the db with auto_increment */
-    protected static $keepKeys = true;
-    /** @var string URL for this class */
-    protected static $url="config.php#";
-
-
-    /** @var string Label to display */
-    protected $label;
-    /** @var string Longer description of item */
-    protected $desc;
-    /** @var string Default value */
-    protected $default;
-    /** @var string Input hint (format to use) */
-    protected $hint;
-    /** @var bool required, whether or not field may be empty*/
-    protected $required=false;
-    /** @var bool internal, internal settings can not be changed from webinterface */
-    protected $internal=false;
-    /** @var array list of items that MUST be enabled for this item to be enabled */
-    protected $requiresEnabled=array();
-    /** @var array list of unmet requirements */
-    protected $unmet=array();
-
-    /**
-     * Create confItem object
-     * @param string id, to fetch object from database.
-     * @retrun confItem new object
-     */
-    public function __construct($id = 0) {
-        if ($id === 0 || preg_match("/^[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/", $id)) {
-            $this->set("conf_id", $id);
-        } else {
-            log::msg("Illegal configuration id", log::FATAL, log::VARS);
-        }
-
-    }
-
-    /**
-     * Update or insert configuration item
-     * checks if item already exists in db
-     * and updates it if it does or inserts
-     * if it does not.
-     */
-    final public function update() {
-        if ($this->checkValue($this->get("value"))) {
-            $qry=new select(array("co" => "conf"));
-            $qry->addFunction(array("count" => "COUNT(conf_id)"));
-            $qry->where(new clause("conf_id=:confid"));
-            $qry->addParam(new param(":confid", $this->fields["conf_id"], PDO::PARAM_STR));
-
-            if ($qry->getCount() > 0) {
-                parent::update();
-            } else {
-                parent::insert();
-            }
-        }
-    }
-
-    /**
-     * Get name of item
-     * @return string name
-     */
-    final public function getName() {
-        return $this->fields["conf_id"];
-    }
-
-
-    /**
-     * Get label for item
-     * @return string label
-     */
-
-    final public function getLabel() {
-        return $this->label;
-    }
-
-    /**
-     * Get description for item
-     * @return string description
-     */
-    final public function getDesc() {
-        return $this->desc;
-    }
-
-    /**
-     * Get value of item
-     * if value is not set, get default
-     * @return string value
-     */
-    final public function getValue() {
-        if (!isset($this->fields["value"]) || $this->fields["value"]===null || !$this->requirementsMet()) {
-            return $this->getDefault();
-        } else {
-            return $this->fields["value"];
-        }
-    }
-
-    /**
-     * Set value of item
-     * @param string value
-     * @throws ConfigurationException
-     */
-    public function setValue($value) {
-        if ($this->checkValue($value)) {
-            $this->fields["value"]=$value;
-        } else {
-            throw new ConfigurationException("Configuration value for " .
-                $this->getName() . " is illegal");
-        }
-    }
-
-    /**
-     * Get default value of item
-     * @return string default value
-     */
-    final public function getDefault() {
-        return $this->default;
-    }
-
-    /**
-     * Get hint for item
-     * @return string hint
-     */
-    final public function getHint() {
-        return $this->hint;
-    }
-
-    /**
-     * Set name (id) of item
-     * @param string name
-     */
-    final public function setName($name) {
-        $this->fields["conf_id"]=$name;
-    }
-
-    /**
-     * Set label for item
-     * @param string label
-     */
-    final public function setLabel($label) {
-        $this->label=$label;
-    }
-
-    /**
-     * Set label for item
-     * @param string label
-     */
-    final public function setDesc($desc) {
-        $this->desc=$desc;
-    }
-
-    /**
-     * Set hint for item
-     * @param string hint
-     */
-    final public function setHint($hint) {
-        $this->hint=$hint;
-    }
-
-    /**
-     * Set whether or not a field is required
-     * @param bool
-     */
-    final public function setRequired($req=true) {
-        $this->required=(bool) $req;
-    }
-
-    /**
-     * Set whether or not a field is internal
-     * an internal field is not exposed in the webinterface
-     * and (at this moment) not stored in the database, although this is not enforced
-     * as there may be a future use-case where this will change.
-     * @param bool
-     */
-    final public function setInternal($int=true) {
-        $this->internal=(bool) $int;
-    }
-
-    /**
-     * Set default value for item
-     * @param string default
-     */
-    final public function setDefault($default) {
-        $this->default=$default;
-    }
-
-    /**
-     * This item requires another item to be enabled
-     */
-    final public function requiresEnabled(confItemBool $item) {
-        $this->requiresEnabled[]=$item;
-    }
-
-    /**
-     * Are all requirements met?
-     */
-    final protected function requirementsMet() {
-        $met=true;
-        foreach ($this->requiresEnabled as $req) {
-            $req->lookup();
-            if ((bool) $req->getValue() === false) {
-                $met=false;
-                $this->unmet[$req->getName()]="enabled";
-            }
-        }
-        return $met;
-    }
-
-    /**
-     * Return a template block to show the unmet requirements for this
-     * confItem.
-     * @return block overview of unmet items
-     */
-    final protected function displayUnmetRequirements() {
-        if (!$this->requirementsMet()) {
-            return new block("confUnmetRequirements", array(
-                "unmet" => $this->unmet
-            ));
-        }
-    }
-
-    /**
-     * Display the item
-     */
-    abstract public function display();
-
-    /**
-     * Check whether value is legal
-     * @param string value
-     */
-    abstract public function checkValue($value);
-
-}
diff -pruN 0.9.4-4/php/classes/confItemNumber.inc.php 0.9.8-1/php/classes/confItemNumber.inc.php
--- 0.9.4-4/php/classes/confItemNumber.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItemNumber.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,80 +0,0 @@
-<?php
-/**
- * A confItemNumber defines a configuration item that is defined using a user-specified number
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * A confItemNumber defines a configuration item that is defined using a user-specified number
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-class confItemNumber extends confItemString {
-
-    protected $regex="[0-9]+";
-    protected $min=0;
-    protected $max=99;
-    protected $step=1;
-
-    public function display() {
-        if ($this->internal) {
-            return;
-        }
-        $tpl=new block("confItemNumber", array(
-            "label" => e(translate($this->getLabel(),0)),
-            "name" => e($this->getName()),
-            "value" => e($this->getValue()),
-            "desc" => e(translate($this->getDesc(),0)),
-            "hint" => e(translate($this->getHint(),0)),
-            "regex" => e($this->regex),
-            "size" => (int) $this->size,
-            "min" => (float) $this->min,
-            "max" => (float) $this->max,
-            "step" => (float) $this->step,
-            "req" => ($this->required ? "required" : "")
-        ));
-        return $tpl;
-    }
-
-    public function checkValue($value) {
-        if ($this->required && $value=="") {
-            return false;
-        }
-
-        if ((isset($this->min) && ($value < $this->min)) ||
-           (isset($this->max) && ($value > $this->max)) ||
-           (isset($this->step) && ($value % $this->step !== 0))) {
-            return false;
-        } else if (isset($this->regex)) {
-            return preg_match("/" . $this->regex ."/", $value);
-        } else {
-            return true;
-        }
-    }
-
-    public function setBounds($min, $max, $step=1) {
-        $this->min=$min;
-        $this->max=$max;
-        $this->step=$step;
-    }
-
-
-}
diff -pruN 0.9.4-4/php/classes/confItemSalt.inc.php 0.9.8-1/php/classes/confItemSalt.inc.php
--- 0.9.4-4/php/classes/confItemSalt.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItemSalt.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,54 +0,0 @@
-<?php
-/**
- * A confItemSalt is a special kind of confItemString, which allows auto generation
- * of a secure salt string.
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * A confItemSalt is a special kind of confItemString, which allows auto generation
- * of a secure salt string.
- * @package Zoph
- * @author Jeroen Roos
- */
-class confItemSalt extends confItemString {
-
-    protected $regex="[a-zA-Z0-9]{10,40}";
-    protected $size=40;
-
-    public function display() {
-        if ($this->internal) {
-            return;
-        }
-        $id=str_replace(".", "_", $this->getName());
-        $tpl=new block("confItemSalt", array(
-            "label" => e(translate($this->getLabel(),0)),
-            "name" => e($this->getName()),
-            "id" => e($id),
-            "value" => e($this->getValue()),
-            "desc" => e(translate($this->getDesc(),0)),
-            "hint" => e(translate($this->getHint(),0)),
-            "regex" => e($this->regex),
-            "size" => (int) $this->size,
-            "req" => ($this->required ? "required" : "")
-        ));
-        return $tpl;
-    }
-}
diff -pruN 0.9.4-4/php/classes/confItemSelect.inc.php 0.9.8-1/php/classes/confItemSelect.inc.php
--- 0.9.4-4/php/classes/confItemSelect.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItemSelect.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,107 +0,0 @@
-<?php
-/**
- * A confItemSelect defines a configuration item that is defined using a selectbox
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * A confItemSelect defines a configuration item that is defined using a selectbox
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-class confItemSelect extends confItem {
-    /** @var array list of options */
-    private $options=array();
-
-    /** @var bool translate options */
-    private $translate=true;
-
-    /**
-     * Add an option
-     * @param string key
-     * @param string description
-     */
-    public function addOption($key, $desc) {
-        $this->options[$key]=$desc;
-    }
-
-    /**
-     * Add multiple options
-     * @param array array of options
-     */
-    public function addOptions(array $options) {
-        foreach ($options as $key=>$desc) {
-            $this->addOption($key, $desc);
-        }
-    }
-
-    /**
-     * Get array of options
-     * @return array options
-     */
-    public function getOptions() {
-        return $this->options;
-    }
-
-    /**
-     * Set whether or not the options must be translated
-     * @param bool translate yes/no
-     */
-    public function setOptionsTranslate($translate) {
-        $this->translate=$translate;
-    }
-
-    /**
-     * Check value
-     * check if a specific value is legal for this option
-     * @param string value
-     * @return bool
-     */
-    public function checkValue($value) {
-        return array_key_exists($value, $this->options);
-    }
-
-    /**
-     * Display this option through template
-     * @return block template block
-     */
-    public function display() {
-        if ($this->internal) {
-            return;
-        }
-        $params=array(
-            "label"     => e(translate($this->getLabel(), 0)),
-            "name"      => e($this->getName()),
-            "value"     => e($this->getValue()),
-            "desc"      => e(translate($this->getDesc(), 0)),
-            "unmet"     => $this->displayUnmetRequirements(),
-            "hint"      => e(translate($this->getHint(), 0)),
-            "enabled"   => (bool) $this->requirementsMet()
-        );
-        if ($this->translate) {
-            $params["options"] = translate($this->getOptions(), 0);
-        } else {
-            $params["options"] = $this->getOptions();
-        }
-        $tpl=new block("confItemSelect", $params);
-        return $tpl;
-     }
-}
diff -pruN 0.9.4-4/php/classes/confItemString.inc.php 0.9.8-1/php/classes/confItemString.inc.php
--- 0.9.4-4/php/classes/confItemString.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/confItemString.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,73 +0,0 @@
-<?php
-/**
- * A confItemString defines a configuration item that is defined using a user-specified string
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-
-/**
- * A confItemString defines a configuration item that is defined using a user-specified string
- *
- * @package Zoph
- * @author Jeroen Roos
- */
-class confItemString extends confItem {
-
-    protected $regex=".+";
-    protected $size=30;
-
-    public function display() {
-        if ($this->internal) {
-            return;
-        }
-        $tpl=new block("confItemString", array(
-            "label" => e(translate($this->getLabel(),0)),
-            "name" => e($this->getName()),
-            "value" => e($this->getValue()),
-            "desc" => e(translate($this->getDesc(),0)),
-            "hint" => e($this->getHint()),
-            "regex" => e($this->regex),
-            "size" => (int) $this->size,
-            "req" => ($this->required ? "required" : "")
-        ));
-        return $tpl;
-    }
-
-    public function setRegex($regex) {
-        $this->regex=$regex;
-    }
-
-    public function checkValue($value) {
-        if ($this->required && $value=="") {
-            return false;
-        }
-
-        if (isset($this->regex)) {
-            return preg_match("/" . $this->regex ."/", $value);
-        } else {
-            return true;
-        }
-    }
-
-    public function setSize($size) {
-        $this->size=(int) $size;
-    }
-
-
-}
diff -pruN 0.9.4-4/php/classes/db/db.inc.php 0.9.8-1/php/classes/db/db.inc.php
--- 0.9.4-4/php/classes/db/db.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/db/db.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -58,7 +58,9 @@ class db {
      * @param string password
      */
     private function __construct($dsn, $dbuser, $dbpass) {
-        static::$connection=new PDO($dsn,$dbuser,$dbpass);
+        static::$connection=new PDO($dsn,$dbuser,$dbpass, array(
+            PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'
+        ));
         static::$connection->setAttribute(
             PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
         static::$connection->setAttribute(
@@ -151,7 +153,8 @@ class db {
             }
 
         } catch (\PDOException $e) {
-            debug_print_backtrace();
+            var_dump($e->getTraceAsString());
+            echo $query->prettyPrint();
             echo $e->getMessage() . "\n";
             log::msg("SQL failed", log::FATAL, log::DB);
         }
diff -pruN 0.9.4-4/php/classes/db/query.inc.php 0.9.8-1/php/classes/db/query.inc.php
--- 0.9.4-4/php/classes/db/query.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/db/query.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -288,7 +288,7 @@ abstract class query {
      */
     public function addWhereFromConstraints(array $constraints, $conj = "AND", $ops = null) {
         $where=null;
-        while (list($name, $value) = each($constraints)) {
+        foreach ($constraints as $name => $value) {
             $op = "=";
             if ($ops && !empty($ops["$name"])) {
                 $op = $ops["$name"];
@@ -345,6 +345,7 @@ abstract class query {
     /**
      * Format a query, including all parameters, for debugging purposes
      * @codeCoverageIgnore
+     * @param bool Output with HTML
      */
     public function prettyPrint($withHTML=false) {
         $sql=(string) $this;
diff -pruN 0.9.4-4/php/classes/db/selectHelper.inc.php 0.9.8-1/php/classes/db/selectHelper.inc.php
--- 0.9.4-4/php/classes/db/selectHelper.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/db/selectHelper.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -46,15 +46,19 @@ class selectHelper {
     public static function getAutoCoverOrder(select $query, $autocover="highest") {
         switch ($autocover) {
         case "oldest":
+            $query->addFields(array("p.date", "p.time"));
             $qry=$query->addOrder("p.date")->addOrder("p.time")->addLimit(1);
             break;
         case "newest":
+            $query->addFields(array("p.date", "p.time"));
             $qry=$query->addOrder("p.date DESC")->addOrder("p.time DESC")->addLimit(1);
             break;
         case "first":
+            $query->addFields(array("p.timestamp"));
             $qry=$query->addOrder("p.timestamp")->addLimit(1);
             break;
         case "last":
+            $query->addFields(array("p.timestamp"));
             $qry=$query->addOrder("p.timestamp DESC")->addLimit(1);
             break;
         case "random":
@@ -62,6 +66,7 @@ class selectHelper {
             break;
         case "highest":
         default:
+            $query->addFields(array("ar.rating"));
             $qry=$query->addOrder("ar.rating DESC")->addLimit(1);
             break;
         }
diff -pruN 0.9.4-4/php/classes/file.inc.php 0.9.8-1/php/classes/file.inc.php
--- 0.9.4-4/php/classes/file.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/file.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @package Zoph
  */
 
+use conf\conf;
+
 /**
  * This class takes care of individual files
  * For now, this is only used in the import module of Zoph
diff -pruN 0.9.4-4/php/classes/form.inc.php 0.9.8-1/php/classes/form.inc.php
--- 0.9.4-4/php/classes/form.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/form.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,130 +0,0 @@
-<?php
-/**
- * Class that takes care of displaying a form
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * This class takes care of displaying forms
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-class form extends block {
-    /**
-     * Add a form field INPUT type text
-     * @param string name
-     * @param string current / initial value
-     * @param string label text for label
-     * @param string input hint
-     * @param int size of the field
-     */
-    public function addInputText($name, $value, $label=null, $hint=null, $maxlength=32, $size=null) {
-        if (!$size) {
-            $size=$maxlength;
-        }
-        $this->addBlock(new block("formInputText", array(
-            "name"  => $name,
-            "value" => e($value),
-            "label" => e($label),
-            "hint"  => e($hint),
-            "size"  => (int) $size,
-            "maxlength"  => (int) $maxlength
-        )));
-    }
-
-    /**
-     * Add a form field INPUT type password
-     * @param string name
-     * @param string label text for label
-     * @param string input hint
-     * @param int size of the field
-     */
-    public function addInputPassword($name, $label=null, $hint=null, $size=32) {
-        $this->addBlock(new block("formInputPassword", array(
-            "name"  => $name,
-            "label" => e($label),
-            "hint"  => e($hint),
-            "size"  => (int) $size
-        )));
-    }
-
-    /**
-     * Add a form field INPUT type hidden
-     * @param string name
-     * @param string value
-     */
-    public function addInputHidden($name, $value) {
-        $this->addBlock(new block("formInputHidden", array(
-            "name"  => $name,
-            "value" => e($value),
-        )));
-    }
-
-    /**
-     * Add a form field INPUT type checkbox
-     * @param string name
-     * @param bool checked
-     * @param string label text for label
-     * @param string input hint
-     */
-    public function addInputCheckbox($name, $checked, $label, $hint=null) {
-        $this->addBlock(new block("formInputCheckbox", array(
-            "name"  => $name,
-            "checked" => $checked,
-            "label" => e($label),
-            "hint"  => e($hint),
-        )));
-    }
-
-    /**
-     * Add a form field TEXTAREA
-     * @param string name
-     * @param string current / initial value
-     * @param string label text for label
-     * @param int columns
-     * @param int rows
-     */
-    public function addTextarea($name, $value, $label=null, $cols=40, $rows=4) {
-        $this->addBlock(new block("formTextarea", array(
-            "name"  => $name,
-            "value" => e($value),
-            "label" => e($label),
-            "cols"  => (int) $cols,
-            "rows"  => (int) $rows
-        )));
-    }
-
-    /**
-     * Add a form field dropdown
-     * this function is not actually creating the dropdown, but
-     * acts as a wrapper around the dropdown, to add a label
-     * @param string name
-     * @param block dropdown
-     * @param string label text for label
-     */
-    public function addPulldown($name, block $dropdown, $label) {
-        $this->addBlock(new block("formPulldown", array(
-            "name"      => $name,
-            "dropdown"  => $dropdown,
-            "label"     => $label
-        )));
-    }
-}
diff -pruN 0.9.4-4/php/classes/generic/collection.inc.php 0.9.8-1/php/classes/generic/collection.inc.php
--- 0.9.4-4/php/classes/generic/collection.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/generic/collection.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,190 @@
+<?php
+/**
+ * Generic collection class
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace generic;
+
+use ArrayIterator;
+
+/**
+ * Collection class
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+abstract class collection implements \ArrayAccess, \IteratorAggregate, \Countable {
+    /** @var array of items in this collection */
+    protected $items=array();
+
+    /**
+     * Check if item exists
+     * For ArrayAccess interface
+     * @param string offset
+     * @return bool whether or not key $off exists in items array
+     */
+    public function offsetExists($off) {
+        return isset($this->items[$off]);
+    }
+
+    /**
+     * Return item
+     * For ArrayAccess interface
+     * @param string offset
+     * @return conf\item
+     */
+    public function offsetGet($off) {
+        return $this->items[$off];
+    }
+
+    /**
+     * Add item
+     * For ArrayAccess interface
+     * @param string offset
+     * @param string value
+     */
+    public function offsetSet($off, $value) {
+        if (!is_null($off)) {
+            $this->items[$off]=$value;
+        } else {
+            $this->items[]=$value;
+        }
+    }
+
+    /**
+     * Unset item (remove)
+     * For ArrayAccess interface
+     * @param string offset
+     */
+    public function offsetUnset($off) {
+        unset($this->items[$off]);
+    }
+
+    /**
+     * For IteratorAggregate interface
+     * allow us to do foreach () on this object
+     */
+    public function getIterator() {
+        return new ArrayIterator($this->items);
+    }
+
+    /**
+     * For Countable interface
+     * return size of this collection
+     */
+    public function count() {
+        return count($this->items);
+    }
+
+    /**
+     * Return a subset of this collection as a new collection
+     * @param int start of subset
+     * @param int size of subset
+     */
+    public function subset($start, $count=null) {
+        return static::createFromArray(array_slice($this->items, $start, $count, true));
+    }
+
+    /**
+     * Pop last element off the collection
+     * @return last object of the collection
+     */
+    public function pop() {
+        return array_pop($this->items);
+    }
+
+    /**
+     * Shift first element off the collection
+     * @return first object of the collection
+     */
+    public function shift() {
+        return array_shift($this->items);
+    }
+
+    /**
+     * Get random element(s) from the collection
+     */
+    public function random($count = 1) {
+        $count = min(sizeof($this), $count);
+        $rndKeys=(array) array_rand($this->items, $count);
+        $rndColl = new static();
+        foreach ($rndKeys as $key) {
+            $rndColl[$key] = $this[$key];
+        }
+        return $rndColl;
+    }
+
+    /**
+     * Merge this collection with other collection(s)
+     * @param collection to merge with [, collection to merge with [ , ... ]]
+     * return collection
+     */
+    public function merge(self ...$toMerge) {
+        $merged=array();
+        array_unshift($toMerge, $this);
+        foreach ($toMerge as $collection) {
+            $merged=array_merge($merged, $collection->toArray());
+        }
+        return static::createFromArray($merged);
+    }
+
+    /**
+     * Renumber the items so that each item has it's key as
+     * it's key in the array
+     * @param callable alternate function to determine key (default ->getId() )
+     * @return collection
+     */
+    public function renumber(callable $function=null) {
+        // Default for callable can only be null
+        if (!$function) {
+            $function="getId";
+        }
+        $return = new static;
+        foreach ($this->items as $item) {
+            $id = call_user_func(array($item, $function));
+            $return[$id]=$item;
+        }
+        return $return;
+    }
+
+    /**
+     * Turn this collection into an array
+     */
+    protected function toArray() {
+        return $this->items;
+    }
+
+    /**
+     * Create a new collection from an array
+     * @param array Items to put in new collection
+     */
+    public static function createFromArray(array $items, $withKeys = false) {
+        $collection = new static();
+        if ($withKeys) {
+            foreach ($items as $item) {
+                $collection[$item->getId()]=$item;
+            }
+        } else {
+            $collection->items = $items;
+        }
+        return $collection;
+    }
+}
diff -pruN 0.9.4-4/php/classes/generic/controller.inc.php 0.9.8-1/php/classes/generic/controller.inc.php
--- 0.9.4-4/php/classes/generic/controller.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/generic/controller.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,180 @@
+<?php
+/**
+ * Generic Controller
+ * Handles basic form actions, such as confirm, delete, edit, insert, new and update
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ * @author Jason Geiger
+ */
+
+namespace generic;
+
+use breadcrumb;
+
+use web\request;
+
+use zophTable;
+
+/**
+ * Generic Controller
+ * Handles basic form actions, such as confirm, delete, edit, insert, new and update
+ */
+abstract class controller {
+
+    /** @var request holds request */
+    protected   $request;
+    /** @var zophTable holds object to operate on */
+    protected   $object;
+    /** @var string where to redirect after action */
+    public      $redirect   = "zoph.php";
+    /** @var array Actions that can be used in this controller */
+    protected   $actions    = array("confirm", "delete", "display", "edit", "insert", "new", "update");
+    /** @var string view to call after action */
+    protected   $view       = "display";
+
+    /**
+     * Create a new controller from a web request
+     * @param request Request to proces
+     */
+    public function __construct(request $request) {
+        $this->request=$request;
+    }
+
+    /**
+     * Set the object to operate on
+     * @param zophTable object to operate on
+     */
+    public function setObject(zophTable $obj) {
+        $this->object=$obj;
+    }
+
+    /**
+     * Do the action as set in the request
+     * in the current mode of operation, no authorization checking is needed,
+     * because currently, the authorization checking is done inside the actions
+     * however, it would be nice to do some checking here as a first line of defense
+     */
+    public function doAction() {
+        $action=$this->request["_action"];
+
+        /** @todo This needs more authorization checking */
+        if (in_array($action, $this->actions)) {
+            $function = "action" . ucwords($action);
+            $this->$function();
+        } else {
+            $this->actionDisplay();
+        }
+    }
+
+    /**
+     * Action: edit
+     * The edit action calls a view that will allow the user to update the
+     * current object.
+     */
+    protected function actionEdit() {
+        $this->view = "update";
+
+    }
+
+    /**
+     * Action: update
+     * The update action processes a form as generated after the "edit" action.
+     * The subsequently called view displays the object.
+     */
+    protected function actionUpdate() {
+        $this->object->setFields($this->request->getRequestVars());
+        $this->object->update();
+        $this->view = "display";
+
+    }
+
+    /**
+     * Action: new
+     * The new action calls a view that displays a form that allows the user
+     * to create a new object.
+     */
+    protected function actionNew() {
+        $this->object->setFields($this->request->getRequestVars());
+        $this->view = "insert";
+    }
+
+    /**
+     * Action: insert
+     * The insert action processes a form as generated after the "new" action.
+     * The subsequently called view displays the object.
+     */
+    protected function actionInsert() {
+        $this->object->setFields($this->request->getRequestVars());
+        $this->object->insert();
+        $this->view = "display";
+
+    }
+
+    /**
+     * Action: delete
+     * The delete action asks for confirmation of a delete of the current object
+     */
+    protected function actionDelete() {
+        $this->view = "confirm";
+
+    }
+
+    /**
+     * Action: confirm
+     * The confirm action is called when the user confirms the delete
+     * this deletes the object and then redirects the user back the the
+     * last page he visited before the delete.
+     */
+    protected function actionConfirm() {
+        $this->object->delete();
+
+        breadcrumb::eat();
+        $crumb = breadcrumb::getLast();
+        if ($crumb instanceof breadcrumb) {
+            $this->redirect=$crumb->getLink();
+        }
+
+        $this->view = "redirect";
+    }
+
+    /**
+     * The display action displays the object
+     */
+    protected function actionDisplay() {
+        $this->view = "display";
+    }
+
+    /**
+     * get View
+     * each of the actions dictate a subsequent view in the workflow,
+     * the view can be called by this function
+     * currently, it simply returns a name, in the future an action View object
+     * may be returned.
+     */
+    public function getView() {
+        return $this->view;
+    }
+
+    /**
+     * Get the object to operate on
+     */
+    public function getObject() {
+        return $this->object;
+    }
+}
diff -pruN 0.9.4-4/php/classes/generic/variable.inc.php 0.9.8-1/php/classes/generic/variable.inc.php
--- 0.9.4-4/php/classes/generic/variable.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/generic/variable.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Various operations on variables such as escaping
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ * @author Jason Geiger
+ * @author David Baldwin
+ */
+
+namespace generic;
+
+/**
+ * Variable handling
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class variable {
+    /** @var holds value of variable */
+    private $value;
+
+    /**
+     * Create object
+     * @param value to assign
+     */
+    public function __construct($value) {
+        $this->value=$value;
+    }
+
+    /**
+     * Get value
+     */
+    public function __toString() {
+        return (string) $this->value;
+    }
+
+    /**
+     * Get value
+     */
+    public function get() {
+        return $this->value;
+    }
+
+    /**
+     * This function will escape the user input and remove HTML tags
+     */
+    public function input() {
+        $var=$this->value;
+        if ($var === "<" || $var === "<=" || $var === ">=" || $var === ">") {
+            // Strip tags breaks some searches
+            $value=$var;
+        } else if (is_array($var)) {
+            $value=array();
+            foreach ($var as $key => $arrayValue) {
+                $keyVar=new variable($key);
+                $valueVar=new variable($arrayValue);
+                $value[$keyVar->input()]=$valueVar->input();
+            }
+        } else {
+            $value=strip_tags(html_entity_decode($var));
+        }
+        return $value;
+    }
+
+    /**
+     * Return escaped output
+     * @param array|string value to be escaped
+     */
+    public function escape($var=null) {
+        if (!$var) {
+            $var=$this->value;
+        }
+
+        if (is_array($var)) {
+            $return=array();
+            foreach ($var as $key => $arrayValue) {
+                $return[static::escape($key)]=static::escape($arrayValue);
+            }
+        } else {
+            $return=htmlspecialchars($var);
+            /* Extra escape for a few chars that may cause troubles but are
+               not escaped by htmlspecialchars. */
+            $return=str_replace(array("<", ">", "\"", "(", ")", "'", "[",  "]", "{", "}", "~", "`"),
+                array("&lt;", "&gt;", "&quot;", "&#40;", "&#41;", "&#39;","&#91;", "&#93;", "&#123;",
+                  "&#125;", "&#126;", "&#96;"), $return);
+        }
+        return $return;
+    }
+}
diff -pruN 0.9.4-4/php/classes/geo/Exception.inc.php 0.9.8-1/php/classes/geo/Exception.inc.php
--- 0.9.4-4/php/classes/geo/Exception.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/Exception.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,30 @@
+<?php
+/**
+ * A geo\Exception is the base for all geo-related exceptions
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package ZophException
+ */
+
+namespace geo;
+
+/**
+ * A geo\Exception is the base for all geo-related exceptions
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class Exception extends \zophException { }
diff -pruN 0.9.4-4/php/classes/geo/gpxException.inc.php 0.9.8-1/php/classes/geo/gpxException.inc.php
--- 0.9.4-4/php/classes/geo/gpxException.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/gpxException.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,30 @@
+<?php
+/**
+ * A gpxException gets thrown when something goes wrong with loading a GPX file
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package ZophException
+ */
+
+namespace geo;
+
+/**
+ * A gpxException gets thrown when something goes wrong with loading a GPX file
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class gpxException extends Exception { }
diff -pruN 0.9.4-4/php/classes/geo/map.inc.php 0.9.8-1/php/classes/geo/map.inc.php
--- 0.9.4-4/php/classes/geo/map.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/map.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,188 @@
+<?php
+/**
+ * Map. Create and display a map using Leaflet.
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace geo;
+
+use conf\conf;
+use place;
+use photo;
+
+/**
+ * Mapping class.
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class map extends \template\block {
+
+    /** @var div id for map */
+    private $map = "map";
+
+    /** @var center lattitude */
+    protected $clat;
+    /** @var center longitude */
+    protected $clon;
+    /** @var zoom level */
+    protected $zoom;
+
+    /** @var array of tracks to display on this map */
+    private $tracks=array();
+    /** @var array of markers to display on this map */
+    private $markers=array();
+
+    /** @var whether or not this map can be changed. i.e. used to add a marker */
+    protected $edit=false;
+
+    /**
+     * Create a new map object
+     * @param string template to use
+     * @param array variables to pass to template
+     * @return map new object
+     */
+    public function __construct($template="map", $vars=null) {
+        if (!is_array($vars)) {
+            $vars=array();
+        }
+        if (!array_key_exists("id", $vars)) {
+            $vars["id"]=$this->map;
+        }
+        if (!array_key_exists("provider", $vars)) {
+            $vars["provider"]=conf::get("maps.provider");
+        }
+        parent::__construct($template, $vars);
+
+    }
+
+    /**
+     * Add a marker to the map
+     * @param marker marker to add
+     */
+    public function addMarker(marker $marker) {
+        $this->markers[]=$marker;
+    }
+
+    /**
+     * Add multiple markers from objects
+     * @param array Array of objects to get markers from
+     */
+    public function addMarkers(array $objs) {
+        foreach ($objs as $obj) {
+            $marker=$obj->getMarker();
+            if ($marker instanceof marker) {
+                $this->addMarker($marker);
+            }
+        }
+    }
+
+    /**
+     * Get markers for this map
+     * @return array array of markers for this map.
+     * if multiple photos are taken in the same place, that place
+     * is multiple times in the array, so this function removes doubles
+     */
+    public function getMarkers() {
+        return array_unique($this->markers, SORT_REGULAR);
+    }
+
+    /**
+     * Checks whether this maps has markers
+     * @return bool
+     */
+    public function hasMarkers() {
+        return !empty($this->markers);
+    }
+
+    /**
+     * Add a track
+     * @param track
+     */
+    public function addTrack(track $track) {
+        $this->tracks[]=$track;
+    }
+
+    /**
+     * Get tracks
+     * @return Array tracks
+     */
+    public function getTracks() {
+        return $this->tracks;
+    }
+
+    /**
+     * Checks whether this maps has tracks
+     * @return bool
+     */
+    public function hasTracks() {
+        return !empty($this->tracks);
+    }
+
+    /**
+     * Set center and zoom
+     * This sets the center point and zoom level for the map
+     * @param float latitude
+     * @param float longitude
+     * @param int zoom level
+     */
+    public function setCenterAndZoom($lat=0, $lon=0, $zoom=2) {
+        $this->clat=(float) $lat;
+        $this->clon=(float) $lon;
+        $this->zoom=(int) $zoom;
+    }
+
+    /**
+     * Set center and zoom from object
+     * Can take a location object and determine center and zoom from there
+     * it can also take a photo object to determine c&s.
+     * If a photo object does not have c&z, it will see if the photo has
+     * a location set, and determine it from there.
+     * If a location does not have c&z, it can go up in the tree until
+     * it find an ancestor with c&z set.
+     * @param photo|place object to get location from
+     * @todo mapable interface should be created
+     */
+    public function setCenterAndZoomFromObj($obj) {
+        $lat=$obj->get("lat");
+        $lon=$obj->get("lon");
+        $zoom=$obj->get("mapzoom");
+        if (!$lat && !$lon) {
+            if ($obj instanceof photo && $obj->location instanceof place) {
+                $this->setCenterAndZoomFromObj($obj->location);
+            } else if ($obj instanceof place && (place::getRoot()->getId() != $obj->getId())) {
+                $this->setCenterAndZoomFromObj($obj->getParent());
+            }
+        } else {
+            $this->setCenterAndZoom($lat ?: 0, $lon ?: 0, $zoom ?: 2);
+        }
+    }
+
+    /**
+     * Set whether or not this map can be changed
+     * (used to add markers to a place or photo)
+     * @param bool
+     */
+    public function setEditable($edit=true) {
+        $this->edit=(bool) $edit;
+    }
+
+}
+
diff -pruN 0.9.4-4/php/classes/geo/marker.inc.php 0.9.8-1/php/classes/geo/marker.inc.php
--- 0.9.4-4/php/classes/geo/marker.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/marker.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,79 @@
+<?php
+/**
+ * Marker to be displayed on map
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace geo;
+
+/**
+ * Class to display markers on a map
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class marker {
+    /** @var float latitude */
+    public $lat=0;
+    /** @var float longitude */
+    public $lon=0;
+    /** @var string icon to be displayed */
+    public $icon="";
+    /** @var string title of marker */
+    public $title="";
+    /** @var string contents of marker 'bubble' */
+    public $quicklook="";
+
+   /**
+    * Create a new marker object
+    * @param float latitude
+    * @param float longitude
+    * @param string icon to be displayed
+    * @param string title of marker
+    * @param string contents of marker 'bubble'
+    * @return marker
+    */
+    public function __construct($lat, $lon, $icon, $title, $quicklook) {
+        $this->lat=$lat;
+        $this->lon=$lon;
+        $this->icon=$icon;
+        $this->title=$title;
+        $this->quicklook=$quicklook;
+    }
+
+    /**
+     * Get marker from object
+     * @param photo|place Object to get marker from
+     * @param string Icon to use
+     * @return marker created marker.
+     * @todo A "mapable" interface should be created to make sure
+     *       only certain objects can get passed to this function.
+     */
+    public static function getFromObj($obj, $icon) {
+        $lat=$obj->get("lat");
+        $lon=$obj->get("lon");
+        if ($lat && $lon) {
+            $title=$obj->get("title");
+            $quicklook=$obj->getQuicklook();
+            return new self($lat, $lon, $icon, $title, $quicklook);
+        } else {
+            return null;
+        }
+    }
+}
diff -pruN 0.9.4-4/php/classes/geo/point.inc.php 0.9.8-1/php/classes/geo/point.inc.php
--- 0.9.4-4/php/classes/geo/point.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/point.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,237 @@
+<?php
+/**
+ * A point is a GPS position + time, used for Geotagging
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace geo;
+
+use db\delete;
+use db\param;
+use db\clause;
+use db\select;
+
+use XMLReader;
+use PDO;
+
+/**
+ * This class describes a point, which is a GPS position + timestamp
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class point extends \zophTable {
+    /** @var string The name of the database table */
+    protected static $tableName="point";
+    /** @var array List of primary keys */
+    protected static $primaryKeys=array("point_id");
+    /** @var array Fields that may not be empty */
+    protected static $notNull=array();
+    /** @var bool keep keys with insert. In most cases the keys are set by
+                  the db with auto_increment */
+    protected static $keepKeys = false;
+    /** @var string URL for this class */
+    protected static $url;
+    /**
+     * Create object from XML-snippet
+     * @param string snippet of XML-code
+     */
+    public static function readFromXML($xmldata) {
+        date_default_timezone_set("UTC");
+        $point=new point();
+        $xml=new XMLReader();
+        $xml->xml($xmldata);
+        $xml->read();
+        $point->set("lat", $xml->getAttribute("lat"));
+        $point->set("lon", $xml->getAttribute("lon"));
+        while ($xml->read()) {
+            if ($xml->nodeType==XMLReader::ELEMENT) {
+                switch ($xml->name) {
+                case "name":
+                    $xml->read();
+                    $name=$xml->value;
+                    $point->set("name", $name);
+                    break;
+                case "ele":
+                    $xml->read();
+                    $point->set("ele", $xml->value);
+                    break;
+                case "speed":
+                    $xml->read();
+                    $point->set("speed", $xml->value);
+                    break;
+                case "time":
+                    $xml->read();
+                    $datetime=strtotime($xml->value);
+                    $point->set("datetime", date("Y-m-d H:i:s", $datetime));
+                    break;
+                default:
+                    // unrecognized element, ignore
+                    break;
+                }
+            }
+        }
+        return $point;
+    }
+
+    /**
+     * Create a query to find the next and previous points
+     * this query will be expanded by the @see getNext() and @see getPrevious() methods
+     * @return array (select, clause);
+     */
+    private function getNextPrevQry() {
+        $qry=new select(array("pt" => "point"));
+        $where=new clause("track_id=:trackid");
+
+        $qry->addParams(array(
+            new param(":trackid", (int) $this->get("track_id"), PDO::PARAM_INT),
+            new param(":datetime", $this->get("datetime"), PDO::PARAM_STR)
+        ));
+
+        $qry->addLimit(1);
+        return array($qry, $where);
+    }
+
+    /**
+     * Get the next (in time) point from a track
+     */
+    public function getNext() {
+        list($qry, $where)=$this->getNextPrevQry();
+
+        $where->addAnd(new clause("datetime>:datetime"));
+        $qry->where($where);
+
+        $qry->addOrder("datetime");
+
+        $points=static::getRecordsFromQuery($qry);
+        if (is_array($points) && sizeof($points) > 0) {
+            return $points[0];
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Get the  previous (in time) point from a track
+     */
+    public function getPrev() {
+        list($qry, $where)=$this->getNextPrevQry();
+
+        $where->addAnd(new clause("datetime<:datetime"));
+
+        $qry->where($where);
+
+        $qry->addOrder("datetime DESC");
+
+        $points=static::getRecordsFromQuery($qry);
+        if (is_array($points) && sizeof($points) > 0) {
+            return $points[0];
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Calculate the distance to another point
+     *
+     * @param point Point to calculate distance to
+     * @param string "km" or "miles"
+     * @return int distance
+     */
+    private function getDistanceTo(point $p2, $entity="km") {
+        $p1=$this;
+        $lat1=$p1->get("lat");
+        $lon1=$p1->get("lon");
+        $lat2=$p2->get("lat");
+        $lon2=$p2->get("lon");
+
+        $distance=(6371 * acos(
+            cos(deg2rad($lat1)) *
+            cos(deg2rad($lat2)) *
+            cos(deg2rad($lon2) - deg2rad($lon1)) +
+            sin(deg2rad($lat1)) *
+            sin(deg2rad($lat2))));
+
+        if ($entity=="miles") {
+            $distance=$distance / 1.609344;
+        }
+
+        return $distance;
+    }
+
+    /**
+     * Interpolate between points to find out the location on a
+     * certain moment
+     *
+     * This is an approximate calculation
+     * and could be very inaccurate if the distance between the
+     * points is large, therefore you can give a max distance in km
+     *
+     * The longer time there is between 2 point the smaller the
+     * chance is you actually travelled in a straight line between
+     * the points, so you can also give a max time, in seconds.
+     * @param point is the point where you are at t1
+     * @param point is the point where you are at t2
+     * @param int t3 is the time you want to calculate the position for
+     * @param int maximum distance to to calculation for
+     * @param string entity of distances ("km" or "miles")
+     * @param int maximum time between two points
+     * @return point this function will return where you are at t3
+     * @todo the "return false" should both be changed into an Exception
+     */
+    public static function interpolate(point $p1, point $p2,
+        $t3, $maxdist=null, $entity="km", $maxtime=null) {
+
+        $t1 = strtotime($p1->get("datetime"));
+        $t2 = strtotime($p2->get("datetime"));
+
+        if ((!($t2 >= $t3 && $t3 >= $t1)) || ($maxtime && (abs($t1 - $t2) > $maxtime))) {
+            return false;
+        }
+
+        if ($maxdist) {
+            $dist=$p1->getDistanceTo($p2, $entity);
+            if ($dist > $maxdist) {
+                return false;
+            }
+        }
+        $lat1=$p1->get("lat");
+        $lon1=$p1->get("lon");
+
+        $lat2=$p2->get("lat");
+        $lon2=$p2->get("lon");
+
+        // Calculate the deltas
+        $dlat=$lat2-$lat1;
+        $dlon=$lon2-$lon1;
+        $dt=$t2-$t1;
+        $dt3=$t3-$t1;
+
+        $lat3=$lat1 + (($dlat/$dt) * $dt3);
+        $lon3=$lon1 + (($dlon/$dt) * $dt3);
+
+        $p3 = new point();
+        $p3->set("lat", $lat3);
+        $p3->set("lon", $lon3);
+
+        return $p3;
+    }
+}
+?>
diff -pruN 0.9.4-4/php/classes/geo/track.inc.php 0.9.8-1/php/classes/geo/track.inc.php
--- 0.9.4-4/php/classes/geo/track.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/geo/track.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,240 @@
+<?php
+/**
+ * A track is a collection of points, which are used for geotagging
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace geo;
+
+use db\delete;
+use db\param;
+use db\clause;
+
+use XMLReader;
+use PDO;
+
+/**
+ * A track is a collection of points, which are used for geotagging
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class track extends \zophTable {
+    /** @var string The name of the database table */
+    protected static $tableName="track";
+    /** @var array List of primary keys */
+    protected static $primaryKeys=array("track_id");
+    /** @var array Fields that may not be empty */
+    protected static $notNull=array("name");
+    /** @var bool keep keys with insert. In most cases the keys are set by
+                  the db with auto_increment */
+    protected static $keepKeys = false;
+    /** @var string URL for this class */
+    protected static $url="track.php?track_id=";
+
+    /** @var array of @see point objects containing the points of this track */
+    private $points=array();
+
+    /**
+     * Insert a track into the database
+     */
+    public function insert() {
+        parent::insert();
+        $this->updatePoints();
+        $this->insertPoints();
+    }
+
+    /**
+     * Lookup a track in the database.
+     *
+     * This will fill the object with the info already in the db
+     */
+    public function lookup() {
+        $result=parent::lookup();
+        $this->points=$this->getPoints();
+        return $result;
+    }
+
+    /**
+     * Deletes a track
+     *
+     * Also deletes all point in the track
+     * @see point
+     */
+    public function delete() {
+        if (!$this->getId()) {
+            return;
+        }
+        parent::delete();
+
+        $qry=new delete(array("pt" => "point"));
+        $qry->where(new clause("track_id=:trackid"));
+        $qry->addParam(new param(":trackid", (int) $this->getId(), PDO::PARAM_INT));
+
+        $qry->execute();
+    }
+
+    /**
+     * Add a new point to a track
+     * @param point point to add
+     */
+    public function addPoint(point $point) {
+        $point->set("track_id", $this->get("track_id"));
+        $this->points[]=$point;
+    }
+
+    /**
+     * This sets the track_id on all points in this track
+     */
+    private function updatePoints() {
+        foreach ($this->points as $point) {
+            $point->set("track_id", $this->get("track_id"));
+        }
+    }
+
+    /**
+     * Insert points into database
+     */
+    private function insertPoints() {
+        foreach ($this->points as $point) {
+            $point->insert();
+        }
+    }
+
+    /**
+     * Read a GPX file and create track & point objects from there
+     * @param string filename to read GPX from
+     */
+    public static function getFromGPX($file) {
+        $track = new track;
+        if (!class_exists("XMLReader")) {
+            throw new Exception("Class XMLReader not found");
+        }
+        $xml=new XMLReader();
+        $xml->open($file);
+
+        $track->set("name", substr($file, strrpos($file, "/") + 1, strrpos($file, ".")));
+
+        $xml->read();
+        if ($xml->name != "gpx") {
+            throw new gpxException($file . " is not a GPX file");
+        } else {
+            $stack[]="gpx";
+        }
+        while ($xml->read()) {
+            if ($xml->nodeType==XMLReader::ELEMENT) {
+                // Keep track of the current open tags
+                if (!$xml->isEmptyElement) {
+                    $stack[]=$xml->name;
+                }
+                switch ($xml->name) {
+                case "name":
+                    $current=$stack[count($stack) - 2];
+                    if ($current=="gpx") {
+                        // only set the name if we're in <gpx>
+                        $xml->read();
+                        $track->set("name", $xml->value);
+                    }
+                    break;
+                case "trkpt":
+                    // For now we are ignoring multiple tracks or segments
+                    // in the same file and we simply look at the points
+                    $xml_point=$xml->readOuterXML();
+                    $point=point::readFromXML($xml_point);
+                    $track->addpoint($point);
+                    break;
+                default:
+                    // not (yet?) supported
+                    break;
+                }
+            } else if ($xml->nodeType==XMLReader::END_ELEMENT) {
+                $element=array_pop($stack);
+                if ($element!=$xml->name) {
+                    throw new gpxException("GPX not well formed: expected &lt;$element&gt;, " .
+                        "found &lt;$xml->name&gt;");
+                }
+            }
+        }
+        return $track;
+    }
+
+    /**
+     * Get all points for this track
+     * @return array Array of all points in this track.
+     */
+    public function getPoints() {
+        if (sizeof($this->points)==0) {
+            $this->points=point::getRecords("datetime", array("track_id" => $this->get("track_id")));
+        }
+        return $this->points;
+    }
+
+    /**
+     * Get the first point from a track
+     * @return point first point
+     */
+    public function getFirstPoint() {
+        $first=$this->getPoints()[0];
+        if (($first instanceof point)) {
+            return $first;
+        } else {
+            return new point;
+        }
+    }
+
+    /**
+     * Get the last point from a track
+     * @return point last point
+     */
+    public function getLastPoint() {
+        $points=$this->getPoints();
+        $last=array_pop($points);
+        if (($last instanceof point)) {
+            return $last;
+        } else {
+            return new point;
+        }
+    }
+
+    /**
+     * Get the number of points in a track
+     * @return int count
+     */
+    public function getPointCount() {
+        return count($this->getPoints());
+    }
+
+    /**
+     * Get array that can be used to generate view for this track
+     * @return array Display array
+     */
+    public function getDisplayArray() {
+        $first=$this->getFirstPoint();
+        $last=$this->getLastPoint();
+        $count=$this->getPointCount();
+
+        $return[translate("name")] = $this->get("name");
+        $return[translate("time of first point")] = $first->get("datetime") . " UTC";
+        $return[translate("time of last point")] = $last->get("datetime") . " UTC";
+        $return[translate("number of points")] = $count;
+
+        return $return;
+    }
+
+}
+?>
diff -pruN 0.9.4-4/php/classes/group/controller.inc.php 0.9.8-1/php/classes/group/controller.inc.php
--- 0.9.4-4/php/classes/group/controller.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/group/controller.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Controller for groups
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace group;
+
+use generic\controller as genericController;
+use group;
+use web\request;
+use user;
+
+/**
+ * Controller for groups
+ */
+class controller extends genericController {
+
+    /** @var Where to redirect after actions */
+    public $redirect="groups.php";
+
+    /**
+     * Create a controller using a web request
+     * @param request request
+     */
+    public function __construct(request $request) {
+        parent::__construct($request);
+        $group = new group($this->request["group_id"]);
+        $group->lookup();
+        $this->setObject($group);
+        $this->doAction();
+    }
+
+    /**
+     * Action: update
+     * The update action processes a form as generated after the "edit" action.
+     * The subsequently called view displays the object.
+     * takes care of adding and removing members of the group
+     */
+    protected function actionUpdate() {
+        $this->object->setFields($this->request->getRequestVars());
+        if (isset($this->request["_member"]) && ((int) $this->request["_member"] > 0)) {
+            $this->object->addMember(new user((int) $this->request["_member"]));
+        }
+
+        if (is_array($this->request["_removeMember"])) {
+            foreach ($this->request["_removeMember"] as $user_id) {
+                $this->object->removeMember(new user((int) $user_id));
+            }
+        }
+        $this->object->update();
+        $this->view = "update";
+    }
+
+    /**
+     * Action: insert
+     * The insert action processes a form as generated after the "new" action.
+     * The subsequently called view displays a form to make more changes to the group.
+     * this is a change from the generic controller, because group access rights can only
+     * be modified after insertion.
+     */
+    protected function actionInsert() {
+        parent::actionInsert();
+        $this->view="update";
+    }
+}
diff -pruN 0.9.4-4/php/classes/group.inc.php 0.9.8-1/php/classes/group.inc.php
--- 0.9.4-4/php/classes/group.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/group.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -27,6 +27,10 @@ use db\delete;
 use db\param;
 use db\clause;
 
+use conf\conf;
+
+use template\template;
+
 /**
  * A class representing a group of users
  *
@@ -54,6 +58,10 @@ class group extends zophTable {
         parent::delete(array("groups_users", "group_permissions"));
     }
 
+    /**
+     * Get name of group
+     * @return string name
+     */
     public function getName() {
         return $this->get("group_name");
     }
@@ -98,32 +106,31 @@ class group extends zophTable {
     }
 
     /**
-     * Create an array describing permissions for all albums
+     * Create an array describing permissions for all groups
      * for display or edit
+     * @param bool Return array of albums instead of array of permissions
+     * @return array permissions
      */
-    public function getPermissionArray() {
+    public function getPermissionArray($getAlbum=false) {
         $albums = album::getSelectArray();
         $perms=array();
         foreach ($albums as $id => $name) {
             if (!$id || $id == 1) {
                 continue;
             }
-            $permissions = $this->getGroupPermissions(new album((int) $id));
+            $album=new album((int) $id);
+            $permissions = $this->getGroupPermissions($album);
             if ($permissions) {
-                $albumPermissions=new stdClass();
-                $albumPermissions->id=$id;
-                $albumPermissions->name=$name;
-                $albumPermissions->access=$permissions->get("access_level");
-                if (conf::get("watermark.enable")) {
-                    $albumPermissions->wm=$permissions->get("watermark_level");
+                if ($getAlbum) {
+                    $perms[]=$album;
+                } else {
+                    $perms[]=$permissions;
                 }
-                $albumPermissions->writable=$permissions->get("writable");
-                $albumPermissions->subalbums=$permissions->get("subalbums");
-                $perms[]=$albumPermissions;
             }
         }
         return $perms;
     }
+
     /**
      * Get members of this group
      * @return array of users
diff -pruN 0.9.4-4/php/classes/import/base.inc.php 0.9.8-1/php/classes/import/base.inc.php
--- 0.9.4-4/php/classes/import/base.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/import/base.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,228 @@
+<?php
+/**
+ * Class that holds all functions for importing and uploading
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+namespace import;
+
+use file;
+use log;
+use photo;
+use settings;
+
+use conf\conf;
+
+use geo\track;
+
+use DomDocument;
+
+
+/**
+ * This class holds the generalized functions importing images
+ * to Zoph.
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+abstract class base {
+
+    /**
+     * Rotates a file based on the EXIF orientation flag
+     *
+     * Calls external program jhead for this.
+     * @param string Filename
+     */
+    protected static function autorotate($file) {
+        $cmd = "jhead -autorot " . escapeshellarg($file);
+        exec($cmd, $output, $return);
+        if ($return > 0) {
+            $msg=implode($output, "<br>");
+            throw new \ImportAutorotException($msg);
+        }
+    }
+
+    /**
+     * Import photos
+     *
+     * Takes an array of files and an array of vars and imports them in Zoph
+     * @param Array Files to be imported
+     * @param  Array Vars to be applied to the photos.
+     */
+    public static function photos(Array $files, Array $vars) {
+        $photos=array();
+
+        $total=sizeof($files);
+        $cur=0;
+
+        if (isset($vars["_path"])) {
+            $path=file::cleanupPath("/" . $vars["_path"] . "/");
+            if (strpos($path, "..") !== false) {
+                log::msg("Illegal characters in path", log::FATAL, log::IMPORT);
+                die();
+            }
+        } else {
+            $path="";
+        }
+
+        foreach ($files as $file) {
+            static::progress($cur, $total);
+            $cur++;
+
+            if ($file instanceof photo) {
+                $photo=$file;
+                $file=$photo->file["orig"];
+            } else if ($file instanceof file) {
+                $photo=new photo();
+            }
+
+            $mime=$file->getMime();
+            if (conf::get("import.cli.exif")===true && $mime=="image/jpeg") {
+                $exif=process_exif($file);
+                if ($exif) {
+                    $photo->setFields($exif);
+                }
+            }
+            if (isset($vars["rating"])) {
+                $rating=$vars["rating"];
+                if (!(is_numeric($rating) && (1 <= $rating) && ($rating <= 10))) {
+                    unset($rating);
+                }
+                unset($vars["rating"]);
+            }
+
+            if (isset($vars["field"]) && is_array($vars["_field"])) {
+                foreach ($vars["_field"] as $key => $field) {
+                    $vars[$field]=$vars["field"][$key];
+                }
+                unset($vars["_field"]);
+                unset($vars["field"]);
+            }
+
+            if ($vars) {
+                $photo->setFields($vars);
+            }
+
+            if (strlen(trim($photo->get("date")))==0) {
+                $date=date("Y-m-d", filemtime($file));
+                log::msg("Photo has no date set, using filedate (" . $date . ").",
+                    log::NOTIFY, log::IMPORT);
+                $photo->set("date", $date);
+            }
+
+            if (strlen(trim($photo->get("time")))==0) {
+                $time=date("H:i:s", filemtime($file));
+                log::msg("Photo has no time set, using time from filedate (" . $time . ").",
+                    log::NOTIFY, log::IMPORT);
+                $photo->set("time", $time);
+            }
+            if (isset($photo->_path)) {
+                $photo->set("path", $path . "/" . $photo->_path);
+                unset($photo->_path);
+            } else {
+                $photo->set("path", $path);
+            }
+
+            try {
+                $photo->import($file);
+            } catch (FileException $e) {
+                log::msg($e->getMessage(), log::FATAL);
+            }
+
+            if (conf::get("import.cli.thumbs")===true) {
+                try {
+                    $photo->thumbnail(false);
+                } catch (Exception $e) {
+                    echo $e->getMessage();
+                }
+            }
+
+            if ($photo->insert()) {
+                if (conf::get("import.cli.size")===true) {
+                    $photo->updateSize();
+                }
+                $photo->update();
+                $photo->updateRelations($vars, "_id");
+                if (isset($rating)) {
+                    $photo->rate($rating);
+                }
+                if (conf::get("import.cli.hash")===true) {
+                    try {
+                        $photo->getHash();
+                    } catch (Exception $e) {
+                        echo $e->getMessage();
+                    }
+                }
+                $photos[]=$photo;
+            } else {
+                echo translate("Insert failed.") . "<br>\n";
+            }
+        }
+        return $photos;
+    }
+
+    /**
+     * Import an XML file
+     *
+     * @param string MD5 hash of the filename to import
+     *
+     * This function tries to recognize the XML file by validating them against .xsd files
+     * For now only GPX (1.0 and 1.1) files are recognized.
+     */
+
+    public static function XMLimport(file $file) {
+        $xml=new DomDocument;
+        $xml->Load($file);
+
+        $schemas = array (
+            "gpx 1.0" => "xml/gpx10.xsd",
+            "gpx 1.1" => "xml/gpx11.xsd" );
+
+        foreach ($schemas as $name => $schema) {
+            if (@$xml->schemaValidate(settings::$php_loc . "/" . $schema)) {
+                log::msg(basename($file) ." is a valid " . $name . " file", log::NOTIFY, log::IMPORT);
+                $xmltype=$name;
+            }
+        }
+        if (!isset($xmltype)) {
+            throw new \ImportFileNotImportableException(basename($file) . " is not a known XML file.");
+        } else {
+            switch($name) {
+            case "gpx 1.0":
+            case "gpx 1.1":
+                $track=track::getFromGPX($file);
+                $track->insert();
+                $file->delete();
+                break;
+            }
+        }
+    }
+
+    /**
+     * Progress bar
+     * Does not display anything by default, but this function can be redefined
+     * in a child class.
+     *
+     * @param int current
+     * @param int total
+     */
+    public static function progress($cur, $total) {
+        return 0;
+    }
+
+}
+
diff -pruN 0.9.4-4/php/classes/import/cli.inc.php 0.9.8-1/php/classes/import/cli.inc.php
--- 0.9.4-4/php/classes/import/cli.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/import/cli.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,81 @@
+<?php
+/**
+ * Takes care of the import throught the CLI
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace import;
+/**
+ * Class that takes care of the import through the CLI
+ */
+class cli extends base {
+    /**
+     * Displays a progressbar on the CLI
+     *
+     * The progressbar will not be wider than 60 characters, so we have
+     * 20 chars left for counter etc. on a 80 char screen
+     * the real width of the screen is not checked because it cannot be
+     * done in PHP without external programs
+     * After displaying the progressbar, it will 'backspace' to the
+     * beginning of the line, so any error message will
+     * not cause a distorted screen
+     * @var int progress
+     * @var int total
+     */
+
+    public static function progress($cur, $total) {
+        if (!defined("CLI")) {
+            return;
+        }
+        if ($total>=60) {
+            $calccur=$cur/$total*60;
+            $dispcur=floor($calccur);
+            $disptotal=60;
+        } else {
+            $calccur=0;
+            $dispcur=$cur;
+            $disptotal=$total;
+        }
+        $display="[";
+        $display.=str_repeat("|", $dispcur);
+        $rem=round($calccur - $dispcur,2);
+        $num=$total/$disptotal;
+        if ($num > 3) {
+            if ($rem > 0.333  && $rem < 0.666) {
+                $display.=".";
+            } else if ($rem > 0.6666 && $rem < 0.999) {
+                $display.=":";
+            } else if ($rem > 0.999) {
+                $display.="|";
+            }
+        } else if ($num == 2) {
+            if ($rem >= 0.5) {
+                $display.=".";
+            }
+        }
+
+        $display=str_pad($display, $disptotal + 1);
+        $display.="]";
+        $perc=floor($cur / $total * 100);
+        $display.= " [ $cur / $total (" . $perc . "%) ]";
+        echo $display;
+        echo str_repeat(chr(8), strlen($display));
+    }
+
+}
+
diff -pruN 0.9.4-4/php/classes/import/web.inc.php 0.9.8-1/php/classes/import/web.inc.php
--- 0.9.4-4/php/classes/import/web.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/import/web.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,508 @@
+<?php
+/**
+ * Class for importing via the webinterface, extends 'general' Import class.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace import;
+
+use file;
+use log;
+use photo;
+
+use conf\conf;
+
+use template\template;
+
+/**
+ * This class holds all the functions for uploading and importing images
+ * to Zoph via the web interface.
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class web extends base {
+
+    /** @param Name of the root node in XML responses */
+    const XMLROOT="importprogress";
+    /** @param Name of the leaf nodes in XML responses */
+    const XMLNODE="import";
+
+    private $upload_id;
+
+    /**
+     * Create object, used to track progress of upload
+     * @return import\web The created object
+     * @param string generated upload_id
+     */
+    function __construct($upload_id) {
+        $this->upload_id=$upload_id;
+    }
+
+    /**
+     * Import photos
+     *
+     * Takes an array of files and an array of vars and imports them in Zoph
+     * @param Array Files to be imported
+     * @param  Array Vars to be applied to the photos.
+     */
+    public static function photos(Array $files, Array $vars) {
+        // thumbnails have already been created, no need to repeat...
+        conf::set("import.cli.thumbs", false);
+        conf::set("import.cli.exif", true);
+        conf::set("import.cli.size", true);
+        parent::photos($files, $vars);
+    }
+
+    /**
+     * Return a translated, textual error message from a PHP upload error
+     *
+     * @param int PHP upload error
+     */
+    public static function handleUploadErrors($error) {
+        $errortext=translate("File upload failed") . "<br>";
+        switch ($error) {
+        case UPLOAD_ERR_INI_SIZE:
+            $errortext.=sprintf(translate("The uploaded file exceeds the " .
+                "upload_max_filesize directive (%s) in php.ini."),
+                ini_get("upload_max_filesize"));
+            $errortext.=" " . sprintf(translate("This may also be caused by " .
+                "the post_max_size (%s) in php.ini."), ini_get("post_max_size"));
+            break;
+        case UPLOAD_ERR_FORM_SIZE:
+            $errortext.=sprintf(translate("The uploaded file exceeds the maximum " .
+                "filesize setting in config.inc.php (%s)."), conf::get("import.maxupload"));
+            break;
+        case UPLOAD_ERR_PARTIAL:
+            $errortext.=translate("The uploaded file was only partially uploaded.");
+            break;
+        case UPLOAD_ERR_NO_FILE:
+            $errortext.=translate("No file was uploaded.");
+            break;
+        case UPLOAD_ERR_NO_TMP_DIR:
+            $errortext.=translate("Missing a temporary folder.");
+            break;
+        case UPLOAD_ERR_CANT_WRITE:
+            $errortext.=translate("Failed to write to disk");
+            break;
+        case UPLOAD_ERR_EXTENSION:
+            $errortext.=translate("A PHP extension stopped the upload. Don't ask me why.");
+            break;
+        default:
+            $errortext.=translate("An unknown file upload error occurred.");
+        }
+        return $errortext;
+    }
+
+    /**
+     * Process uploaded file
+     *
+     * Catches the uploaded file, runs some checks and moves it into the
+     * upload directory.
+     * @param array PHP _FILE var with data about the uploaded file
+     */
+    public static function processUpload($file) {
+        $filename=$file["name"];
+        $tmp_name=$file["tmp_name"];
+
+        $error=$file["error"];
+
+        if ($error) {
+            // should do some nicer printing to this error some time
+            log::msg(static::handleUploadErrors($error), log::FATAL, log::IMPORT);
+            return false;
+        }
+
+        $file=new file($tmp_name);
+        $mime=$file->getMime();
+
+        if (!$file->type) {
+            log::msg("Illegal filetype: $mime", log::FATAL, log::IMPORT);
+            return false;
+        }
+
+        $dir=conf::get("path.images") . DIRECTORY_SEPARATOR . conf::get("path.upload");
+        $realDir=realpath($dir);
+        if ($realDir === false) {
+            log::msg($dir . " does not exist, creating...", log::WARN, log::IMPORT);
+            try {
+                file::createDirRecursive($dir);
+            } catch (\FileDirCreationFailedException $e) {
+                log::msg($dir . " does not exist, and I can not create it. (" .
+                    $e->getMessage() . ")", log::FATAL, log::IMPORT);
+                die();
+            }
+            // doublecheck if path really has been correctly created.
+            $realDir=realpath($dir);
+            if ($realDir === false) {
+                log::msg($dir . " does not exist, and I can not create it.", log::WARN, log::FATAL);
+            }
+        }
+        $dir=$realDir;
+        $dest=$dir . "/" . basename($filename);
+        if (is_writable($dir)) {
+            if (!file_exists($dest)) {
+                move_uploaded_file($tmp_name, $dest);
+            } else {
+                log::msg("A file named <b>" . $filename .
+                    "</b> already exists in <b>" . $dir . "</b>", log::FATAL, log::IMPORT);
+            }
+        } else {
+            log::msg("Directory <b>" . $dir . "</b> is not writable",
+                log::FATAL, log::IMPORT);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Processes a file
+     *
+     * Depending on file type it will either launch a resize or an unpack
+     * function.
+     * This function is called from a javascript call
+     * @param string MD5 hash of the file <b>name</b>.
+     */
+    public static function processFile($md5) {
+        // continue when hitting fatal error.
+        log::$stopOnFatal=false;
+
+        $dir=conf::get("path.images") . "/" . conf::get("path.upload") . "/";
+        $file=file::getFromMD5($dir, $md5);
+
+        if ($file instanceof file) {
+            $mime=$file->getMime();
+            $type=$file->type;
+        } else {
+            $type="unknown (file not found)";
+        }
+
+        switch($type) {
+        case "image":
+            if ($mime=="image/jpeg" && conf::get("import.rotate")) {
+                static::autorotate($file);
+            }
+            static::resizeImage($file);
+            $return=null;
+            break;
+        case "archive":
+            $return=static::unpackArchive($file);
+            break;
+        case "xml":
+            $return=static::XMLimport($file);
+            break;
+        default:
+            log::msg("Unknown filetype " . $type .
+                 " for file" . $file, log::FATAL, log::IMPORT);
+            $return=false;
+            break;
+        }
+        return $return;
+    }
+
+    /**
+     * Automatically rotate images based on EXIF tag.
+     * @param string filename
+     */
+    protected static function autorotate($file) {
+        try {
+            parent::autorotate($file);
+        } catch (\ImportAutorotException $e) {
+            touch($file . ".zophignore");
+            log::msg($e->getMessage(), log::FATAL, log::IMPORT);
+            die;
+        }
+    }
+
+    /**
+     * Unpack archive of different types
+     * *WARNING* this function is *not* safe to run on unchecked user-input
+     * use processFile() as a wrapper for this function
+     * @see processFile
+     * @param string full path to file
+     * @todo unpack_dir should be removed when done
+     */
+    private static function unpackArchive(file $file) {
+        $dir = conf::get("path.images") . "/" . conf::get("path.upload");
+        $mime=$file->getMime();
+        switch($mime) {
+        case "application/zip":
+            $extr = conf::get("path.unzip");
+            $msg = "Unzip command";
+            break;
+        case "application/x-tar":
+            $extr = conf::get("path.untar");
+            $msg = "Untar command";
+            break;
+        case "application/x-gzip":
+            $extr = conf::get("path.ungz");
+            $msg = "Ungzip command";
+            break;
+        case "application/x-bzip2":
+            $extr = conf::get("path.unbz");
+            $msg = "Unbzip command";
+            break;
+        }
+        if (empty($extr)) {
+            log::msg("To be able to process an archive of type " . $mime .
+                ", you need to set \"" . $msg . "\" in the configuration screen " .
+                " to a program that can unpack this file.", log::FATAL, log::IMPORT);
+            touch($file . ".zophignore");
+            return false;
+        }
+        $upload_id=uniqid("zoph_");
+        $unpack_dir=$dir . "/" . $upload_id;
+        $unpack_file=$unpack_dir . "/" . basename($file);
+        ob_start();
+            mkdir($unpack_dir);
+            rename($file, $unpack_file);
+
+            $cmd = "cd " . escapeshellarg($unpack_dir) . " && " .
+                $extr . " " .  escapeshellarg($unpack_file) . " 2>&1";
+            system($cmd);
+            if (file_exists($unpack_file)) {
+                unlink($unpack_file);
+            }
+        $output=ob_end_clean();
+        log::msg($output, log::NOTIFY, log::IMPORT);
+        $files=file::getFromDir($unpack_dir, true);
+        foreach ($files as $import_file) {
+            $type=$import_file->type;
+            if ($type == "image" or $type == "archive" or $type == "xml") {
+                $import_file->setDestination($dir);
+                try {
+                    $import_file->move();
+                } catch (\fileException $e) {
+                    echo $e->getMessage() . "<br>\n";
+                }
+            }
+        }
+    }
+
+    /**
+     * Resize an image before import
+     *
+     * @param string filename
+     */
+    private static function resizeImage($file) {
+        log::msg("resizing" . $file, log::DEBUG, log::IMPORT);
+        $photo = new photo();
+
+        $photo->set("path", conf::get("path.upload"));
+        $photo->set("name", basename($file));
+
+        ob_start();
+            $dir=conf::get("path.images") . "/" . conf::get("path.upload");
+            $thumb_dir=$dir. "/" . THUMB_PREFIX;
+            $mid_dir=$dir . "/" . MID_PREFIX;
+            if (!file_exists($thumb_dir)) {
+                mkdir($thumb_dir);
+            } else if (!is_dir($thumb_dir)) {
+                log::msg("Cannot create " . $thumb_dir . ", file exists.", log::FATAL, log::IMPORT);
+            }
+            if (!file_exists($mid_dir)) {
+                mkdir($mid_dir);
+            } else if (!is_dir($mid_dir)) {
+                log::msg("Cannot create " . $mid_dir . ", file exists.", log::FATAL, log::IMPORT);
+            }
+            try {
+                $photo->thumbnail();
+            } catch (\Exception $e) {
+                echo "Thumb could not be made: " . $e->getMessage();
+                touch($file . ".zophignore");
+            }
+            log::msg("Thumb made succesfully.", log::DEBUG, log::IMPORT);
+        $log=ob_get_contents();
+        ob_end_clean();
+        echo $log;
+    }
+
+    /**
+     * Get XML for Import
+     * @todo This is a temporary function to distinguish between the two XML responses
+     *       this class can give. Eventually, both functions should be called directly
+     */
+    public static function getXML($search) {
+        if ($search=="thumbs") {
+            return static::getThumbsXML();
+        } else {
+            return static::getProgressXML($search);
+        }
+    }
+
+    /**
+     * Get XML indicating progress of a certain upload
+     */
+    public static function getProgressXML($uploadId) {
+        $xml = new \DOMDocument('1.0','UTF-8');
+        $rootnode=$xml->createElement(static::XMLROOT);
+        $node=$xml->createElement(static::XMLNODE);
+
+        if (ini_get("session.upload_progress.enabled")==true) {
+            $upl_prog=$_SESSION[ini_get("session.upload_progress.prefix") . $uploadId];
+            $progress['current']=$upl_prog["bytes_processed"];
+            $progress['total']=$upl_prog["content_length"];
+            // for now we take the first file as multiple uploads
+            // are not yet supported
+            $progress['filename']=$upl_prog["files"][0]["name"];
+        } else {
+            // session.upload_progress not enables extension not available
+            $progress['current']=0;
+            $progress['total']=0;
+            $progress['filename']="Enable session.upload_progress.enabled in php.ini";
+        }
+
+        $id=$xml->createElement("id");
+        $current=$xml->createElement("current");
+        $total=$xml->createElement("total");
+        $filename=$xml->createElement("filename");
+
+        $id->appendChild($xml->createTextNode($uploadId));
+        $current->appendChild($xml->createTextNode($progress['current']));
+        $total->appendChild($xml->createTextNode($progress['total']));
+        $filename->appendChild($xml->createTextNode($progress['filename']));
+
+        $node->appendChild($id);
+        $node->appendChild($current);
+        $node->appendChild($total);
+        $node->appendChild($filename);
+
+        $rootnode->appendChild($node);
+        $xml->appendChild($rootnode);
+        return $xml;
+    }
+
+    /**
+     * Generate an XML file with thumbs in the import dir
+     */
+    public static function getThumbsXML() {
+        $xml=new \DOMDocument('1.0','UTF-8');
+        $root=$xml->createElement("files");
+
+        $dir=conf::get("path.images") . DIRECTORY_SEPARATOR . conf::get("path.upload");
+        $files = file::getFromDir($dir);
+        foreach ($files as $file) {
+            unset($icon);
+            unset($status);
+
+            $md5=$file->getMD5();
+
+            $type=$file->type;
+
+            switch ($type) {
+            case "image":
+                $thumb=THUMB_PREFIX . DIRECTORY_SEPARATOR . THUMB_PREFIX . "_" . $file->getName();
+                $mid=MID_PREFIX . DIRECTORY_SEPARATOR . MID_PREFIX . "_" . $file->getName();
+                if (file_exists($dir . DIRECTORY_SEPARATOR . $thumb) &&
+                  file_exists($dir . DIRECTORY_SEPARATOR . $mid)) {
+                    $status="done";
+                } else {
+                    $icon=template::getImage("icons/pause.png");
+                    $status="waiting";
+                }
+                break;
+            case "archive":
+                $icon=template::getImage("icons/archive.png");
+                $status="waiting";
+                break;
+            case "xml":
+                $icon=template::getImage("icons/tracks.png");
+                $status="done";
+                break;
+            case "ignore":
+                $icon=template::getImage("icons/error.png");
+                $status="ignore";
+                break;
+            }
+
+            $xmlfile=$xml->createElement("file");
+            $xmlfile->setAttribute("name", $file->getName());
+            $xmlfile->setAttribute("type",$type);
+            $xmlmd5=$xml->createElement("md5", $md5);
+            $xmlfile->appendChild($xmlmd5);
+            if (!empty($icon)) {
+                $xmlicon=$xml->createElement("icon", $icon);
+                $xmlfile->appendChild($xmlicon);
+            }
+            if (!empty($status)) {
+                $xmlstatus=$xml->createElement("status", $status);
+                $xmlfile->appendChild($xmlstatus);
+            }
+            $root->appendChild($xmlfile);
+        }
+        $xml->appendChild($root);
+        return $xml;
+    }
+
+    /**
+     * Retry making of thumbnails
+     *
+     * This function reacts to a click on the "retry" link in the thumbnail
+     * list on the import page. It looks up which file is referenced by the
+     * supplied MD5 and deleted thumbnail, mid and 'ignore" files, this will
+     * cause the webinterface to retry making thumbnail and midsize images
+     *
+     * @param string md5 hash of the filename
+     */
+
+    public static function retryFile($md5) {
+        $dir=conf::get("path.images") . "/" . conf::get("path.upload");
+
+        $file=file::getFromMD5($dir, $md5);
+        // only delete "related files", not the referenced file.
+        $file->delete(true, true);
+    }
+
+    /**
+     * Delete a file
+     *
+     * Deletes a file referenced by the MD5 hash of the filename and all
+     * related files, such as thumbnail, midsize images and "ignore" files.
+     * @param string md5 hash of the filename
+     */
+    public static function deleteFile($md5) {
+        $dir=conf::get("path.images") . "/" . conf::get("path.upload");
+
+        $file=file::getFromMD5($dir, $md5);
+        $file->delete(true);
+    }
+
+    /**
+     * Get a file list from a list of MD5 hashes.
+     *
+     * Take a list of MD5 hashes (in $vars["_import_image"]) and return an
+     * array of @see file objects
+     * @param Array $vars
+     */
+    public static function getFileList(Array $import) {
+        foreach ($import as $md5) {
+            $file=file::getFromMD5(conf::get("path.images") . "/" . conf::get("path.upload"), $md5);
+            if (!empty($file)) {
+                $files[]=$file;
+            }
+        }
+        if (is_array($files)) {
+            return $files;
+        } else {
+            log::msg("No files specified", log::FATAL, log::IMPORT);
+            return false;
+        }
+    }
+}
+
+?>
diff -pruN 0.9.4-4/php/classes/language.inc.php 0.9.8-1/php/classes/language.inc.php
--- 0.9.4-4/php/classes/language.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/language.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -23,6 +23,8 @@
  * @package Zoph
  */
 
+use conf\conf;
+
 /**
  * This class contains a set of translations read from a file in the static::LANG_DIR
  * directory.
diff -pruN 0.9.4-4/php/classes/mailMime.inc.php 0.9.8-1/php/classes/mailMime.inc.php
--- 0.9.4-4/php/classes/mailMime.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/mailMime.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -367,7 +367,7 @@ class MailMime {
      */
     public function get($build_params = null) {
         if (isset($build_params)) {
-            while (list($key, $value) = each($build_params)) {
+            foreach ($build_params as $key => $value) {
                 $this->build_params[$key] = $value;
             }
         }
diff -pruN 0.9.4-4/php/classes/mailMimePart.inc.php 0.9.8-1/php/classes/mailMimePart.inc.php
--- 0.9.4-4/php/classes/mailMimePart.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/mailMimePart.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -269,9 +269,7 @@ class mailMimePart {
         $eol    = PHP_EOL;
         $escape = '=';
         $output = '';
-
-        while (list(, $line) = each($lines)){
-
+        foreach ($lines as $line) {
             $linlen     = strlen($line);
             $newline = '';
 
diff -pruN 0.9.4-4/php/classes/map.inc.php 0.9.8-1/php/classes/map.inc.php
--- 0.9.4-4/php/classes/map.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/map.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,213 +0,0 @@
-<?php
-/**
- * Map. Create and display a map using the mapstraction library.
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * Mapping class.
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-class map extends block {
-
-    /** @var div id for map */
-    private $map = "map";
-
-    /** @var center lattitude */
-    protected $clat;
-    /** @var center longitude */
-    protected $clon;
-    /** @var zoom level */
-    protected $zoom;
-
-    /** @var array of tracks to display on this map */
-    private $tracks=array();
-    /** @var array of markers to display on this map */
-    private $markers=array();
-
-    /** @var whether or not this map can be changed. i.e. used to add a marker */
-    protected $edit=false;
-
-    /**
-     * Create a new map object
-     * @param string template to use
-     * @param array variables to pass to template
-     * @return map new object
-     */
-    function __construct($template="map", $vars=null) {
-        if (!is_array($vars)) {
-            $vars=array();
-        }
-        if (!array_key_exists("id", $vars)) {
-            $vars["id"]=$this->map;
-        }
-        if (!array_key_exists("provider", $vars)) {
-            $vars["provider"]=conf::get("maps.provider");
-        }
-        parent::__construct($template, $vars);
-
-    }
-
-    /**
-     * Add a marker to the map
-     * @param marker marker to add
-     */
-    public function addMarker(marker $marker) {
-        $this->markers[]=$marker;
-    }
-
-    /**
-     * Add multiple markers from objects
-     * @param array Array of objects to get markers from
-     */
-    public function addMarkers(array $objs) {
-        foreach ($objs as $obj) {
-            $marker=$obj->getMarker();
-            if ($marker instanceof marker) {
-                $this->addMarker($marker);
-            }
-        }
-    }
-
-    /**
-     * Get marker from object
-     * @param photo|place Object to get marker from
-     * @param string Icon to use
-     * @return marker created marker.
-     * @todo A "mapable" interface should be created to make sure
-             only certain objects can get passed to this function.
-     */
-    public static function getMarkerFromObj($obj, $icon) {
-        $lat=$obj->get("lat");
-        $lon=$obj->get("lon");
-        if ($lat && $lon) {
-            $title=$obj->get("title");
-            $quicklook=$obj->getQuicklook();
-            return new marker($lat, $lon, $icon, $title, $quicklook);
-        } else {
-            return null;
-        }
-    }
-
-    /**
-     * Get markers for this map
-     */
-    public function getMarkers() {
-        // if multiple photos are taken in the same place, that place
-        // is multiple times in the array, let's remove doubles:
-        $markers=array_unique($this->markers, SORT_REGULAR);
-        return $markers;
-    }
-
-    /**
-     * Checks whether this maps has markers
-     * @return bool
-     */
-    public function hasMarkers() {
-        return !empty($this->markers);
-    }
-
-    /**
-     * Add a track
-     * @param track
-     */
-    public function addTrack(track $track) {
-        $this->tracks[]=$track;
-    }
-
-    /**
-     * Get tracks
-     * @return Array tracks
-     */
-    public function getTracks() {
-        return $this->tracks;
-    }
-
-    /**
-     * Checks whether this maps has tracks
-     * @return bool
-     */
-    public function hasTracks() {
-        return !empty($this->tracks);
-    }
-
-    /**
-     * Set center and zoom
-     * This sets the center point and zoom level for the map
-     * @param float latitude
-     * @param float longitude
-     * @param int zoom level
-     */
-    public function setCenterAndZoom($lat, $lon, $zoom) {
-        $this->clat=(float) $lat;
-        $this->clon=(float) $lon;
-        $this->zoom=(int) $zoom;
-    }
-
-    /**
-     * Set center and zoom from object
-     * Can take a location object and determine center and zoom from there
-     * it can also take a photo object to determine c&s.
-     * If a photo object does not have c&z, it will see if the photo has
-     * a location set, and determine it from there.
-     * If a location does not have c&z, it can go up in the tree until
-     * it find an ancestor with c&z set.
-     * @param photo|place object to get location from
-     * @todo mapable interface should be created
-     */
-    public function setCenterAndZoomFromObj($obj) {
-        $lat=$obj->get("lat");
-        $lon=$obj->get("lon");
-        $zoom=$obj->get("mapzoom");
-        if (!$lat && !$lon) {
-            if ($obj instanceof photo && $obj->location instanceof place) {
-                $lat=$obj->location->get("lat");
-                $lon=$obj->location->get("lon");
-                $zoom=$obj->location->get("mapzoom");
-            } else if ($obj instanceof place) {
-                foreach ($obj->get_ancestors() as $parent) {
-                    $lat=$parent->get("lat");
-                    $lon=$parent->get("lon");
-                    $zoom=$parent->get("mapzoom");
-                    if ($lat && $lon) {
-                        break;
-                    }
-                }
-            }
-        }
-        if (!$lat) { $lat=0; }
-        if (!$lon) { $lon=0; }
-        if (!$zoom) { $zoom=2; }
-        $this->setCenterAndZoom($lat, $lon, $zoom);
-    }
-
-    /**
-     * Set whether or not this map can be changed
-     * (used to add markers to a place or photo)
-     * @param bool
-     */
-    public function setEditable($edit=true) {
-        $this->edit=(bool) $edit;
-    }
-
-}
-
diff -pruN 0.9.4-4/php/classes/marker.inc.php 0.9.8-1/php/classes/marker.inc.php
--- 0.9.4-4/php/classes/marker.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/marker.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,44 +0,0 @@
-<?php
-/**
- * Marker to be displayed on map
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * Class to display markers on a map
- * @author Jeroen Roos
- * @package Zoph
- */
-class marker {
-
-    public $lat=0;
-    public $lon=0;
-    public $icon=0;
-    public $title="";
-    public $quicklook="";
-
-    public function __construct($lat, $lon, $icon, $title, $quicklook) {
-        $this->lat=$lat;
-        $this->lon=$lon;
-        $this->icon=$icon;
-        $this->title=$title;
-        $this->quicklook=$quicklook;
-    }
-}
diff -pruN 0.9.4-4/php/classes/page.inc.php 0.9.8-1/php/classes/page.inc.php
--- 0.9.4-4/php/classes/page.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/page.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -27,6 +27,8 @@ use db\select;
 use db\param;
 use db\clause;
 
+use template\block;
+
 /**
  * Page class
  * A page is plaintext or zophCode that can be used to personalize parts
@@ -48,6 +50,10 @@ class page extends zophTable {
     /** @var string URL for this class */
     protected static $url="page.php?page_id=";
 
+    /**
+     * Return the page in a string
+     * @return string page
+     */
     public function __toString() {
         return (string) $this->display();
     }
diff -pruN 0.9.4-4/php/classes/pager.inc.php 0.9.8-1/php/classes/pager.inc.php
--- 0.9.4-4/php/classes/pager.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/pager.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,99 +0,0 @@
-<?php
-/**
- * Pager class
- * Displays a list of pages, usually at the bottom of a page, to navigate to different pages
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * Pager class
- * Displays a list of pages, usually at the bottom of a page, to navigate to different pages
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-class pager {
-    private $current=0;
-    private $pages=array();
-
-    public function __construct($current, $total, $numPages, $pageSize, $maxSize, $requestVars, $var) {
-        $url=$_SERVER['PHP_SELF'];
-        $pageNum = floor($current / $pageSize) + 1;
-        $this->current=(string) $pageNum;
-
-        $pageGroup=0;
-        $pages[$pageGroup]=array();
-
-        if ($current > 0) {
-            $newOffset = max(0, $current - $pageSize);
-            $this->pages[$pageGroup][translate("Prev")]= $url . "?" . update_query_string($requestVars, $var, $newOffset);
-        }
-
-        if ($numPages > 1) {
-            $midPage = floor($maxSize / 2);
-            $page = $pageNum - $midPage;
-            if ($page <= 0) {
-                $page = 1;
-            }
-
-            $lastPage = $page + $maxSize - 1;
-            if ($lastPage > $numPages) {
-                $page = $page - $lastPage + $numPages;
-                if ($page <= 0) {
-                    $page = 1;
-                }
-                $lastPage = $numPages;
-            }
-
-            if ($page > 1) {
-                $this->pages[$pageGroup]["1"] = $url . "?" . update_query_string($requestVars, $var, 0);
-            }
-
-            $pages[++$pageGroup]=array();
-
-            while ($page <= $lastPage) {
-                $newOffset = ($page - 1) * $pageSize;
-                $this->pages[$pageGroup][(string) $page] = $url . "?" . update_query_string($requestVars, $var, $newOffset);
-                $page++;
-            }
-
-            $pages[++$pageGroup]=array();
-
-            if ($page <= $numPages) {
-                $this->pages[$pageGroup][(string) $numPages] = $url . "?" . update_query_string($requestVars, $var, ($numPages-1) * $pageSize);
-            }
-        }
-        if ($total >  $current + $pageSize) {
-            $newOffset = $current + $pageSize;
-            $this->pages[$pageGroup][translate("Next")]= $url . "?" . update_query_string($requestVars, $var, $newOffset);
-        }
-    }
-
-    public function __toString() {
-        return (string) $this->getBlock();
-    }
-
-    public function getBlock() {
-        return new block("pager", array(
-            "pages"     => $this->pages,
-            "current"   => $this->current
-        ));
-    }
-}
diff -pruN 0.9.4-4/php/classes/pageset.inc.php 0.9.8-1/php/classes/pageset.inc.php
--- 0.9.4-4/php/classes/pageset.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/pageset.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -31,6 +31,9 @@ use db\update;
 use db\clause;
 use db\delete;
 
+use template\block;
+use template\template;
+
 /**
  * The pageset class groups a set of pages in a certain order
  * @author Jeroen Roos
diff -pruN 0.9.4-4/php/classes/permissions/controller.inc.php 0.9.8-1/php/classes/permissions/controller.inc.php
--- 0.9.4-4/php/classes/permissions/controller.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/permissions/controller.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Controller for permissions
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace permissions;
+
+use album;
+use conf\conf;
+use generic\controller as genericController;
+use group;
+use permissions;
+use web\request;
+
+/**
+ * Controller for permissions
+ */
+class controller extends genericController {
+    /** @var array Actions that can be performed with this controller */
+    protected $actions=array("updatealbums", "updategroups");
+
+    /**
+     * Create controller from web\request
+     * @param request request to use to run this controller
+     */
+    public function __construct(request $request) {
+        parent::__construct($request);
+        if ($this->request["_action"]=="updatealbums") {
+            $this->setObject(new group($this->request["group_id"]));
+        } else if ($this->request["_action"]=="updategroups") {
+            $this->setObject(new album($this->request["album_id"]));
+        }
+        $this->doAction();
+    }
+
+    /**
+     * Process changes to group permissions
+     */
+    protected function actionUpdategroups() {
+        // Check if the "Grant access to all groups" checkbox is ticked
+        if ($this->request["_access_level_all_checkbox"]) {
+            $groups = group::getAll();
+            foreach ($groups as $group) {
+                $permissions = new permissions($group->getId(), $this->object->getId());
+                $permissions->setFields($this->request->getRequestVars(), "", "_all");
+                if (!conf::get("watermark.enable")) {
+                    $permissions->set("watermark_level", 0);
+                }
+                $permissions->insert();
+            }
+        }
+
+        $groups = $this->object->getPermissionArray(true);
+        foreach ($groups as $group) {
+            $group->lookup();
+            $id=$group->getId();
+
+            if (isset($this->request["_remove_permission_group__$id"])) {
+                $permissions = new permissions($id, $this->object->getId());
+                $permissions->delete();
+            } else {
+                $permissions = new permissions();
+                $permissions->setFields($this->request->getRequestVars(), "", "__$id");
+                $permissions->update();
+            }
+        }
+        // Check if new album should be added
+        if ($this->request["group_id_new"]) {
+            $permissions = new permissions();
+            $permissions->setFields($this->request->getRequestVars(), "", "_new");
+
+            if (!conf::get("watermark.enable")) {
+                $permissions->set("watermark_level", 0);
+            }
+            $permissions->insert();
+        }
+
+        $this->view="album";
+
+    }
+
+    /**
+     * Process changes to album permissions
+     */
+    protected function actionUpdatealbums() {
+        // Check if the "Grant access to all albums" checkbox is ticked
+        if ($this->request["_access_level_all_checkbox"]) {
+            $albums = album::getAll();
+            foreach ($albums as $alb) {
+                $permissions = new permissions($this->object->getId(), $alb->getId());
+                $permissions->setFields($this->request->getRequestVars(), "", "_all");
+                if (!conf::get("watermark.enable")) {
+                    $permissions->set("watermark_level", 0);
+                }
+                $permissions->insert();
+            }
+        }
+
+        $albums = $this->object->getAlbums();
+        foreach ($albums as $album) {
+            $album->lookup();
+            $id=$album->getId();
+
+            if (isset($this->request["_remove_permission_album__$id"])) {
+                $permissions = new permissions($this->object->getId(), $id);
+                $permissions->delete();
+            } else {
+                $permissions = new permissions();
+                $permissions->setFields($this->request->getRequestVars(), "", "__$id");
+                $permissions->update();
+            }
+        }
+        // Check if new album should be added
+        if ($this->request["album_id_new"]) {
+            $permissions = new permissions();
+            $permissions->setFields($this->request->getRequestVars(), "", "_new");
+
+            if (!conf::get("watermark.enable")) {
+                $permissions->set("watermark_level", 0);
+            }
+            $permissions->insert();
+        }
+
+        $this->view="group";
+
+    }
+
+}
diff -pruN 0.9.4-4/php/classes/permissions/view/edit.inc.php 0.9.8-1/php/classes/permissions/view/edit.inc.php
--- 0.9.4-4/php/classes/permissions/view/edit.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/permissions/view/edit.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,98 @@
+<?php
+/**
+ * View for editting permissions
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace permissions\view;
+
+use album;
+use conf\conf;
+use group;
+use template\block;
+
+/**
+ * View for editting permissions
+ */
+class edit {
+
+    /**
+     * @var group|album Object to operate on
+     */
+    private $object;
+
+    /**
+     * Create view from object
+     * @param group|album Object to operate on
+     */
+    public function __construct($obj) {
+        $this->object=$obj;
+    }
+
+    /**
+     * Output view
+     */
+    public function view() {
+        $accessLevelAll=new block("formInputText", array(
+            "label" => null,
+            "name"  => "access_level_all",
+            "size"  => 4,
+            "maxlength"  => 2,
+            "value" => "5"
+        ));
+        $wmLevelAll=new block("formInputText", array(
+            "label" => null,
+            "name"  => "watermark_level_all",
+            "size"  => 4,
+            "maxlength"  => 2,
+            "value" => "5"
+        ));
+        $accessLevelNew=new block("formInputText", array(
+            "label" => null,
+            "name"  => "access_level_new",
+            "size"  => 4,
+            "maxlength"  => 2,
+            "value" => "5"
+        ));
+        $wmLevelNew=new block("formInputText", array(
+            "label" => null,
+            "name"  => "watermark_level_new",
+            "size"  => 4,
+            "maxlength"  => 2,
+            "value" => "5"
+        ));
+
+        $class = get_class($this->object);
+        $edit = $this->object instanceof album ? "group" : "album";
+        $gp = new block("editPermissions", array(
+            "watermark"         => conf::get("watermark.enable"),
+            "edit"              => $edit,
+            "fixed"             => get_class($this->object),
+            "id"                => $this->object->getId(),
+            "edit_id"           => $edit . "_id",
+            "accessLevelAll"    => $accessLevelAll,
+            "wmLevelAll"        => $wmLevelAll,
+            "accessLevelNew"    => $accessLevelNew,
+            "wmLevelNew"        => $wmLevelNew,
+            "permissions"       => $this->object->getPermissionArray()
+        ));
+        return $gp;
+    }
+}
diff -pruN 0.9.4-4/php/classes/permissions.inc.php 0.9.8-1/php/classes/permissions.inc.php
--- 0.9.4-4/php/classes/permissions.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/permissions.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -1,9 +1,7 @@
 <?php
 /**
- * This class corresponds to the group_permissions table which maps a group_id
- * to a ablum_id + access_level + writable flag.  If the user is not an admin,
- * access to any photo must involve a join with this table to make sure the
- * user has access to an album that the photo is in.
+ * Permissions contain a (group, album) tuple to determine which albums
+ * a group can see, along with a few parameters to fine tune
  *
  * This file is part of Zoph.
  *
@@ -25,6 +23,15 @@
  * @package Zoph
  */
 
+/**
+ * Permissions contain a (group, album) tuple to determine which albums
+ * a group can see, along with a few parameters to fine tune
+ *
+ * This class corresponds to the group_permissions table which maps a group_id
+ * to a ablum_id + access_level + writable flag.  If the user is not an admin,
+ * access to any photo must involve a join with this table to make sure the
+ * user has access to an album that the photo is in.
+ */
 class permissions extends zophTable {
     /** @var string The name of the database table */
     protected static $tableName="group_permissions";
@@ -68,6 +75,26 @@ class permissions extends zophTable {
     }
 
     /**
+     * Get name of the group in this permission
+     * @return string group name
+     */
+    public function getGroupName() {
+        $group=new group($this->get("group_id"));
+        $group->lookup();
+        return $group->getName();
+    }
+
+    /**
+     * Get name of the album in this permission
+     * @return string album name
+     */
+    public function getAlbumName() {
+        $album=new album($this->get("album_id"));
+        $album->lookup();
+        return $album->getName();
+    }
+
+    /**
      * Insert a new permissions object into the db
      * Because of the way permissions work, if the album in question is a child of another
      * album (which it will be in most cases - except for the root album), this will
diff -pruN 0.9.4-4/php/classes/person.inc.php 0.9.8-1/php/classes/person.inc.php
--- 0.9.4-4/php/classes/person.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/person.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -32,6 +32,10 @@ use db\db;
 use db\clause;
 use db\selectHelper;
 
+use conf\conf;
+
+use photo\collection;
+
 
 /**
  * Person class
@@ -59,6 +63,8 @@ class person extends zophTable implement
     /** @var bool keep keys with insert. In most cases the keys are set
                   by the db with auto_increment */
     protected static $keepKeys = false;
+    /** @var array Array of values that must be integer */
+    protected static $isInteger=array("person_id", "home_id", "work_id", "father_id", "mother_id", "spouse_id", "createdby");
     /** @var string URL for this class */
     protected static $url="person.php?person_id=";
 
@@ -75,10 +81,31 @@ class person extends zophTable implement
      */
     public function insert() {
         $this->set("createdby", (int) user::getCurrent()->getId());
+        $this->setDatesNull();
         return parent::insert();
     }
 
     /**
+     * Update an existing record in the database
+     */
+    public function update() {
+        $this->setDatesNull();
+        return parent::update();
+    }
+
+    /**
+     * Set dates to NULL if they're set to an empty string
+     */
+    private function setDatesNull() {
+        if ($this->get("dob")==="") {
+            $this->set("dob", null);
+        }
+        if ($this->get("dod")==="") {
+            $this->set("dod", null);
+        }
+    }
+
+    /**
      * Add this person to a photo.
      * This records in the database that this person appears on the photo
      * @param photo Photo to add the person to
@@ -341,13 +368,9 @@ class person extends zophTable implement
      * @return int count
      */
     public function getPhotoCount() {
-        $user=user::getCurrent();
-
-        $ignore=null;
-        $vars=array(
+        return sizeof(collection::createFromVars(array(
             "person_id" => $this->getId()
-        );
-        return get_photos($vars, 0, 1, $ignore, $user);
+        )));
     }
 
     /**
@@ -523,16 +546,17 @@ class person extends zophTable implement
      * Lookup person by name;
      * @param string name
      */
-    public static function getByName($name) {
+    public static function getByName($name, $like=false) {
         if (empty($name)) {
             return false;
         }
         $qry=new select(array("ppl" => "people"));
         $qry->addFields(array("person_id"));
-        $where=new clause("CONCAT_WS(\" \", lower(first_name), lower(last_name))=lower(:name)");
-        $qry->addParam(new param(":name", $name, PDO::PARAM_STR));
+        $where=new clause("CONCAT_WS(\" \", lower(first_name), lower(last_name))" . (
+            $like ? " LIKE :name" : "=lower(:name)")
+        );
+        $qry->addParam(new param(":name", $like ? "%" . $name . "%" : $name, PDO::PARAM_STR));
         $qry->where($where);
-
         return static::getRecordsFromQuery($qry);
     }
 
diff -pruN 0.9.4-4/php/classes/photo/collection.inc.php 0.9.8-1/php/classes/photo/collection.inc.php
--- 0.9.4-4/php/classes/photo/collection.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/photo/collection.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,120 @@
+<?php
+/**
+ * A photo\collection is a collection of photos (@see photo).
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace photo;
+
+use photo;
+
+use user;
+
+use web\request;
+
+/**
+ * Collection of photo objects
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class collection extends \generic\collection {
+
+    /**
+     * Remove all photos that have no valid timezone
+     * @todo this is now a wrapper around the static photo::removePhotosWithNoValidTZ() function
+     *       once all references to that function have been moved to this, the implementation
+     *       should move as well
+     */
+    public function removeNoValidTZ() {
+        $this->items=photo::removePhotosWithNoValidTZ($this->items);
+        return $this;
+    }
+
+    /**
+     * Remove all photos that have lat/lon set
+     * @todo this is now a wrapper around the static photo::removePhotosWithLatLon() function
+     *       once all references to that function have been moved to this, the implementation
+     *       should move as well
+     */
+    public function removeWithLatLon() {
+        $this->items=photo::removePhotosWithLatLon($this->items);
+        return $this;
+    }
+
+    /**
+     * Get a subset of photos to do geotagging test on
+     * This will select a subset of photos containing of the first x, last x and or random x photos
+     * from the subset. This is used to give the user a preview of what is going to be geotagged.
+     * @param array subset array that can contain "first", "last" and/or "random"
+     * @param int number of each to select
+     */
+    public function getSubsetForGeotagging(array $subset, $count) {
+        $begin=0;
+
+        $max=count($this);
+
+        $count = min($max, $count);
+        $return = new self;
+        if (in_array("first", $subset)) {
+            $first=$this->subset(0, $count);
+            $max=$max-$count;
+            $begin=$count;
+            $return = $first;
+        }
+        if (in_array("last", $subset)) {
+            $last=$this->subset(-$count);
+            $max=$max-$count;
+            $return = $return->merge($last);
+        }
+
+        if (in_array("random", $subset) && ($max > 0)) {
+            $center=$this->subset($begin, $max);
+
+            $max=count($center);
+
+            if ($max!=0) {
+                $random = $center->random($count);
+                $return = $return->merge($random);
+            }
+        }
+
+
+        return $return->renumber();
+    }
+
+    /**
+     * Create a new photo\collection from request
+     * @param request web request
+     */
+    public static function createFromRequest(request $request) {
+        return static::createFromVars($request->getRequestVarsClean());
+    }
+
+    /**
+     * Create a new photo\collection from request vars
+     * @param array http request vars
+     */
+    public static function createFromVars(array $vars) {
+        $search=new search($vars);
+        $photos=photo::getRecordsFromQuery($search->getQuery());
+        return static::createFromArray($photos, true);
+    }
+
+}
diff -pruN 0.9.4-4/php/classes/photo/search.inc.php 0.9.8-1/php/classes/photo/search.inc.php
--- 0.9.4-4/php/classes/photo/search.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/photo/search.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,637 @@
+<?php
+/**
+ * Photo Search
+ *
+ * This file converts a set of http request vars into an SQL query
+ * in this way, a selection of photos can be made, based on constraints
+ * This file is more or less the core of Zoph. Any modification should
+ * be made with extreme care!
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jason Geiger
+ * @author Jeroen Roos
+ */
+
+namespace photo;
+
+use album;
+use category;
+use PDO;
+use person;
+use photo;
+use place;
+use user;
+
+use db\select;
+use db\param;
+use db\clause;
+use db\selectHelper;
+
+use conf\conf;
+
+/**
+ * Photo Search
+ *
+ * This class converts a set of http request vars into an SQL query
+ * in this way, a selection of photos can be made, based on constraints
+ *
+ * @package Zoph
+ * @author Jason Geiger
+ * @author Jeroen Roos
+ */
+class search {
+    /** @var Valid comparison operators */
+    const OPS       = array("=", "!=", "less than", "more than", ">", ">=",
+                        "<", "<=", "like", "not like", "is in photo", "is not in photo");
+    /** @var Valid conjunction operators */
+    const CONJ      = array("and", "or");
+    /** @var Valid sort directions */
+    const SORTDIR   = array("asc", "desc");
+    /** @var Valid search fields */
+    const FIELDS    = array("location_id", "rating", "photographer_id",
+                        "date", "time", "timestamp", "name", "path", "title", "view", "description",
+                        "width", "height", "size", "aperture", "camera_make", "camera_model",
+                        "compression", "exposure", "flash_used", "focal_length", "iso_equiv", "metering_mode");
+    /** @var Valid text search fields */
+    const TEXT      = array("album", "category", "person", "photographer");
+
+    /** @var Holds the query */
+    private $qry;
+    /** Holds the variables that are used to build the constraint */
+
+    /** @var array holds the request vars */
+    private $vars;
+
+    /**
+     * Create seach object based on http request vars
+     * @param array vars http request vars
+     */
+    public function __construct(array $vars) {
+
+        $this->qry = new select(array("p" => "photos"));
+        $this->vars = $vars;
+        $this->processVars();
+        $this->setOrder();
+    }
+
+    /**
+     * Get the resulting query
+     * @return db\query SQL query that can be used to get photos from database
+     */
+    public function getQuery() {
+        return $this->qry;
+    }
+
+    /**
+     * Process the fields needed to determine the ORDER in the SQL query
+     */
+    private function setOrder() {
+        if (isset($this->vars["_order"])) {
+            $order = $this->vars["_order"];
+        } else {
+            $order = conf::get("interface.sort.order");
+        }
+
+        if (isset($this->vars["_dir"])) {
+            $dir = $this->vars["_dir"];
+        } else {
+            $dir = conf::get("interface.sort.dir");
+        }
+
+        if (!in_array(strtolower($dir), static::SORTDIR)) {
+            throw new \IllegalValueSecurityException("Illegal sort direction: " . e($dir));
+        }
+
+        if (isset($this->vars["_random"])) {
+            // get one random result
+            $this->qry->addOrder("rand()");
+            $this->qry->addLimit(1);
+        } else {
+            $this->qry->addFields(array($order));
+            $this->qry->addOrder("p." . $order . " " . $dir);
+            if ($order == "date") {
+                $this->qry->addFields(array("p.time"));
+                $this->qry->addOrder("p.time " . $dir);
+            }
+            $this->qry->addOrder("p.photo_id " . $dir);
+        }
+    }
+
+    /**
+     * Process variables
+     * This function loops over all the variables and adds the various
+     * contstraints (clauses) to the SQL query
+     */
+    private function processVars() {
+
+        foreach ($this->vars as $key => $val) {
+            if (empty($key) || empty($val) || $key[0] == "_" || strpos(" $key", "PHP") == 1) {
+                continue;
+            }
+
+            // handle refinements of searches
+            $suffix = "";
+            $hashPos = strrpos($key, "#");
+            if ($hashPos > 0) {
+                $suffix = substr($key, $hashPos);
+                $key = substr($key, 0, $hashPos);
+            }
+
+            $index = "_" . $key . $suffix;
+            $origSuffix=$suffix;
+            $suffix=str_replace("#", "_", $suffix);
+            if (!empty($this->vars[$index . "-conj"])) {
+                $conj = $this->vars[$index . "-conj"];
+            } else {
+                $conj = "and";
+            }
+            if (!in_array($conj, static::CONJ)) {
+                throw new \IllegalValueSecurityException("Illegal conjunction: " . e($conj));
+            }
+
+            if (!empty($this->vars[$index . "-op"])) {
+                $op = $this->vars[$index . "-op"];
+            } else {
+                $op = "=";
+            }
+
+            if (!in_array($op, static::OPS)) {
+                throw new \IllegalValueSecurityException("Illegal operator: " . e($op));
+            }
+
+            if (!empty($this->vars[$index . "-children"])) {
+                $object=explode("_", $key);
+                if ($object[0]=="location") {
+                    $object[0] = "place";
+                }
+                $obj=new $object[0]($val);
+                $val=$obj->getBranchIdArray();
+            }
+
+            if ($key == "text") {
+                $key = $this->vars["_" . $key . $origSuffix];
+
+                if (!in_array($key, static::TEXT)) {
+                    throw new \IllegalValueSecurityException("Illegal text search: " . e($key));
+                }
+
+                $val = e($val);
+                $key = e($key);
+            }
+
+            // the regexp matches a list of numbers, separated by comma's.
+            if (!is_array($val) && preg_match("/^([0-9]+)(,([0-9]+))+$/", $val)) {
+                $val=explode(",", $val);
+            }
+
+
+            if ($key == "person" || $key == "photographer") {
+                $this->processPerson($key, $val, $suffix, $conj);
+                // continue, because processPerson already modifies the query
+                continue;
+            } else if ($key == "album") {
+                $key = "album_id";
+                $val = $this->processAlbum($val);
+            } else if ($key == "category") {
+                $key = "category_id";
+                $val = $this->processCategory($val);
+            }
+
+            if (($key == "album_id" || $key == "category_id") && $op == "like") {
+                $op = "=";
+            } else if (($key == "album_id" || $key == "category_id") && $op == "not like") {
+                $op = "!=";
+            }
+
+            if ($key == "album_id") {
+                $this->processAlbumId($val, $suffix, $op, $conj);
+            } else if ($key == "category_id") {
+                $this->processCategoryId($val, $suffix, $op, $conj);
+            } else if ($key == "location_id") {
+                $this->processLocationId($val, $suffix, $op, $conj);
+            } else if ($key == "person_id") {
+                $this->processPersonId($val, $suffix, $op, $conj);
+            } else if ($key == "userrating") {
+                $this->processUserRating($val, $suffix, $conj);
+            } else if ($key=="rating") {
+                $this->processRating($val, $suffix, $op, $conj);
+            } else if ($key=="lat" || $key=="lon") {
+                $latlon[$key]=$val;
+
+                if (!empty($latlon["lat"]) && !empty($latlon["lon"])) {
+                    $lat=(float) $latlon["lat"];
+                    $lon=(float) $latlon["lon"];
+                    $this->processLatLon($lat, $lon, $suffix, $conj);
+                }
+            } else {
+                $this->processOtherFields($key, $val, $suffix, $origSuffix, $op, $conj);
+            }
+
+        }
+
+        $this->qry = selectHelper::expandQueryForUser($this->qry, user::getCurrent());
+
+        $distinct=true;
+        $this->qry->addFields(array("p.photo_id"), $distinct);
+        $this->qry->addFields(array("p.name", "p.path", "p.width", "p.height"));
+    }
+
+    /**
+     * This can be used to reference persons by name directly from the URL
+     * it's not actually used in Zoph and it's not well documented.
+     * But it could be used to create a URL like http://www.zoph.org/search.php?person=Jeroen Roos
+     * With the help of url rewrite, one could even change that into something like
+     * http://www.zoph.org/person/Jeroen Roos
+     * @param string key name of the field (person|photographer)
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processPerson($key, $val, $suffix, $conj) {
+        $people = person::getByName($val, true);
+
+        $peopleIds=array();
+
+        if ($people && count($people) > 0) {
+            foreach ($people as $person) {
+                $peopleIds[]=$person->getId();
+            }
+        } else {
+            // the person did not exist, no photos should be found
+            // however, we can't just return 0 here, as there may be an OR clause in the query...
+            $peopleIds[]=-1;
+        }
+
+        $param=new param(":peopleIds" . $suffix, $peopleIds, PDO::PARAM_INT);
+        $this->qry->addParam($param);
+        if ($key=="person") {
+            $alias = "pp" . substr($suffix, 1);
+            $this->qry->addClause(clause::InClause($alias . ".person_id", $param), $conj);
+            $this->qry->join(array($alias => "photo_people"), "p.photo_id=" . $alias . ".photo_id");
+        } else if ($key=="photographer") {
+            $this->qry->addClause(clause::InClause("photographer_id", $param), $conj);
+        }
+    }
+
+    /**
+     * Search for album by name
+     * @param string val value of the field
+     * @return int|array album_id or array of album_ids
+     */
+    private function processAlbum($val) {
+        $album=album::getByNameHierarchical($val);
+        if ($album instanceof album) {
+            $val=$album->getId();
+        } else if (is_array($album)) {
+            $val=array();
+            foreach ($album as $alb) {
+                $val[]=$alb->getId();
+            }
+        } else {
+            // the album did not exist, no photos should be found
+            // however, we can't just return 0 here, as there may be an OR clause in the query...
+            $val=-1;
+        }
+        return $val;
+    }
+
+    /**
+     * Search for category by name
+     * @param string val value of the field
+     * @return int|array category_id or array of category_ids
+     */
+    private function processCategory($val) {
+        $category=category::getByNameHierarchical($val);
+        if ($category instanceof category) {
+            $val=$category->getId();
+        } else if (is_array($category)) {
+            $val=array();
+            foreach ($category as $cat) {
+                $val[]=$cat->getId();
+            }
+        } else {
+            // the category did not exist, no photos should be found
+            // however, we can't just return 0 here, as there may be an OR clause in the query...
+            $val=-1;
+        }
+        return $val;
+    }
+
+    /**
+     * Search for album by id
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processAlbumId($val, $suffix, $op, $conj) {
+        if ($op == "=") {
+            $alias = "pa" . substr($suffix, 1);
+            /*
+             * Because the query builder expects the photo_album table to be aliased to "pa",
+             * the first occurence does not have number suffix
+             */
+            if ($alias=="pa1") {
+                $alias="pa";
+            }
+            $this->qry->join(array($alias => "photo_albums"), "p.photo_id=" . $alias . ".photo_id");
+            if (is_numeric($val)) {
+                $this->qry->addClause(new clause($alias . ".album_id=:albumId" . $suffix), $conj);
+                $this->qry->addParam(new param(":albumId" . $suffix, (int) $val, PDO::PARAM_INT));
+            } else if (is_array($val)) {
+                $param=new param(":albumIds" . $suffix, $val, PDO::PARAM_INT);
+                $this->qry->addParam($param);
+                $this->qry->addClause(clause::InClause($alias . ".album_id", $param), $conj);
+            } else {
+                throw new \KeyMustBeNumericSecurityException("album_id must be numeric");
+            }
+        } else {
+            // assume "not in"
+            $exclAlbumsQry=new select(array("p" => "photos"));
+            $exclAlbumsQry->addFields(array("photo_id"), true);
+
+            $exclAlbumsQry->join(array("pa" => "photo_albums"), "p.photo_id=pa.photo_id");
+
+            $param=new param(":albumIds" . $suffix, (array) $val, PDO::PARAM_INT);
+            $exclAlbumsQry->addParam($param);
+            $exclAlbumsQry->where(clause::InClause("pa.album_id", $param));
+
+            $exclPhotoIds=$exclAlbumsQry->toArray();
+
+            $param=new param(":photoIds" . $suffix, (array) $exclPhotoIds, PDO::PARAM_INT);
+            $this->qry->addParam($param);
+            $this->qry->addClause(clause::NotInClause("p.photo_id", $param), $conj);
+        }
+    }
+
+    /**
+     * Search for category by id
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processCategoryId($val, $suffix, $op, $conj) {
+        if ($op == "=") {
+            $alias = "pc" . substr($suffix, 1);
+            $this->qry->join(array($alias => "photo_categories"), "p.photo_id=" . $alias . ".photo_id");
+            if (is_numeric($val)) {
+                $this->qry->addClause(new clause($alias . ".category_id=:categoryId" . $suffix), $conj);
+                $this->qry->addParam(new param(":categoryId" . $suffix, (int) $val, PDO::PARAM_INT));
+            } else if (is_array($val)) {
+                $param=new param(":categoryIds" . $suffix, $val, PDO::PARAM_INT);
+                $this->qry->addParam($param);
+                $this->qry->addClause(clause::InClause($alias . ".category_id", $param), $conj);
+            } else {
+                throw new \KeyMustBeNumericSecurityException("category_id must be numeric");
+            }
+        } else {
+            /* assume "not in" */
+            $exclCategoryQry=new select(array("p" => "photos"));
+            $exclCategoryQry->addFields(array("photo_id"), true);
+
+            $exclCategoryQry->join(array("pc" => "photo_categories"), "p.photo_id=pc.photo_id");
+
+            $param=new param(":categoryIds" . $suffix, (array) $val, PDO::PARAM_INT);
+            $exclCategoryQry->addParam($param);
+            $exclCategoryQry->where(clause::InClause("pc.category_id", $param));
+
+            $exclPhotoIds=$exclCategoryQry->toArray();
+            $param=new param(":photoIds" . $suffix, (array) $exclPhotoIds, PDO::PARAM_INT);
+            $this->qry->addParam($param);
+            $this->qry->addClause(clause::NotInClause("p.photo_id", $param), $conj);
+        }
+    }
+
+    /**
+     * Search for location by id
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processLocationId($val, $suffix, $op, $conj) {
+        if (is_numeric($val)) {
+            $this->qry->addParam(new param(":locationId" . $suffix, (int) $val, PDO::PARAM_INT));
+            if ($op == "=") {
+                $this->qry->addClause(new clause("p.location_id=:locationId" . $suffix), $conj);
+            } else {
+                $clause=new clause("p.location_id != :locationId" . $suffix);
+                $clause->addOr(new clause("p.location_id is null"));
+                $this->qry->addClause($clause, $conj);
+            }
+        } else if (is_array($val)) {
+            $param=new param(":locationIds" . $suffix, $val, PDO::PARAM_INT);
+            $this->qry->addParam($param);
+            $this->qry->addClause(clause::InClause("p.location_id", $param), $conj);
+        } else {
+            throw new \KeyMustBeNumericSecurityException("location_id must be numeric");
+        }
+    }
+
+    /**
+     * Search for person by id
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processPersonId($val, $suffix, $op, $conj) {
+        if ($op == "=") {
+            $alias = "ppl" . substr($suffix, 1);
+            $this->qry->join(array($alias => "photo_people"), "p.photo_id=" . $alias . ".photo_id");
+            if (is_numeric($val)) {
+                $this->qry->addClause(new clause($alias . ".person_id=:personId" . $suffix), $conj);
+                $this->qry->addParam(new param(":personId" . $suffix, (int) $val, PDO::PARAM_INT));
+            } else if (is_array($val)) {
+                $param=new param(":personIds" . $suffix, $val, PDO::PARAM_INT);
+                $this->qry->addParam($param);
+                $this->qry->addClause(clause::InClause($alias . ".person_id", $param), $conj);
+            } else {
+                throw new \KeyMustBeNumericSecurityException("person_id must be numeric");
+            }
+
+        } else {
+            // assume "not in"
+            $exclPeopleQry=new select(array("p" => "photos"));
+            $exclPeopleQry->addFields(array("photo_id"), true);
+
+            $exclPeopleQry->join(array("ppl" => "photo_people"), "p.photo_id=ppl.photo_id");
+
+            $param=new param(":personIds" . $suffix, (array) $val, PDO::PARAM_INT);
+            $exclPeopleQry->addParam($param);
+            $exclPeopleQry->where(clause::InClause("ppl.person_id", $param));
+
+            $exclPhotoIds=$exclPeopleQry->toArray();
+
+            $param=new param(":photoIds" . $suffix, (array) $exclPhotoIds, PDO::PARAM_INT);
+            $this->qry->addParam($param);
+            $this->qry->addClause(clause::NotInClause("p.photo_id", $param), $conj);
+        }
+    }
+
+    /**
+     * Search for user rating
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processUserRating($val, $suffix, $conj) {
+        $user=user::getCurrent();
+        if ($user->isAdmin() && isset($this->vars["_userrating_user"])) {
+            $ratingUserId=$this->vars["_userrating_user"];
+        } else {
+            $ratingUserId=$user->getId();
+        }
+        if ($val != "null") {
+            $alias = "pr" . substr($suffix, 1);
+            $this->qry->join(array($alias => "photo_ratings"), "p.photo_id=" . $alias . ".photo_id");
+
+            $clause=new clause($alias . ".user_id=:ratingUserId" . $suffix);
+            $clause->addAnd(new clause($alias . ".rating=:rating" . $suffix));
+
+            $this->qry->addParam(new param(":ratingUserId", $ratingUserId, PDO::PARAM_INT));
+            $this->qry->addParam(new param(":rating", $val, PDO::PARAM_INT));
+
+            $this->qry->addClause($clause, $conj);
+        } else {
+            $noRateQry=new select(array("pr" => "photo_ratings"));
+            $noRateQry->addFields(array("photo_id"), true);
+            $noRateQry->where(new clause("pr.user_id=:ratingUserId"));
+            $noRateQry->addParam(new param(":ratingUserId", $ratingUserId, PDO::PARAM_INT));
+
+            $photoIds=$noRateQry->toArray();
+
+            if (sizeOf($photoIds) > 0) {
+                $param=new param(":photoIds" . $suffix, (array) $photoIds, PDO::PARAM_INT);
+                $this->qry->addParam($param);
+                $this->qry->addClause(clause::NotInClause("p.photo_id", $param), $conj);
+            }
+        }
+    }
+
+    /**
+     * Search for rating
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processRating($val, $suffix, $op, $conj) {
+        $alias = "vpr" . substr($suffix, 1);
+        $this->qry->join(array($alias => "view_photo_avg_rating"), "p.photo_id=" . $alias . ".photo_id");
+        if ($val=="null") {
+            if ($op == "!=") {
+                $clause=new clause($alias . ".rating is not null");
+            } else if ($op == "=") {
+                $clause=new clause($alias . ".rating is null");
+            }
+        } else {
+            $this->qry->addParam(new param(":rating" . $suffix, $val, PDO::PARAM_INT));
+            $clause=new clause($alias . ".rating " . $op . " :rating" . $suffix);
+        }
+        $this->qry->addClause($clause, $conj);
+    }
+
+    /**
+     * Search for Latitude / longitude
+     * @param float latitude value
+     * @param float longitude value
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processLatLon($lat, $lon, $suffix, $conj) {
+        $ids=array();
+        $distance=(float) $this->vars["_latlon_distance"];
+        if (isset($this->vars["_latlon_entity"]) && $this->vars["_latlon_entity"]=="miles") {
+            $distance=$distance * 1.609344;
+        }
+        if (isset($this->vars["_latlon_photos"])) {
+            $photos=photo::getPhotosNear($lat, $lon, $distance, null);
+            if ($photos) {
+                foreach ($photos as $photo) {
+                    $ids[]=$photo->getId();
+                }
+            }
+        }
+        if (isset($this->vars["_latlon_places"])) {
+            $places=place::getPlacesNear($lat, $lon, $distance, null);
+            foreach ($places as $place) {
+                $photos=$place->getPhotos(user::getCurrent());
+                foreach ($photos as $photo) {
+                    $ids[]=$photo->getId();
+                }
+            }
+        }
+        if ($ids) {
+            $param=new param(":photoIds" . $suffix, $ids, PDO::PARAM_INT);
+            $this->qry->addParam($param);
+            $this->qry->addClause(clause::InClause("p.photo_id", $param), $conj);
+        } else {
+            // No photos were found
+            $this->qry->addClause(new clause("p.photo_id=-1"), $conj);
+        }
+    }
+
+    /**
+     * Search for other fields
+     * @param string key name of the field
+     * @param string val value of the field
+     * @param string suffix, the suffix can be used to search for the same field multiple times
+     * @param string original suffix, the unprocessed suffix
+     * @param string operator, how the values should be compared (=, !=, like, etc.)
+     * @param string conj, conjugation, whether this is an AND or OR search
+     */
+    private function processOtherFields($key, $val, $suffix, $origSuffix, $op, $conj) {
+        // any other field
+        $clause=null;
+        /* if the key name starts with is "field", we replace te keyname with the contents
+           of _field#0, which holds the real field name */
+        if (strncasecmp($key, "field", 5) == 0) {
+            $key = $this->vars["_" . $key . $origSuffix];
+        }
+        if (!in_array($key, static::FIELDS)) {
+            throw new \IllegalValueSecurityException("Illegal field: " . e($key));
+        }
+
+        $val = e($val);
+        $key = e($key);
+        if ($val=="null") {
+            if ($op == "!=") {
+                $clause=new clause("p." . $key . " is not null");
+            } else if ($op == "=") {
+                $clause=new clause("p." . $key . " is null");
+            }
+        } else {
+            $clause=new clause("p." . $key . " " . $op . " :" . $key . $suffix);
+
+            if ($op == "like" || $op == "not like") {
+                $val="%" . $val . "%";
+            } else if ($op == "!=") {
+                $clause->addOr(new clause("p." . $key . " is null"));
+            }
+
+            $this->qry->addParam(new param(":" . $key . $suffix, $val, PDO::PARAM_STR));
+
+        }
+        if ($clause instanceof clause) {
+            $this->qry->addClause($clause, $conj);
+        }
+    }
+}
+?>
diff -pruN 0.9.4-4/php/classes/photographer.inc.php 0.9.8-1/php/classes/photographer.inc.php
--- 0.9.4-4/php/classes/photographer.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/photographer.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -29,6 +29,8 @@ use db\param;
 use db\clause;
 use db\selectHelper;
 
+use photo\collection;
+
 /**
  * Photographer class
  *
@@ -63,13 +65,9 @@ class photographer extends person implem
      * @return int count
      */
     public function getPhotoCount() {
-        $user=user::getCurrent();
-
-        $ignore=null;
-        $vars=array(
+        return sizeof(collection::createFromVars(array(
             "photographer_id" => $this->getId()
-        );
-        return get_photos($vars, 0, 1, $ignore, $user);
+        )));
     }
 
     /**
diff -pruN 0.9.4-4/php/classes/photo.inc.php 0.9.8-1/php/classes/photo.inc.php
--- 0.9.4-4/php/classes/photo.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/photo.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -28,6 +28,18 @@ use db\param;
 use db\clause;
 use db\selectHelper;
 
+use conf\conf;
+
+use geo\map;
+use geo\point;
+use geo\track;
+use geo\marker;
+
+use template\block;
+use template\template;
+
+use photo\collection;
+
 /**
  * A class corresponding to the photos table.
  *
@@ -41,7 +53,12 @@ class photo extends zophTable {
     /** @var array List of primary keys */
     protected static $primaryKeys=array("photo_id");
     /** @var array Fields that may not be empty */
-    protected static $notNull=array();
+    protected static $notNull=array("time_corr");
+    /** @var array Fields that are integers */
+    protected static $isInteger=array(
+        "photo_id", "width", "height", "size", "photographer_id", "location_id", "time_corr", "level", "mapzoom");
+    /** @var array Fields that are floats */
+    protected static $isFloat=array("lat", "lon");
     /** @var bool keep keys with insert. In most cases the keys are set by the
              db with auto_increment */
     protected static $keepKeys = false;
@@ -77,6 +94,10 @@ class photo extends zophTable {
         }
         $image_path .= $name;
 
+        if (!file_exists($image_path)) {
+            throw new photoNotFoundException($name . " could not be found");
+        }
+
         $mtime = filemtime($image_path);
         $filesize = filesize($image_path);
         $gmt_mtime = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
@@ -199,6 +220,20 @@ class photo extends zophTable {
         );
     }
 
+    public function update() {
+        if (empty($this->get("time_corr"))) {
+            $this->set("time_corr", 0);
+        }
+        return parent::update();
+    }
+
+    public function insert() {
+        if (empty($this->get("time_corr"))) {
+            $this->set("time_corr", 0);
+        }
+        return parent::insert();
+    }
+
     /**
      * Update photo relations, such as albums, categories, etc.
      * @param array array of variables to update
@@ -381,7 +416,7 @@ class photo extends zophTable {
         $qry->join(array("pp" => "photo_people"), "pp.person_id = p.person_id");
         $distinct=true;
         $qry->addFields(array("person_id"), $distinct);
-        $qry->addFields(array("last_name", "first_name", "called"));
+        $qry->addFields(array("last_name", "first_name", "called", "pp.position"));
 
         $where=new clause("pp.photo_id=:photoid");
 
@@ -563,6 +598,24 @@ class photo extends zophTable {
 
         $file=$this->getFilePath($type);
 
+        if (!file_exists($file)) {
+            switch ($type) {
+            case MID_PREFIX:
+                $size="width='" . MID_SIZE . "'";
+                break;
+            case THUMB_PREFIX:
+                $size="width='" . THUMB_SIZE . "'";
+                break;
+            default:
+                $size="";
+            }
+            return new block("img", array(
+                "src"   => template::getImage("notfound.png"),
+                "class" => $type,
+                "size"  => $size,
+                "alt"   => "file not found"
+            ));
+        }
         list($width, $height, $filetype, $size)=getimagesize($file);
 
         $alt = e($this->get("title"));
@@ -793,7 +846,7 @@ class photo extends zophTable {
         }
 
         // make a system call to convert or jpegtran to do the rotation.
-        while (list($file, $tmp_file) = each($images)) {
+        foreach ($images as $file => $tmp_file) {
             if (!file_exists($file)) {
                 throw new FileNotFoundException("Could not find " . $file);
             }
@@ -1114,9 +1167,9 @@ class photo extends zophTable {
      */
     public function exifToHTML() {
         if (exif_imagetype($this->getFilePath())==IMAGETYPE_JPEG) {
-            $exif=read_exif_data($this->getFilePath());
+            $exif=exif_read_data($this->getFilePath());
             if ($exif) {
-                $return="<dl class='allexif'>\n";
+                $return="<dl id='allexif'>\n";
 
                 foreach ($exif as $key => $value) {
                     if (!is_array($value)) {
@@ -1163,8 +1216,8 @@ class photo extends zophTable {
         } else {
             $html="<h2>" . e($file) . "<\/h2>";
         }
-        $html.=$this->getThumbnailLink()->toStringNoEnter() .
-          "<p><small>" .
+        $html.=str_replace("\n", "", $this->getThumbnailLink());
+        $html.="<p><small>" .
           $this->get("date") . " " . $this->get("time") . "<br>";
         if ($this->photographer) {
             $html.=translate("by", 0) . " " . $this->photographer->getLink(1) . "<br>";
@@ -1179,7 +1232,7 @@ class photo extends zophTable {
      * @return marker instance of marker class
      */
     public function getMarker($icon="geo-photo") {
-        $marker=map::getMarkerFromObj($this, $icon);
+        $marker=marker::getFromObj($this, $icon);
         if (!$marker instanceof marker) {
             $loc=$this->location;
             if ($loc instanceof place) {
@@ -1618,7 +1671,7 @@ class photo extends zophTable {
      * @param array Array of photos
      * @return int size in bytes
      */
-    public static function getFilesize(array $photos) {
+    public static function getFilesize(collection $photos) {
         $bytes=0;
         foreach ($photos as $photo) {
             $photo->lookup();
diff -pruN 0.9.4-4/php/classes/place.inc.php 0.9.8-1/php/classes/place.inc.php
--- 0.9.4-4/php/classes/place.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/place.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -29,6 +29,14 @@ use db\db;
 use db\clause;
 use db\selectHelper;
 
+use conf\conf;
+
+use geo\map;
+use geo\marker;
+
+use template\block;
+use template\template;
+
 /**
  * A class corresponding to the places table.
  *
@@ -52,6 +60,12 @@ class place extends zophTreeTable implem
     protected static $primaryKeys=array("place_id");
     /** @var array Fields that may not be empty */
     protected static $notNull=array("title");
+    /** @var array Fields that are integers */
+    protected static $isInteger=array(
+        "place_id", "parent_place_id", "contact_type", "coverphoto", "pageset", "mapzoom", "createdby"
+    );
+    /** @var array Fields that are floats */
+    protected static $isFloat=array("lat", "lon");
     /** @var bool keep keys with insert. In most cases the keys are set
                   by the db with auto_increment */
     protected static $keepKeys = false;
@@ -402,7 +416,7 @@ class place extends zophTreeTable implem
      * @return marker instance of marker class
      */
     public function getMarker($icon="geo-place") {
-        return map::getMarkerFromObj($this, $icon);
+        return marker::getFromObj($this, $icon);
     }
 
     /**
diff -pruN 0.9.4-4/php/classes/prefs.inc.php 0.9.8-1/php/classes/prefs.inc.php
--- 0.9.4-4/php/classes/prefs.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/prefs.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -23,6 +23,8 @@
  * @package Zoph
  */
 
+use template\colorScheme;
+
 /**
  * A class representing a set of user preferences
  *
@@ -44,23 +46,22 @@ class prefs extends zophTable {
     protected static $url="prefs.php#";
 
 
-    private $color_scheme;
+    private $colorScheme;
 
     private function lookupColorScheme($force = 0) {
 
         // avoid unnecessary lookups
-        if ($this->color_scheme && $this->color_scheme->get("name") != null && !$force) {
-            return $this->color_scheme;
+        if ($this->colorScheme && $this->colorScheme->get("name") != null && !$force) {
+            return $this->colorScheme;
         }
 
         if ($this->get("color_scheme_id")) {
-            $this->color_scheme =
-                new color_scheme($this->get("color_scheme_id"));
-            $this->color_scheme->lookup();
+            $this->colorScheme = new colorScheme($this->get("color_scheme_id"));
+            $this->colorScheme->lookup();
 
             // make sure it was actually found
-            if ($this->color_scheme->get("name") != null) {
-                return $this->color_scheme;
+            if ($this->colorScheme->get("name") != null) {
+                return $this->colorScheme;
             }
         }
 
@@ -69,7 +70,7 @@ class prefs extends zophTable {
 
     public function load($force = 0) {
         if ($this->lookupColorScheme($force)) {
-            color_scheme::setCurrent($this->color_scheme);
+            colorScheme::setCurrent($this->colorScheme);
         }
     }
 }
diff -pruN 0.9.4-4/php/classes/rating.inc.php 0.9.8-1/php/classes/rating.inc.php
--- 0.9.4-4/php/classes/rating.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/rating.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -27,6 +27,9 @@ use db\db;
 use db\clause;
 use db\selectHelper;
 
+use template\block;
+use template\template;
+
 /**
  * Photo ratings
  * @author Jeroen Roos
@@ -298,5 +301,31 @@ class rating extends zophTable {
 
         return $graph;
 
-     }
+    }
+
+    /**
+     * Create a pulldown to select ratings
+     * @param string name
+     * @param int value to make 'selected'
+     * @return block pulldown template block
+     */
+    public static function createPulldown($name = "rating", $val = null) {
+        $ratingArray = array(
+            "1" => translate("1 - close your eyes", 0),
+            "2" => translate("2", 0),
+            "3" => translate("3", 0),
+            "4" => translate("4", 0),
+            "5" => translate("5 - so so", 0),
+            "6" => translate("6", 0),
+            "7" => translate("7", 0),
+            "8" => translate("8", 0),
+            "9" => translate("9", 0),
+            "10" => translate("10 - museum", 0)
+        );
+        if (empty($val)) {
+            $ratingArray = array("0" => translate("not rated", 0)) + $ratingArray;
+        }
+
+        return template::createPulldown($name, $val, $ratingArray);
+    }
 }
diff -pruN 0.9.4-4/php/classes/report.inc.php 0.9.8-1/php/classes/report.inc.php
--- 0.9.4-4/php/classes/report.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/report.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,8 @@
  * @package Zoph
  */
 
+use template\template;
+
 /**
  * Report class. Eventually everything report-related will move here.
  *
@@ -39,7 +41,7 @@ class report {
         $album= album::getRoot();
         $category = category::getRoot();
 
-        $size=getHuman(photo::getTotalSize());
+        $size=template::getHumanReadableBytes(photo::getTotalSize());
         return array(
             translate("number of photos") => photo::getCount(),
             translate("size of photos") => "$size",
diff -pruN 0.9.4-4/php/classes/search/controller.inc.php 0.9.8-1/php/classes/search/controller.inc.php
--- 0.9.4-4/php/classes/search/controller.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/search/controller.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Controller for searches
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace search;
+
+use generic\controller as genericController;
+use search;
+use web\request;
+use user;
+
+/**
+ * Controller for searches
+ */
+class controller extends genericController {
+
+    /** @var array Actions that can be used in this controller */
+    protected   $actions    = array("confirm", "delete", "display", "edit", "insert", "new", "update", "search");
+
+    /** @var Where to redirect after actions */
+    public $redirect="search.php";
+
+    /**
+     * Create a controller using a web request
+     * @param request request
+     */
+    public function __construct(request $request) {
+        parent::__construct($request);
+        if (isset($this->request["search_id"])) {
+            $search = new search($this->request["search_id"]);
+            $search->lookup();
+        } else if ($this->request["_action"]=="new") {
+            $vars=$request->getRequestVarsClean();
+            unset($vars["_action"]);
+            unset($vars["_crumb"]);
+            $urlVars=array();
+
+            foreach ($vars as $key => $val) {
+                // Change key#0 into key[0]:
+                $key=preg_replace("/\#([0-9]+)/", "[$1]", $key);
+                // Change key[0]-children into key_children[0] because everything
+                // after ] in a variable name is lost fix for bug#2890387
+                $key=preg_replace("/\[(.+)\]-([a-z]+)/", "_$2[$1]", $key);
+                $urlVars[]=e($key) . "=" . e($val);
+            }
+            $url = implode("&amp;", $urlVars);
+            $search=new search();
+            $search->set("search", $url);
+            $search->set("owner", user::getCurrent()->getId());
+        } else {
+            $search=new search();
+            $this->request["_action"]="display";
+        }
+        $this->setObject($search);
+        $this->doAction();
+    }
+
+    /**
+     * Do action 'search'
+     */
+    public function actionSearch() {
+        $this->view="photos";
+    }
+}
diff -pruN 0.9.4-4/php/classes/search/view/display.inc.php 0.9.8-1/php/classes/search/view/display.inc.php
--- 0.9.4-4/php/classes/search/view/display.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/search/view/display.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,231 @@
+<?php
+/**
+ * View for search page
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace search\view;
+
+use album;
+use category;
+use conf\conf;
+use group;
+use geo\map;
+use geo\marker;
+use search;
+use template\template;
+use template\block;
+use web\request;
+
+/**
+ * This view displays the search page
+ */
+class display {
+
+    /**
+     * @var request variables
+     */
+    private $vars;
+
+    /**
+     * Create view
+     * @param request web request
+     */
+    public function __construct(request $request) {
+        $this->vars=$request->getRequestVars();
+    }
+
+    /**
+     * Output view
+     */
+    public function view() {
+        if (conf::get("maps.provider")) {
+            $map = new map();
+            $map->setEditable();
+
+            if (isset($this->vars["lat"]) && isset($this->vars["lon"])) {
+                $map->addMarker(new marker($this->vars["lat"], $this->vars["lon"], null, null, null));
+            }
+        }
+
+        $search = new template("main", array(
+            "title" => translate("Search"),
+            "map"   => isset($map) ? $map : null
+        ));
+        $form = new block("searchForm", array(
+            "submit"        => translate("search")
+        ));
+
+        foreach ($this->getSearchTerms() as $param => $term) {
+            $form->addBlocks($this->buildTerm($param, $term));
+
+        }
+
+        if (conf::get("maps.provider")) {
+            $form->addBlock($this->buildMapTerm());
+        }
+        $search->addBlocks(array($form));
+
+        $search->addBlocks(array(search::getList()));
+
+        return $search;
+    }
+
+    /**
+     * Get an array of search terms, this array is used to build the search page
+     * @return array elements ('search terms') to build search page with
+     */
+    private function getSearchTerms() {
+        return array(
+            "date"  =>  array(
+                "label" => translate("photos taken"),
+                "op"    => array("template\\template", "createInequalityOperatorPulldown"),
+                "value" => array("template\\template", "createDaysAgoPulldown"),
+                "value_text"    => translate("days ago")
+            ),
+            "timestamp"  =>  array(
+                "label" => translate("photos modified"),
+                "op"    => array("template\\template", "createInequalityOperatorPulldown"),
+                "value" => array("template\\template", "createDaysAgoPulldown"),
+                "value_text"    => translate("days ago")
+            ),
+            "album_id"     => array(
+                "label" => translate("album"),
+                "op"    => array("template\\template", "createBinaryOperatorPulldown"),
+                "value" => array("album", "createPullDown"),
+                "child" => "album_id_children",
+                "child_label"   => translate("include sub-albums")
+            ),
+            "category_id"     => array(
+                "label" => translate("category"),
+                "op"    => array("template\\template", "createBinaryOperatorPulldown"),
+                "value" => array("category", "createPullDown"),
+                "child" => "category_id_children",
+                "child_label"   => translate("include sub-categories")
+            ),
+            "location_id"     => array(
+                "label" => translate("location"),
+                "op"    => array("template\\template", "createBinaryOperatorPulldown"),
+                "value" => array("place", "createPullDown"),
+                "child" => "location_id_children",
+                "child_label"   => translate("include sub-locations")
+            ),
+            "rating"     => array(
+                "label" => translate("rating"),
+                "op"    => array("template\\template", "createOperatorPulldown"),
+                "value" => array("rating", "createPullDown"),
+            ),
+            "person_id"     => array(
+                "label" => translate("person"),
+                "op"    => array("template\\template", "createPresentOperatorPulldown"),
+                "value" => array("person", "createPullDown"),
+            ),
+            "photographer_id"     => array(
+                "label" => translate("photographer"),
+                "op"    => array("template\\template", "createBinaryOperatorPulldown"),
+                "value" => array("photographer", "createPullDown"),
+            ),
+            "field"     => array(
+                "label" => array("template\\template", "createPhotoFieldPulldown"),
+                "op"    => array("template\\template", "createOperatorPulldown"),
+            ),
+            "text"     => array(
+                "label" => array("template\\template", "createPhotoTextPulldown"),
+                "op"    => array("template\\template", "createTextOperatorPulldown"),
+            ));
+    }
+
+    /**
+     * construct template blocks from searchTerms and the GET / POST parameters given to the page
+     * @param string parameter to build searchTerm for
+     * @param array searchTerm array with fields
+     *      label       : label for the searchterm
+     *      op          : template containing the operator (=, >, <, etc)
+     *      value       : template for value of the field (usually a dropdown)  * optional
+     *      value_text  : text to add after the value                           * optional
+     *      child       : tickbox for 'include children'                        * optional
+     *      child_text  : text for the tickbox                                  * optional
+     */
+    private function buildTerm($param, array $term) {
+        $blocks=array();
+        $count = isset($this->vars[$param]) ? sizeof($this->vars[$param]) - 1: 0;
+        for ($i = 0; $i <= $count; $i++) {
+            $conj   = isset($this->vars["_${param}_conj"][$i])  ? $this->vars["_${param}_conj"][$i] : null;
+            $op     = isset($this->vars["_${param}_op"][$i])    ? $this->vars["_${param}_op"][$i]   : null;
+            $value  = isset($this->vars[$param][$i])            ? $this->vars[$param][$i]           : null;
+            $value  = $value == "+"                             ? ""                                : $value;
+            if (is_array($term["label"])) {
+                $labelVal = isset($this->vars["_${param}"][$i]) ? $this->vars["_${param}"][$i]      : null;
+                $label = call_user_func($term["label"], "_${param}[$i]", $labelVal);
+
+                $value = template::createInput("${param}[$i]", $value, 20);
+            } else {
+                $label = $term["label"];
+                $value = call_user_func($term["value"], "${param}[$i]", $value);
+            }
+            $templateParams=array(
+                "inc"   => ($i == $count) ?  $param . "[" . ($i + 1) ."]": false,
+                "label" => $label,
+                "conj"  => template::createConjunctionPulldown("_${param}_conj[$i]", $conj),
+                "op"    => call_user_func($term["op"], "_${param}_op[$i]", $op),
+                "value" => $value,
+                "value_text"    => isset($term["value_text"]) ? $term["value_text"] : null,
+            );
+            if (isset($term["child"])) {
+                $children = isset($this->vars["_${term["child"]}"][$i]);
+                $templateParams += array(
+                    "child" => "_${term["child"]}[$i]",
+                    "child_checked" => $children ? "checked" : "",
+                    "child_label"   => $term["child_label"]
+                );
+            }
+            $blocks[]=new block("searchTerm", $templateParams);
+        }
+        return $blocks;
+    }
+
+    /**
+     * Build search term to search using the map
+     */
+    private function buildMapTerm() {
+        $conj   = isset($this->vars["_latlon_conj"])        ? $this->vars["_latlon_conj"]       : null;
+        $value  = isset($this->vars["_latlon_distance"])    ? $this->vars["_latlon_distance"]   : null;
+        $entity = isset($this->vars["_latlon_entity"])      ? $this->vars["_latlon_entity"]     : "km";
+        $lat    = isset($this->vars["lat"])                 ? $this->vars["lat"]                : null;
+        $lon    = isset($this->vars["lon"])                 ? $this->vars["lon"]                : null;
+        $places = isset($this->vars["_latlon_places"]);
+        $photos = isset($this->vars["_latlon_photos"]);
+
+        $entityDropdown = template::createPulldown("_latlon_entity", $entity,
+                array("km" => "km", "miles" => "miles"));
+        $valueInput = template::createInput("_latlon_distance", $value, 10);
+
+        $templateParams=array(
+            "conj"      => template::createConjunctionPulldown("_latlon_conj", $conj),
+            "value"     => $valueInput,
+            "entity"    => $entityDropdown,
+            "places_checked" => $places ? "checked" : "",
+            "photos_checked" => $photos ? "checked" : "",
+            "lat"       => template::createInput("lat", $lat, 10),
+            "lon"       => template::createInput("lon", $lon, 10)
+        );
+        return new block("searchTermMap", $templateParams);
+    }
+}
diff -pruN 0.9.4-4/php/classes/search/view/photos.inc.php 0.9.8-1/php/classes/search/view/photos.inc.php
--- 0.9.4-4/php/classes/search/view/photos.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/search/view/photos.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,60 @@
+<?php
+/**
+ * View for search page
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace search\view;
+
+use web\request;
+use user;
+
+/**
+ * This view displays the search results page
+ * For now, it is just a wrapper around the photos page
+ */
+class photos {
+
+    /** * @var array request variables */
+    private $vars;
+    /** * @var request web request */
+    private $request;
+
+    /**
+     * Create view
+     * @param request web request
+     */
+    public function __construct(request $request) {
+        $this->request=$request;
+        $this->vars=$request->getRequestVars();
+    }
+
+    /**
+     * Output view
+     */
+    public function view() {
+        $request_vars=$this->vars;
+        $request=$this->request;
+        $user=user::getCurrent();
+        ob_start();
+        require("photos.php");
+        return ob_get_clean();
+    }
+}
diff -pruN 0.9.4-4/php/classes/search.inc.php 0.9.8-1/php/classes/search.inc.php
--- 0.9.4-4/php/classes/search.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/search.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -25,6 +25,9 @@ use db\select;
 use db\param;
 use db\clause;
 
+use template\block;
+use template\template;
+
 /**
  * Store and retrieve searches
  *
@@ -48,8 +51,7 @@ class search extends zophTable {
      * Update an existing search in the db
      */
     public function update() {
-        // Set timestamp to NULL so db will set it to current
-        $this->set("timestamp", null);
+        $this->set("timestamp", "now()");
         parent::update();
     }
 
@@ -91,33 +93,6 @@ class search extends zophTable {
     }
 
     /**
-     * Get array that can be used to build an edit form
-     * @return array edit array
-     */
-    public function getEditArray() {
-        $user=user::getCurrent();
-        $editArray=array();
-
-
-        $editArray[]=array(
-            translate("Name"),
-            create_text_input("name", $this->get("name"), 40, 64));
-
-        if ($user->isAdmin()) {
-            $editArray[]=array (
-                translate("Owner"),
-                template::createPulldown("owner", $this->get("owner"),
-                    template::createSelectArray(user::getRecords("user_name"),
-                    array("user_name"))));
-            $editArray[]=array(
-                translate("Public"),
-                template::createYesNoPulldown("public", $this->get("public")));
-
-        }
-        return $editArray;
-    }
-
-    /**
      * Display the search
      */
     public function getLink() {
diff -pruN 0.9.4-4/php/classes/selection.inc.php 0.9.8-1/php/classes/selection.inc.php
--- 0.9.4-4/php/classes/selection.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/selection.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -23,6 +23,8 @@
  * @package Zoph
  */
 
+use template\block;
+
 /**
  * Selection class
  * A photo can be "selected" to be used in another part of Zoph,
@@ -42,7 +44,7 @@ class selection {
      *              be the url to which the user is redirected back
      *              afterwards.
      */
-    public function __construct($session, $links) {
+    public function __construct($session, $links, photo $thisPhoto=null) {
         if (!isset($session["selected_photo"]) || sizeof($session["selected_photo"])===0) {
             throw new PhotoNoSelectionException("No photos selected");
         }
@@ -50,10 +52,16 @@ class selection {
         $this->links=$links;
 
         foreach ($session["selected_photo"] as $photo_id) {
-            $photo=new photo($photo_id);
-            $photo->lookup();
-            $this->photos[]=$photo;
+            if ($thisPhoto && $thisPhoto->getId() != $photo_id) {
+                $photo=new photo($photo_id);
+                $photo->lookup();
+                $this->photos[]=$photo;
+            }
+        }
+        if (sizeof($this->photos) === 0) {
+            throw new PhotoNoSelectionException("No photos selected");
         }
+
     }
 
     /**
diff -pruN 0.9.4-4/php/classes/template/block.inc.php 0.9.8-1/php/classes/template/block.inc.php
--- 0.9.4-4/php/classes/template/block.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/block.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Class that takes care of displaying blocks
+ *  A block is a template for a part of the screen,
+ *  while a template is a full page.
+ * @todo this separation is still ongoing and won't be finished
+ *       until all HTML has moved into templates (and blocks).
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace template;
+
+/**
+ * This class takes care of displaying blocks
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class block extends template {
+    /**
+     * Create block object
+     *
+     * @param string Name of template (without path or extension)
+     * @param array Array of variables that can be used in the template
+     * @return template
+     */
+    public function __construct($template, $vars=null) {
+        $this->vars=$vars;
+        if (!preg_match("/^[A-Za-z0-9_]+$/", $template)) {
+            log::msg("Illegal characters in template", log::FATAL, log::GENERAL);
+        } else {
+            $this->template="templates/default/blocks/" . $template . ".tpl.php";
+        }
+    }
+}
diff -pruN 0.9.4-4/php/classes/template/colorScheme.inc.php 0.9.8-1/php/classes/template/colorScheme.inc.php
--- 0.9.4-4/php/classes/template/colorScheme.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/colorScheme.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,135 @@
+<?php
+/**
+ * A class corresponding to the color_themes table.
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jason Geiger
+ * @author Jeroen Roos
+ */
+
+namespace template;
+
+/**
+ * A class corresponding to the color_schemes table.
+ *
+ * @package Zoph
+ * @author Jason Geiger
+ * @author Jeroen Roos
+ */
+class colorScheme extends \zophTable {
+
+    /** @var string The name of the database table */
+    protected static $tableName="color_schemes";
+    /** @var array List of primary keys */
+    protected static $primaryKeys=array("color_scheme_id");
+    /** @var array Fields that may not be empty */
+    protected static $notNull=array("name");
+    /** @var bool keep keys with insert. In most cases the keys are set by
+                  the db with auto_increment */
+    protected static $keepKeys = false;
+    /** @var string URL for this class */
+    protected static $url="color_schemes.php?color_scheme_id=";
+
+    /** @var color_scheme the currently loaded scheme */
+    private static $current=null;
+
+   /**
+    * Update the color scheme in the db
+    */
+    public function update() {
+        foreach ($this->fields as $field => $value) {
+            $this->set($field, str_replace("#", "", $value));
+        }
+        parent::update();
+    }
+
+    /**
+     * Get color from current color scheme
+     * or fall back to default
+     * @param string Name of color to retrieve
+     * @return string #xxxxxx HTML color code
+     */
+    public static function getColor($color) {
+        if (!is_null(static::$current)) {
+            return "#" . static::$current->get($color);
+        } else {
+            return static::getDefault($color);
+        }
+    }
+
+    /**
+     * Get all colours from the current schem
+     * @return array of name => value pairs
+     */
+    public function getColors() {
+        $this->lookup();
+        $colors=array();
+        foreach ($this->fields as $field => $value) {
+            if ($this->isKey($field) || $field=="name") {
+                continue;
+            }
+            $colors[$field]=$this->fields[$field];
+        }
+        return $colors;
+    }
+
+    /**
+     * Define a default for each color
+     * for now, this is a fallback for whenever no color scheme has been loaded,
+     * e.g. when the user is not logged in yet. Eventually, it will be possible
+     * to define a "default" color scheme, and then this will only be used in
+     * a worst case fall back (for example when an admin deletes *all* color
+     * schemes.
+     * @param string Name of color to retrieve
+     * @return array of name => #xxxxxx HTML color code pairs
+     * @throws Exception
+     * @todo Maybe a custom Exception should be created.
+     */
+    private static function getDefault($color) {
+        $cs=array(
+            "page_bg_color"             => "#ffffff",
+            "text_color"                => "#000000",
+            "link_color"                => "#111111",
+            "vlink_color"               => "#444444",
+            "table_bg_color"            => "#ffffff",
+            "table_border_color"        => "#000000",
+            "breadcrumb_bg_color"       => "#ffffff",
+            "title_bg_color"            => "#f0f0f0",
+            "title_font_color"          => "#000000",
+            "tab_bg_color"              => "#000000",
+            "tab_font_color"            => "#ffffff",
+            "selected_tab_bg_color"     => "#c0c0c0",
+            "selected_tab_font_color"   => "#000000"
+        );
+
+        if (array_key_exists($color, $cs)) {
+            return $cs[$color];
+        } else {
+            throw new \Exception("Undefined Color: " . e($color));
+        }
+    }
+
+    /**
+     * Set current color scheme
+     * @param colorScheme the color scheme to use
+     */
+    public static function setCurrent(colorScheme $cs) {
+        static::$current=$cs;
+    }
+}
+?>
diff -pruN 0.9.4-4/php/classes/template/fieldset.inc.php 0.9.8-1/php/classes/template/fieldset.inc.php
--- 0.9.4-4/php/classes/template/fieldset.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/fieldset.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Class that takes care of displaying a fieldset in a form.
+ *
+ * Since a fieldset could be approached as a sub-form, that
+ * is exactly what we do here.
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace template;
+
+/**
+ * This class takes care of displaying form fieldsets
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class fieldset extends form {
+
+}
diff -pruN 0.9.4-4/php/classes/template/form.inc.php 0.9.8-1/php/classes/template/form.inc.php
--- 0.9.4-4/php/classes/template/form.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/form.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Class that takes care of displaying a form
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace template;
+
+/**
+ * This class takes care of displaying forms
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class form extends block {
+    /**
+     * Add a form field INPUT type text
+     * @param string name
+     * @param string current / initial value
+     * @param string label text for label
+     * @param string input hint
+     * @param int maximum input length
+     * @param int size of the field
+     */
+    public function addInputText($name, $value, $label=null, $hint=null, $maxlength=32, $size=null) {
+        $this->addBlock(template::createInput($name, $value, $maxlength, $label, $size, $hint));
+    }
+
+    /**
+     * Add a form field INPUT type password
+     * @param string name
+     * @param string label text for label
+     * @param string input hint
+     * @param int size of the field
+     */
+    public function addInputPassword($name, $label=null, $hint=null, $size=32) {
+        $this->addBlock(new block("formInputPassword", array(
+            "name"  => $name,
+            "label" => e($label),
+            "hint"  => e($hint),
+            "size"  => (int) $size
+        )));
+    }
+
+    /**
+     * Add a form field INPUT type hidden
+     * @param string name
+     * @param string value
+     */
+    public function addInputHidden($name, $value) {
+        $this->addBlock(new block("formInputHidden", array(
+            "name"  => $name,
+            "value" => e($value),
+        )));
+    }
+
+    /**
+     * Add a form field INPUT type checkbox
+     * @param string name
+     * @param bool checked
+     * @param string label text for label
+     * @param string input hint
+     */
+    public function addInputCheckbox($name, $checked, $label, $hint=null) {
+        $this->addBlock(new block("formInputCheckbox", array(
+            "name"  => $name,
+            "checked" => $checked,
+            "label" => e($label),
+            "hint"  => e($hint),
+        )));
+    }
+
+    /**
+     * Add a form field TEXTAREA
+     * @param string name
+     * @param string current / initial value
+     * @param string label text for label
+     * @param int columns
+     * @param int rows
+     */
+    public function addTextarea($name, $value, $label=null, $cols=40, $rows=4) {
+        $this->addBlock(new block("formTextarea", array(
+            "name"  => $name,
+            "value" => e($value),
+            "label" => e($label),
+            "cols"  => (int) $cols,
+            "rows"  => (int) $rows
+        )));
+    }
+
+    /**
+     * Add a form field dropdown
+     * this function is not actually creating the dropdown, but
+     * acts as a wrapper around the dropdown, to add a label
+     * @param string name
+     * @param block dropdown
+     * @param string label text for label
+     */
+    public function addPulldown($name, block $dropdown, $label) {
+        $this->addBlock(new block("formPulldown", array(
+            "name"      => $name,
+            "dropdown"  => $dropdown,
+            "label"     => $label
+        )));
+    }
+
+    /**
+     * add fieldset
+     * @param fieldset fieldset
+     */
+    public function addFieldset(fieldset $fieldset) {
+        $this->addBlock($fieldset);
+    }
+}
diff -pruN 0.9.4-4/php/classes/template/pager.inc.php 0.9.8-1/php/classes/template/pager.inc.php
--- 0.9.4-4/php/classes/template/pager.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/pager.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Pager class
+ * Displays a list of pages, usually at the bottom of a page, to navigate to different pages
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace template;
+
+/**
+ * Pager class
+ * Displays a list of pages, usually at the bottom of a page, to navigate to different pages
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class pager {
+    private $current=0;
+    private $pages=array();
+
+    public function __construct($current, $total, $numPages, $pageSize, $maxSize, $requestVars, $var) {
+        $url=$_SERVER['PHP_SELF'];
+        $pageNum = floor($current / $pageSize) + 1;
+        $this->current=(string) $pageNum;
+
+        $pageGroup=0;
+        $pages[$pageGroup]=array();
+
+        if ($current > 0) {
+            $newOffset = max(0, $current - $pageSize);
+            $this->pages[$pageGroup][translate("Prev")]= $url . "?" . update_query_string($requestVars, $var, $newOffset);
+        }
+
+        if ($numPages > 1) {
+            $midPage = floor($maxSize / 2);
+            $page = $pageNum - $midPage;
+            if ($page <= 0) {
+                $page = 1;
+            }
+
+            $lastPage = $page + $maxSize - 1;
+            if ($lastPage > $numPages) {
+                $page = $page - $lastPage + $numPages;
+                if ($page <= 0) {
+                    $page = 1;
+                }
+                $lastPage = $numPages;
+            }
+
+            if ($page > 1) {
+                $this->pages[$pageGroup]["1"] = $url . "?" . update_query_string($requestVars, $var, 0);
+            }
+
+            $pages[++$pageGroup]=array();
+
+            while ($page <= $lastPage) {
+                $newOffset = ($page - 1) * $pageSize;
+                $this->pages[$pageGroup][(string) $page] = $url . "?" . update_query_string($requestVars, $var, $newOffset);
+                $page++;
+            }
+
+            $pages[++$pageGroup]=array();
+
+            if ($page <= $numPages) {
+                $this->pages[$pageGroup][(string) $numPages] = $url . "?" . update_query_string($requestVars, $var, ($numPages-1) * $pageSize);
+            }
+        }
+        if ($total >  $current + $pageSize) {
+            $newOffset = $current + $pageSize;
+            $this->pages[$pageGroup][translate("Next")]= $url . "?" . update_query_string($requestVars, $var, $newOffset);
+        }
+    }
+
+    public function __toString() {
+        return (string) $this->getBlock();
+    }
+
+    public function getBlock() {
+        return new block("pager", array(
+            "pages"     => $this->pages,
+            "current"   => $this->current
+        ));
+    }
+}
diff -pruN 0.9.4-4/php/classes/template/template.inc.php 0.9.8-1/php/classes/template/template.inc.php
--- 0.9.4-4/php/classes/template/template.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/template/template.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,562 @@
+<?php
+/**
+ * Class that takes care of displaying templates
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+
+namespace template;
+
+use log;
+use page;
+use photo;
+use settings;
+use user;
+use Time;
+use DateInterval;
+
+use conf\conf;
+
+/**
+ * This class takes care of displaying templates
+ *
+ * @author Jeroen Roos
+ * @package Zoph
+ */
+class template {
+    /** @var array javascript for this template */
+    public $js=array();
+    /** @var array variables that can be used in the template
+                    all variables will be preceeded with $tpl_ */
+    public $vars=array();
+    /** @var string CSS style for this template */
+    public $style="";
+    /** @var string Javascript to be included in header (/
+    public $script="";
+    /** @var array CSS files to be included */
+    public $css=array();
+    /** @var string HTML title for the page to be displayed
+    public $title="Zoph";
+
+    /** @var array contains actionlinks */
+    private $actionlinks=array();
+
+    /**
+     * @var array contains blocks or sub-templates that will be
+     *              displayed inside this template by calling the
+     *              getBlocks() function from within the template
+     */
+    private $blocks=array();
+
+    /**
+     * Create template object
+     *
+     * @param string Name of template (without path or extension)
+     * @param array Array of variables that can be used in the template
+     * @return template
+     */
+    public function __construct($template, $vars=null) {
+        $tpl=conf::get("interface.template");
+        $this->vars=$vars;
+        if (preg_match("/^[A-Za-z0-9_\-]+$/", $tpl) &&
+                preg_match("/^[A-Za-z0-9_\-]+$/", $template)) {
+            $file="templates/" . $tpl . "/" . $template . ".tpl.php";
+            if (!file_exists($file)) {
+                $file="templates/default/" . $template . ".tpl.php";
+            }
+            $this->template=$file;
+
+            $this->css[]="css.php";
+        } else {
+            log::msg("Illegal characters in template", log::FATAL, log::GENERAL);
+        }
+    }
+    /**
+     * Get image URL for specific template.
+     * if the image does not exist in the current template, the default will be be returned
+     * This enables template builders to only include the parts of the template that
+     * have been changed
+     * @param string image name
+     * @return string relative image url
+     */
+    public static function getImage($image) {
+        $tpl=conf::get("interface.template");
+        if (preg_match("/^[A-Za-z0-9_\-\/\.]+$/", $image) && !preg_match("/\.\./", $image)) {
+            $file="templates/" . $tpl . "/images/" . $image;
+            if (!file_exists($file)) {
+                $file="templates/default/images/" . $image;
+            }
+            return $file;
+        } else {
+            log::msg("Illegal characters in icon name", log::FATAL, log::GENERAL);
+        }
+    }
+
+    /**
+     * Print the template
+     *
+     * @return string
+     */
+    public function __toString() {
+        if ($this->vars) {
+            extract($this->vars, EXTR_PREFIX_ALL, "tpl");
+        }
+        if (!defined("ZOPH")) {
+            define('ZOPH', true);
+        }
+        try {
+            ob_start();
+                include $this->template;
+            return trim(ob_get_clean());
+        } catch (Exception $e) {
+            echo $e->getMessage();
+            die();
+        }
+    }
+
+    /**
+     * Return the header section of the page
+     *
+     * @return string
+     * @access private
+     * @todo This should be in a template, cannot be done at the moment because
+     *       there are so many pages not moved to the templating system.
+     */
+    private function getHead() {
+        $html="";
+        foreach ($this->js as $js_src) {
+            $html.="    <script type='text/javascript' src='" . $js_src . "'>" .
+                "</script>\n";
+        }
+
+        if (!empty($this->script)) {
+            $html.="    <script type='text/javascript'>";
+            $html.="        " . $this->script;
+            $html.="    </script>";
+        }
+
+        foreach ($this->css as $css_href) {
+            $html.="    <link type='text/css' rel='stylesheet' href='" .
+                $css_href . "'>\n";
+        }
+        if (!empty($this->style)) {
+            $html.="    <style>" . $this->style . "</style>\n";
+        }
+        if (!empty($title)) {
+            $html.="    <title>" . $title . "</title>\n";
+        }
+
+        return $html;
+    }
+
+
+    /**
+     * Add a block
+     * @param block Block to be added
+     */
+    public function addBlock(block $block=null) {
+        $this->blocks[]=$block;
+    }
+
+    /**
+     * Add a page
+     * A page can simply be added to the list of blocks as it can be displayed
+     * with the __toString() function
+     * @param page Page to be added
+     */
+    public function addPage(page $page) {
+        $this->blocks[]=$page;
+    }
+
+    /**
+     * Add multiple blocks
+     * @param array Blocks to be added
+     */
+    public function addBlocks(array $blocks) {
+        foreach ($blocks as $block) {
+            $this->addBlock($block);
+        }
+    }
+
+    /**
+     * Get the blocks inside this template
+     * @return array blocks
+     */
+    protected function getBlocks() {
+        return $this->blocks;
+    }
+
+    /**
+     * Display the blocks inside this template
+     * @return string HTML code for the blocks
+     */
+    protected function displayBlocks() {
+        $html="";
+        foreach ($this->getBlocks() as $block) {
+            $html.=$block;
+        }
+        return $html;
+    }
+
+    /**
+     * Add an actionlink
+     * @param string Title to be displayed
+     * @param string URL
+     */
+    public function addActionlink($title, $link) {
+        $this->actionlinks[$title]=$link;
+    }
+
+    /**
+     * Add multiple actionlinks
+     * @param array of actionlinks
+     */
+    public function addActionlinks(array $al) {
+        foreach ($al as $title => $link) {
+            $this->addActionlink($title, $link);
+        }
+    }
+
+    /**
+     * Markup an array of actionlinks using the actionlinks template
+     * @param array Optional array of actionlinks, otherwise use the ones in the class
+     */
+    private function getActionlinks(array $actionlinks=null) {
+        if ($actionlinks==null) {
+            $actionlinks=$this->actionlinks;
+        }
+        if (is_array($actionlinks)) {
+            return new block("actionlinks", array(
+                "actionlinks" => $actionlinks)
+            );
+        }
+    }
+
+    /**
+     * Create a link list
+     * Creates a comma separated list of links from the given records.
+     * The class of the records must implement the getLink function.
+     * @param array Array of records to be displayed
+     * @return string Comma separated links to records
+     * @todo Could maybe better move into zophTable?
+     * @todo Should check whether the object is of a supported class
+     */
+    public static function createLinkList(array $records) {
+        $links = "";
+        if ($records) {
+            foreach ($records as $rec) {
+                if ($links) {
+                    $links .= ", ";
+                }
+                $links .= $rec->getLink();
+            }
+        }
+
+        return $links;
+    }
+
+    /**
+     * Creates an array to be used in the createPulldown methods.  The
+     * values of the fields in the name_fields parameter are concatentated
+     * together to construnct the titles of the selections.
+     * @param array Records to be processed
+     * @param array fields to use to contruct title
+     * @return array Array that can be fed to the createPulldown methods.
+     */
+    public static function createSelectArray(array $records, array $name_fields, $addEmpty=false) {
+        if (empty($records) || !$name_fields) {
+            return array();
+        }
+        $sa=array();
+
+        if ($addEmpty) {
+            $sa[]="&nbsp;";
+        }
+
+        foreach ($records as $rec) {
+            // this only makes sense when there is one key
+            $id = $rec->getId();
+
+            $name = "";
+            foreach ($name_fields as $n) {
+                if ($name) {
+                    $name .= " ";
+                }
+                $name .= $rec->get($n);
+            }
+
+            $sa[$id] = $name;
+        }
+
+        return $sa;
+    }
+
+    /**
+     * Create form input field
+     * @param string name of the input
+     * @param string initial value
+     * @param int maximum length
+     * @param string label to be added
+     * @param int|null display size, will be set from maxlength if null
+     */
+    public static function createInput($name, $value, $maxlength, $label=null, $size=null, $hint=null) {
+        if (!$size) {
+            $size=$maxlength;
+        }
+        return new block("formInputText", array(
+            "label"     => e($label),
+            "name"      => e($name),
+            "value"     => e($value),
+            "size"      => (int) $size,
+            "maxlength" => (int) $maxlength,
+            "hint"      => e($hint),
+        ));
+    }
+
+    /**
+     * Create pulldown (select)
+     * @param string name for select box
+     * @param string current value
+     * @param array array of options
+     * @param bool autosubmit form after making a change
+     */
+    public static function createPulldown($name, $value, $selectArray, $autosubmit=false) {
+        return new block("select", array(
+            "name"  => $name,
+            "id"    => preg_replace("/^_+/", "", $name),
+            "options" => $selectArray,
+            "value" => $value,
+            "autosubmit"    => (bool) $autosubmit
+        ));
+    }
+
+    /**
+     * Create pulldown (select) to change the view
+     * @param string name for select box
+     * @param string current value
+     * @param bool autosubmit form after making a change
+     */
+    public static function createViewPulldown($name, $value, $autosubmit=false) {
+        return static::createPulldown($name, $value, array(
+            "list" => translate("List", 0),
+            "tree" => translate("Tree", 0),
+            "thumbs" => translate("Thumbnails", 0)), (bool) $autosubmit);
+    }
+
+    /**
+     * Create pulldown (select) to determine how the automatic thumbnail is selected
+     * @param string name for select box
+     * @param string current value
+     * @param bool autosubmit form after making a change
+     */
+    public static function createAutothumbPulldown($name, $value, $autosubmit=false) {
+        return  static::createPulldown($name, $value, array(
+            "oldest" => translate("Oldest photo", 0),
+            "newest" => translate("Newest photo", 0),
+            "first" => translate("Changed least recently", 0),
+            "last" => translate("Changed most recently", 0),
+            "highest" => translate("Highest ranked", 0),
+            "random" => translate("Random", 0)),
+        (bool)$autosubmit);
+    }
+
+    /**
+     * Create pulldown (select) that lists all photo fields
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createPhotoFieldPulldown($name, $value) {
+        return  static::createPulldown($name, $value, translate(photo::getFields(), 0));
+    }
+
+    public static function createPhotoTextPulldown($name, $value) {
+        return template::createPulldown($name, $value, array(
+            "" => "",
+            "album" => translate("album", 0),
+            "category" => translate("category", 0),
+            "person" => translate("person", 0),
+            "photographer" => translate("photographer", 0)));
+    }
+
+    /**
+     * Create pulldown (select) that lists photo fields for the import page
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createImportFieldPulldown($name, $value) {
+        return  static::createPulldown($name, $value, translate(photo::getImportFields(), 0));
+    }
+
+    /**
+     * Create comparison operator pulldown, tailored for text comparison
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createTextOperatorPulldown($name, $value = "=") {
+        return static::createPulldown($name, $value,
+            array(
+                "="     => "=",
+                "!="    => "!=",
+                "like" => translate("like", 0),
+                "not like" => translate("not like", 0)
+        ));
+    }
+
+    /**
+     * Create comparison operator pulldown
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createOperatorPulldown($name, $value = "=") {
+        return static::createPulldown($name, $value,
+            array(
+                "="     => "=",
+                "!="    => "!=",
+                ">"     => ">",
+                ">="    => ">=",
+                "<"     => "<",
+                "<="    => "<=",
+                "like" => translate("like", 0),
+                "not like" => translate("not like", 0)
+        ));
+    }
+
+    /**
+     * Create inequality operator [less than/more than] pulldown
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createInequalityOperatorPulldown($name, $value = "") {
+        return template::createPulldown($name, $value,
+           array(">" => translate("less than"), "<" => translate("more than")));
+    }
+
+    /**
+     * Create pulldown (select) with options "yes" and "no" (translated)
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createBinaryOperatorPulldown($name, $value = "=") {
+        return  static::createPulldown($name, $value, array(
+            "=" => "=",
+            "!=" => "!="
+        ));
+    }
+
+    public static function createPresentOperatorPulldown($name, $value = "=") {
+        return template::createPulldown($name, $value,
+            array(
+                "=" => translate("is in photo", 0),
+                "!=" => translate("is not in photo", 0)
+        ));
+    }
+
+
+    /**
+     * Create pulldown (select) with options "yes" and "no" (translated)
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createYesNoPulldown($name, $value) {
+        return  static::createPulldown($name, $value, array(
+            "0" => translate("No", 0),
+            "1" => translate("Yes", 0)
+        ));
+    }
+
+    /**
+     * Create conjunction [and/or] pulldown
+     * @param string name for select box
+     * @param string current value
+     */
+    public static function createConjunctionPulldown($name, $value = "") {
+        return template::createPulldown($name, $value,
+            array("" => "", "and" => translate("and", 0), "or" => translate("or", 0)));
+    }
+
+    public static function createDaysAgoPulldown($name, $value) {
+        $dt=new Time(date("Y-m-d"));
+        $dateArray=array("" => "");
+
+        $day=new DateInterval("P1D");
+        for ($i = 1; $i <= conf::get("interface.max.days"); $i++) {
+            $dt->sub($day);
+            $dateArray[$dt->format("Y-m-d")] = $i;
+        }
+
+        return template::createPulldown($name, $value, $dateArray);
+    }
+
+    /**
+     * transforms a size in bytes into a human readable format using
+     * Ki Mi Gi, etc. prefixes
+     * Give me a call if your database grows bigger than 1024 Yobbibytes. :-)
+     * @param int bytes number of bytes
+     * @return string human readable filesize
+     */
+    public static function getHumanReadableBytes($bytes) {
+        if ($bytes==0) {
+            // prevents div by 0
+            return "0B";
+        } else {
+            $prefixes=array("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi");
+            $length=floor(log($bytes, 2)/10);
+            return round($bytes/pow(2, 10*($length)), 1) . $prefixes[floor($length)] . "B";
+        }
+    }
+
+    /**
+     * Display warning about disabled Javascript
+     */
+    public static function showJSwarning() {
+        $user=user::getCurrent();
+        if ((($user->prefs->get("autocomp_albums")) ||
+            ($user->prefs->get("autocomp_categories")) ||
+            ($user->prefs->get("autocomp_places")) ||
+            ($user->prefs->get("autocomp_people")) ||
+            ($user->prefs->get("autocomp_photographer"))) &&
+            conf::get("interface.autocomplete")) {
+
+            $warning=new block("message", array(
+                "class" => "warning",
+                "text"  => translate("You have enabled autocompletion for one or more dropdown " .
+                                     "boxes on this page, however, you do not seem to have Javascript " .
+                                     "support. You should either enable javascript or turn autocompletion " .
+                                     "off, or this page will not work as expected!")
+            ));
+
+            $noscript=new block("noscript");
+            $noscript->addBlocks(array($warning));
+            return $noscript;
+        }
+    }
+
+    /**
+     * Get all templates
+     * Search the template directory for directory entries
+     */
+    public static function getAll() {
+        $templates=array();
+        foreach (glob(settings::$php_loc . "/templates/*", GLOB_ONLYDIR) as $tpl) {
+            $tpl=basename($tpl);
+            $templates[$tpl]=$tpl;
+        }
+        return $templates;
+    }
+}
diff -pruN 0.9.4-4/php/classes/template.inc.php 0.9.8-1/php/classes/template.inc.php
--- 0.9.4-4/php/classes/template.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/template.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,437 +0,0 @@
-<?php
-/**
- * Class that takes care of displaying templates
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * This class takes care of displaying templates
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-class template {
-    public $js=array();
-    public $vars=array();
-    public $style="";
-    public $script="";
-    public $css=array();
-    public $title="Zoph";
-
-    /** @var array contains actionlinks */
-    private $actionlinks=array();
-
-    /**
-     * @var array contains blocks or sub-templates that will be
-     *              displayed inside this template by calling the
-     *              getBlocks() function from within the template
-     */
-    private $blocks=array();
-
-    /**
-     * Create template object
-     *
-     * @param string Name of template (without path or extension)
-     * @param array Array of variables that can be used in the template
-     * @return template
-     */
-    public function __construct($template, $vars=null) {
-        $tpl=conf::get("interface.template");
-        $this->vars=$vars;
-        if (preg_match("/^[A-Za-z0-9_\-]+$/", $tpl) &&
-                preg_match("/^[A-Za-z0-9_\-]+$/", $template)) {
-            $file="templates/" . $tpl . "/" . $template . ".tpl.php";
-            if (!file_exists($file)) {
-                $file="templates/default/" . $template . ".tpl.php";
-            }
-            $this->template=$file;
-
-            $this->css[]="css.php";
-        } else {
-            log::msg("Illegal characters in template", log::FATAL, log::GENERAL);
-        }
-    }
-    /**
-     * Get image URL for specific template
-     * if the image does not exist in the current template, the default will be be returned
-     * This enables template builders to only include the parts of the template that
-     * have been changed
-     * @param string image name
-     * @return string relative image url
-     */
-    public static function getImage($image) {
-        $tpl=conf::get("interface.template");
-        if (preg_match("/^[A-Za-z0-9_\-\/\.]+$/", $image) && !preg_match("/\.\./", $image)) {
-            $file="templates/" . $tpl . "/images/" . $image;
-            if (!file_exists($file)) {
-                $file="templates/default/images/" . $image;
-            }
-            return $file;
-        } else {
-            log::msg("Illegal characters in icon name", log::FATAL, log::GENERAL);
-        }
-    }
-
-    /**
-     * Print the template
-     *
-     * @return string
-     */
-    public function __toString() {
-        if ($this->vars) {
-            extract($this->vars, EXTR_PREFIX_ALL, "tpl");
-        }
-        if (!defined("ZOPH")) {
-            define('ZOPH', true);
-        }
-        try {
-            ob_start();
-                include $this->template;
-            return trim(ob_get_clean());
-        } catch (Exception $e) {
-            echo $e->getMessage();
-            die();
-        }
-    }
-
-    /**
-     * Return the template in a string
-     *
-     * @return string
-     */
-    public function toString() {
-        return sprintf("%s", $this);
-    }
-
-    public function toStringNoEnter() {
-        return str_replace("\n", "", sprintf("%s", $this));
-    }
-
-    /**
-     * Return the header section of the page
-     *
-     * @return string
-     * @access private
-     * @todo This should be in a template, cannot be done at the moment because
-     *       there are so many pages not moved to the templating system.
-     */
-    private function getHead() {
-        $html="";
-        foreach ($this->js as $js_src) {
-            $html.="    <script type='text/javascript' src='" . $js_src . "'>" .
-                "</script>\n";
-        }
-
-        if (!empty($this->script)) {
-            $html.="    <script type='text/javascript'>";
-            $html.="        " . $this->script;
-            $html.="    </script>";
-        }
-
-        foreach ($this->css as $css_href) {
-            $html.="    <link type='text/css' rel='stylesheet' href='" .
-                $css_href . "'>\n";
-        }
-        if (!empty($this->style)) {
-            $html.="    <style>" . $this->style . "</style>\n";
-        }
-        if (!empty($title)) {
-            $html.="    <title>" . $title . "</title>\n";
-        }
-
-        return $html;
-    }
-
-
-    /**
-     * Add a block
-     * @param block Block to be added
-     */
-    public function addBlock(block $block) {
-        $this->blocks[]=$block;
-    }
-
-    /**
-     * Add a page
-     * A page can simply be added to the list of blocks as it can be displayed
-     * with the __toString() function
-     * @param page Page to be added
-     */
-    public function addPage(page $page) {
-        $this->blocks[]=$page;
-    }
-
-    /**
-     * Add multiple blocks
-     * @param array Blocks to be added
-     */
-    public function addBlocks(array $blocks) {
-        foreach ($blocks as $block) {
-            $this->addBlock($block);
-        }
-    }
-
-    /**
-     * Get the blocks inside this template
-     * @return array blocks
-     */
-    protected function getBlocks() {
-        return $this->blocks;
-    }
-
-    /**
-     * Display the blocks inside this template
-     * @return string HTML code for the blocks
-     */
-    protected function displayBlocks() {
-        $html="";
-        foreach ($this->getBlocks() as $block) {
-            $html.=$block;
-        }
-        return $html;
-    }
-
-
-    /**
-     * Add an actionlink
-     * @param string Title to be displayed
-     * @param string URL
-     */
-    public function addActionlink($title, $link) {
-        $this->actionlinks[$title]=$link;
-    }
-
-    /**
-     * Add multiple actionlinks
-     * @param array of actionlinks
-     */
-    public function addActionlinks(array $al) {
-        foreach ($al as $title => $link) {
-            $this->addActionlink($title, $link);
-        }
-    }
-
-    /**
-     * Markup an array of actionlinks using the actionlinks template
-     * @param array Optional array of actionlinks, otherwise use the ones in the class
-     */
-    private function getActionlinks(array $actionlinks=null) {
-        if ($actionlinks==null) {
-            $actionlinks=$this->actionlinks;
-        }
-        if (is_array($actionlinks)) {
-            $tpl=new block("actionlinks", array(
-                "actionlinks" => $actionlinks)
-            );
-            return $tpl->toString();
-        }
-    }
-
-    /**
-     * Create a link list
-     * Creates a comma separated list of links from the given records.
-     * The class of the records must implement the getLink function.
-     * @param array Array of records to be displayed
-     * @return string Comma separated links to records
-     * @todo Could maybe better move into zophTable?
-     * @todo Should check whether the object is of a supported class
-     */
-    public static function createLinkList(array $records) {
-        $links = "";
-        if ($records) {
-            foreach ($records as $rec) {
-                if ($links) {
-                    $links .= ", ";
-                }
-                $links .= $rec->getLink();
-            }
-        }
-
-        return $links;
-    }
-
-    /**
-     * Creates an array to be used in the createPulldown methods.  The
-     * values of the fields in the name_fields parameter are concatentated
-     * together to construnct the titles of the selections.
-     * @param array Records to be processed
-     * @param array fields to use to contruct title
-     * @return array Array that can be fed to the createPulldown methods.
-     */
-    public static function createSelectArray(array $records, array $name_fields, $addEmpty=false) {
-        if (empty($records) || !$name_fields) {
-            return array();
-        }
-        $sa=array();
-
-        if ($addEmpty) {
-            $sa[]="&nbsp;";
-        }
-
-        foreach ($records as $rec) {
-            // this only makes sense when there is one key
-            $id = $rec->getId();
-
-            $name = "";
-            foreach ($name_fields as $n) {
-                if ($name) {
-                    $name .= " ";
-                }
-                $name .= $rec->get($n);
-            }
-
-            $sa[$id] = $name;
-        }
-
-        return $sa;
-    }
-
-    /**
-     * Create form input field
-     */
-    public static function createInput($name, $value, $maxlength, $label=null, $size=null) {
-        if (!$size) {
-            $size=$maxlength;
-        }
-        return new block("formInputText", array(
-            "label"     => $label,
-            "name"      => $name,
-            "value"     => $value,
-            "size"      => $size,
-            "maxlength" => $maxlength
-        ));
-    }
-
-    /**
-     * Create pulldown (select)
-     * @param string name for select box
-     * @param string current value
-     *
-     * @param bool autosubmit form after making a change
-     */
-    public static function createPulldown($name, $value, $selectArray, $autosubmit=false) {
-        return new block("select", array(
-            "name"  => $name,
-            "id"    => preg_replace("/^_+/", "", $name),
-            "options" => $selectArray,
-            "value" => $value,
-            "autosubmit"    => (bool) $autosubmit
-        ));
-    }
-
-    /**
-     * Create pulldown (select) to change the view
-     * @param string name for select box
-     * @param string current value
-     * @param bool autosubmit form after making a change
-     */
-    public static function createViewPulldown($name, $value, $autosubmit=false) {
-        return static::createPulldown($name, $value, array(
-            "list" => translate("List", 0),
-            "tree" => translate("Tree", 0),
-            "thumbs" => translate("Thumbnails", 0)), (bool) $autosubmit);
-    }
-
-    /**
-     * Create pulldown (select) to determine how the automatic thumbnail is selected
-     * @param string name for select box
-     * @param string current value
-     * @param bool autosubmit form after making a change
-     */
-    public static function createAutothumbPulldown($name, $value, $autosubmit=false) {
-        return  static::createPulldown($name, $value, array(
-            "oldest" => translate("Oldest photo", 0),
-            "newest" => translate("Newest photo", 0),
-            "first" => translate("Changed least recently", 0),
-            "last" => translate("Changed most recently", 0),
-            "highest" => translate("Highest ranked", 0),
-            "random" => translate("Random", 0)),
-        (bool)$autosubmit);
-    }
-
-    /**
-     * Create pulldown (select) that lists all photo fields
-     * @param string name for select box
-     * @param string current value
-     */
-    public static function createPhotoFieldPulldown($name, $value) {
-        return  static::createPulldown($name, $value, translate(photo::getFields(), 0));
-    }
-
-    /**
-     * Create pulldown (select) that lists photo fields for the import page
-     * @param string name for select box
-     * @param string current value
-     */
-    public static function createImportFieldPulldown($name, $value) {
-        return  static::createPulldown($name, $value, translate(photo::getImportFields(), 0));
-    }
-
-    /**
-     * Create pulldown (select) with options "yes" and "no" (translated)
-     * @param string name for select box
-     * @param string current value
-     */
-    public static function createYesNoPulldown($name, $value) {
-        return  static::createPulldown($name, $value,array(
-            "0" => translate("No", 0),
-            "1" => translate("Yes", 0)
-        ));
-    }
-
-    /**
-     * Display warning about disabled Javascript
-     */
-    public static function showJSwarning() {
-        $user=user::getCurrent();
-        if (($user->prefs->get("autocomp_albums")) ||
-            ($user->prefs->get("autocomp_categories")) ||
-            ($user->prefs->get("autocomp_places")) ||
-            ($user->prefs->get("autocomp_people")) ||
-            ($user->prefs->get("autocomp_photographer")) &&
-            conf::get("interface.autocomplete")) {
-
-            $warning=new block("message", array(
-                "class" => "warning",
-                "text"  => translate("You have enabled autocompletion for one or more dropdown " .
-                                     "boxes on this page, however, you do not seem to have Javascript " .
-                                     "support. You should either enable javascript or turn autocompletion " .
-                                     "off, or this page will not work as expected!")
-            ));
-
-            $noscript=new block("noscript");
-            $noscript->addBlocks(array($warning));
-            return $noscript;
-        }
-    }
-
-    /**
-     * Get all templates
-     * Search the template directory for directory entries
-     */
-    public static function getAll() {
-        $templates=array();
-        foreach (glob(settings::$php_loc . "/templates/*", GLOB_ONLYDIR) as $tpl) {
-            $tpl=basename($tpl);
-            $templates[$tpl]=$tpl;
-        }
-        return $templates;
-    }
-}
diff -pruN 0.9.4-4/php/classes/TimeZone.inc.php 0.9.8-1/php/classes/TimeZone.inc.php
--- 0.9.4-4/php/classes/TimeZone.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/TimeZone.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,9 @@
  * @package Zoph
  */
 
+use conf\conf;
+use template\template;
+
 /**
  * TimeZone class, extension of the standard PHP DateTimeZone class
  *
@@ -94,27 +97,11 @@ class TimeZone extends DateTimeZone {
      * Create Pulldown menu for timezone selection
      * @param string name for the html document
      * @param string current value
-     * @return string HTML code to display pulldown
-     * @todo Returns HTML!
+     * @return block pulldown
      */
     public static function createPulldown($name, $value=null) {
-        $id=preg_replace("/^_+/", "", $name);
-        if ($value) {
-            $text=$value;
-        } else {
-            $text="";
-        }
-
-        if (conf::get("interface.autocomplete")) {
-            $html="<input type=hidden id='" . $id . "' name='" . $name. "'" .
-                " value='" . $value . "'>";
-            $html.="<input type=text id='_" . $id . "' name='_" . $name. "'" .
-                " value='" . $text . "' class='autocomplete'>";
-        } else {
-            $html=template::createPulldown("timezone_id", static::getKey($value),
-                static::getSelectArray());
-        }
-        return $html;
+        return template::createPulldown("timezone_id", static::getKey($value),
+            static::getSelectArray());
     }
 
     /**
diff -pruN 0.9.4-4/php/classes/user.inc.php 0.9.8-1/php/classes/user.inc.php
--- 0.9.4-4/php/classes/user.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/user.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -26,6 +26,7 @@ use db\select;
 use db\clause;
 use db\param;
 use db\selectHelper;
+use conf\conf;
 
 /**
  * A class representing a user of Zoph
@@ -41,6 +42,8 @@ class user extends zophTable {
     protected static $primaryKeys=array("user_id");
     /** @var array Fields that may not be empty */
     protected static $notNull=array("user_name");
+    /** @var array Fields that are integers */
+    protected static $isInteger=array("user_id", "person_id", "lightbox_id");
     /** @var bool keep keys with insert. In most cases the keys are set by
                   the db with auto_increment */
     protected static $keepKeys = false;
@@ -81,6 +84,13 @@ class user extends zophTable {
         parent::delete(array("prefs", "groups_users"));
     }
 
+    public function lookup() {
+        parent::lookup();
+        if ($this->get("lastlogin") == "") {
+            $this->set("lastlogin", null);
+        }
+    }
+
     /**
      * Lookup the person linked to this user
      */
diff -pruN 0.9.4-4/php/classes/validator.inc.php 0.9.8-1/php/classes/validator.inc.php
--- 0.9.4-4/php/classes/validator.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/validator.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -19,6 +19,7 @@
 use db\select;
 use db\param;
 use db\clause;
+use conf\conf;
 
 /*
  * A class to validate a user.
diff -pruN 0.9.4-4/php/classes/watermarkedPhoto.inc.php 0.9.8-1/php/classes/watermarkedPhoto.inc.php
--- 0.9.4-4/php/classes/watermarkedPhoto.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/watermarkedPhoto.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @author Jeroen Roos
  */
 
+use conf\conf;
+
 /**
  * A class representing a watermarked photo
  * This is a photo with a "watermark" superimposed over it
diff -pruN 0.9.4-4/php/classes/web/request.inc.php 0.9.8-1/php/classes/web/request.inc.php
--- 0.9.4-4/php/classes/web/request.inc.php	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/classes/web/request.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,286 @@
+<?php
+/**
+ * A request represents a http request
+ *
+ * Currently, superglobals such as $_GET, $_POST and $_SERVER are accessed
+ * either through getvar() or directly, this is bad practice and hard to make
+ * testable. Eventually, this class must replace all of this (and more).
+ *
+ * This file is part of Zoph.
+ *
+ * Zoph is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Zoph is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with Zoph; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+
+namespace web;
+
+use ArrayAccess;
+use generic\variable;
+
+/**
+ * The request class is used to access request-related variables
+ * such as $_GET, $_POST and $_SERVER.
+ *
+ * In the future $_FILES and $_COOKIE will be added.
+ *
+ * A variable can be accessed through ArrayAccess ($request["variable"] or object
+ * access ($request->variable);
+ *
+ * @package Zoph
+ * @author Jeroen Roos
+ */
+class request implements ArrayAccess {
+    /** @var holds $_GET variables */
+    private $get;
+
+    /** @var holds $_POST variables */
+    private $post;
+
+    /** @var holds $_SERVER variables */
+    private $server;
+
+    /** @var request vars, holds $_GET for GET requests and $_POST for POST requests
+             actually, a POST request can have GET variables as well, but this has
+             always been how Zoph works, so for now I am not changing this, note
+             that this is *different* from the $_REQUEST superglobals - hence it's
+             not called $request */
+    private $requestVars;
+
+    /**
+     * Create object
+     * @param array array of variables, can contain GET, POST and SERVER
+     */
+    public function __construct(array $vars) {
+        foreach ([ "GET", "POST", "SERVER" ] as $var) {
+            if (isset($vars[$var])) {
+                $value=new variable($vars[$var]);
+                $prop=strtolower($var);
+                $this->$prop=$value->input();
+            }
+        }
+        $this->buildRequest();
+    }
+
+    /**
+     * Create object and fill with superglobals
+     * @return request new request
+     */
+    public static function create() {
+        return new self(array(
+            "GET"   =>  $_GET,
+            "POST"  =>  $_POST,
+            "SERVER"    => $_SERVER
+        ));
+    }
+
+    /**
+     * Fill the REQUESTVARS property with either the GET variables
+     * OR the POST variables.
+     * Note that this behaviour is different from PHP's $_REQUEST superglobal
+     */
+    private function buildRequest() {
+        if (!empty($this->get)) {
+            $this->requestVars=&$this->get;
+        } else {
+            $this->requestVars=&$this->post;
+        }
+    }
+
+    /**
+     * For ArrayAccess: does the offset exist
+     * @param int|string offset
+     * @return bool offset exists
+     */
+    public function offsetExists($off) {
+        return (isset($this->get[$off]) || isset($this->post[$off]));
+    }
+
+    /**
+     * For ArrayAccess: Get value of parameter
+     * if $_GET parameter is available, return it, if it is not but $_POST is available
+     * return that, otherwise null
+     * @param int|string offset
+     * @return mixed value
+     */
+    public function offsetGet($off) {
+        if (isset($this->get[$off])) {
+            return $this->get[$off];
+        } else if (isset($this->post[$off])) {
+            return $this->post[$off];
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * For ArrayAccess: Set value of parameter
+     * not supported
+     * @param int|string offset
+     * @param mixed value
+     */
+    public function offsetSet($off, $val) {
+    }
+
+    /**
+     * For ArrayAccess: Unset value of parameter
+     * not supported
+     * @param int|string offset
+     */
+    public function offsetUnset($off) {
+    }
+
+    /**
+     * For ObjectAccess: Get value of parameter
+     * if $_GET parameter is available, return it, if it is not but $_POST is available
+     * return that, otherwise null
+     * @param int|string offset
+     * @return mixed value
+     */
+    public function __get($off) {
+        return $this->offsetGet($off);
+    }
+
+    /**
+     * Get RequestVars
+     * @return array requestvars
+     */
+    public function getRequestVars() {
+        return (array) $this->requestVars;
+    }
+
+    /**
+     * Get $_SERVER variables
+     * @param Variable to return
+     * @return mixed value
+     */
+    public function getServerVar($var) {
+        if (isset($this->server[$var])) {
+            return $this->server[$var];
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Remove any params without values and operator params without corresponding
+     * fields (e.g. _album_id-op when there is no _album_id).  This can be called
+     * once after a search is performed.  It allows for shorter urls that are
+     * more readable and easier to debug.
+     * @todo This code is pretty horrible and I wonder if we could do without...
+     */
+    public function getRequestVarsClean() {
+        $cleanVars = array();
+        $interimVars = array();
+
+        /*
+          First pass through vars will flatten out any arrays in the list.
+          arrays were used in search.php to make the form extensible. -RB
+        */
+        foreach ((array)$this->requestVars as $key => $val) {
+            // trim empty values
+            if (($key == "_button") || empty($val)) {
+                continue;
+            }
+
+            if (is_array($val)) {
+                foreach ($val as $subkey => $subval) {
+                    if (empty($subval)) {
+                        continue;
+                    }
+
+                    if (substr($key, -3) == "_op") {
+                        //  change var_op[key] to var#key_op
+                        $newkey = substr($key, 0, -3) . '#' . $subkey . '_op';
+                    } else if (substr($key, -5) == "_conj") {
+                        //  change var_conj[key] to var#key_conj
+                        $newkey = substr($key, 0, -5) . '#' . $subkey . '_conj';
+                    } else if (substr($key, -9) == "_children") {
+                        //  change var_children[key] to var#key_children
+                        $newkey = substr($key, 0, -9) . '#' . $subkey . '_children';
+                    } else {
+                        //  change var[key] to var#key
+                        $newkey = $key . '#' . $subkey;
+                    }
+
+                    $interimVars[$newkey] = $subval;
+                }
+            } else {
+                $interimVars[$key] = $val;
+            }
+        }
+
+        /*
+          Second pass through will get rid of ops and conjs without fields
+          and fix the keys for compatability with the rest of zoph.  It will also remove
+          "field" entries without a corresponding "_field" type and vice versa.
+          A hyphen is not valid as part of a variable name in php so underscore was used
+          while processing the form in search.php
+        */
+        foreach ($interimVars as $key => $val) {
+            // process _var variables
+            if (substr($key, 0, 1) == "_") {
+
+                //process _op variables
+                if (substr($key, -3) == "_op") {
+                    // replace _op with -op to be compatible with the rest of application
+                    $key = substr_replace($key, '-', -3, -2);
+                    // get rid of ops without fields
+                    $field = substr($key, 1, -3);
+                    if (empty($interimVars[$field]) && empty($interimVars["_$field"])) {
+                        continue;
+                    }
+
+                    //process _conj variables
+                } else if (substr($key, -5) == "_conj") {
+                    // replace _conj with -conj to be compatible
+                    // with the rest of application
+                    $key = substr_replace($key, '-', -5, -4);
+                    // get rid of ops without fields
+                    $field = substr($key, 1, -5);
+                    if (empty($interimVars[$field]) && empty($interimVars["_$field"])) {
+                        continue;
+                    }
+                } else if (substr($key, -9) == "_children") {
+                    // process _children variables
+                    // replace _children with -children to be compatable
+                    // with the rest of application
+                    $key = substr_replace($key, '-', -9, -8);
+                    // get rid of ops without fields
+                    $field = substr($key, 1, -9);
+                    if (empty($interimVars[$field]) && empty($interimVars["_$field"])) {
+                        continue;
+                    }
+                } else {
+                    $field = substr($key, 1);
+                }
+
+                //process "_field" type variables
+                if (substr($field, 0, 5) == "field" && empty($interimVars[$field]) && empty($interimVars["_$field"])) {
+                    continue;
+                }
+            } else {
+                //process "field" type variables
+                if (substr($key, 0, 5) == "field" && empty($interimVars["_$key"])) {
+                    continue;
+                }
+            }
+
+            $cleanVars[$key] = $val;
+        }
+
+        return $cleanVars;
+    }
+}
diff -pruN 0.9.4-4/php/classes/zophCode/smiley.inc.php 0.9.8-1/php/classes/zophCode/smiley.inc.php
--- 0.9.4-4/php/classes/zophCode/smiley.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/zophCode/smiley.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -23,8 +23,8 @@
 
 namespace zophCode;
 
-use template;
-use block;
+use template\block;
+use template\template;
 
 /**
  * Create smileys
diff -pruN 0.9.4-4/php/classes/zophTable.inc.php 0.9.8-1/php/classes/zophTable.inc.php
--- 0.9.4-4/php/classes/zophTable.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/zophTable.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -33,6 +33,11 @@ use db\query;
 use db\clause;
 use db\delete;
 
+use conf\conf;
+
+use template\block;
+use template\template;
+
 /**
  * A generic table class.  Is is meant to be subclassed by particular
  * table classes.  A table is represented by a name, an array of
@@ -47,6 +52,10 @@ abstract class zophTable {
     protected static $tableName;
     /** @var array List of primary keys */
     protected static $primaryKeys=array();
+    /** @var array Fields that are integers */
+    protected static $isInteger=array();
+    /** @var array Fields that are floats */
+    protected static $isFloat=array();
     /** @var array Fields that may not be empty */
     protected static $notNull=array();
     /** @var bool keep keys with insert. In most cases the keys are set
@@ -117,9 +126,7 @@ abstract class zophTable {
      * @param bool Whether or not to process empty fields
       */
     public function setFields(array $vars, $prefix = null, $suffix = null, $null=true) {
-
-        reset($vars);
-        while (list($key, $val) = each($vars)) {
+        foreach ($vars as $key => $val) {
             log::msg("<b>" . $key . "</b> = " . implode(",", (array) $val), log::DEBUG, log::VARS);
 
             // ignore empty keys or values unless the field must be set.
@@ -231,18 +238,12 @@ abstract class zophTable {
             if (!static::$keepKeys && $this->isKey($name)) {
                 continue;
             }
-
             if ($value === "now()") {
-                /* Lastnotify is normaly set to "now()" and should not be escaped */
+                /* Lastnotify is normally set to "now()" and should not be escaped */
                 $qry->addSet($name, "now()");
-            } else if ($value =="" && in_array($name, static::$notNull)) {
-                die("<p class='error'><b>$name</b> may not be empty</p>");
-            } else if ($value !== "") {
-                $qry->addParam(new param(":" . $name, $value, PDO::PARAM_STR));
             } else {
-                $qry->addParam(new param(":" . $name, null, PDO::PARAM_STR));
+                $qry=$this->processValues($name, $value, $qry);
             }
-
         }
 
         $id=$qry->execute();
@@ -335,18 +336,14 @@ abstract class zophTable {
             }
 
             if ($value === "now()") {
-                /* Lastnotify is normaly set to "now()" and should not be escaped */
+                /* Lastnotify is normally set to "now()" and should not be escaped */
                 $qry->addSetFunction($name . "=now()");
-            } else if ($value =="" && in_array($name, static::$notNull)) {
-                die("<p class='error'><b>$name</b> may not be empty</p>");
-            } else if ($value !== "" && !is_null($value)) {
-                $qry->addSet($name, $name);
-                $qry->addParam(new param(":" . $name, $value, PDO::PARAM_STR));
             } else {
+                $qry=$this->processValues($name, $value, $qry);
                 $qry->addSet($name, $name);
-                $qry->addParam(new param(":" . $name, null, PDO::PARAM_STR));
             }
 
+
         }
 
         if (sizeOf($qry->getParams()) === 0 || sizeOf($qry->getSet()) === 0) {
@@ -362,6 +359,29 @@ abstract class zophTable {
         }
     }
 
+    protected function processValues($name, $value, $qry) {
+        if ((is_null($value) || $value==="") && in_array($name, static::$notNull)) {
+            throw new NotNullValueIsNullDataException(e($name) . "may not be empty");
+        } else {
+            if (in_array($name, static::$isFloat) && empty($value)) {
+                $value = null;
+            }
+            if (in_array($name, static::$isInteger)) {
+                if (is_null($value) || $value==="") {
+                    $qry->addParam(new param(":" . $name, null, PDO::PARAM_NULL));
+                } else {
+                    $qry->addParam(new param(":" . $name, (int) $value, PDO::PARAM_INT));
+                }
+            } else {
+                if (is_null($value)) {
+                    $qry->addParam(new param(":" . $name, null, PDO::PARAM_NULL));
+                } else {
+                    $qry->addParam(new param(":" . $name, $value, PDO::PARAM_STR));
+                }
+            }
+        }
+        return $qry;
+    }
     /**
      * Creates an alphabetized array of field names and values.
      * @return array Array for displaying object
@@ -738,6 +758,19 @@ abstract class zophTable {
         return $tpl;
     }
 
+    /**
+     * Get an array of id => name to build a non-hierarchical array
+     * this function does NOT check user permissions
+     * @return array
+     */
+    public static function getSelectArray() {
+        $records=static::getRecords();
+        $selectArray=array(null => "");
+        foreach ($records as $record) {
+            $selectArray[(string) $record->getId()] = $record->getName();
+        }
+        return $selectArray;
+    }
 
 }
 ?>
diff -pruN 0.9.4-4/php/classes/zophTreeTable.inc.php 0.9.8-1/php/classes/zophTreeTable.inc.php
--- 0.9.4-4/php/classes/zophTreeTable.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/classes/zophTreeTable.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -51,14 +51,15 @@ abstract class zophTreeTable extends zop
      * @param array Names of tables from which entries also should be deleted.
      */
     public function delete() {
-
         // simulate overloading
         if (func_num_args()>=1) {
             $extra_tables = func_get_arg(0);
         } else {
             $extra_tables = null;
         }
-
+        if ($this->getId()==0) {
+            return;
+        }
         $this->getChildren();
         if ($this->children) {
             foreach ($this->children as $child) {
@@ -81,12 +82,14 @@ abstract class zophTreeTable extends zop
         return ($this->getId() == $root->getId());
     }
 
-
-
-    /*
-     * Gets the ancestors of this record.
+    /**
+     * Get the parent node for this node
+     * @return zophTreeTable parent node
      */
-    public function get_ancestors($anc = array()) {
+    public function getParent() {
+        if ($this->isRoot()) {
+            return null;
+        }
         $key = static::$primaryKeys[0];
         $pid = $this->get("parent_" . $key);
 
@@ -95,17 +98,26 @@ abstract class zophTreeTable extends zop
             $pid = $this->get("parent_" . $key);
         }
 
-        // root of tree
-        if ($pid == 0) {
-            $this->ancestors = null;
-            return $anc;
-        }
-
         $parent = new static($pid);
         $parent->lookup();
 
-        array_push($anc, $parent);
-        return $parent->get_ancestors($anc);
+        return $parent;
+    }
+
+    /**
+     * Gets the ancestors of this record.
+     * @param array ancestors
+     * @return array ancestors
+     */
+    public function getAncestors($anc = array()) {
+        $parent=$this->getParent();
+
+        if ($parent) {
+            array_push($anc, $parent);
+            return $parent->getAncestors($anc);
+        } else {
+            return $anc;
+        }
     }
 
     /**
@@ -124,7 +136,7 @@ abstract class zophTreeTable extends zop
         foreach ($records as $record) {
             $ids[$record->getId()]=$record->getId();
 
-            $parents=$record->get_ancestors();
+            $parents=$record->getAncestors();
 
             foreach ($parents as $parent) {
                 $ids[$parent->getId()]=$parent->getId();
@@ -164,6 +176,11 @@ abstract class zophTreeTable extends zop
         return implode(",", $id_array);
     }
 
+    /**
+     * Create an XML tree from this object
+     * @param DOMDocument XML document to insert the new node in
+     * @param Only include nodes that begin with this string
+     */
     private function getXMLtree(DOMDocument $xml, $search) {
         $rootname=static::XMLROOT;
         $nodename=static::XMLNODE;
@@ -283,10 +300,6 @@ abstract class zophTreeTable extends zop
         return $xml;
     }
 
-    public static function getSelectArray() {
-        return static::getTreeSelectArray();
-    }
-
     public static function getTreeSelectArray($rec = null, $select_array = null, $depth=0) {
         $user=user::getCurrent();
         $user->lookupPrefs();
diff -pruN 0.9.4-4/php/cli/arguments.inc.php 0.9.8-1/php/cli/arguments.inc.php
--- 0.9.4-4/php/cli/arguments.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/cli/arguments.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,509 +0,0 @@
-<?php
-/**
- * This file reads and interpretes the CLI arguments
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * This class reads and interpretes CLI arguments
- */
-
-class arguments {
-    /** Contains the non-interpreted arguments */
-    private $arguments = array();
-    /** Contains the interpreted arguments, before lookup */
-    private $processed = array();
-    /** Contains the interpreted arguments, after lookup */
-    private $vars = array();
-
-    /** Default command */
-    public static $command="import";
-
-    /**
-     * Create a new instance of the class.
-     * This construct also takes care of interpreting and looking up of the
-     * values
-     * @param array CLI arguments
-     */
-    public function __construct(array $argv) {
-        // We don't care about the name of the script
-        array_shift($argv);
-        $this->arguments=$argv;
-        if (count($this->arguments)===0) {
-            $this->arguments[]="--help";
-        }
-        $this->process();
-        $this->lookup();
-    }
-
-    /**
-     * Process the arguments
-     * @todo This function contains a list of all arguments Zoph can understand
-     * this really doesn't belong here and should be moved into a controller
-     * part of the app.
-     */
-    private function process() {
-        $argv=$this->arguments;
-        $args=&$this->processed;
-
-        $args["albums"]=array();
-        $args["categories"]=array();
-        $args["files"]=array();
-        $args["people"]=array();
-        $args["photographer"]=array();
-        $args["location"]=array();
-        $args["instance"]="";
-        $args["fields"]=array();
-        $args["path"]="";
-        $args["dirpattern"]="";
-
-        /* For new albums, categories, places, people */
-
-        $parent=0;
-        $args["palbum"]=array();
-        $args["pcat"]=array();
-        $args["pplace"]=array();
-
-        /*
-          Used short arguments: A C D H I N P V a c d f h i l n p r t u v w
-        */
-        $size=sizeof($argv);
-        for ($i=0; $i<$size; $i++) {
-            switch($argv[$i]) {
-            case "":
-                break;
-            case "--instance":
-            case "-i":
-                $args["instance"]=$argv[++$i];
-                break;
-
-
-            case "--albums":
-            case "--album":
-            case "-a":
-                $albums=explode(",",$argv[++$i]);
-                foreach ($albums as $album) {
-                    $args["albums"][]=trim($album);
-                    if (isset($parent)) {
-                        $args["palbum"][]=trim($parent);
-                    }
-                }
-                $parent=0;
-                break;
-
-            case "--category":
-            case "--categories":
-            case "-c":
-                $cats=explode(",",$argv[++$i]);
-                foreach ($cats as $cat) {
-                    $args["categories"][]=trim($cat);
-                    if (isset($parent)) {
-                        $args["pcat"][]=trim($parent);
-                    }
-                }
-                $parent=0;
-                break;
-
-            case "--config":
-            case "-C":
-                static::$command="config";
-                $args["_configitem"]=$argv[++$i];
-                if (isset($argv[$i+1])) {
-                    $args["_configvalue"]=$argv[++$i];
-                } else {
-                    $args["_configdefault"]=true;
-                }
-                break;
-            case "--dumpconfig":
-                static::$command="dumpconfig";
-                break;
-            case "--fields":
-            case "--field":
-            case "-f":
-                $args["fields"][]=$argv[++$i];
-                break;
-            case "--import":
-                static::$command="import";
-                break;
-            case "--place":
-            case "--location":
-            case "-l":
-                // Multiple locations are possible when using --new
-                $locs=explode(",",$argv[++$i]);
-                foreach ($locs as $loc) {
-                    $args["location"][]=trim($loc);
-                    if (isset($parent)) {
-                        $args["pplace"][]=trim($parent);
-                    }
-                }
-                $parent=0;
-                break;
-            case "--people":
-            case "--persons":
-            case "--person":
-            case "-p":
-                $people=explode(",",$argv[++$i]);
-                foreach ($people as $person) {
-                    $args["people"][]=trim($person);
-                }
-                break;
-            case "--photographer":
-            case "-P":
-                $args["photographer"]=$argv[++$i];
-                break;
-
-            case "--parent":
-                $parent=$argv[++$i];
-                break;
-
-            case "--thumbs":
-            case "-t":
-                conf::set("import.cli.thumbs", true);
-                break;
-            case "--nothumbs":
-            case "--no-thumbs":
-            case "-n":
-                conf::set("import.cli.thumbs", false);
-                break;
-            case "--exif":
-            case "--EXIF":
-                conf::set("import.cli.exif", true);
-                break;
-            case "--no-exif":
-            case "--noEXIF":
-            case "--noexif":
-            case "--no-EXIF":
-                conf::set("import.cli.exif", false);
-                break;
-            case "--size":
-                conf::set("import.cli.size", true);
-                break;
-            case "--nosize":
-            case "--no-size":
-                conf::set("import.cli.size", false);
-                break;
-            case "--hash":
-                conf::set("import.cli.hash", true);
-                break;
-            case "--no-hash":
-                conf::set("import.cli.hash", false);
-                break;
-
-
-            case "--update":
-            case "-u":
-                static::$command="update";
-                break;
-            case "--import":
-            case "-I":
-                static::$command="import";
-                break;
-            case "--new":
-            case "-N":
-                static::$command="new";
-                break;
-
-            case "--useIds":
-            case "--useids":
-            case "--use-ids":
-            case "--useid":
-            case "--use-id":
-                conf::set("import.cli.useids", true);
-                break;
-
-            case "--copy":
-                conf::set("import.cli.copy", true);
-                break;
-            case "--move":
-                conf::set("import.cli.copy", false);
-                break;
-
-            case "-A":
-            case "--autoadd":
-            case "--auto-add":
-                conf::set("import.cli.add.auto", true);
-                break;
-
-            case "-w":
-            case "--add-always":
-            case "--addalways":
-                conf::set("import.cli.add.always", true);
-                break;
-
-            case "-r":
-            case "--recursive":
-                conf::set("import.cli.recursive", true);
-                break;
-
-
-            case "--dateddirs":
-            case "--datedDirs":
-            case "--dated":
-            case "-d":
-                conf::set("import.dated", true);
-                break;
-            case "--hierarchical":
-            case "--hier":
-            case "-H":
-                conf::set("import.dated", true);
-                conf::set("import.dated.hier", true);
-                break;
-            case "--no-dateddirs":
-            case "--no-datedDirs":
-            case "--no-dated":
-            case "--nodateddirs":
-            case "--nodatedDirs":
-            case "--nodated":
-                conf::set("import.dated", false);
-                break;
-            case "--no-hierarchical":
-            case "--no-hier":
-            case "--nohierarchical":
-            case "--nohier":
-                conf::set("import.dated.hier", false);
-                break;
-            case "-D":
-            case "--path":
-                $args["path"]=$argv[++$i];
-                break;
-
-            case "--dirpattern":
-                $args["dirpattern"]=$argv[++$i];
-                break;
-
-            case "-V":
-            case "--version":
-                static::$command="version";
-                break;
-            case "-h":
-            case "--help":
-                static::$command="help";
-                break;
-            case "-v":
-            case "--verbose":
-                $verbose=conf::get("import.cli.verbose");
-                conf::set("import.cli.verbose", ++$verbose);
-                break;
-            default:
-                if (substr($argv[$i],0,1)=="-") {
-                    echo "unknown argument: " . $argv[$i] . "\n";
-                    exit(1);
-                } else {
-                    $args["files"][]=$argv[$i];
-                }
-                break;
-            }
-        }
-        if (isset($args["fields"])) {
-            $newfields=array();
-            foreach ($args["fields"] as $f) {
-                $field=explode("=", $f);
-                $newfields[$field[0]]=$field[1];
-            }
-            $args["fields"]=$newfields;
-        }
-
-        if (conf::get("import.cli.useids")==true && static::$command=="import") {
-            static::$command="update";
-        }
-    }
-    /**
-     * Looks up the given parameters in the database and gives back ids
-     */
-    private function lookup() {
-        $args=$this->processed;
-        $vars=&$this->vars;
-        foreach ($args as $type=>$arg) {
-            if (empty($arg) || empty($type)) {
-                continue;
-            }
-
-            log::msg($type . "\t->\t" . implode(",", (array) $arg), log::DEBUG, log::IMPORT);
-            switch($type) {
-            case "albums":
-                foreach ($arg as $name) {
-                    if (static::$command=="new" ||
-                      (conf::get("import.cli.add.auto") && !album::getByName($name))) {
-                        $parent=array_shift($args["palbum"]);
-                        // this is a string comparison because the trim() in process() changes
-                        // everything into a string...
-                        if ($parent==="0") {
-                            if (conf::get("import.cli.add.always")) {
-                                $parent_id=album::getRoot()->getId();
-                            } else {
-                                throw new CliNoParentException("No parent for album " . $name);
-                            }
-                        } else {
-                            $palbum=album::getByName($parent);
-                            if ($palbum) {
-                                $parent_id=$palbum[0]->getId();
-                            } else {
-                                throw new AlbumNotFoundException("Album not found: $parent");
-                            }
-                        }
-                        $vars["_new_album"][]=array("parent" => $parent_id, "name" => $name);
-                    } else {
-                        $album=album::getByName($name);
-                        if ($album) {
-                            $album_id=$album[0]->getId();
-                            $vars["_album_id"][]=$album_id;
-                        } else {
-                            throw new AlbumNotFoundException("Album not found: $name");
-                        }
-                    }
-                }
-                break;
-            case "categories":
-                foreach ($arg as $name) {
-                    if (static::$command=="new" ||
-                      (conf::get("import.cli.add.auto") && !category::getByName($name))) {
-                        $parent=array_shift($args["pcat"]);
-                        // this is a string comparison because the trim() in process() changes
-                        // everything into a string...
-                        if ($parent==="0") {
-                            if (conf::get("import.cli.add.always")) {
-                                $parent_id=category::getRoot()->getId();
-                            } else {
-                                throw new CliNoParentException("No parent for category " . $name);
-                            }
-                        } else {
-                            $pcat=category::getByName($parent);
-                            if ($pcat) {
-                                $parent_id=$pcat[0]->getId();
-                            } else {
-                                throw new CategoryNotFoundException("Category not found: $parent");
-                            }
-                        }
-                        $vars["_new_cat"][]=array("parent" => $parent_id, "name" => $name);
-                    } else {
-                        $cat=category::getByName($name);
-                        if ($cat) {
-                            $cat_id=$cat[0]->getId();
-                            $vars["_category_id"][]=$cat_id;
-                        } else {
-                            throw new CategoryNotFoundException("Category not found: $name");
-                        }
-                    }
-                }
-                break;
-            case "people":
-                foreach ($arg as $name) {
-                    if (static::$command=="new" || (conf::get("import.cli.add.auto") &&
-                      !person::getByName($name))) {
-                        $vars["_new_person"][]=$name;
-                    } else {
-                        $person=person::getByName($name);
-                        if ($person) {
-                            $person_id=$person[0]->getId();
-                            $vars["_person_id"][]=$person_id;
-                        } else {
-                            throw new PersonNotFoundException("Person not found: $name");
-                        }
-                    }
-                }
-                break;
-            case "photographer":
-                $name=$arg;
-                if (static::$command=="new" ||
-                  (conf::get("import.cli.add.auto") && !person::getByName($name))) {
-                    $vars["_new_photographer"][]=$name;
-                } else {
-                    $person=person::getByName($name);
-                    if ($person) {
-                        $person_id=$person[0]->getId();
-                        $vars["photographer_id"]=$person_id;
-                    } else {
-                        throw new PersonNotFoundException("Person not found: $name");
-                    }
-                }
-                break;
-            case "location":
-                foreach ($arg as $name) {
-                    if (static::$command=="new" || (conf::get("import.cli.add.auto") &&
-                      !place::getByName($name))) {
-                        $parent=array_shift($args["pplace"]);
-                        // this is a string comparison because the trim() in process() changes
-                        // everything into a string...
-                        if ($parent==="0") {
-                            if (conf::get("import.cli.add.always")) {
-                                $parent_id=place::getRoot()->getId();
-                            } else {
-                                throw new CliNoParentException("No parent for location " . $name);
-                            }
-                        } else {
-                            $pplace=place::getByName($parent);
-                            if ($pplace) {
-                                $parent_id=$pplace[0]->getId();
-                            } else {
-                                throw new PlaceNotFoundException("Location not found: $parent");
-                            }
-                        }
-                        $vars["_new_place"][]=array("parent" => $parent_id, "name" => $name);
-                    } else {
-                        $name=$arg[0];
-                        $place=place::getByName($name);
-                        if ($place) {
-                            $place_id=$place[0]->getId();
-                            $vars["location_id"]=$place_id;
-                        } else {
-                            throw new PlaceNotFoundException("Location not found: $name");
-                        }
-                    }
-                }
-                break;
-            case "path":
-                $vars["_path"]=$arg;
-                break;
-            case "dirpattern":
-                if (!preg_match("/^[aclpDP]+$/", $arg)) {
-                    throw new CliIllegalDirpatternException("Illegal characters in " .
-                        "--dirpattern, allowed are: aclpDP");
-                } else {
-                    $vars["_dirpattern"]=$arg;
-                }
-                break;
-
-            case "fields":
-                foreach ($arg as $field=>$value) {
-                    $vars[$field]=$value;
-                }
-                break;
-            case "_configitem":
-            case "_configvalue":
-            case "_configdefault":
-                $vars[$type]=$arg;
-                break;
-            }
-        }
-    }
-    /**
-     * Returns the list of files
-     */
-    public function getFiles() {
-        return $this->processed["files"];
-    }
-
-    /**
-     * Returns an array of variables, with keys.
-     */
-    public function getVars() {
-        return $this->vars;
-    }
-
-}
-?>
diff -pruN 0.9.4-4/php/cli/cliimport.inc.php 0.9.8-1/php/cli/cliimport.inc.php
--- 0.9.4-4/php/cli/cliimport.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/cli/cliimport.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,80 +0,0 @@
-<?php
-/**
- * Takes care of the import throught the CLI
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * Class that takes care of the import through the CLI
- */
-class CliImport extends Import {
-    /**
-     * Displays a progressbar on the CLI
-     *
-     * The progressbar will not be wider than 60 characters, so we have
-     * 20 chars left for counter etc. on a 80 char screen
-     * the real width of the screen is not checked because it cannot be
-     * done in PHP without external programs
-     * After displaying the progressbar, it will 'backspace' to the
-     * beginning of the line, so any error message will
-     * not cause a distorted screen
-     * @var int progress
-     * @var int total
-     */
-
-    public static function progress($cur, $total) {
-        if (!defined("CLI")) {
-            return;
-        }
-        if ($total>=60) {
-            $calccur=$cur/$total*60;
-            $dispcur=floor($calccur);
-            $disptotal=60;
-        } else {
-            $calccur=0;
-            $dispcur=$cur;
-            $disptotal=$total;
-        }
-        $display="[";
-        $display.=str_repeat("|", $dispcur);
-        $rem=round($calccur - $dispcur,2);
-        $num=$total/$disptotal;
-        if ($num > 3) {
-            if ($rem > 0.333  && $rem < 0.666) {
-                $display.=".";
-            } else if ($rem > 0.6666 && $rem < 0.999) {
-                $display.=":";
-            } else if ($rem > 0.999) {
-                $display.="|";
-            }
-        } else if ($num == 2) {
-            if ($rem >= 0.5) {
-                $display.=".";
-            }
-        }
-
-        $display=str_pad($display, $disptotal + 1);
-        $display.="]";
-        $perc=floor($cur / $total * 100);
-        $display.= " [ $cur / $total (" . $perc . "%) ]";
-        echo $display;
-        echo str_repeat(chr(8), strlen($display));
-    }
-
-}
-
diff -pruN 0.9.4-4/php/cli/cli.inc.php 0.9.8-1/php/cli/cli.inc.php
--- 0.9.4-4/php/cli/cli.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/cli/cli.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,521 +0,0 @@
-<?php
-/**
- * Controller for the CLI
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-
-/**
- * Controller class for the CLI
- */
-class cli {
-    /**
-     * Defines the API version between the /bin/zoph binary and the files in the webroot
-     * these must be equal.
-     */
-    const API=4;
-
-    /**
-     * @var The user that is doing the import
-     */
-    private $user;
-    /**
-     * @var Commandline arguments
-     */
-    private $args;
-    /**
-     * List of files to be imported
-     */
-    private $files=array();
-    private $photos=array();
-
-    /**
-     * Create cli object
-     * @param User user doing the import
-     * @param int API version of the executable script. This is used to check if the executable
-     *            script is compatible with the scripts in php directory
-     * @param $args array of CLI arguments
-     */
-    public function __construct(user $user, $api, array $args) {
-        if ($api != static::API) {
-            throw new CliAPINotCompatibleException("This Zoph installation is not compatible " .
-                "with the Zoph executable you are running.");
-        }
-        $this->user=$user;
-
-        if (!$user->isAdmin()) {
-            throw new CliUserNotAdminException("CLI_USER must be an admin user");
-        }
-        $user->prefs->load();
-        $user->loadLanguage();
-        $this->args=new arguments($args);
-    }
-
-    /**
-     * Run the CLI
-     */
-    public function run() {
-        $this->processFiles();
-        switch(arguments::$command) {
-        case "import":
-            $this->doImport();
-            break;
-        case "update":
-            $this->doUpdate();
-            break;
-        case "new":
-            $this->addNew();
-            break;
-        case "config":
-            $this->doConfig();
-            break;
-        case "dumpconfig":
-            $this->doDumpCondig();
-            break;
-        case "version":
-            static::showVersion();
-            break;
-        case "help":
-            static::showHelp();
-            break;
-        default:
-            throw new CliUnknownErrorException("Unknown command, please file a bug");
-        }
-
-    }
-
-    /**
-     * Check list of files
-     */
-    private function processFiles() {
-        $files=$this->args->getFiles();
-        foreach ($files as $filename) {
-            try {
-                if (arguments::$command=="import") {
-
-                    $file=new file($filename);
-                    $file->check();
-
-                    $file->getMime();
-                    if ($file->type=="directory" && conf::get("import.cli.recursive")) {
-                        $this->files=array_merge($this->files, file::getFromDir($file, true));
-                    } else if ($file->type!="image") {
-                        throw new ImportFileNotImportableException("$file is not an image\n");
-                    } else {
-                        $this->files[]=$file;
-                    }
-                } else {
-                    if (conf::get("import.cli.useids")) {
-                        $file=$filename;
-                        if (is_numeric($file)) {
-                            $this->photos[]=$this->lookupFileById($file);
-                        } else if (preg_match("/^[0-9]+-[0-9]+$/", $file)) {
-                            list($start, $end) = explode("-",$file);
-                            foreach (range($start, $end) as $id) {
-                                try {
-                                    $this->photos[]=$this->lookupFileById($id);
-                                } catch (ImportException $e) {
-                                    echo $e->getMessage();
-                                }
-                             }
-                        } else {
-                            throw new ImportIdIsNotNumericException(
-                                "$file is not numeric, but --useids is set.\n");
-                        }
-                    } else {
-                        $this->photos[]=$this->lookupFile($filename);
-                    }
-                }
-            } catch (Exception $e) {
-                echo $e->getMessage();
-            }
-        }
-    }
-
-    /**
-     * Looks up a photo by photo_id
-     */
-    private function lookupFileById($id) {
-        $photo=new photo((int) $id);
-        $count=$photo->lookup();
-        if ($count==1) {
-            return $photo;
-        } else if ($count==0) {
-            throw new ImportFileNotFoundException("No photo with id $id was found\n");
-        } else {
-            throw new ImportMultipleMatchesException(
-                "Multiple photos with id $id were found. This is probably a bug");
-        }
-    }
-
-    /**
-     * Looks up a file by filename
-     * @todo Maybe this should be moved into the file object?
-     */
-    private function lookupFile($file) {
-        $filename=basename($file);
-        $path=dirname($file);
-        if ($path==".") {
-            // No path given
-            //unset($path);
-            $path="./";
-        }
-
-        if (substr($path,0,2)=="./") {
-            // Path relative to the current dir given, change into absolute path
-            $path="/" . file::cleanupPath(getcwd() . "/" . $path);
-        }
-        if ($path[0]=="/") {
-            // absolute path given
-
-            $path="/" . file::cleanupPath($path) . "/";
-
-            // check if path is in conf::get("path.images")
-            if (substr($path, 0, strlen(conf::get("path.images")))!=conf::get("path.images")) {
-                throw new ImportFileNotInPathException($file ." is not in the images path (" .
-                    conf::get("path.images") . "), skipping.\n");
-            } else {
-                $path=substr($path, strlen(conf::get("path.images")));
-                if ($path[0]=="/") {
-                    // conf::get("path.images") didn't end in '/', let's cut it off
-                    $path=substr($path, 1);
-                }
-            }
-        } else {
-            $path=file::cleanupPath($path);
-        }
-        $photos=photo::getByName($filename, $path);
-        if (sizeof($photos)==0) {
-            throw new ImportFileNotFoundException($file ." not found.\n");
-        } else if (sizeof($photos)==1) {
-            return $photos[0];
-        } else {
-            throw new ImportMultipleMatchesException("Multiple files named " . $file ." found.\n");
-        }
-    }
-
-    /**
-     * Process --import
-     */
-    private function doImport() {
-        $vars=$this->args->getVars();
-        if (conf::get("import.cli.add.auto")) {
-            $vars=$this->addNew();
-        }
-        if (is_array($this->files) && sizeof($this->files)>0) {
-            if (!isset($vars["_dirpattern"])) {
-                $photos=array();
-                foreach (array_unique($this->files) as $file) {
-                    $photo=new photo();
-                    $photo->file["orig"]=$file;
-                    $photos[]=$photo;
-                }
-            } else {
-                $photos=$this->processDirpattern();
-            }
-            CliImport::photos($photos, $vars);
-        } else {
-            throw new CliNoFilesException("Nothing to do, exiting");
-        }
-    }
-
-    /**
-     * Process --update
-     */
-    private function doUpdate() {
-        if (is_array($this->photos) && sizeof($this->photos)>0) {
-            $total=sizeof($this->photos);
-            $cur=0;
-            foreach ($this->photos as $photo) {
-                cliimport::progress($cur, $total);
-                $cur++;
-                $photo->lookup();
-                $photo->setFields($this->args->getVars());
-                $photo->update();
-                $photo->updateRelations($this->args->getVars(), "_id");
-                if (conf::get("import.cli.thumbs")===true) {
-                    $photo->thumbnail(true);
-                }
-                if (conf::get("import.cli.exif")===true) {
-                    $photo->updateEXIF();
-                }
-                if (conf::get("import.cli.size")===true) {
-                    $photo->updateSize();
-                }
-                if (conf::get("import.cli.hash")===true) {
-                    $photo->getHash();
-                }
-            }
-        } else {
-            throw new CliNoFilesException("Nothing to do, exiting");
-        }
-    }
-    /**
-     * Add albums, categories, places, people that should be added because of --new or --autoadd
-     * if $vars is given,
-     */
-    public function addNew() {
-        $vars=$this->args->getVars();
-        $newvars=array();
-        $return_vars=array();
-
-        foreach ($vars as $var=>$array) {
-            switch($var) {
-            case "_new_album":
-                $newvars["_album_id"]=array();
-                foreach ($array as $new) {
-                    $album=new album();
-                    $album->set("album", $new["name"]);
-                    $album->set("parent_album_id", (int) $new["parent"]);
-                    $album->insert();
-                    $newvars["_album_id"][]=$album->getId();
-                }
-                break;
-            case "_new_cat":
-                $newvars["_category_id"]=array();
-                foreach ($array as $new) {
-                    $cat=new category();
-                    $cat->set("category", $new["name"]);
-                    $cat->set("parent_category_id", (int) $new["parent"]);
-                    $cat->insert();
-                    $newvars["_category_id"][]=$cat->getId();
-                }
-                break;
-            case "_new_place":
-                foreach ($array as $new) {
-                    $place=new place();
-                    $place->set("title", $new["name"]);
-                    $place->set("parent_place_id", (int) $new["parent"]);
-                    $place->insert();
-                    $newvars["location_id"]=$place->getId();
-                }
-
-                break;
-            case "_new_person":
-                $newvars["_person_id"]=array();
-                foreach ($array as $new) {
-                    $person=new person();
-                    $person->setName($new);
-                    $person->insert();
-                    $newvars["_person_id"][]=$person->getId();
-                }
-                break;
-            case "_new_photographer":
-                foreach ($array as $new) {
-                    $person=new person();
-                    $person->setName($new);
-                    $person->insert();
-                    $newvars["photographer_id"]=$person->getId();
-                }
-            default:
-                $return_vars[$var]=$array;
-            }
-        }
-        foreach ($newvars as $name=>$array) {
-            if (array_key_exists($name, $return_vars) && is_array($return_vars[$name])) {
-                $return_vars[$name]=array_merge($return_vars[$name], $array);
-            }
-            $return_vars[$name]=$array;
-        }
-        return($return_vars);
-    }
-
-    /**
-     * Process --config
-     */
-    private function doConfig() {
-        $vars=$this->args->getVars();
-        $name=$vars["_configitem"];
-        $default=isset($vars["_configdefault"]);
-        $item=conf::getItemByName($name);
-
-        if ($default) {
-            $value=$item->getDefault();
-        } else {
-            $value=$vars["_configvalue"];
-        }
-
-        if (conf::get("import.cli.verbose") > 0) {
-            echo "Setting config \"$name\" to \"$value\""  .
-                ($default ? " (default)" : "") . "\n";
-        }
-
-
-        $item->setValue($value);
-        $item->update();
-    }
-
-    /**
-     * Process --dump-config
-     */
-    private function doDumpConfig() {
-        $conf=conf::getAll();
-        foreach ($conf as $item) {
-            foreach ($item as $citem) {
-                if ($citem instanceof confItemBool) {
-                    $value=($citem->getValue() ? "true": "false");
-                } else {
-                    $value=$citem->getValue();
-                }
-                echo $citem->getName() . ": " . $value . "\n";
-            }
-        }
-    }
-
-    /**
-     * Process the --dirpattern setting
-     */
-    public function processDirpattern() {
-        $vars=$this->args->getVars();
-
-        $patt=str_split($vars["_dirpattern"]);
-
-        $cur=getcwd();
-        $curlen=strlen($cur);
-        foreach ($this->files as $file) {
-            if (substr($file, 0, $curlen) != $cur) {
-                throw new CliNotInCWDException("Sorry, --dirpattern can only be used when " .
-                    "importing files under the current dir. i.e. do not use absolute paths " .
-                    "or '../' when specifying --dirpattern.");
-            }
-            $filename=substr($file, $curlen + 1);
-            $dirs=explode("/", $filename);
-            array_pop($dirs);
-
-            $photo=new photo();
-            $photo->file["orig"]=$file;
-
-            $counter=0;
-            foreach ($dirs as $dir) {
-                if (isset($patt[$counter])) {
-                    switch($patt[$counter]) {
-                    case "a":
-                        // album
-                        $album=album::getByName($dir);
-                        if ($album[0] instanceof album) {
-                            if (!is_array($photo->_album_id)) {
-                                $photo->_album_id=array();
-                            }
-                            $photo->_album_id[]=$album[0]->getId();
-                        } else {
-                            throw new AlbumNotFoundException("Album not found: " . $dir);
-                        }
-                        break;
-                    case "c":
-                        // category
-                        $cat=category::getByName($dir);
-                        if ($cat[0] instanceof category) {
-                            if (!is_array($photo->_category_id)) {
-                                $photo->_category_id=array();
-                            }
-                            $photo->_category_id[]=$cat[0]->getId();
-                        } else {
-                            throw new CategoryNotFoundException("Category not found: " . $dir);
-                        }
-                        break;
-                    case "l":
-                        // location
-                        $place=place::getByName($dir);
-                        if ($place[0] instanceof place) {
-                            $photo->set("location_id", $place[0]->getId());
-                        } else {
-                            throw new PlaceNotFoundException("Place not found: " . $dir);
-                        }
-                        break;
-                    case "p":
-                        // person
-                        $person=person::getByName($dir);
-                        if ($person[0] instanceof person) {
-                            if (!is_array($photo->_person_id)) {
-                                $photo->_person_id=array();
-                            }
-                            $photo->_person_id[]=$person[0]->getId();
-                        } else {
-                            throw new PersonNotFoundException("Person not found: " . $dir);
-                        }
-                        break;
-                    case "D":
-                        // dir / path
-                        $path=$photo->_path;
-                        if (!empty($path)) {
-                            $path .= "/";
-                        }
-                        $photo->_path=$path . $dir;
-                        break;
-                    case "P":
-                        // photographer
-                        $person=person::getByName($dir);
-                        if ($person[0] instanceof person) {
-                            $photo->set("photographer_id", $person[0]->getId());
-                        } else {
-                            throw new PersonNotFoundException("Person not found: " . $dir);
-                        }
-                        break;
-                    default:
-                        // should never happen...
-                        throw new CliUnknownErrorException("Unknown error");
-                    }
-                }
-                $counter++;
-            }
-            $photos[]=$photo;
-        }
-        return $photos;
-    }
-    /**
-     * Show help
-     */
-    private static function showHelp() {
-        echo "zoph " . VERSION . "\n";
-        echo <<<END
-Usage: zoph [OPTIONS] [IMAGE ...]
-OPTIONS:
-    --instance "INSTANCE"
-
-    --import
-    --update
-    --version
-    --help
-
-    --album "ALBUM"
-    --category "CATEGORY"
-    --photographer "FIRST_NAME LAST_NAME"
-    --location "PLACE"
-    --person "FIRST_NAME LAST_NAME"
-    --field "FIELD=VALUE"
-
-    --[no-]thumbs
-    --[no-]exif
-    --[no-]size
-    --useids
-    --move
-    --copy
-    --[no-]dateddirs
-    --[no-]hierarchical
-    --path
-
-END;
-    }
-
-    /**
-     * Tells user which Zoph version is being used
-     */
-    private static function showVersion() {
-        echo "Zoph v" . VERSION . ", released " . RELEASEDATE . "\n";
-    }
-}
-?>
diff -pruN 0.9.4-4/php/color_scheme.inc.php 0.9.8-1/php/color_scheme.inc.php
--- 0.9.4-4/php/color_scheme.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/color_scheme.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,126 +0,0 @@
-<?php
-
-/**
- * A class corresponding to the color_themes table.
- *
- * This file is part of Zoph.
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @package Zoph
- * @author Jason Geiger
- * @author Jeroen Roos
- */
-
-/**
- * A class corresponding to the color_themes table.
- *
- * @package Zoph
- * @author Jason Geiger
- * @author Jeroen Roos
- */
-class color_scheme extends zophTable {
-
-    /** @var string The name of the database table */
-    protected static $tableName="color_schemes";
-    /** @var array List of primary keys */
-    protected static $primaryKeys=array("color_scheme_id");
-    /** @var array Fields that may not be empty */
-    protected static $notNull=array("name");
-    /** @var bool keep keys with insert. In most cases the keys are set by
-                  the db with auto_increment */
-    protected static $keepKeys = false;
-    /** @var string URL for this class */
-    protected static $url="color_schemes.php?color_scheme_id=";
-
-    private static $current=null;
-
-    public function update() {
-        foreach ($this->fields as $field => $value) {
-            $this->set($field, str_replace("#", "", $value));
-        }
-        parent::update();
-    }
-
-    /**
-     * Get color from current color scheme
-     * or fall back to default
-     * @param string Name of color to retrieve
-     * @return string #xxxxxx HTML color code
-     */
-    public static function getColor($color) {
-        if (!is_null(static::$current)) {
-            return "#" . static::$current->get($color);
-        } else {
-            return static::getDefault($color);
-        }
-    }
-
-    public function getColors() {
-        $this->lookup();
-        $colors=array();
-        foreach ($this->fields as $field => $value) {
-            if ($this->isKey($field) || $field=="name") {
-                continue;
-            }
-            $colors[$field]=$this->fields[$field];
-        }
-        return $colors;
-    }
-
-    /**
-     * Define a default for each color
-     * for now, this is a fallback for whenever no color scheme has been loaded,
-     * e.g. when the user is not logged in yet. Eventually, it will be possible
-     * to define a "default" color scheme, and then this will only be used in
-     * a worst case fall back (for example when an admin deletes *all* color
-     * schemes.
-     * @param string Name of color to retrieve
-     * @param string #xxxxxx HTML color code
-     * @throws Exception
-     * @todo Maybe a custom Exception should be created.
-     */
-    private static function getDefault($color) {
-        $cs=array(
-            "page_bg_color"             => "#ffffff",
-            "text_color"                => "#000000",
-            "link_color"                => "#111111",
-            "vlink_color"               => "#444444",
-            "table_bg_color"            => "#ffffff",
-            "table_border_color"        => "#000000",
-            "breadcrumb_bg_color"       => "#ffffff",
-            "title_bg_color"            => "#f0f0f0",
-            "title_font_color"          => "#000000",
-            "tab_bg_color"              => "#000000",
-            "tab_font_color"            => "#ffffff",
-            "selected_tab_bg_color"     => "#c0c0c0",
-            "selected_tab_font_color"   => "#000000"
-        );
-
-        if (array_key_exists($color, $cs)) {
-            return $cs[$color];
-        } else {
-            throw new Exception("Undefined Color: " . e($color));
-        }
-    }
-
-    /**
-     * Set current color scheme
-     * @param color_scheme the color scheme to use
-     */
-    public static function setCurrent(color_scheme $cs) {
-        static::$current=$cs;
-    }
-}
-?>
diff -pruN 0.9.4-4/php/color_scheme.php 0.9.8-1/php/color_scheme.php
--- 0.9.4-4/php/color_scheme.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/color_scheme.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,8 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+use template\colorScheme;
+
 require_once "include.inc.php";
 
 if (!$user->isAdmin()) {
@@ -29,17 +31,17 @@ if (!$user->isAdmin()) {
 
 $color_scheme_id = getvar("color_scheme_id");
 
-$color_scheme = new color_scheme($color_scheme_id);
+$colorScheme = new colorScheme($color_scheme_id);
 
 if ($_action == "copy") {
     $title = translate("Copy Color Scheme");
-    $color_scheme->lookup();
-    $name = "copy of " . $color_scheme->get("name");
+    $colorScheme->lookup();
+    $name = "copy of " . $colorScheme->get("name");
     $color_scheme_id = 0;
     $_action = "new";
     $copy=1;
 }
-$obj = &$color_scheme;
+$obj = &$colorScheme;
 $redirect = "color_schemes.php";
 require_once "actions.inc.php";
 
@@ -48,8 +50,8 @@ if ($_action == "update") {
 }
 
 if ($action != "insert") {
-    $color_scheme->lookup();
-    $title = $color_scheme->get("name");
+    $colorScheme->lookup();
+    $title = $colorScheme->get("name");
 } else {
     $title = translate("New Color Scheme");
 }
@@ -65,10 +67,10 @@ if ($action == "display") {
         ?>
         <ul class="actionlink">
           <li><a href="color_scheme.php?_action=edit&amp;color_scheme_id=<?php
-              echo $color_scheme->getId() ?>"><?php echo translate("edit") ?>
+              echo $colorScheme->getId() ?>"><?php echo translate("edit") ?>
           </a></li>
           <li><a href="color_scheme.php?_action=delete&amp;color_scheme_id=<?php
-              echo $color_scheme->getId() ?>"><?php echo translate("delete") ?>
+              echo $colorScheme->getId() ?>"><?php echo translate("delete") ?>
           </a></li>
           <li><a href="color_scheme.php?_action=new"><?php echo translate("new") ?></a></li>
         </ul>
@@ -78,12 +80,10 @@ if ($action == "display") {
     <?php echo translate("color scheme") ?>
     </h1>
     <div class="main">
-      <h2><?php echo $color_scheme->get("name") ?></h2>
-        <dl class="display color_scheme">
+      <h2><?php echo $colorScheme->get("name") ?></h2>
+        <dl class="display colorScheme">
     <?php
-    $colors = $color_scheme->getDisplayArray();
-
-    while (list($name, $value) = each($colors)) {
+    foreach ($colorScheme->getDisplayArray() as $name => $value) {
         if ($name == "Name") { continue; }
         ?>
         <dt><?php echo $name ?></dt>
@@ -102,16 +102,16 @@ if ($action == "display") {
       <div class="main">
         <ul class="actionlink">
           <li><a href="color_scheme.php?_action=confirm&amp;color_scheme_id=<?php
-            echo $color_scheme->getId() ?>">
+            echo $colorScheme->getId() ?>">
             <?php echo translate("delete") ?>
           </a></li>
           <li><a href="color_schemes.php"><?php echo translate("cancel") ?></a></li>
         </ul>
-        <?php echo sprintf(translate("Confirm deletion of '%s'"), $color_scheme->get("name")) ?>:
+        <?php echo sprintf(translate("Confirm deletion of '%s'"), $colorScheme->get("name")) ?>:
         <br>
     <?php
 } else {
-    $colors = $color_scheme->getColors();
+    $colors = $colorScheme->getColors();
     ?>
     <h1>
       <ul class="actionlink">
@@ -123,14 +123,14 @@ if ($action == "display") {
       <form action="color_scheme.php">
        <input type="hidden" name="_action" value="<?php echo $action ?>">
        <input type="hidden" name="color_scheme_id" value="<?php
-         echo $color_scheme->get("color_scheme_id") ?>">
+         echo $colorScheme->get("color_scheme_id") ?>">
        <label for="name">Name</label>
        <div class="colordef">
     <?php
     if (isset($copy)) {
-        echo create_text_input("name", "copy of " . $color_scheme->get("name"), 16, 64);
+        echo create_text_input("name", "copy of " . $colorScheme->get("name"), 16, 64);
     } else {
-        echo create_text_input("name", $color_scheme->get("name"), 16, 64);
+        echo create_text_input("name", $colorScheme->get("name"), 16, 64);
     }
     ?>
         </div>
diff -pruN 0.9.4-4/php/color_schemes.php 0.9.8-1/php/color_schemes.php
--- 0.9.4-4/php/color_schemes.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/color_schemes.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,8 @@
  * @author Jeroen Roos
  */
 
+use template\colorScheme;
+
 require_once "include.inc.php";
 
 if (!$user->isAdmin()) {
@@ -39,10 +41,10 @@ require_once "header.inc.php";
       </h1>
   <div class="main">
 <?php
-$color_schemes = color_scheme::getRecords("name");
+$colorSchemes = colorScheme::getRecords("name");
 
-if ($color_schemes) {
-    foreach ($color_schemes as $cs) {
+if ($colorSchemes) {
+    foreach ($colorSchemes as $cs) {
         ?>
         <ul class="actionlink">
           <li><a href="color_scheme.php?_action=delete&amp;color_scheme_id=<?php
diff -pruN 0.9.4-4/php/comment.php 0.9.8-1/php/comment.php
--- 0.9.4-4/php/comment.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/comment.php	2018-03-02 20:49:58.000000000 +0000
@@ -20,6 +20,9 @@
  * @package Zoph
  * @author Jeroen Roos
  */
+
+use conf\conf;
+
 require_once "include.inc.php";
 
 if (!conf::get("feature.comments")) {
diff -pruN 0.9.4-4/php/comments.php 0.9.8-1/php/comments.php
--- 0.9.4-4/php/comments.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/comments.php	2018-03-02 20:49:58.000000000 +0000
@@ -20,6 +20,8 @@
  * @package Zoph
  * @author Jeroen Roos
  */
+use conf\conf;
+
 require_once "include.inc.php";
 
 if (!conf::get("feature.comments")) {
diff -pruN 0.9.4-4/php/config.inc.php 0.9.8-1/php/config.inc.php
--- 0.9.4-4/php/config.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/config.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -24,9 +24,8 @@
 // VERSION, RELEASEDATE, INI_FILE, THUMB_SIZE, MID_SIZE, THUMB_PREFIX, MID_PREFIX, LOG_ALWAYS
 // LOG_SEVERITY, LOG_SUBJECT.
 // All other settings are now made from the webinterface
-
-define('VERSION', '0.9.4');
-define('RELEASEDATE', '18-09-2016');
+define('VERSION', '0.9.8');
+define('RELEASEDATE', '02-03-2018');
 // DB_HOST, DB_NAME, DB_USER, DB_PASS and DB_PREFIX have been moved to
 // zoph.ini. The location can be set by the next config item:
 
diff -pruN 0.9.4-4/php/config.php 0.9.8-1/php/config.php
--- 0.9.4-4/php/config.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/config.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,9 @@
  * @author Jeroen Roos
  */
 
+use conf\conf;
+use template\template;
+
 require_once "include.inc.php";
 $title=translate("Configuration");
 
@@ -37,7 +40,6 @@ $_action=getvar("_action");
 if ($_action == "setconfig") {
     conf::loadFromRequestVars($request_vars);
 }
-conf::loadFromDB();
 
 $tpl=new template("config", array(
     "title" => $title,
diff -pruN 0.9.4-4/php/credits.html 0.9.8-1/php/credits.html
--- 0.9.4-4/php/credits.html	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/credits.html	2018-03-02 20:49:58.000000000 +0000
@@ -80,6 +80,10 @@
     <td>zophImport.pl patches</td>
   </tr>
   <tr>
+    <th>Pontus Fröding</th>
+    <td>Bugfixes</td>
+  </tr>
+  <tr>
     <th><a href="http://www.geonames.org">Geonames project</a></th>
     <td>Coordinates to timezone lookup</td>
   </tr>
@@ -168,8 +172,12 @@
     <td>Swedish translation</td>
   </tr>
   <tr>
-    <th><a href="http://www.mapstraction.com">Mapstraction project</a></th>
-    <td>Mapstraction mapping abstraction layer</td>
+    <th><a href="http://leafletjs.com">Leaflet project</a></th>
+    <td>Leaflet mapping project</td>
+  </tr>
+  <tr>
+    <th>Iv&aacute;n S&aacute;nchez Ortega</th>
+    <td>Leaflet Google Plugin</td>
   </tr>
   <tr>
     <th>Neil McBride</th>
diff -pruN 0.9.4-4/php/css.php 0.9.8-1/php/css.php
--- 0.9.4-4/php/css.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/css.php	2018-03-02 20:49:58.000000000 +0000
@@ -16,6 +16,8 @@
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
  */
 
+use conf\conf;
+
 header("Content-Type: text/css");
 if (isset($_GET['logged_on'])) {
     define("LOGON", true);
diff -pruN 0.9.4-4/php/define_annotated_photo.php 0.9.8-1/php/define_annotated_photo.php
--- 0.9.4-4/php/define_annotated_photo.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/define_annotated_photo.php	2018-03-02 20:49:58.000000000 +0000
@@ -29,6 +29,9 @@
  * @copyright 2003 Nixon P. Childs
  */
 
+use conf\conf;
+use template\template;
+
 require_once "include.inc.php";
 
 if (!conf::get("feature.annotate")) {
diff -pruN 0.9.4-4/php/download.php 0.9.8-1/php/download.php
--- 0.9.4-4/php/download.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/download.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,8 +22,16 @@
  *
  */
 
+use conf\conf;
+
+use photo\collection;
+
+use template\template;
+
+use web\request;
+
 require_once "include.inc.php";
-$vars=clean_request_vars($request_vars);
+$vars=$request->getRequestVarsClean();
 
 $_action=getvar("_action");
 if (!conf::get("feature.download") || (!$user->get("download") && !$user->isAdmin())) {
@@ -37,7 +45,9 @@ if ($_action=="getfile" || $_action=="do
     }
 
     $filenum=getvar("_filenum");
-    if (!$filenum) { $filenum=1; }
+    if (!$filenum) {
+        $filenum=1;
+    }
 }
 
 if ($_action=="download") {
@@ -55,15 +65,34 @@ if ($_action=="download") {
     flush();
     exit;
 }
+$title=translate("Download zipfile");
+
 require_once "header.inc.php";
 ?>
-<h1>
-    <?php echo translate("Download zipfile") . "\n" ?>
-</h1>
+<h1><?= $title ?></h1>
 <div class="main">
 
 <?php
 
+$offset=getvar("_off");
+if (!$offset) {
+    $offset=0;
+}
+
+$maxfiles=getvar("_maxfiles");
+if (!$maxfiles) {
+    $maxfiles=200;
+}
+if (!is_numeric($maxfiles)) {
+    die("Maximum files must be numeric");
+}
+
+$photoCollection = collection::createFromRequest(request::create());
+$photos=$photoCollection->subset($offset, $maxfiles);
+
+$totalPhotoCount = sizeof($photoCollection);
+$downloadCount = sizeof($photos);
+
 if ($_action=="getfile") {
     $maxsize=getvar("_maxsize");
     if (!$maxsize) { $maxsize=25000000; }
@@ -71,27 +100,13 @@ if ($_action=="getfile") {
     if (!is_numeric($maxsize)) {
         die("Maximum size must be numeric");
     }
-    $maxfiles=getvar("_maxfiles");
-    if (!$maxfiles) { $maxfiles=200; }
-    if (!is_numeric($maxfiles)) {
-        die("Maximum files must be numeric");
-    }
     $dateddirs=getvar("dateddirs");
 
-    $offset=getvar("_off");
-    if (!$offset) { $offset=0; }
-
-    $photos;
-
-    $totalPhotoCount =
-        get_photos($vars, $offset, $maxfiles, $photos, $user);
-
-    $num_photos = sizeof($photos);
-    if ($num_photos) {
+    if ($downloadCount) {
         echo translate("The zipfile is being created...") . "<br>";
         flush();
         $number=create_zipfile($photos, $maxsize, $filename, $filenum, $user);
-        $newoffset=$offset + $number + 1;
+        $newoffset=$offset + $number;
         echo "<iframe style=\"border: none; width: 100%; height: 4em\" " .
             "src=download.php?_action=download&_filename=" . $filename .
             "&_filenum=" . $filenum . "></iframe>";
@@ -134,19 +149,15 @@ if ($_action=="getfile") {
         echo translate("No photos were found matching your search criteria.") . "\n";
     }
 } else {
-    # Give me a call if you have more than 999999999 photos!
-    $totalPhotoCount =
-        get_photos($vars, 0, 999999999, $photos, $user);
-    $num_photos=sizeof($photos);
-    if ($num_photos<= 0) {
+    if ($totalPhotoCount <= 0) {
         echo translate("No photos were found matching your search criteria.") . "\n";
     } else {
         ?>
         <form class="download">
           <p>
             <?php printf(translate("You have requested the download of %s photos," .
-                "with a total size of  %s."), $num_photos,
-                getHuman(photo::getFilesize($photos))); ?>
+                "with a total size of  %s."), $totalPhotoCount,
+                template::getHumanReadableBytes(photo::getFilesize($photos))); ?>
           </p>
           <p>
             <?php echo create_form($vars, array("_off", "_action")) ?>
diff -pruN 0.9.4-4/php/edit_person.inc.php 0.9.8-1/php/edit_person.inc.php
--- 0.9.4-4/php/edit_person.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/edit_person.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -15,6 +15,9 @@
  * along with Zoph; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
  */
+
+use template\template;
+
 ?>
 <!-- begin edit_person.inc !-->
           <h1>
diff -pruN 0.9.4-4/php/edit_photo.inc.php 0.9.8-1/php/edit_photo.inc.php
--- 0.9.4-4/php/edit_photo.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/edit_photo.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -15,262 +15,81 @@
  * along with Zoph; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
  */
+use conf\conf;
+use template\template;
+
 if ($num_photos) {
-    $title_bar = sprintf(translate("photo %s of %s"),  ($offset + 1) , $num_photos);
+    $title = sprintf(translate("photo %s of %s"),  ($offset + 1) , $num_photos);
 } else {
-    $title_bar = translate("photo");
+    $title = translate("photo");
+}
+
+try {
+    $selection=new selection($_SESSION, array(
+        "relate"        => "relation.php?_action=new&amp;photo_id_1=" . $photo->getId() .
+                           "&amp;photo_id_2=",
+        "return"        => "_return=photo.php&amp;_qs=" . $encoded_qs
+    ), $photo);
+} catch (PhotoNoSelectionException $e) {
+    $selection=null;
 }
-?>
-  <h1>
-<?php
-  echo create_actionlinks($actionlinks);
-  echo $title_bar;
-?>
-  </h1>
-<?php
-  require_once "selection.inc.php";
-?>
 
-<div class="main">
-    <?= template::showJSwarning() ?>
-<form action="photo.php" method="POST">
-<input type="hidden" name="_action" value="<?php echo $action ?>">
-<input type="hidden" name="_qs" value="<?php echo $return_qs ?>">
-<?php
 if ($action == "insert") {
     unset($actionlinks["email"]);
     unset($actionlinks["edit"]);
     unset($actionlinks["add comment"]);
     unset($actionlinks["select"]);
     unset($actionlinks["delete"]);
-    ?>
-      <label for="filename"><?php echo translate("file name") ?></label>
-      <?php echo create_text_input("name", $photo->get("name"), 40, 64) ?>
-      <span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-    <?php
-} else {
-    ?>
-    <input type="hidden" name="photo_id" value="<?php echo $photo->get("photo_id") ?>">
-    <?php
-    if (conf::get("rotate.enable") && ($user->isAdmin() || $permissions->get("writable"))) {
-        ?>
-        <div class="rotate">
-        <?php echo translate("rotate", 0) ?>
-
-            <select name="_deg">
-                <option>&nbsp;</option>
-                <option>90</option>
-                <option>180</option>
-                <option>270</option>
-            </select>
-
-            <br>
-        <?php echo translate("recreate thumbnails", 0) ?>
-
-            <input type="radio" name="_thumbnail" value="1">
-        <?php echo translate("yes") ?>
-
-            <input type="radio" name="_thumbnail" value="0" checked>
-        <?php echo translate("no") ?>
-        </div>
-        <?php
-    }
-    ?>
-
-        <div class="prev"><?php echo $prev_link ? "[ $prev_link ]" : "&nbsp;" ?></div>
-        <div class="photohdr">
-    <?php echo $photo->getFullsizeLink($photo->get("name")) ?> :
-    <?php echo $photo->get("width") ?> x <?php echo $photo->get("height") ?>,
-    <?php echo $photo->get("size") ?> <?php echo translate("bytes") ?>
-        </div>
-        <div class="next"><?php echo $next_link ? "[ $next_link ]" : "&nbsp;" ?></div>
-
-    <ul class="tabs">
-    <?php
-    if (conf::get("share.enable") && ($user->isAdmin() || $user->get("allow_share"))) {
-        $hash=$photo->getHash();
-        $full_hash=sha1(conf::get("share.salt.full") . $hash);
-        $mid_hash=sha1(conf::get("share.salt.mid") . $hash);
-        $full_link=getZophURL() . "image.php?hash=" . $full_hash;
-        $mid_link=getZophURL() . "image.php?hash=" . $mid_hash;
+}
 
-        $tpl_share=new template("photo_share", array(
-            "hash" => $hash,
-            "full_link" => $full_link,
-            "mid_link" => $mid_link
-        ));
-        echo $tpl_share;
-    }
-    ?>
-    </ul>
+$rotate=conf::get("rotate.enable") && ($user->isAdmin() || $permissions->get("writable"));
 
-    <?php echo $photo->getFullsizeLink($photo->getImageTag(MID_PREFIX)) ?>
-    <?php
-}
-?>
-<input class="updatebutton" type="submit" value="<?php echo translate($action, 0) ?>">
-<label for="title"><?php echo translate("title") ?></label>
-<?php echo create_text_input("title", $photo->get("title"), 40, 64) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-<label for="_location_id"><?php echo translate("location") ?></label>
-<?php echo place::createPulldown("location_id", $photo->get("location_id")); ?>
-<br>
-<fieldset class="map">
-    <legend><?php echo translate("map") ?></legend>
-    <label for="lat"><?php echo translate("latitude") ?></label>
-    <?php echo create_text_input("lat", $photo->get("lat"), 10, 10) ?><br>
-    <label for="lat"><?php echo translate("longitude") ?></label>
-    <?php echo create_text_input("lon", $photo->get("lon"), 10, 10) ?><br>
-    <label for="mapzoom"><?php echo translate("zoom level") ?></label>
-    <?php echo place::createZoomPulldown($photo->get("mapzoom")) ?><br>
-</fieldset>
-<label for="date"><?php echo translate("date") ?></label>
-<?php echo create_text_input("date", $photo->get("date"), 12, 10, "date") ?>
-<span class="inputhint">YYYY-MM-DD</span><br>
-<label for="time"><?php echo translate("time") ?></label>
-<?php echo create_text_input("time", $photo->get("time"), 10, 8, "time") ?>
-<span class="inputhint">HH:MM:SS</span><br>
-<label for="time_corr"><?php echo translate("time correction") ?></label>
-<?php echo create_text_input("time_corr", $photo->get("time_corr"), 10, 8) ?>
-<span class="inputhint"><?php echo translate("in minutes") ?></span><br>
-<label for="view"><?php echo translate("view") ?></label>
-<?php echo create_text_input("view", $photo->get("view"), 40, 64) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-<label for="_photographer_id"><?php echo translate("photographer") ?></label>
-<?php
-echo photographer::createPulldown("photographer_id", $photo->get("photographer_id"));
-?>
-<br>
-<?php
-if ($user->isAdmin()) {
-    ?>
-    <label for="level"><?php echo translate("level") ?></label>
-    <?php echo create_text_input("level", $photo->get("level"), 4, 2) ?>
-    <span class="inputhint">1 - 10</span><br>
-    <?php
-}
-?>
-<label><?php echo translate("description") ?></label>
-<textarea name="description" cols="60" rows="4">
-  <?php echo $photo->get("description") ?>
-</textarea><br>
-<?php
-if ($action != "insert") {
-    ?>
-    <label for="person_id[0]"><?php echo translate("people") ?><br>
-        <span class="inputhint"><?php echo translate("(left to right, front to back).") ?></span>
-    </label>
-    <fieldset class="multiple">
-    <?php
-    $people = $photo->getPeople();
-    if ($people) {
-        foreach ($people as $person) {
-            ?>
-            <input class="remove" type="checkbox" name="_remove_person_id[]"
-                value="<?php echo $person->get("person_id")?>">
-            <?php
-            echo $person->getLink() . "<br>\n";
-        }
-    } else {
-        ?>
-        <?php echo translate("No people have been added to this photo.") ?><br>
-        <?php
-    }
-    echo person::createPulldown("_person_id[0]");
-    ?>
-    </fieldset>
-    <label for="albums"><?php echo translate("albums") ?></label>
-    <fieldset class="albums multiple">
-    <?php
-    $albums = $photo->getAlbums($user);
-    if ($albums) {
-        foreach ($albums as $album) {
-            ?>
-            <input type="checkbox" name="_remove_album_id[]"
-                value="<?php echo $album->get("album_id")?>">
-            <?php echo $album->getLink() ?><br>
-            <?php
-        }
-    } else {
-        echo translate("This photo is not in any albums.");
-        echo "<br>\n";
-    }
-    echo album::createPulldown("_album_id[0]");
-    ?>
-    </fieldset>
-    <label for="categories"><?php echo translate("categories") ?></label>
-    <fieldset class="categories multiple">
-    <?php
-    $categories = $photo->getCategories($user);
-    if ($categories) {
-        foreach ($categories as $category) {
-            ?>
-            <input type="checkbox" name="_remove_category_id[]"
-                value="<?php echo $category->get("category_id")?>">
-            <?php echo $category->getLink() ?><br>
-            <?php
-        }
-    } else {
-        ?>
-        <?php echo translate("This photo is not in any categories.") ?><br>
-        <?php
-    }
-    echo category::createPulldown("_category_id[0]", "");
-    ?>
-    </fieldset>
-    <br>
-    <?php
-    $_show = getvar("_show");
-    if ($_show) {
-        ?>
-        <hr>
-        <label for="path"><?php echo translate("path") ?></label>
-        <?php echo create_text_input("path", $photo->get("path"), 40, 64) ?>
-        <span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-        <label for="width"><?php echo translate("width") ?></label>
-        <?php echo create_text_input("width", $photo->get("width"), 6, 6) ?><br>
-        <label for="height"><?php echo translate("height") ?></label>
-        <?php echo create_text_input("height", $photo->get("height"), 6, 6) ?><br>
-        <label for="camera_make"><?php echo translate("camera make") ?></label>
-        <?php echo create_text_input("camera_make", $photo->get("camera_make"), 32, 32) ?><br>
-        <label for="camera_model"><?php echo translate("camera model") ?></label>
-        <?php echo create_text_input("camera_model", $photo->get("camera_model"), 32, 32) ?><br>
-        <label for="flash_used"><?php echo translate("flash used") ?></label>
-        <?php echo template::createPulldown("flash_used", $photo->get("flash_used"),
-            array("" => "", "Y" => translate("Yes",0), "N" => translate("No",0))) ?><br>
-        <label for="focal_length"><?php echo translate("focal length") ?></label>
-        <?php echo create_text_input("focal_length", $photo->get("focal_length"), 10, 64) ?><br>
-        <label for="exposure"><?php echo translate("exposure") ?></label>
-        <?php echo create_text_input("exposure", $photo->get("exposure"), 32, 64) ?><br>
-        <label for="aperture"><?php echo translate("aperture") ?></label>
-        <?php echo create_text_input("aperture", $photo->get("aperture"), 8, 16) ?><br>
-        <label for="compression"><?php echo translate("compression") ?></label>
-        <?php echo create_text_input("compression", $photo->get("compression"), 32, 64) ?><br>
-        <label for="iso_equiv"><?php echo translate("iso equiv") ?></label>
-        <?php echo create_text_input("iso_equiv", $photo->get("iso_equiv"), 8, 8) ?><br>
-        <label for="metering_mode"><?php echo translate("metering mode") ?></label>
-        <?php echo create_text_input("metering_mode", $photo->get("metering_mode"), 16, 16) ?><br>
-        <label for="focus_distance"><?php echo translate("focus distance") ?></label>
-        <?php echo create_text_input("focus_dist", $photo->get("focus_dist"), 16, 16) ?><br>
-        <label for="ccd_width"><?php echo translate("ccd width") ?></label>
-        <?php echo create_text_input("ccd_width", $photo->get("ccd_width"), 16, 16) ?><br>
-        <label for="comment"><?php echo translate("comment") ?></label>
-        <?php echo create_text_input("comment", $photo->get("comment"), 40, 128) ?></br>
-        <?php
-    } // additional atts
-    if (!$_show) {
-        ?>
-        <a href="photo.php?_action=edit&amp;photo_id=<?php
-            echo $photo->get("photo_id") ?>&amp;_show=all">
-          <?php echo translate("show additional attributes") ?>
-        </a>
-        <?php
-    }
-    ?>
-    <br>
-    <input type="submit" value="<?php echo translate($action, 0) ?>">
-    <?php
+$full=$photo->getFullsizeLink($photo->get("name"));
+$width=$photo->get("width");
+$height=$photo->get("height");
+$size=template::getHumanReadableBytes($photo->get("size"));
+if (conf::get("share.enable") && ($user->isAdmin() || $user->get("allow_share"))) {
+    $hash=$photo->getHash();
+    $full_hash=sha1(conf::get("share.salt.full") . $hash);
+    $mid_hash=sha1(conf::get("share.salt.mid") . $hash);
+    $full_link=getZophURL() . "image.php?hash=" . $full_hash;
+    $mid_link=getZophURL() . "image.php?hash=" . $mid_hash;
+
+    $share=new template("photo_share", array(
+        "hash" => $hash,
+        "full_link" => $full_link,
+        "mid_link" => $mid_link
+    ));
+} else {
+    $share=null;
 }
-?>
-</form>
 
+$tpl=new template("editPhoto", array(
+    "photo"             => $photo,
+    "title"             => $title,
+    "selection"         => $selection,
+    "admin"             => (bool) $user->isAdmin(),
+    "action"            => $action,
+    "actionlinks"       => $actionlinks,
+    "return_qs"         => $return_qs,
+    "rotate"            => $rotate,
+    "prev"              => $prev_link,
+    "next"              => $next_link,
+    "full"              => $full,
+    "width"             => $width,
+    "height"            => $height,
+    "size"              => $size,
+    "share"             => $share,
+    "image"             => $photo->getFullsizeLink($photo->getImageTag(MID_PREFIX)),
+    "people"            => $photo->getPeople(),
+    "albums"            => $photo->getAlbums($user),
+    "categories"        => $photo->getCategories($user),
+    "locPulldown"       => place::createPulldown("location_id", $photo->get("location_id")),
+    "pgPulldown"        => photographer::createPulldown("photographer_id", $photo->get("photographer_id")),
+    "personPulldown"    => person::createPulldown("_person_id[0]"),
+    "albumPulldown"     => album::createPulldown("_album_id[0]"),
+    "catPulldown"       => category::createPulldown("_category_id[0]", ""),
+    "zoomPulldown"      => place::createZoomPulldown($photo->get("mapzoom")),
+    "show"              => getvar("_show")
+));
+echo $tpl;
diff -pruN 0.9.4-4/php/edit_photos.php 0.9.8-1/php/edit_photos.php
--- 0.9.4-4/php/edit_photos.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/edit_photos.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,6 +21,15 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+use conf\conf;
+
+use photo\collection;
+
+use template\template;
+use template\pager;
+
+use web\request;
+
 require_once "include.inc.php";
 
 $_cols = (int) getvar("_cols");
@@ -44,7 +53,7 @@ $cells = $_cols * $_rows;
 $offset = $_off;
 
 $thumbnails;
-$clean_vars = clean_request_vars($request_vars);
+$clean_vars=$request->getRequestVarsClean();
 
 $_qs=getvar("_qs");
 
@@ -62,21 +71,21 @@ if (empty($qs)) {
 
 $actionlinks["return"]="photos.php?" .  $qs;
 
+$photoCollection = collection::createFromRequest(request::create());
+$toDisplay = $photoCollection->subset($offset, $cells);
 
-$num_photos =
-    get_photos($clean_vars, $offset, $cells, $thumbnails, $user);
+$photoCount=sizeof($photoCollection);
+$displayCount=sizeof($toDisplay);
 
-$num_thumbnails = sizeof($thumbnails);
+if  ($displayCount) {
+    $pageCount = ceil($photoCount / $cells);
+    $currentPage = floor($offset / $cells) + 1;
 
-if  ($num_thumbnails) {
-    $num_pages = ceil($num_photos / $cells);
-    $page_num = floor($offset / $cells) + 1;
+    $num = min($cells, $displayCount);
 
-    $num = min($cells, $num_thumbnails);
-
-    $title = sprintf(translate("Edit Photos (Page %s/%s)", 0), $page_num, $num_pages);
+    $title = sprintf(translate("Edit Photos (Page %s/%s)", 0), $currentPage, $pageCount);
     $title_bar = sprintf(translate("edit photos %s to %s of %s"),
-        ($offset + 1), ($offset + $num), $num_photos);
+        ($offset + 1), ($offset + $num), $photoCount);
 } else {
     $title = translate("No Photos Found");
     $title_bar = translate("edit photos");
@@ -94,7 +103,7 @@ echo $title_bar
     <form action="edit_photos.php" method="POST">
       <p>
 <?php
-if ($num_thumbnails <= 0) {
+if ($displayCount <= 0) {
     ?>
        <div class="error">
     <?php echo translate("No photos were found matching your search criteria.") ?>
@@ -168,9 +177,9 @@ if ($num_thumbnails <= 0) {
     unset($request_vars["___photographer_id__all"]);
     unset($request_vars["__album__all"]);
     unset($request_vars["__category__all"]);
-    for ($i = 0; $i < $num_thumbnails; $i++) {
-        $photo_id = $thumbnails[$i]->get('photo_id');
-        $photo = new photo($photo_id);
+    foreach ($toDisplay as $photo) {
+        $photo->lookup();
+        $photo_id = $photo->getId();
 
         unset($request_vars["___location_id__" . $photo_id]);
         unset($request_vars["___photographer_id__" . $photo_id]);
@@ -228,15 +237,18 @@ if ($num_thumbnails <= 0) {
 
             $photo->updateRelations($request_vars, '__all');
 
-            $deg = $request_vars["_deg__$photo_id"];
-            if ($deg && $deg != 0) {
-                $photo->lookup();
-                try {
-                    $photo->rotate($deg);
-                } catch (Exception $e) {
-                    echo $e->getMessage();
-                    die;
-                }
+            if ($can_edit && conf::get("rotate.enable") &&
+                ($user->isAdmin() || $permissions->get("writable"))) {
+                    $deg = $request_vars["_deg__$photo_id"];
+                    if ($deg && $deg != 0) {
+                        $photo->lookup();
+                        try {
+                            $photo->rotate($deg);
+                        } catch (Exception $e) {
+                            echo $e->getMessage();
+                            die;
+                        }
+                    }
             }
         } else if ($can_edit && $action == 'delete') {
             $photo->delete();
@@ -444,12 +456,12 @@ if ($num_thumbnails <= 0) {
     <?php
     // Here we clean out $request_vars, so the pager links will not contain
     // all the edits made on this page.
-    while (list($key, $val) = each($clean_vars)) {
+    foreach ($clean_vars as $key => $value) {
         if (in_array($key, $queryIgnoreArray)) { continue; }
         $pager_vars[$key] = $val;
     }
     $request_vars = $pager_vars;
-    echo new pager($offset, $num_photos, $num_pages, $cells,
+    echo new pager($offset, $photoCount, $pageCount, $cells,
         $user->prefs->get("max_pager_size"), $request_vars, "_off");
 } // if photos
 ?>
diff -pruN 0.9.4-4/php/edit_place.inc.php 0.9.8-1/php/edit_place.inc.php
--- 0.9.4-4/php/edit_place.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/edit_place.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -1,6 +1,6 @@
 <?php
 /**
- * Edit places
+ * Edit places.
  *
  * This file is part of Zoph.
  *
@@ -21,115 +21,111 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
-?>
-<!-- begin edit_place.inc !-->
-    <h1>
-      <ul class="actionlink">
-        <li><a href="places.php"><?php echo translate("return") ?></a></li>
-        <li><a href="place.php?_action=new"><?php echo translate("new") ?></a></li>
-      </ul>
-      <?php echo translate($_action) ?> <?php echo translate("place") ?>
-    </h1>
-    <div class="main">
-        <?= template::showJSwarning() ?>
-      <form action="place.php" method="GET">
-        <input type="hidden" name="_action" value="<?php echo $action ?>">
-        <input type="hidden" name="place_id" value="<?php echo $place->get("place_id") ?>">
-        <label for="title"><?php echo translate("title") ?></label>
-        <?php echo create_text_input("title", $place->get("title"), 40, 64) ?>
-        <span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-        <label for="parent_place_id"><?php echo translate("parent location") ?></label>
-<?php
+
+/** @todo get rid of difference between "action" and "_action" */
+
+use conf\conf;
+
+use template\block;
+use template\fieldset;
+use template\form;
+use template\template;
+
+$actionlinks=array(
+    "return"    => "places.php",
+    "new"       => "place.php?_action=new"
+);
+
+$tpl=new template("edit", array(
+    "title"         => translate($_action) . " " . translate("place"),
+    "actionlinks"   => $actionlinks
+));
+
+$tpl->addBlock(template::showJSwarning());
+
 if ($place->isRoot()) {
-    echo translate("places");
+    $parentPlace=translate("places");
 } else {
-    echo place::createPulldown("parent_place_id", $place->get("parent_place_id"));
+    $parentPlace=place::createPulldown("parent_place_id", $place->get("parent_place_id"));
 }
-?>
-<br>
-<label for="address"><?php echo translate("address") ?></label>
-<?php echo create_text_input("address", $place->get("address"), 40, 40) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-<label for="address2"><?php echo translate("address continued") ?></label>
-<?php echo create_text_input("address2", $place->get("address2"), 40, 40) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "64") ?></span><br>
-<label for="city"><?php echo translate("city") ?></label>
-<?php echo create_text_input("city", $place->get("city"), 32, 32) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "32") ?></span><br>
-<label for="state"><?php echo translate("state") ?></label>
-<?php echo create_text_input("state", $place->get("state"), 16, 32) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "32") ?></span><br>
-<label for="zip"><?php echo translate("zip") ?></label>
-<?php echo create_text_input("zip", $place->get("zip"), 10, 10) ?>
-<span class="inputhint"><?php echo translate("zip or zip+4") ?></span><br>
-<label for="country"><?php echo translate("country") ?></label>
-<?php echo create_text_input("country", $place->get("country"), 32, 32) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "32") ?></span><br>
-<label for="url"><?php echo translate("url") ?></label>
-<?php echo create_text_input("url", $place->get("url"), 32, 1024) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "1024") ?></span><br>
-<label for="urldesc"><?php echo translate("url description") ?></label>
-
-<?php echo create_text_input("urldesc", $place->get("urldesc"), 32, 32) ?>
-<span class="inputhint"><?php echo sprintf(translate("%s chars max"), "32") ?></span><br>
-<label for="pageset"><?php echo translate("pageset") ?></label>
-<?php echo template::createPulldown("pageset", $place->get("pageset"),
-    template::createSelectArray(pageset::getRecords("title"), array("title"), true)) ?><br>
-<fieldset class="map">
-  <legend><?php echo translate("map") ?></legend>
-  <label for="lat"><?php echo translate("latitude") ?></label>
-  <?php echo create_text_input("lat", $place->get("lat"), 10, 10) ?><br>
-  <label for="lat"><?php echo translate("longitude") ?></label>
-  <?php echo create_text_input("lon", $place->get("lon"), 10, 10) ?><br>
-  <label for="mapzoom"><?php echo translate("zoom level") ?></label>
-  <?php echo place::createZoomPulldown($place->get("mapzoom")) ?><br>
-  <?php if (conf::get("maps.geocode")): ?>
-    <div class="geocode">
-      <input id="geocode" class="geocode" type="button"
-        value="<?php echo translate("search", false) ?>">
-      <div id="geocoderesults"></div>
-      <script type="text/javascript">
-        var translate={
-          "An error occurred": "<?php echo trim(translate("An error occurred.", false)); ?>",
-          "Nothing found": "<?php echo trim(translate("Nothing found", false)); ?>"
-        };
-        zGeocode.checkGeocode();
-      </script>
-    </div>
-  <?php endif; ?>
-</fieldset>
-<?php
+
+$form=new form("form", array(
+    "formAction"    => "place.php",
+    "onsubmit"      => null,
+    "action"        => $action,
+    "submit"        => translate($action, 0)
+));
+
+$form->addInputHidden("place_id", $place->getId());
+$form->addInputText("title", $place->get("title"), translate("title"),
+    sprintf(translate("%s chars max"), "64"), 64, 40);
+
+if (!$place->isRoot()) {
+    $parentPlace=place::createPulldown("parent_place_id", $place->get("parent_place_id"));
+    $form->addPulldown("parent_place_id", $parentPlace, translate("parent location"));
+}
+
+$form->addInputText("address", $place->get("address"), translate("address"),
+    sprintf(translate("%s chars max"), "64"), 64, 40);
+$form->addInputText("address2", $place->get("address2"), translate("address continued"),
+    sprintf(translate("%s chars max"), "64"), 64, 40);
+$form->addInputText("city", $place->get("city"), translate("city"),
+    sprintf(translate("%s chars max"), "32"), 32);
+$form->addInputText("state", $place->get("state"), translate("state"),
+    sprintf(translate("%s chars max"), "32"), 32, 16);
+$form->addInputText("zip", $place->get("zip"), translate("zip"),
+    translate("zip or zip+4"), 10);
+$form->addInputText("country", $place->get("country"), translate("country"),
+    sprintf(translate("%s chars max"), "32"), 32);
+$form->addInputText("url", $place->get("url"), translate("url"),
+    sprintf(translate("%s chars max"), "1024"), 1024, 32);
+$form->addInputText("urldesc", $place->get("urldesc"), translate("urldesc"),
+    sprintf(translate("%s chars max"), "32"), 32);
+
+$pageset=template::createPulldown("pageset", $place->get("pageset"),
+    template::createSelectArray(pageset::getRecords("title"), array("title"), true));
+$form->addPulldown("pageset", $pageset, translate("pageset"));
+
+$fieldset=new fieldset("formFieldset", array(
+    "class"     => "map",
+    "legend"    => translate("map")
+));
+
+$fieldset->addInputText("lat", $place->get("lat"), translate("latitude"), null, 10);
+$fieldset->addInputText("lon", $place->get("lon"), translate("longitude"), null, 10);
+$mapzoom=place::createZoomPulldown($place->get("mapzoom"));
+$fieldset->addPulldown("mapzoom", $mapzoom, translate("zoom level"));
+
+if (conf::get("maps.geocode")) {
+    $fieldset->addBlock(new block("geocode"));
+}
+
+$form->addBlock($fieldset);
+
+$tzActionlinks=array();
 if (conf::get("date.guesstz")) {
     $tz=e($place->guessTZ());
     if (!empty($tz)) {
-        ?>
-        <ul class="actionlink">
-          <li>
-            <a href="place.php?_action=update&place_id=<?php
-                echo (int) $place->getId() ?>&timezone=<?php echo $tz ?>">
-              <?php echo $tz ?>
-            </a>
-          </li>
-        </ul>
-        <?php
+        $tzActionlinks[$tz] = "place.php?_action=update&place_id=" . $place->getId() . "&timezone=" . $tz;
     }
 }
+
 if ($place->get("timezone")) {
-    ?>
-    <ul class="actionlink">
-      <li><a href="place.php?_action=settzchildren&place_id=<?php echo $place->get("place_id") ?>">
-        <?php printf(translate("set %s for children"), $place->get("timezone"))?>
-      </a></li>
-    </ul>
-    <?php
-}
-?>
-
-    <label for="timezone_id"><?php echo translate("timezone") ?></label>
-    <?php echo TimeZone::createPulldown("timezone_id", $place->get("timezone")); ?>
-
-    <label for="notes"><?php echo translate("notes") ?></label>
-    <textarea name="notes" cols="40" rows="4"><?php echo $place->get("notes") ?></textarea>
-    <input type="submit" value="<?php echo translate($action, 0) ?>">
-  </form>
-<!-- end edit_place.inc !-->
+    $tzActionlinks[sprintf(translate("set %s for children"), $place->get("timezone"))] =
+        "place.php?_action=settzchildren&place_id=" . $place->getId();
+}
+
+if (!empty($tzActionlinks)) {
+    $form->addBlock(new block("actionlinks", array(
+        "actionlinks" => $tzActionlinks
+    )));
+}
+
+$timezone=TimeZone::createPulldown("timezone_id", $place->get("timezone"));
+$form->addPulldown("timezone_id", $timezone, translate("timezone"));
+
+$form->addTextarea("notes", $place->get("notes"), translate("notes"), 40, 4);
+
+$tpl->addBlock($form);
+
+echo $tpl;
diff -pruN 0.9.4-4/php/exception.inc.php 0.9.8-1/php/exception.inc.php
--- 0.9.4-4/php/exception.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/exception.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -99,6 +99,20 @@ class CategoryException extends Organize
 class CategoryNotFoundException extends CategoryException {}
 
 /**
+ * Exceptions for data errors
+ * @author Jeroen Roos
+ * @package ZophException
+ */
+class DataException extends ZophException {}
+
+/**
+ * A value that may not be NULL is NULL
+ * @author Jeroen Roos
+ * @package ZophException
+ */
+class NotNullValueIsNullDataException extends DataException {}
+
+/**
  * Exceptions for pages and pagesets
  * @author Jeroen Roos
  * @package ZophException
@@ -344,6 +358,7 @@ class DatabaseException extends ZophExce
  */
 class SecurityException extends ZophException {}
 class KeyMustBeNumericSecurityException extends SecurityException {}
+class IllegalValueSecurityException extends SecurityException {}
 
 
 /**
diff -pruN 0.9.4-4/php/exif.inc.php 0.9.8-1/php/exif.inc.php
--- 0.9.4-4/php/exif.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/exif.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -31,7 +31,7 @@ function process_exif($image) {
     $mime=$file->getMime();
 
     if ($mime == "image/jpeg") {
-        $exif = read_exif_data($image);
+        $exif = exif_read_data($image);
     } else {
         $exif = false;
     }
diff -pruN 0.9.4-4/php/getxmldata.php 0.9.8-1/php/getxmldata.php
--- 0.9.4-4/php/getxmldata.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/getxmldata.php	2018-03-02 20:49:58.000000000 +0000
@@ -1,6 +1,8 @@
 <?php
-
-/* This file is part of Zoph.
+/**
+ * Retrieve data in XML format
+ *
+ * This file is part of Zoph.
  *
  * Zoph is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -14,6 +16,9 @@
  * You should have received a copy of the GNU General Public License
  * along with Zoph; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
  */
 
 require_once "include.inc.php";
@@ -38,9 +43,9 @@ if ($obj_array[0]=="details") {
     } else if ($object=="timezone") {
         $object="TimeZone";
     } else if ($object=="import_progress") {
-        $object="WebImport";
+        $object="import\web";
     } else if ($object=="import_thumbs") {
-        $object="WebImport";
+        $object="import\web";
         $search="thumbs";
     }
 
diff -pruN 0.9.4-4/php/group.php 0.9.8-1/php/group.php
--- 0.9.4-4/php/group.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/group.php	2018-03-02 20:49:58.000000000 +0000
@@ -1,5 +1,8 @@
 <?php
-/* This file is part of Zoph.
+/**
+ * Define and modify a group of users
+ *
+ * This file is part of Zoph.
  *
  * Zoph is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -13,99 +16,41 @@
  * You should have received a copy of the GNU General Public License
  * along with Zoph; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @package Zoph
+ * @author Jeroen Roos
  */
 
-require_once "include.inc.php";
+use conf\conf;
 
-if (!$user->isAdmin()) {
-    redirect("zoph.php");
-}
+use group\controller as groupController;
 
-$group_id = getvar("group_id");
-$album_id_new = getvar("album_id_new");
+use template\block;
+use template\form;
+use template\template;
 
-$group = new group($group_id);
+use web\request;
 
-if ($_action == "update_albums") {
-    // Check if the "Grant access to all albums" checkbox is ticked
-    $_access_level_all_checkbox = getvar("_access_level_all_checkbox");
-
-    if ($_access_level_all_checkbox) {
-        $albums = album::getAll();
-        foreach ($albums as $alb) {
-            $permissions = new permissions($group_id, $alb->get("album_id"));
-            $permissions->setFields($request_vars, "", "_all");
-            if (!conf::get("watermark.enable")) {
-                $permissions->set("watermark_level", 0);
-            }
-            $permissions->insert();
-        }
-    }
 
-    $albums = $group->getAlbums();
-    foreach ($albums as $album) {
-        $album->lookup();
-        $id=$album->getId();
-        $name=$album->getName();
-
-        if (isset($request_vars["_remove_permission_album__$id"])) {
-            $remove_permission_album = $request_vars["_remove_permission_album__$id"];
-            // first check if album needs to be revoked
-            if ($remove_permission_album) {
-                $permissions = new permissions($group_id, $id);
-                $permissions->delete();
-            }
-        } else {
-            $permissions = new permissions();
-            $permissions->setFields($request_vars, "", "__$id");
-            $permissions->update();
-        }
-    }
-    // Check if new album should be added
-    if ($album_id_new) {
-        $permissions = new permissions();
-        $permissions->setFields($request_vars,"","_new");
-        if (!conf::get("watermark.enable")) {
-            $permissions->set("watermark_level", 0);
-        }
-        $permissions->insert();
-    }
-
-    $action = "update";
-} else if ($_action=="update") {
-    $group->setFields($request_vars);
-    if (isset($request_vars["_member"]) && ((int) $request_vars["_member"] > 0)) {
-        $group->addMember(new user((int) $request_vars["_member"]));
-    }
+require_once "include.inc.php";
 
-    if (is_array(getvar("_removeMember"))) {
-        foreach (getvar("_removeMember") as $user_id) {
-            $group->removeMember(new user((int) $user_id));
-        }
-    }
-    $group->update();
-    $action = "update";
-} else {
-    $obj = &$group;
-    $redirect = "groups.php";
-    require_once "actions.inc.php";
+if (!user::getCurrent()->isAdmin()) {
+    redirect("zoph.php");
 }
 
-// edit after insert to add album permissions
-if ($_action == "insert") {
-    $action = "update";
-}
+$controller = new groupController(request::create());
 
-if ($action != "insert") {
-    $group->lookup();
-    $title = $group->get("group_name");
-} else {
+$group=$controller->getObject();
+
+if ($controller->getView() == "insert") {
     $title = translate("New Group");
+} else {
+    $title = $group->get("group_name");
 }
 
 require_once "header.inc.php";
 
-if ($action == "display") {
+if ($controller->getView() == "display") {
     $actionlinks=array(
         "edit"      => "group.php?_action=edit&amp;group_id=" . $group->getId(),
         "delete"    => "group.php?_action=delete&amp;group_id=" . $group->getId(),
@@ -117,11 +62,12 @@ if ($action == "display") {
         "title"         => $title,
         "actionlinks"   => $actionlinks,
         "obj"           => $group,
+        "view"          => "album",
         "fields"        => $group->getDisplayArray(),
         "watermark"     => conf::get("watermark.enable"),
         "permissions"   => $group->getPermissionArray()
     ));
-} else if ($action == "confirm") {
+} else if ($controller->getView() == "confirm") {
     $actionlinks=array(
         translate("delete") => "group.php?_action=confirm&amp;group_id=" . $group->getId(),
         translate("cancel") => "group.php?_action=display&amp;group_id=" . $group->getId(),
@@ -132,6 +78,8 @@ if ($action == "display") {
         "mainActionlinks"   => $actionlinks,
         "obj"               => $group
     ));
+} else if ($controller->getView() == "redirect") {
+    redirect($controller->redirect);
 } else {
     $actionlinks=array(
         translate("return") => "group.php?group_id=" . $group->getId(),
@@ -148,7 +96,7 @@ if ($action == "display") {
     $form=new form("form", array(
         "formAction"        => "group.php",
         "onsubmit"          => null,
-        "action"            => $action,
+        "action"            => $controller->getView(),
         "submit"            => translate("submit")
     ));
 
@@ -160,7 +108,7 @@ if ($action == "display") {
     $form->addInputText("description", $group->get("description"),
         translate("description"), sprintf(translate("%s chars max"), 128), 128, 32);
 
-    if ($action!="insert") {
+    if ($controller->getView()!="insert") {
         $curMembers=$group->getMembers();
         $members=new block("members", array(
             "members"   => $curMembers,
@@ -171,51 +119,14 @@ if ($action == "display") {
 
     $tpl->addBlock($form);
 
-    if ($action == "insert") {
+    if ($controller->getView() == "insert") {
         $tpl->addBlock(new block("message", array(
             "class" => "info",
             "text" => translate("After this group is created it can be given access to albums."
         ))));
     } else {
-        $accessLevelAll=new block("formInputText", array(
-            "label" => null,
-            "name"  => "access_level_all",
-            "size"  => 4,
-            "maxlength"  => 2,
-            "value" => "5"
-        ));
-        $wmLevelAll=new block("formInputText", array(
-            "label" => null,
-            "name"  => "watermark_level_all",
-            "size"  => 4,
-            "maxlength"  => 2,
-            "value" => "5"
-        ));
-        $accessLevelNew=new block("formInputText", array(
-            "label" => null,
-            "name"  => "access_level_new",
-            "size"  => 4,
-            "maxlength"  => 2,
-            "value" => "5"
-        ));
-        $wmLevelNew=new block("formInputText", array(
-            "label" => null,
-            "name"  => "watermark_level_new",
-            "size"  => 4,
-            "maxlength"  => 2,
-            "value" => "5"
-        ));
-
-        $gp = new block("editGroupPermissions", array(
-            "watermark"         => conf::get("watermark.enable"),
-            "group_id"          => $group->getId(),
-            "accessLevelAll"    => $accessLevelAll,
-            "wmLevelAll"        => $wmLevelAll,
-            "accessLevelNew"    => $accessLevelNew,
-            "wmLevelNew"        => $wmLevelNew,
-            "permissions"       => $group->getPermissionArray()
-        ));
-        $tpl->addBlock($gp);
+        $view=new permissions\view\edit($group);
+        $tpl->addBlock($view->view());
     }
 }
 echo $tpl;
diff -pruN 0.9.4-4/php/header.inc.php 0.9.8-1/php/header.inc.php
--- 0.9.4-4/php/header.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/header.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -22,6 +22,11 @@
  * @author Jeroen Roos
  */
 
+use conf\conf;
+
+use template\block;
+use template\template;
+
 header("Content-Type: text/html; charset=utf-8");
 $user=user::getCurrent();
 
@@ -63,17 +68,18 @@ if (conf::get("interface.autocomplete"))
 }
 
 if (conf::get("maps.provider")) {
-    $scripts[]="js/mxn/mxn.js?(" . conf::get("maps.provider") .")";
+    $scripts[]="js/leaflet-src.js";
     $scripts[]="js/maps.js";
-    $scripts[]="js/custommaps.js";
+    if (conf::get("maps.provider") == "googlev3") {
+        $scripts[]="https://maps.googleapis.com/maps/api/js";
+        $scripts[]="js/leaflet/GoogleMutant.js";
+    } else if (conf::get("maps.provider") == "mapbox") {
+        $javascript[]="var mapbox_api_key = '" . conf::get("maps.mapbox.apikey") . "';";
+    }
+
     if (conf::get("maps.geocode")) {
         $scripts[]="js/geocode.js";
     }
-    switch (strtolower(conf::get("maps.provider"))) {
-    case "googlev3":
-        $scripts[]="https://maps.google.com/maps/api/js?sensor=false";
-        break;
-    }
 }
 
 $html_title=conf::get("interface.title");
@@ -86,13 +92,23 @@ if (isset($title)) {
 <html>
 
 <?php
-$tpl=new block("header", array(
+$hdrParams=array(
     "icons"         => $icons,
     "scripts"       => $scripts,
     "javascript"    => $javascript,
     "extrastyle"    => isset($extrastyle) ? $extrastyle : null,
     "title"         => $html_title
-));
+);
+
+if (isset($prev_url)) {
+    $hdrParams["next"] = $prev_url;
+}
+if (isset($next_url)) {
+    $hdrParams["next"] = $next_url;
+}
+
+$tpl=new block("header", $hdrParams);
+
 
 echo $tpl;
 ?>
diff -pruN 0.9.4-4/php/image.php 0.9.8-1/php/image.php
--- 0.9.4-4/php/image.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/image.php	2018-03-02 20:49:58.000000000 +0000
@@ -20,7 +20,12 @@
  * @author Jeroen Roos
  * @author Alan Shutko
  */
+use conf\conf;
+use template\template;
+
 session_cache_limiter("public");
+require_once "autoload.inc.php";
+require_once "settings.inc.php";
 require_once "variables.inc.php";
 $hash = getvar("hash");
 $annotated = getvar('annotated');
@@ -60,8 +65,16 @@ if (($type=="import_thumb" || $type=="im
             $type="mid";
             $found = true;
         } catch(PhotoNotFoundException $e) {
-            /** @todo This should be changed into a nicer error display; */
-            die($e->getMessage());
+            header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found", true, 404);
+            $tpl=new template("error", array(
+                "title"   => "Not Found",
+                "message" => $e->getMessage()
+            ));
+            $tpl->addActionLinks(array(
+                "return" => "zoph.php"
+            ));
+            echo $tpl;
+            exit;
         }
     }
 } else if (conf::get("feature.annotate") && $annotated) {
@@ -109,8 +122,20 @@ if ($found) {
             $photo->lookup();
         }
     }
-
-    list($headers, $image)=$photo->display($type);
+    try {
+        list($headers, $image)=$photo->display($type);
+    } catch(PhotoNotFoundException $e) {
+        header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found", true, 404);
+        $tpl=new template("error", array(
+            "title"   => "Not Found",
+            "message" => $e->getMessage()
+        ));
+        $tpl->addActionLinks(array(
+            "return" => "zoph.php"
+        ));
+        echo $tpl;
+        exit;
+    }
 
     foreach ($headers as $label=>$value) {
         if ($label=="http_status") {
@@ -126,6 +151,7 @@ if ($found) {
     }
     exit;
 }
+header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found", true, 404);
 require_once "header.inc.php";
 ?>
   <h1>
diff -pruN 0.9.4-4/php/import.inc.php 0.9.8-1/php/import.inc.php
--- 0.9.4-4/php/import.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/import.inc.php	1970-01-01 00:00:00.000000000 +0000
@@ -1,216 +0,0 @@
-<?php
-/**
- * Class that holds all functions for importing and uploading
- *
- * Zoph is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * Zoph is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- * You should have received a copy of the GNU General Public License
- * along with Zoph; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-require_once "exif.inc.php";
-
-/**
- * This class holds the generalized functions importing images
- * to Zoph.
- *
- * @author Jeroen Roos
- * @package Zoph
- */
-abstract class Import {
-
-    /**
-     * Rotates a file based on the EXIF orientation flag
-     *
-     * Calls external program jhead for this.
-     * @param string Filename
-     */
-    protected static function autorotate($file) {
-        $cmd = "jhead -autorot " . escapeshellarg($file);
-        exec($cmd, $output, $return);
-        if ($return > 0) {
-            $msg=implode($output, "<br>");
-            throw new ImportAutorotException($msg);
-        }
-    }
-
-    /**
-     * Import photos
-     *
-     * Takes an array of files and an array of vars and imports them in Zoph
-     * @param Array Files to be imported
-     * @param  Array Vars to be applied to the photos.
-     */
-    public static function photos(Array $files, Array $vars) {
-        $photos=array();
-
-        $total=sizeof($files);
-        $cur=0;
-
-        if (isset($vars["_path"])) {
-            $path=file::cleanupPath("/" . $vars["_path"] . "/");
-            if (strpos($path, "..") !== false) {
-                log::msg("Illegal characters in path", log::FATAL, log::IMPORT);
-                die();
-            }
-        } else {
-            $path="";
-        }
-
-        foreach ($files as $file) {
-            static::progress($cur, $total);
-            $cur++;
-
-            if ($file instanceof photo) {
-                $photo=$file;
-                $file=$photo->file["orig"];
-            } else if ($file instanceof file) {
-                $photo=new photo();
-            }
-
-            $mime=$file->getMime();
-            if (conf::get("import.cli.exif")===true && $mime=="image/jpeg") {
-                $exif=process_exif ($file);
-                if ($exif) {
-                    $photo->setFields($exif);
-                }
-            }
-            if (isset($vars["rating"])) {
-                $rating=$vars["rating"];
-                if (!(is_numeric($rating) && (1 <= $rating) && ($rating <= 10))) {
-                    unset($rating);
-                }
-                unset($vars["rating"]);
-            }
-
-            if (isset($vars["field"]) && is_array($vars["_field"])) {
-                foreach ($vars["_field"] as $key => $field) {
-                    $vars[$field]=$vars["field"][$key];
-                }
-                unset($vars["_field"]);
-                unset($vars["field"]);
-            }
-
-            if ($vars) {
-                $photo->setFields($vars);
-            }
-
-            if (strlen(trim($photo->get("date")))==0) {
-                $date=date("Y-m-d", filemtime($file));
-                log::msg("Photo has no date set, using filedate (" . $date . ").",
-                    log::NOTIFY, log::IMPORT);
-                $photo->set("date", $date);
-            }
-
-            if (strlen(trim($photo->get("time")))==0) {
-                $time=date("H:i:s", filemtime($file));
-                log::msg("Photo has no time set, using time from filedate (" . $time . ").",
-                    log::NOTIFY, log::IMPORT);
-                $photo->set("time", $time);
-            }
-            if (isset($photo->_path)) {
-                $photo->set("path", $path . "/" . $photo->_path);
-                unset($photo->_path);
-            } else {
-                $photo->set("path", $path);
-            }
-
-            try {
-                $photo->import($file);
-            } catch (FileException $e) {
-                log::msg($e->getMessage(), log::FATAL);
-            }
-
-            if (conf::get("import.cli.thumbs")===true) {
-                try {
-                    $photo->thumbnail(false);
-                } catch (Exception $e) {
-                    echo $e->getMessage();
-                }
-            }
-
-            if ($photo->insert()) {
-                if (conf::get("import.cli.size")===true) {
-                    $photo->updateSize();
-                }
-                $photo->update();
-                $photo->updateRelations($vars, "_id");
-                if (isset($rating)) {
-                    $photo->rate($rating);
-                }
-                if (conf::get("import.cli.hash")===true) {
-                    try {
-                        $photo->getHash();
-                    } catch (Exception $e) {
-                        echo $e->getMessage();
-                    }
-                }
-                $photos[]=$photo;
-            } else {
-                echo translate("Insert failed.") . "<br>\n";
-            }
-        }
-        return $photos;
-    }
-
-    /**
-     * Import an XML file
-     *
-     * @param string MD5 hash of the filename to import
-     *
-     * This function tries to recognize the XML file by validating them against .xsd files
-     * For now only GPX (1.0 and 1.1) files are recognized.
-     */
-
-    public static function XMLimport(file $file) {
-        $xml=new DomDocument;
-        $xml->Load($file);
-
-        $schemas = array (
-            "gpx 1.0" => "xml/gpx10.xsd",
-            "gpx 1.1" => "xml/gpx11.xsd" );
-
-        foreach ($schemas as $name => $schema) {
-            if (@$xml->schemaValidate($schema)) {
-                echo basename($file) ." is a valid " . $name . " file";
-                $xmltype=$name;
-            }
-        }
-        if (!isset($xmltype)) {
-            throw ImportFileNotImportableException(basename($file) . " is not a known XML file.");
-        } else {
-            switch($name) {
-            case "gpx 1.0":
-            case "gpx 1.1":
-                $track=track::getFromGPX($file);
-                $track->insert();
-                $file->delete();
-                break;
-            }
-        }
-    }
-
-    /**
-     * Progress bar
-     * Does not display anything by default, but this function can be redefined
-     * in a child class.
-     *
-     * @param int current
-     * @param int total
-     */
-    public static function progress($cur, $total) {
-        return 0;
-    }
-
-}
-
diff -pruN 0.9.4-4/php/import.php 0.9.8-1/php/import.php
--- 0.9.4-4/php/import.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/import.php	2018-03-02 20:49:58.000000000 +0000
@@ -18,6 +18,10 @@
  * @author Jeroen Roos
  * @package Zoph
  */
+use conf\conf;
+
+use template\block;
+use template\template;
 
 require_once "include.inc.php";
 if ((!conf::get("import.enable")) || (!$user->isAdmin() && !$user->get("import"))) {
@@ -26,7 +30,7 @@ if ((!conf::get("import.enable")) || (!$
 
 // Detect upload larger than upload_max_filesize.
 if (isset($_GET["upload"]) && $_GET["upload"]==1 && $_POST==null) {
-    echo WebImport::handleUploadErrors(UPLOAD_ERR_INI_SIZE);
+    echo import\web::handleUploadErrors(UPLOAD_ERR_INI_SIZE);
     die();
 }
 $_action=getvar("_action");
@@ -116,11 +120,11 @@ if (empty($_action)) {
         }
         $upload_num=getvar(ini_get("session.upload_progress.name"));
 
-        WebImport::processUpload($file);
+        import\web::processUpload($file);
 
         $body=new template("uploadprogressbar", array(
             "name" => $file["name"],
-            "size" => getHuman($file["size"]),
+            "size" => template::getHumanReadableBytes($file["size"]),
             "upload_num" => $upload_num,
             "complete" => 100,
             "width" => 300));
@@ -135,14 +139,14 @@ if (empty($_action)) {
     }
 } else if ($_action=="process") {
     $file=getvar("file");
-    WebImport::processFile($file);
+    import\web::processFile($file);
 } else if ($_action=="retry") {
     $file=getvar("file");
-    WebImport::retryFile($file);
+    import\web::retryFile($file);
 } else if ($_action=="delete") {
     $file=getvar("file");
-    WebImport::deleteFile($file);
+    import\web::deleteFile($file);
 } else if ($_action=="import") {
-    $files=WebImport::getFileList($request_vars["_import_image"]);
-    WebImport::photos($files, $request_vars);
+    $files=import\web::getFileList($request_vars["_import_image"]);
+    import\web::photos($files, $request_vars);
 }
diff -pruN 0.9.4-4/php/include.inc.php 0.9.8-1/php/include.inc.php
--- 0.9.4-4/php/include.inc.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/include.inc.php	2018-03-02 20:49:58.000000000 +0000
@@ -25,7 +25,6 @@
 require_once "autoload.inc.php";
 
 require_once "exception.inc.php";
-require_once "variables.inc.php";
 require_once "log.inc.php";
 
 require_once "config.inc.php";
@@ -33,26 +32,13 @@ require_once "settings.inc.php";
 require_once "requirements.inc.php";
 require_once "util.inc.php";
 
-require_once "track.inc.php";
-require_once "point.inc.php";
-
-
-require_once "color_scheme.inc.php";
+require_once "variables.inc.php";
 
 if (!defined("LOGON")) {
     if (!defined("TEST")) {
         require_once "auth.inc.php";
     }
 
-    require_once "photo_search.inc.php";
-
-    require_once "import.inc.php";
-    if (defined("CLI") || defined("TEST")) {
-        require_once "cli/cli.inc.php";
-        require_once "cli/arguments.inc.php";
-        require_once "cli/cliimport.inc.php";
-    } else {
-        require_once "webimport.inc.php";
-    }
+    require_once "exif.inc.php";
 }
 ?>
diff -pruN 0.9.4-4/php/info.php 0.9.8-1/php/info.php
--- 0.9.4-4/php/info.php	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/info.php	2018-03-02 20:49:58.000000000 +0000
@@ -21,45 +21,18 @@
  * @author Jason Geiger
  * @author Jeroen Roos
  */
+
+use template\template;
+
 require_once "include.inc.php";
 
 $title = translate("About");
-require_once "header.inc.php";
-?>
-<h1><?php echo translate("about") ?></h1>
-<div class="main">
-  <h2>zoph</h2>
-  <p>
-    <?php echo translate("Zoph stands for <strong>z</strong>oph <strong>o</strong>rganizes " .
-        "<strong>ph</strong>otos.", 0) ?>
-    <?php echo translate("Zoph is free software.", 0) ?>
-  </p>
-  <p>
-    <?php echo sprintf(translate("Releases and documentation can be found at %s.", 0),
-      "<a href=\"http://www.zoph.org/\">http://www.zoph.org/</a>") ?>
-    <?php echo sprintf(translate("Send feedback to %s.", 0), "<img src=\"" .
-      template::getImage("mailaddr.png") . "\">") ?>
-  </p>
-<?php
-if ($user->isAdmin()) {
-    ?>
-    <br>
-    <table id="zophinfo">
-      <?php echo create_field_html_table(report::getInfoArray()) ?>
-    <?php
-}
-?>
-</table>
-<p>
-<?php echo sprintf(translate("Zoph version %s, released %s.", 0), VERSION, RELEASEDATE) ?>
-</p>
-<p>
-<?php echo translate("Originally written by Jason Geiger, now maintained by Jeroen Roos " .
-    "with thanks to the following for their contributions:", 0) ?>
-</p>
-<?php include "credits.html"; ?>
-</div>
 
-<?php
-require_once "footer.inc.php";
+$tpl = new template("info", array(
+    "infoArray" => report::getInfoArray(),
+    "title"     => $title,
+    "mailaddr"  => template::getImage("mailaddr.png")
+));
+
+echo $tpl;
 ?>
diff -pruN 0.9.4-4/php/js/custommaps.js 0.9.8-1/php/js/custommaps.js
--- 0.9.4-4/php/js/custommaps.js	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/js/custommaps.js	1970-01-01 00:00:00.000000000 +0000
@@ -1,62 +0,0 @@
-// This file is part of Zoph.
-//
-// Zoph is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation; either version 2 of the License, or
-// (at your option) any later version.
-// 
-// Zoph is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-// You should have received a copy of the GNU General Public License
-// along with Zoph; if not, write to the Free Software
-// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
-
-var zMapsCustom=function() {
-    function customMap(map) {
-        // This function is called after the map has been built
-        // add functions here to add a custom mapping code.
-        
-        // ============================================
-        // Overlay image
-        // ============================================
-        // Add an overlay image. 
-        // (note: this function is not yet implemented in OpenLayers 
-        // and CloudMade)
-        
-        // map.addImageOverlay("id", "url", opacity [0-100], west, south, east, north)
-        // Example: 
-        // This example adds a previously made image over the city of Santo
-        // Domingo.
-        //map.addImageOverlay("overlay","http://mapstraction.com/images/santodomingo.png",50,-70.01544, 18.39777, -69.80567, 18.563517);
-
-
-        // ============================================
-        // Add tile layer
-        // ============================================
-        // add a tile (map) layer.
-        // (note: I only could get this to work with Google)
-        // Example: 
-        // This example adds openstreetmap tiles.
-        // map.addTileLayer("http://tile.openstreetmap.org/{Z}/{X}/{Y}.png", 1.0, "Openstreetmap", 1, 19, false);
-        // Add Big Tin Can tile layer:
-        // map.addTileLayer("http://tiles.bigtincan.com/john/{Z}/{X}/{Y}.png", 1.0, "Big Tin Can", 1, 19, false);
-        
-        // ============================================
-        // Add a map overlay
-        // ============================================
-
-        // map.addOverlay("url", autoCenterAndZoom [true/false]);
-        // Example:
-        // Add Flickr's GeoRSS feed:
-        // map.addOverlay("http://api.flickr.com/services/feeds/geo/?format=rss_200", true);
-        // Add Panoramio's KML feed:
-        //map.addOverlay("http://www.panoramio.com/kml", true);
-//        map.addOverlay("http://www.ambiotek.com/srtm", true);
-    }
-
-    return {
-        customMap:customMap
-    };
-}();
diff -pruN 0.9.4-4/php/js/geocode.js 0.9.8-1/php/js/geocode.js
--- 0.9.4-4/php/js/geocode.js	2016-09-18 19:55:46.000000000 +0000
+++ 0.9.8-1/php/js/geocode.js	2018-03-02 20:49:58.000000000 +0000
@@ -14,8 +14,8 @@
 // Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 
 var zGeocode=function() {
-    var geourl="http://api.geonames.org/search?style=SHORT&username=zoph&q=";
-    var wikiurl="http://api.geonames.org/wikipediaSearch?username=zoph&q=";
+    var geourl="https://secure.geonames.org/search?style=SHORT&username=zoph&q=";
+    var wikiurl="https://secure.geonames.org/wikipediaSearch?username=zoph&q=";
     var url;
     var geotag="geoname";
     var wikitag="entry"
diff -pruN 0.9.4-4/php/js/leaflet/GoogleMutant.js 0.9.8-1/php/js/leaflet/GoogleMutant.js
--- 0.9.4-4/php/js/leaflet/GoogleMutant.js	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/js/leaflet/GoogleMutant.js	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,391 @@
+// Based on https://github.com/shramov/leaflet-plugins
+// GridLayer like https://avinmathew.com/leaflet-and-google-maps/ , but using MutationObserver instead of jQuery
+
+
+// 🍂class GridLayer.GoogleMutant
+// 🍂extends GridLayer
+L.GridLayer.GoogleMutant = L.GridLayer.extend({
+	includes: L.Mixin.Events,
+
+	options: {
+		minZoom: 0,
+		maxZoom: 18,
+		tileSize: 256,
+		subdomains: 'abc',
+		errorTileUrl: '',
+		attribution: '',	// The mutant container will add its own attribution anyways.
+		opacity: 1,
+		continuousWorld: false,
+		noWrap: false,
+		// 🍂option type: String = 'roadmap'
+		// Google's map type. Valid values are 'roadmap', 'satellite' or 'terrain'. 'hybrid' is not really supported.
+		type: 'roadmap',
+		maxNativeZoom: 21
+	},
+
+	initialize: function (options) {
+		L.GridLayer.prototype.initialize.call(this, options);
+
+		this._ready = !!window.google && !!window.google.maps && !!window.google.maps.Map;
+
+		this._GAPIPromise = this._ready ? Promise.resolve(window.google) : new Promise(function (resolve, reject) {
+			var checkCounter = 0;
+			var intervalId = null;
+			intervalId = setInterval(function () {
+				if (checkCounter >= 10) {
+					clearInterval(intervalId);
+					return reject(new Error('window.google not found after 10 attempts'));
+				}
+				if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {
+					clearInterval(intervalId);
+					return resolve(window.google);
+				}
+				checkCounter++;
+			}, 500);
+		});
+
+		// Couple data structures indexed by tile key
+		this._tileCallbacks = {};	// Callbacks for promises for tiles that are expected
+		this._freshTiles = {};	// Tiles from the mutant which haven't been requested yet
+
+		this._imagesPerTile = (this.options.type === 'hybrid') ? 2 : 1;
+		this.createTile = (this.options.type === 'hybrid') ? this._createMultiTile : this._createSingleTile;
+	},
+
+	onAdd: function (map) {
+		L.GridLayer.prototype.onAdd.call(this, map);
+		this._initMutantContainer();
+
+		this._GAPIPromise.then(function () {
+			this._ready = true;
+			this._map = map;
+
+			this._initMutant();
+
+			map.on('viewreset', this._reset, this);
+			map.on('move', this._update, this);
+			map.on('zoomend', this._handleZoomAnim, this);
+			map.on('resize', this._resize, this);
+
+			//20px instead of 1em to avoid a slight overlap with google's attribution
+			map._controlCorners.bottomright.style.marginBottom = '20px';
+
+			this._reset();
+			this._update();
+		}.bind(this));
+	},
+
+	onRemove: function (map) {
+		L.GridLayer.prototype.onRemove.call(this, map);
+		map._container.removeChild(this._mutantContainer);
+		this._mutantContainer = undefined;
+
+		map.off('viewreset', this._reset, this);
+		map.off('move', this._update, this);
+		map.off('zoomend', this._handleZoomAnim, this);
+		map.off('resize', this._resize, this);
+
+		map._controlCorners.bottomright.style.marginBottom = '0em';
+	},
+
+	getAttribution: function () {
+		return this.options.attribution;
+	},
+
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+		if (opacity < 1) {
+			L.DomUtil.setOpacity(this._mutantContainer, opacity);
+		}
+	},
+
+	setElementSize: function (e, size) {
+		e.style.width = size.x + 'px';
+		e.style.height = size.y + 'px';
+	},
+
+	_initMutantContainer: function () {
+		if (!this._mutantContainer) {
+			this._mutantContainer = L.DomUtil.create('div', 'leaflet-google-mutant leaflet-top leaflet-left');
+			this._mutantContainer.id = '_MutantContainer_' + L.Util.stamp(this._mutantContainer);
+// 			this._mutantContainer.style.zIndex = 'auto';
+			this._mutantContainer.style.pointerEvents = 'none';
+
+			this._map.getContainer().appendChild(this._mutantContainer);
+		}
+
+		this.setOpacity(this.options.opacity);
+		this.setElementSize(this._mutantContainer, this._map.getSize());
+
+		this._attachObserver(this._mutantContainer);
+	},
+
+	_initMutant: function () {
+		if (!this._ready || !this._mutantContainer) return;
+		this._mutantCenter = new google.maps.LatLng(0, 0);
+
+		var map = new google.maps.Map(this._mutantContainer, {
+			center: this._mutantCenter,
+			zoom: 0,
+			tilt: 0,
+			mapTypeId: this.options.type,
+			disableDefaultUI: true,
+			keyboardShortcuts: false,
+			draggable: false,
+			disableDoubleClickZoom: true,
+			scrollwheel: false,
+			streetViewControl: false,
+			styles: this.options.styles || {},
+			backgroundColor: 'transparent'
+		});
+
+		this._mutant = map;
+
+		// 🍂event spawned
+		// Fired when the mutant has been created.
+		this.fire('spawned', {mapObject: map});
+	},
+
+	_attachObserver: function _attachObserver (node) {
+// 		console.log('Gonna observe', node);
+
+		var observer = new MutationObserver(this._onMutations.bind(this));
+
+		// pass in the target node, as well as the observer options
+		observer.observe(node, { childList: true, subtree: true });
+	},
+
+	_onMutations: function _onMutations (mutations) {
+		for (var i = 0; i < mutations.length; ++i) {
+			var mutation = mutations[i];
+			for (var j = 0; j < mutation.addedNodes.length; ++j) {
+				var node = mutation.addedNodes[j];
+
+				if (node instanceof HTMLImageElement) {
+					this._onMutatedImage(node);
+				} else if (node instanceof HTMLElement) {
+					Array.prototype.forEach.call(node.querySelectorAll('img'), this._onMutatedImage.bind(this));
+				}
+			}
+		}
+	},
+
+	// Only images which 'src' attrib match this will be considered for moving around.
+	// Looks like some kind of string-based protobuf, maybe??
+	// Only the roads (and terrain, and vector-based stuff) match this pattern
+	_roadRegexp: /!1i(\d+)!2i(\d+)!3i(\d+)!/,
+
+	// On the other hand, raster imagery matches this other pattern
+	_satRegexp: /x=(\d+)&y=(\d+)&z=(\d+)/,
+
+	// On small viewports, when zooming in/out, a static image is requested
+	// This will not be moved around, just removed from the DOM.
+	_staticRegExp: /StaticMapService\.GetMapImage/,
+
+	_onMutatedImage: function _onMutatedImage (imgNode) {
+// 		if (imgNode.src) {
+// 			console.log('caught mutated image: ', imgNode.src);
+// 		}
+
+		var coords;
+		var match = imgNode.src.match(this._roadRegexp);
+		var sublayer, parent;
+
+		if (match) {
+			coords = {
+				z: match[1],
+				x: match[2],
+				y: match[3]
+			};
+			if (this._imagesPerTile > 1) { imgNode.style.zIndex = 1; }
+			sublayer = 1;
+		} else {
+			match = imgNode.src.match(this._satRegexp);
+			if (match) {
+				coords = {
+					x: match[1],
+					y: match[2],
+					z: match[3]
+				};
+			}
+// 			imgNode.style.zIndex = 0;
+			sublayer = 0;
+		}
+
+		if (coords) {
+			var key = this._tileCoordsToKey(coords);
+			if (this._imagesPerTile > 1) { key += '/' + sublayer; }
+			if (key in this._tileCallbacks && this._tileCallbacks[key]) {
+// console.log('Fullfilling callback ', key);
+				this._tileCallbacks[key].pop()(imgNode);
+				if (!this._tileCallbacks[key].length) { delete this._tileCallbacks[key]; }
+			} else {
+// console.log('Caching for later', key);
+				parent = imgNode.parentNode;
+				if (parent) {
+					parent.removeChild(imgNode);
+					parent.removeChild = L.Util.falseFn;
+// 					imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
+				}
+				if (key in this._freshTiles) {
+					this._freshTiles[key].push(imgNode);
+				} else {
+					this._freshTiles[key] = [imgNode];
+				}
+			}
+		} else if (imgNode.src.match(this._staticRegExp)) {
+			parent = imgNode.parentNode;
+			if (parent) {
+				// Remove the image, but don't store it anywhere.
+				// Image needs to be replaced instead of removed, as the container
+				// seems to be reused.
+				imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
+			}
+		}
+	},
+
+	// This will be used as this.createTile for 'roadmap', 'sat', 'terrain'
+	_createSingleTile: function createTile (coords, done) {
+		var key = this._tileCoordsToKey(coords);
+// console.log('Need:', key);
+
+		if (key in this._freshTiles) {
+			var tile = this._freshTiles[key].pop();
+			if (!this._freshTiles[key].length) { delete this._freshTiles[key]; }
+			L.Util.requestAnimFrame(done);
+// 			console.log('Got ', key, ' from _freshTiles');
+			return tile;
+		} else {
+			var tileContainer = L.DomUtil.create('div');
+			this._tileCallbacks[key] = this._tileCallbacks[key] || [];
+			this._tileCallbacks[key].push( (function (c/*, k*/) {
+				return function (imgNode) {
+					var parent = imgNode.parentNode;
+					if (parent) {
+						parent.removeChild(imgNode);
+						parent.removeChild = L.Util.falseFn;
+// 						imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
+					}
+					c.appendChild(imgNode);
+					done();
+// 					console.log('Sent ', k, ' to _tileCallbacks');
+				}.bind(this);
+			}.bind(this))(tileContainer/*, key*/) );
+
+			return tileContainer;
+		}
+	},
+
+	// This will be used as this.createTile for 'hybrid'
+	_createMultiTile: function createTile (coords, done) {
+		var key = this._tileCoordsToKey(coords);
+
+		var tileContainer = L.DomUtil.create('div');
+		tileContainer.dataset.pending = this._imagesPerTile;
+
+		for (var i = 0; i < this._imagesPerTile; i++) {
+			var key2 = key + '/' + i;
+			if (key2 in this._freshTiles) {
+				tileContainer.appendChild(this._freshTiles[key2].pop());
+				if (!this._freshTiles[key2].length) { delete this._freshTiles[key2]; }
+				tileContainer.dataset.pending--;
+// 				console.log('Got ', key2, ' from _freshTiles');
+			} else {
+				this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];
+				this._tileCallbacks[key2].push( (function (c/*, k2*/) {
+					return function (imgNode) {
+						var parent = imgNode.parentNode;
+						if (parent) {
+							parent.removeChild(imgNode);
+							parent.removeChild = L.Util.falseFn;
+// 							imgNode.parentNode.replaceChild(L.DomUtil.create('img'), imgNode);
+						}
+						c.appendChild(imgNode);
+						c.dataset.pending--;
+						if (!parseInt(c.dataset.pending)) { done(); }
+// 						console.log('Sent ', k2, ' to _tileCallbacks, still ', c.dataset.pending, ' images to go');
+					}.bind(this);
+				}.bind(this))(tileContainer/*, key2*/) );
+			}
+		}
+
+		if (!parseInt(tileContainer.dataset.pending)) {
+			L.Util.requestAnimFrame(done);
+		}
+		return tileContainer;
+	},
+
+	_checkZoomLevels: function () {
+		//setting the zoom level on the Google map may result in a different zoom level than the one requested
+		//(it won't go beyond the level for which they have data).
+		// verify and make sure the zoom levels on both Leaflet and Google maps are consistent
+		if ((this._map.getZoom() !== undefined) && (this._mutant.getZoom() !== this._map.getZoom())) {
+			//zoom levels are out of sync. Set the leaflet zoom level to match the google one
+			this._map.setZoom(this._mutant.getZoom());
+		}
+	},
+
+	_reset: function () {
+		this._initContainer();
+	},
+
+	_update: function () {
+		L.GridLayer.prototype._update.call(this);
+		if (!this._mutant) return;
+
+		var center = this._map.getCenter();
+		var _center = new google.maps.LatLng(center.lat, center.lng);
+
+		this._mutant.setCenter(_center);
+		var zoom = this._map.getZoom();
+		if (zoom !== undefined) {
+			this._mutant.setZoom(Math.round(this._map.getZoom()));
+		}
+	},
+
+	_resize: function () {
+		var size = this._map.getSize();
+		if (this._mutantContainer.style.width === size.x &&
+			this._mutantContainer.style.height === size.y)
+			return;
+		this.setElementSize(this._mutantContainer, size);
+		if (!this._mutant) return;
+		google.maps.event.trigger(this._mutant, 'resize');
+	},
+
+	_handleZoomAnim: function () {
+		var center = this._map.getCenter();
+		var _center = new google.maps.LatLng(center.lat, center.lng);
+
+		this._mutant.setCenter(_center);
+		this._mutant.setZoom(Math.round(this._map.getZoom()));
+	},
+
+	// Agressively prune _freshtiles when a tile with the same key is removed,
+	// this prevents a problem where Leaflet keeps a loaded tile longer than
+	// GMaps, so that GMaps makes two requests but Leaflet only consumes one,
+	// polluting _freshTiles with stale data.
+	_removeTile: function (key) {
+		if (this._imagesPerTile > 1) {
+			for (var i=0; i<this._imagesPerTile; i++) {
+				var key2 = key + '/' + i;
+				if (key2 in this._freshTiles) { delete this._freshTiles[key2]; }
+// 				console.log('Pruned spurious hybrid _freshTiles');
+			}
+		} else {
+			if (key in this._freshTiles) {
+				delete this._freshTiles[key];
+// 				console.log('Pruned spurious _freshTiles', key);
+			}
+		}
+
+		return L.GridLayer.prototype._removeTile.call(this, key);
+	}
+});
+
+
+// 🍂factory gridLayer.googleMutant(options)
+// Returns a new `GridLayer.GoogleMutant` given its options
+L.gridLayer.googleMutant = function (options) {
+	return new L.GridLayer.GoogleMutant(options);
+};
diff -pruN 0.9.4-4/php/js/leaflet/GoogleMutant.LICENCE 0.9.8-1/php/js/leaflet/GoogleMutant.LICENCE
--- 0.9.4-4/php/js/leaflet/GoogleMutant.LICENCE	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/js/leaflet/GoogleMutant.LICENCE	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,3 @@
+"THE BEER-WARE LICENSE": <ivan@sanchezortega.es> wrote this file. As long as you retain this notice you can do whatever you want with this stuff. If we meet some day, and you think this stuff is worth it, you can buy me a beer in return.
+
+
diff -pruN 0.9.4-4/php/js/leaflet-src.js 0.9.8-1/php/js/leaflet-src.js
--- 0.9.4-4/php/js/leaflet-src.js	1970-01-01 00:00:00.000000000 +0000
+++ 0.9.8-1/php/js/leaflet-src.js	2018-03-02 20:49:58.000000000 +0000
@@ -0,0 +1,13044 @@
+/*
+ Leaflet 1.0.1+ffcfcc1, a JS library for interactive maps. http://leafletjs.com
+ (c) 2010-2016 Vladimir Agafonkin, (c) 2010-2011 CloudMade
+*/
+(function (window, document, undefined) {
+var L = {
+	version: "1.0.1+ffcfcc1"
+};
+
+function expose() {
+	var oldL = window.L;
+
+	L.noConflict = function () {
+		window.L = oldL;
+		return this;
+	};
+
+	window.L = L;
+}
+
+// define Leaflet for Node module pattern loaders, including Browserify
+if (typeof module === 'object' && typeof module.exports === 'object') {
+	module.exports = L;
+
+// define Leaflet as an AMD module
+} else if (typeof define === 'function' && define.amd) {
+	define(L);
+}
+
+// define Leaflet as a global L variable, saving the original L to restore later if needed
+if (typeof window !== 'undefined') {
+	expose();
+}
+
+
+
+/*
+ * @namespace Util
+ *
+ * Various utility functions, used by Leaflet internally.
+ */
+
+L.Util = {
+
+	// @function extend(dest: Object, src?: Object): Object
+	// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut.
+	extend: function (dest) {
+		var i, j, len, src;
+
+		for (j = 1, len = arguments.length; j < len; j++) {
+			src = arguments[j];
+			for (i in src) {
+				dest[i] = src[i];
+			}
+		}
+		return dest;
+	},
+
+	// @function create(proto: Object, properties?: Object): Object
+	// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create)
+	create: Object.create || (function () {
+		function F() {}
+		return function (proto) {
+			F.prototype = proto;
+			return new F();
+		};
+	})(),
+
+	// @function bind(fn: Function, …): Function
+	// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind).
+	// Has a `L.bind()` shortcut.
+	bind: function (fn, obj) {
+		var slice = Array.prototype.slice;
+
+		if (fn.bind) {
+			return fn.bind.apply(fn, slice.call(arguments, 1));
+		}
+
+		var args = slice.call(arguments, 2);
+
+		return function () {
+			return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments);
+		};
+	},
+
+	// @function stamp(obj: Object): Number
+	// Returns the unique ID of an object, assiging it one if it doesn't have it.
+	stamp: function (obj) {
+		/*eslint-disable */
+		obj._leaflet_id = obj._leaflet_id || ++L.Util.lastId;
+		return obj._leaflet_id;
+		/*eslint-enable */
+	},
+
+	// @property lastId: Number
+	// Last unique ID used by [`stamp()`](#util-stamp)
+	lastId: 0,
+
+	// @function throttle(fn: Function, time: Number, context: Object): Function
+	// Returns a function which executes function `fn` with the given scope `context`
+	// (so that the `this` keyword refers to `context` inside `fn`'s code). The function
+	// `fn` will be called no more than one time per given amount of `time`. The arguments
+	// received by the bound function will be any arguments passed when binding the
+	// function, followed by any arguments passed when invoking the bound function.
+	// Has an `L.bind` shortcut.
+	throttle: function (fn, time, context) {
+		var lock, args, wrapperFn, later;
+
+		later = function () {
+			// reset lock and call if queued
+			lock = false;
+			if (args) {
+				wrapperFn.apply(context, args);
+				args = false;
+			}
+		};
+
+		wrapperFn = function () {
+			if (lock) {
+				// called too soon, queue to call later
+				args = arguments;
+
+			} else {
+				// call and lock until later
+				fn.apply(context, arguments);
+				setTimeout(later, time);
+				lock = true;
+			}
+		};
+
+		return wrapperFn;
+	},
+
+	// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number
+	// Returns the number `num` modulo `range` in such a way so it lies within
+	// `range[0]` and `range[1]`. The returned value will be always smaller than
+	// `range[1]` unless `includeMax` is set to `true`.
+	wrapNum: function (x, range, includeMax) {
+		var max = range[1],
+		    min = range[0],
+		    d = max - min;
+		return x === max && includeMax ? x : ((x - min) % d + d) % d + min;
+	},
+
+	// @function falseFn(): Function
+	// Returns a function which always returns `false`.
+	falseFn: function () { return false; },
+
+	// @function formatNum(num: Number, digits?: Number): Number
+	// Returns the number `num` rounded to `digits` decimals, or to 5 decimals by default.
+	formatNum: function (num, digits) {
+		var pow = Math.pow(10, digits || 5);
+		return Math.round(num * pow) / pow;
+	},
+
+	// @function trim(str: String): String
+	// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim)
+	trim: function (str) {
+		return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '');
+	},
+
+	// @function splitWords(str: String): String[]
+	// Trims and splits the string on whitespace and returns the array of parts.
+	splitWords: function (str) {
+		return L.Util.trim(str).split(/\s+/);
+	},
+
+	// @function setOptions(obj: Object, options: Object): Object
+	// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut.
+	setOptions: function (obj, options) {
+		if (!obj.hasOwnProperty('options')) {
+			obj.options = obj.options ? L.Util.create(obj.options) : {};
+		}
+		for (var i in options) {
+			obj.options[i] = options[i];
+		}
+		return obj.options;
+	},
+
+	// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String
+	// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}`
+	// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will
+	// be appended at the end. If `uppercase` is `true`, the parameter names will
+	// be uppercased (e.g. `'?A=foo&B=bar'`)
+	getParamString: function (obj, existingUrl, uppercase) {
+		var params = [];
+		for (var i in obj) {
+			params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i]));
+		}
+		return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&');
+	},
+
+	// @function template(str: String, data: Object): String
+	// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'`
+	// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string
+	// `('Hello foo, bar')`. You can also specify functions instead of strings for
+	// data values — they will be evaluated passing `data` as an argument.
+	template: function (str, data) {
+		return str.replace(L.Util.templateRe, function (str, key) {
+			var value = data[key];
+
+			if (value === undefined) {
+				throw new Error('No value provided for variable ' + str);
+
+			} else if (typeof value === 'function') {
+				value = value(data);
+			}
+			return value;
+		});
+	},
+
+	templateRe: /\{ *([\w_\-]+) *\}/g,
+
+	// @function isArray(obj): Boolean
+	// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray)
+	isArray: Array.isArray || function (obj) {
+		return (Object.prototype.toString.call(obj) === '[object Array]');
+	},
+
+	// @function indexOf(array: Array, el: Object): Number
+	// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf)
+	indexOf: function (array, el) {
+		for (var i = 0; i < array.length; i++) {
+			if (array[i] === el) { return i; }
+		}
+		return -1;
+	},
+
+	// @property emptyImageUrl: String
+	// Data URI string containing a base64-encoded empty GIF image.
+	// Used as a hack to free memory from unused images on WebKit-powered
+	// mobile devices (by setting image `src` to this string).
+	emptyImageUrl: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='
+};
+
+(function () {
+	// inspired by http://paulirish.com/2011/requestanimationframe-for-smart-animating/
+
+	function getPrefixed(name) {
+		return window['webkit' + name] || window['moz' + name] || window['ms' + name];
+	}
+
+	var lastTime = 0;
+
+	// fallback for IE 7-8
+	function timeoutDefer(fn) {
+		var time = +new Date(),
+		    timeToCall = Math.max(0, 16 - (time - lastTime));
+
+		lastTime = time + timeToCall;
+		return window.setTimeout(fn, timeToCall);
+	}
+
+	var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer,
+	    cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') ||
+	               getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); };
+
+
+	// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number
+	// Schedules `fn` to be executed when the browser repaints. `fn` is bound to
+	// `context` if given. When `immediate` is set, `fn` is called immediately if
+	// the browser doesn't have native support for
+	// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame),
+	// otherwise it's delayed. Returns a request ID that can be used to cancel the request.
+	L.Util.requestAnimFrame = function (fn, context, immediate) {
+		if (immediate && requestFn === timeoutDefer) {
+			fn.call(context);
+		} else {
+			return requestFn.call(window, L.bind(fn, context));
+		}
+	};
+
+	// @function cancelAnimFrame(id: Number): undefined
+	// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame).
+	L.Util.cancelAnimFrame = function (id) {
+		if (id) {
+			cancelFn.call(window, id);
+		}
+	};
+})();
+
+// shortcuts for most used utility functions
+L.extend = L.Util.extend;
+L.bind = L.Util.bind;
+L.stamp = L.Util.stamp;
+L.setOptions = L.Util.setOptions;
+
+
+
+
+// @class Class
+// @aka L.Class
+
+// @section
+// @uninheritable
+
+// Thanks to John Resig and Dean Edwards for inspiration!
+
+L.Class = function () {};
+
+L.Class.extend = function (props) {
+
+	// @function extend(props: Object): Function
+	// [Extends the current class](#class-inheritance) given the properties to be included.
+	// Returns a Javascript function that is a class constructor (to be called with `new`).
+	var NewClass = function () {
+
+		// call the constructor
+		if (this.initialize) {
+			this.initialize.apply(this, arguments);
+		}
+
+		// call all constructor hooks
+		this.callInitHooks();
+	};
+
+	var parentProto = NewClass.__super__ = this.prototype;
+
+	var proto = L.Util.create(parentProto);
+	proto.constructor = NewClass;
+
+	NewClass.prototype = proto;
+
+	// inherit parent's statics
+	for (var i in this) {
+		if (this.hasOwnProperty(i) && i !== 'prototype') {
+			NewClass[i] = this[i];
+		}
+	}
+
+	// mix static properties into the class
+	if (props.statics) {
+		L.extend(NewClass, props.statics);
+		delete props.statics;
+	}
+
+	// mix includes into the prototype
+	if (props.includes) {
+		L.Util.extend.apply(null, [proto].concat(props.includes));
+		delete props.includes;
+	}
+
+	// merge options
+	if (proto.options) {
+		props.options = L.Util.extend(L.Util.create(proto.options), props.options);
+	}
+
+	// mix given properties into the prototype
+	L.extend(proto, props);
+
+	proto._initHooks = [];
+
+	// add method for calling all hooks
+	proto.callInitHooks = function () {
+
+		if (this._initHooksCalled) { return; }
+
+		if (parentProto.callInitHooks) {
+			parentProto.callInitHooks.call(this);
+		}
+
+		this._initHooksCalled = true;
+
+		for (var i = 0, len = proto._initHooks.length; i < len; i++) {
+			proto._initHooks[i].call(this);
+		}
+	};
+
+	return NewClass;
+};
+
+
+// @function include(properties: Object): this
+// [Includes a mixin](#class-includes) into the current class.
+L.Class.include = function (props) {
+	L.extend(this.prototype, props);
+	return this;
+};
+
+// @function mergeOptions(options: Object): this
+// [Merges `options`](#class-options) into the defaults of the class.
+L.Class.mergeOptions = function (options) {
+	L.extend(this.prototype.options, options);
+	return this;
+};
+
+// @function addInitHook(fn: Function): this
+// Adds a [constructor hook](#class-constructor-hooks) to the class.
+L.Class.addInitHook = function (fn) { // (Function) || (String, args...)
+	var args = Array.prototype.slice.call(arguments, 1);
+
+	var init = typeof fn === 'function' ? fn : function () {
+		this[fn].apply(this, args);
+	};
+
+	this.prototype._initHooks = this.prototype._initHooks || [];
+	this.prototype._initHooks.push(init);
+	return this;
+};
+
+
+
+/*
+ * @class Evented
+ * @aka L.Evented
+ * @inherits Class
+ *
+ * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event).
+ *
+ * @example
+ *
+ * ```js
+ * map.on('click', function(e) {
+ * 	alert(e.latlng);
+ * } );
+ * ```
+ *
+ * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function:
+ *
+ * ```js
+ * function onClick(e) { ... }
+ *
+ * map.on('click', onClick);
+ * map.off('click', onClick);
+ * ```
+ */
+
+
+L.Evented = L.Class.extend({
+
+	/* @method on(type: String, fn: Function, context?: Object): this
+	 * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`).
+	 *
+	 * @alternative
+	 * @method on(eventMap: Object): this
+	 * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}`
+	 */
+	on: function (types, fn, context) {
+
+		// types can be a map of types/handlers
+		if (typeof types === 'object') {
+			for (var type in types) {
+				// we don't process space-separated events here for performance;
+				// it's a hot path since Layer uses the on(obj) syntax
+				this._on(type, types[type], fn);
+			}
+
+		} else {
+			// types can be a string of space-separated words
+			types = L.Util.splitWords(types);
+
+			for (var i = 0, len = types.length; i < len; i++) {
+				this._on(types[i], fn, context);
+			}
+		}
+
+		return this;
+	},
+
+	/* @method off(type: String, fn?: Function, context?: Object): this
+	 * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener.
+	 *
+	 * @alternative
+	 * @method off(eventMap: Object): this
+	 * Removes a set of type/listener pairs.
+	 *
+	 * @alternative
+	 * @method off: this
+	 * Removes all listeners to all events on the object.
+	 */
+	off: function (types, fn, context) {
+
+		if (!types) {
+			// clear all listeners if called without arguments
+			delete this._events;
+
+		} else if (typeof types === 'object') {
+			for (var type in types) {
+				this._off(type, types[type], fn);
+			}
+
+		} else {
+			types = L.Util.splitWords(types);
+
+			for (var i = 0, len = types.length; i < len; i++) {
+				this._off(types[i], fn, context);
+			}
+		}
+
+		return this;
+	},
+
+	// attach listener (without syntactic sugar now)
+	_on: function (type, fn, context) {
+		this._events = this._events || {};
+
+		/* get/init listeners for type */
+		var typeListeners = this._events[type];
+		if (!typeListeners) {
+			typeListeners = [];
+			this._events[type] = typeListeners;
+		}
+
+		if (context === this) {
+			// Less memory footprint.
+			context = undefined;
+		}
+		var newListener = {fn: fn, ctx: context},
+		    listeners = typeListeners;
+
+		// check if fn already there
+		for (var i = 0, len = listeners.length; i < len; i++) {
+			if (listeners[i].fn === fn && listeners[i].ctx === context) {
+				return;
+			}
+		}
+
+		listeners.push(newListener);
+		typeListeners.count++;
+	},
+
+	_off: function (type, fn, context) {
+		var listeners,
+		    i,
+		    len;
+
+		if (!this._events) { return; }
+
+		listeners = this._events[type];
+
+		if (!listeners) {
+			return;
+		}
+
+		if (!fn) {
+			// Set all removed listeners to noop so they are not called if remove happens in fire
+			for (i = 0, len = listeners.length; i < len; i++) {
+				listeners[i].fn = L.Util.falseFn;
+			}
+			// clear all listeners for a type if function isn't specified
+			delete this._events[type];
+			return;
+		}
+
+		if (context === this) {
+			context = undefined;
+		}
+
+		if (listeners) {
+
+			// find fn and remove it
+			for (i = 0, len = listeners.length; i < len; i++) {
+				var l = listeners[i];
+				if (l.ctx !== context) { continue; }
+				if (l.fn === fn) {
+
+					// set the removed listener to noop so that's not called if remove happens in fire
+					l.fn = L.Util.falseFn;
+
+					if (this._firingCount) {
+						/* copy array in case events are being fired */
+						this._events[type] = listeners = listeners.slice();
+					}
+					listeners.splice(i, 1);
+
+					return;
+				}
+			}
+		}
+	},
+
+	// @method fire(type: String, data?: Object, propagate?: Boolean): this
+	// Fires an event of the specified type. You can optionally provide an data
+	// object — the first argument of the listener function will contain its
+	// properties. The event might can optionally be propagated to event parents.
+	fire: function (type, data, propagate) {
+		if (!this.listens(type, propagate)) { return this; }
+
+		var event = L.Util.extend({}, data, {type: type, target: this});
+
+		if (this._events) {
+			var listeners = this._events[type];
+
+			if (listeners) {
+				this._firingCount = (this._firingCount + 1) || 1;
+				for (var i = 0, len = listeners.length; i < len; i++) {
+					var l = listeners[i];
+					l.fn.call(l.ctx || this, event);
+				}
+
+				this._firingCount--;
+			}
+		}
+
+		if (propagate) {
+			// propagate the event to parents (set with addEventParent)
+			this._propagateEvent(event);
+		}
+
+		return this;
+	},
+
+	// @method listens(type: String): Boolean
+	// Returns `true` if a particular event type has any listeners attached to it.
+	listens: function (type, propagate) {
+		var listeners = this._events && this._events[type];
+		if (listeners && listeners.length) { return true; }
+
+		if (propagate) {
+			// also check parents for listeners if event propagates
+			for (var id in this._eventParents) {
+				if (this._eventParents[id].listens(type, propagate)) { return true; }
+			}
+		}
+		return false;
+	},
+
+	// @method once(…): this
+	// Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed.
+	once: function (types, fn, context) {
+
+		if (typeof types === 'object') {
+			for (var type in types) {
+				this.once(type, types[type], fn);
+			}
+			return this;
+		}
+
+		var handler = L.bind(function () {
+			this
+			    .off(types, fn, context)
+			    .off(types, handler, context);
+		}, this);
+
+		// add a listener that's executed once and removed after that
+		return this
+		    .on(types, fn, context)
+		    .on(types, handler, context);
+	},
+
+	// @method addEventParent(obj: Evented): this
+	// Adds an event parent - an `Evented` that will receive propagated events
+	addEventParent: function (obj) {
+		this._eventParents = this._eventParents || {};
+		this._eventParents[L.stamp(obj)] = obj;
+		return this;
+	},
+
+	// @method removeEventParent(obj: Evented): this
+	// Removes an event parent, so it will stop receiving propagated events
+	removeEventParent: function (obj) {
+		if (this._eventParents) {
+			delete this._eventParents[L.stamp(obj)];
+		}
+		return this;
+	},
+
+	_propagateEvent: function (e) {
+		for (var id in this._eventParents) {
+			this._eventParents[id].fire(e.type, L.extend({layer: e.target}, e), true);
+		}
+	}
+});
+
+var proto = L.Evented.prototype;
+
+// aliases; we should ditch those eventually
+
+// @method addEventListener(…): this
+// Alias to [`on(…)`](#evented-on)
+proto.addEventListener = proto.on;
+
+// @method removeEventListener(…): this
+// Alias to [`off(…)`](#evented-off)
+
+// @method clearAllEventListeners(…): this
+// Alias to [`off()`](#evented-off)
+proto.removeEventListener = proto.clearAllEventListeners = proto.off;
+
+// @method addOneTimeEventListener(…): this
+// Alias to [`once(…)`](#evented-once)
+proto.addOneTimeEventListener = proto.once;
+
+// @method fireEvent(…): this
+// Alias to [`fire(…)`](#evented-fire)
+proto.fireEvent = proto.fire;
+
+// @method hasEventListeners(…): Boolean
+// Alias to [`listens(…)`](#evented-listens)
+proto.hasEventListeners = proto.listens;
+
+L.Mixin = {Events: proto};
+
+
+
+/*
+ * @namespace Browser
+ * @aka L.Browser
+ *
+ * A namespace with static properties for browser/feature detection used by Leaflet internally.
+ *
+ * @example
+ *
+ * ```js
+ * if (L.Browser.ielt9) {
+ *   alert('Upgrade your browser, dude!');
+ * }
+ * ```
+ */
+
+(function () {
+
+	var ua = navigator.userAgent.toLowerCase(),
+	    doc = document.documentElement,
+
+	    ie = 'ActiveXObject' in window,
+
+	    webkit    = ua.indexOf('webkit') !== -1,
+	    phantomjs = ua.indexOf('phantom') !== -1,
+	    android23 = ua.search('android [23]') !== -1,
+	    chrome    = ua.indexOf('chrome') !== -1,
+	    gecko     = ua.indexOf('gecko') !== -1  && !webkit && !window.opera && !ie,
+
+	    win = navigator.platform.indexOf('Win') === 0,
+
+	    mobile = typeof orientation !== 'undefined' || ua.indexOf('mobile') !== -1,
+	    msPointer = !window.PointerEvent && window.MSPointerEvent,
+	    pointer = window.PointerEvent || msPointer,
+
+	    ie3d = ie && ('transition' in doc.style),
+	    webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23,
+	    gecko3d = 'MozPerspective' in doc.style,
+	    opera12 = 'OTransition' in doc.style;
+
+
+	var touch = !window.L_NO_TOUCH && (pointer || 'ontouchstart' in window ||
+			(window.DocumentTouch && document instanceof window.DocumentTouch));
+
+	L.Browser = {
+
+		// @property ie: Boolean
+		// `true` for all Internet Explorer versions (not Edge).
+		ie: ie,
+
+		// @property ielt9: Boolean
+		// `true` for Internet Explorer versions less than 9.
+		ielt9: ie && !document.addEventListener,
+
+		// @property edge: Boolean
+		// `true` for the Edge web browser.
+		edge: 'msLaunchUri' in navigator && !('documentMode' in document),
+
+		// @property webkit: Boolean
+		// `true` for webkit-based browsers like Chrome and Safari (including mobile versions).
+		webkit: webkit,
+
+		// @property gecko: Boolean
+		// `true` for gecko-based browsers like Firefox.
+		gecko: gecko,
+
+		// @property android: Boolean
+		// `true` for any browser running on an Android platform.
+		android: ua.indexOf('android') !== -1,
+
+		// @property android23: Boolean
+		// `true` for browsers running on Android 2 or Android 3.
+		android23: android23,
+
+		// @property chrome: Boolean
+		// `true` for the Chrome browser.
+		chrome: chrome,
+
+		// @property safari: Boolean
+		// `true` for the Safari browser.
+		safari: !chrome && ua.indexOf('safari') !== -1,
+
+
+		// @property win: Boolean
+		// `true` when the browser is running in a Windows platform
+		win: win,
+
+
+		// @property ie3d: Boolean
+		// `true` for all Internet Explorer versions supporting CSS transforms.
+		ie3d: ie3d,
+
+		// @property webkit3d: Boolean
+		// `true` for webkit-based browsers supporting CSS transforms.
+		webkit3d: webkit3d,
+
+		// @property gecko3d: Boolean
+		// `true` for gecko-based browsers supporting CSS transforms.
+		gecko3d: gecko3d,
+
+		// @property opera12: Boolean
+		// `true` for the Opera browser supporting CSS transforms (version 12 or later).
+		opera12: opera12,
+
+		// @property any3d: Boolean
+		// `true` for all browsers supporting CSS transforms.
+		any3d: !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantomjs,
+
+
+		// @property mobile: Boolean
+		// `true` for all browsers running in a mobile device.
+		mobile: mobile,
+
+		// @property mobileWebkit: Boolean
+		// `true` for all webkit-based browsers in a mobile device.
+		mobileWebkit: mobile && webkit,
+
+		// @property mobileWebkit3d: Boolean
+		// `true` for all webkit-based browsers in a mobile device supporting CSS transforms.
+		mobileWebkit3d: mobile && webkit3d,
+
+		// @property mobileOpera: Boolean
+		// `true` for the Opera browser in a mobile device.
+		mobileOpera: mobile && window.opera,
+
+		// @property mobileGecko: Boolean
+		// `true` for gecko-based browsers running in a mobile device.
+		mobileGecko: mobile && gecko,
+
+
+		// @property touch: Boolean
+		// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events).
+		touch: !!touch,
+
+		// @property msPointer: Boolean
+		// `true` for browsers implementing the Microsoft touch events model (notably IE10).
+		msPointer: !!msPointer,
+
+		// @property pointer: Boolean
+		// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx).
+		pointer: !!pointer,
+
+
+		// @property retina: Boolean
+		// `true` for browsers on a high-resolution "retina" screen.
+		retina: (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1
+	};
+
+}());
+
+
+
+/*
+ * @class Point
+ * @aka L.Point
+ *
+ * Represents a point with `x` and `y` coordinates in pixels.
+ *
+ * @example
+ *
+ * ```js
+ * var point = L.point(200, 300);
+ * ```
+ *
+ * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent:
+ *
+ * ```js
+ * map.panBy([200, 300]);
+ * map.panBy(L.point(200, 300));
+ * ```
+ */
+
+L.Point = function (x, y, round) {
+	this.x = (round ? Math.round(x) : x);
+	this.y = (round ? Math.round(y) : y);
+};
+
+L.Point.prototype = {
+
+	// @method clone(): Point
+	// Returns a copy of the current point.
+	clone: function () {
+		return new L.Point(this.x, this.y);
+	},
+
+	// @method add(otherPoint: Point): Point
+	// Returns the result of addition of the current and the given points.
+	add: function (point) {
+		// non-destructive, returns a new point
+		return this.clone()._add(L.point(point));
+	},
+
+	_add: function (point) {
+		// destructive, used directly for performance in situations where it's safe to modify existing point
+		this.x += point.x;
+		this.y += point.y;
+		return this;
+	},
+
+	// @method subtract(otherPoint: Point): Point
+	// Returns the result of subtraction of the given point from the current.
+	subtract: function (point) {
+		return this.clone()._subtract(L.point(point));
+	},
+
+	_subtract: function (point) {
+		this.x -= point.x;
+		this.y -= point.y;
+		return this;
+	},
+
+	// @method divideBy(num: Number): Point
+	// Returns the result of division of the current point by the given number.
+	divideBy: function (num) {
+		return this.clone()._divideBy(num);
+	},
+
+	_divideBy: function (num) {
+		this.x /= num;
+		this.y /= num;
+		return this;
+	},
+
+	// @method multiplyBy(num: Number): Point
+	// Returns the result of multiplication of the current point by the given number.
+	multiplyBy: function (num) {
+		return this.clone()._multiplyBy(num);
+	},
+
+	_multiplyBy: function (num) {
+		this.x *= num;
+		this.y *= num;
+		return this;
+	},
+
+	// @method scaleBy(scale: Point): Point
+	// Multiply each coordinate of the current point by each coordinate of
+	// `scale`. In linear algebra terms, multiply the point by the
+	// [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation)
+	// defined by `scale`.
+	scaleBy: function (point) {
+		return new L.Point(this.x * point.x, this.y * point.y);
+	},
+
+	// @method unscaleBy(scale: Point): Point
+	// Inverse of `scaleBy`. Divide each coordinate of the current point by
+	// each coordinate of `scale`.
+	unscaleBy: function (point) {
+		return new L.Point(this.x / point.x, this.y / point.y);
+	},
+
+	// @method round(): Point
+	// Returns a copy of the current point with rounded coordinates.
+	round: function () {
+		return this.clone()._round();
+	},
+
+	_round: function () {
+		this.x = Math.round(this.x);
+		this.y = Math.round(this.y);
+		return this;
+	},
+
+	// @method floor(): Point
+	// Returns a copy of the current point with floored coordinates (rounded down).
+	floor: function () {
+		return this.clone()._floor();
+	},
+
+	_floor: function () {
+		this.x = Math.floor(this.x);
+		this.y = Math.floor(this.y);
+		return this;
+	},
+
+	// @method ceil(): Point
+	// Returns a copy of the current point with ceiled coordinates (rounded up).
+	ceil: function () {
+		return this.clone()._ceil();
+	},
+
+	_ceil: function () {
+		this.x = Math.ceil(this.x);
+		this.y = Math.ceil(this.y);
+		return this;
+	},
+
+	// @method distanceTo(otherPoint: Point): Number
+	// Returns the cartesian distance between the current and the given points.
+	distanceTo: function (point) {
+		point = L.point(point);
+
+		var x = point.x - this.x,
+		    y = point.y - this.y;
+
+		return Math.sqrt(x * x + y * y);
+	},
+
+	// @method equals(otherPoint: Point): Boolean
+	// Returns `true` if the given point has the same coordinates.
+	equals: function (point) {
+		point = L.point(point);
+
+		return point.x === this.x &&
+		       point.y === this.y;
+	},
+
+	// @method contains(otherPoint: Point): Boolean
+	// Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values).
+	contains: function (point) {
+		point = L.point(point);
+
+		return Math.abs(point.x) <= Math.abs(this.x) &&
+		       Math.abs(point.y) <= Math.abs(this.y);
+	},
+
+	// @method toString(): String
+	// Returns a string representation of the point for debugging purposes.
+	toString: function () {
+		return 'Point(' +
+		        L.Util.formatNum(this.x) + ', ' +
+		        L.Util.formatNum(this.y) + ')';
+	}
+};
+
+// @factory L.point(x: Number, y: Number, round?: Boolean)
+// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values.
+
+// @alternative
+// @factory L.point(coords: Number[])
+// Expects an array of the form `[x, y]` instead.
+
+// @alternative
+// @factory L.point(coords: Object)
+// Expects a plain object of the form `{x: Number, y: Number}` instead.
+L.point = function (x, y, round) {
+	if (x instanceof L.Point) {
+		return x;
+	}
+	if (L.Util.isArray(x)) {
+		return new L.Point(x[0], x[1]);
+	}
+	if (x === undefined || x === null) {
+		return x;
+	}
+	if (typeof x === 'object' && 'x' in x && 'y' in x) {
+		return new L.Point(x.x, x.y);
+	}
+	return new L.Point(x, y, round);
+};
+
+
+
+/*
+ * @class Bounds
+ * @aka L.Bounds
+ *
+ * Represents a rectangular area in pixel coordinates.
+ *
+ * @example
+ *
+ * ```js
+ * var p1 = L.point(10, 10),
+ * p2 = L.point(40, 60),
+ * bounds = L.bounds(p1, p2);
+ * ```
+ *
+ * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this:
+ *
+ * ```js
+ * otherBounds.intersects([[10, 10], [40, 60]]);
+ * ```
+ */
+
+L.Bounds = function (a, b) {
+	if (!a) { return; }
+
+	var points = b ? [a, b] : a;
+
+	for (var i = 0, len = points.length; i < len; i++) {
+		this.extend(points[i]);
+	}
+};
+
+L.Bounds.prototype = {
+	// @method extend(point: Point): this
+	// Extends the bounds to contain the given point.
+	extend: function (point) { // (Point)
+		point = L.point(point);
+
+		// @property min: Point
+		// The top left corner of the rectangle.
+		// @property max: Point
+		// The bottom right corner of the rectangle.
+		if (!this.min && !this.max) {
+			this.min = point.clone();
+			this.max = point.clone();
+		} else {
+			this.min.x = Math.min(point.x, this.min.x);
+			this.max.x = Math.max(point.x, this.max.x);
+			this.min.y = Math.min(point.y, this.min.y);
+			this.max.y = Math.max(point.y, this.max.y);
+		}
+		return this;
+	},
+
+	// @method getCenter(round?: Boolean): Point
+	// Returns the center point of the bounds.
+	getCenter: function (round) {
+		return new L.Point(
+		        (this.min.x + this.max.x) / 2,
+		        (this.min.y + this.max.y) / 2, round);
+	},
+
+	// @method getBottomLeft(): Point
+	// Returns the bottom-left point of the bounds.
+	getBottomLeft: function () {
+		return new L.Point(this.min.x, this.max.y);
+	},
+
+	// @method getTopRight(): Point
+	// Returns the top-right point of the bounds.
+	getTopRight: function () { // -> Point
+		return new L.Point(this.max.x, this.min.y);
+	},
+
+	// @method getSize(): Point
+	// Returns the size of the given bounds
+	getSize: function () {
+		return this.max.subtract(this.min);
+	},
+
+	// @method contains(otherBounds: Bounds): Boolean
+	// Returns `true` if the rectangle contains the given one.
+	// @alternative
+	// @method contains(point: Point): Boolean
+	// Returns `true` if the rectangle contains the given point.
+	contains: function (obj) {
+		var min, max;
+
+		if (typeof obj[0] === 'number' || obj instanceof L.Point) {
+			obj = L.point(obj);
+		} else {
+			obj = L.bounds(obj);
+		}
+
+		if (obj instanceof L.Bounds) {
+			min = obj.min;
+			max = obj.max;
+		} else {
+			min = max = obj;
+		}
+
+		return (min.x >= this.min.x) &&
+		       (max.x <= this.max.x) &&
+		       (min.y >= this.min.y) &&
+		       (max.y <= this.max.y);
+	},
+
+	// @method intersects(otherBounds: Bounds): Boolean
+	// Returns `true` if the rectangle intersects the given bounds. Two bounds
+	// intersect if they have at least one point in common.
+	intersects: function (bounds) { // (Bounds) -> Boolean
+		bounds = L.bounds(bounds);
+
+		var min = this.min,
+		    max = this.max,
+		    min2 = bounds.min,
+		    max2 = bounds.max,
+		    xIntersects = (max2.x >= min.x) && (min2.x <= max.x),
+		    yIntersects = (max2.y >= min.y) && (min2.y <= max.y);
+
+		return xIntersects && yIntersects;
+	},
+
+	// @method overlaps(otherBounds: Bounds): Boolean
+	// Returns `true` if the rectangle overlaps the given bounds. Two bounds
+	// overlap if their intersection is an area.
+	overlaps: function (bounds) { // (Bounds) -> Boolean
+		bounds = L.bounds(bounds);
+
+		var min = this.min,
+		    max = this.max,
+		    min2 = bounds.min,
+		    max2 = bounds.max,
+		    xOverlaps = (max2.x > min.x) && (min2.x < max.x),
+		    yOverlaps = (max2.y > min.y) && (min2.y < max.y);
+
+		return xOverlaps && yOverlaps;
+	},
+
+	isValid: function () {
+		return !!(this.min && this.max);
+	}
+};
+
+
+// @factory L.bounds(topLeft: Point, bottomRight: Point)
+// Creates a Bounds object from two coordinates (usually top-left and bottom-right corners).
+// @alternative
+// @factory L.bounds(points: Point[])
+// Creates a Bounds object from the points it contains
+L.bounds = function (a, b) {
+	if (!a || a instanceof L.Bounds) {
+		return a;
+	}
+	return new L.Bounds(a, b);
+};
+
+
+
+/*
+ * @class Transformation
+ * @aka L.Transformation
+ *
+ * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d`
+ * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing
+ * the reverse. Used by Leaflet in its projections code.
+ *
+ * @example
+ *
+ * ```js
+ * var transformation = new L.Transformation(2, 5, -1, 10),
+ * 	p = L.point(1, 2),
+ * 	p2 = transformation.transform(p), //  L.point(7, 8)
+ * 	p3 = transformation.untransform(p2); //  L.point(1, 2)
+ * ```
+ */
+
+
+// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number)
+// Creates a `Transformation` object with the given coefficients.
+L.Transformation = function (a, b, c, d) {
+	this._a = a;
+	this._b = b;
+	this._c = c;
+	this._d = d;
+};
+
+L.Transformation.prototype = {
+	// @method transform(point: Point, scale?: Number): Point
+	// Returns a transformed point, optionally multiplied by the given scale.
+	// Only accepts real `L.Point` instances, not arrays.
+	transform: function (point, scale) { // (Point, Number) -> Point
+		return this._transform(point.clone(), scale);
+	},
+
+	// destructive transform (faster)
+	_transform: function (point, scale) {
+		scale = scale || 1;
+		point.x = scale * (this._a * point.x + this._b);
+		point.y = scale * (this._c * point.y + this._d);
+		return point;
+	},
+
+	// @method untransform(point: Point, scale?: Number): Point
+	// Returns the reverse transformation of the given point, optionally divided
+	// by the given scale. Only accepts real `L.Point` instances, not arrays.
+	untransform: function (point, scale) {
+		scale = scale || 1;
+		return new L.Point(
+		        (point.x / scale - this._b) / this._a,
+		        (point.y / scale - this._d) / this._c);
+	}
+};
+
+
+
+/*
+ * @namespace DomUtil
+ *
+ * Utility functions to work with the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model)
+ * tree, used by Leaflet internally.
+ *
+ * Most functions expecting or returning a `HTMLElement` also work for
+ * SVG elements. The only difference is that classes refer to CSS classes
+ * in HTML and SVG classes in SVG.
+ */
+
+L.DomUtil = {
+
+	// @function get(id: String|HTMLElement): HTMLElement
+	// Returns an element given its DOM id, or returns the element itself
+	// if it was passed directly.
+	get: function (id) {
+		return typeof id === 'string' ? document.getElementById(id) : id;
+	},
+
+	// @function getStyle(el: HTMLElement, styleAttrib: String): String
+	// Returns the value for a certain style attribute on an element,
+	// including computed values or values set through CSS.
+	getStyle: function (el, style) {
+
+		var value = el.style[style] || (el.currentStyle && el.currentStyle[style]);
+
+		if ((!value || value === 'auto') && document.defaultView) {
+			var css = document.defaultView.getComputedStyle(el, null);
+			value = css ? css[style] : null;
+		}
+
+		return value === 'auto' ? null : value;
+	},
+
+	// @function create(tagName: String, className?: String, container?: HTMLElement): HTMLElement
+	// Creates an HTML element with `tagName`, sets its class to `className`, and optionally appends it to `container` element.
+	create: function (tagName, className, container) {
+
+		var el = document.createElement(tagName);
+		el.className = className || '';
+
+		if (container) {
+			container.appendChild(el);
+		}
+
+		return el;
+	},
+
+	// @function remove(el: HTMLElement)
+	// Removes `el` from its parent element
+	remove: function (el) {
+		var parent = el.parentNode;
+		if (parent) {
+			parent.removeChild(el);
+		}
+	},
+
+	// @function empty(el: HTMLElement)
+	// Removes all of `el`'s children elements from `el`
+	empty: function (el) {
+		while (el.firstChild) {
+			el.removeChild(el.firstChild);
+		}
+	},
+
+	// @function toFront(el: HTMLElement)
+	// Makes `el` the last children of its parent, so it renders in front of the other children.
+	toFront: function (el) {
+		el.parentNode.appendChild(el);
+	},
+
+	// @function toBack(el: HTMLElement)
+	// Makes `el` the first children of its parent, so it renders back from the other children.
+	toBack: function (el) {
+		var parent = el.parentNode;
+		parent.insertBefore(el, parent.firstChild);
+	},
+
+	// @function hasClass(el: HTMLElement, name: String): Boolean
+	// Returns `true` if the element's class attribute contains `name`.
+	hasClass: function (el, name) {
+		if (el.classList !== undefined) {
+			return el.classList.contains(name);
+		}
+		var className = L.DomUtil.getClass(el);
+		return className.length > 0 && new RegExp('(^|\\s)' + name + '(\\s|$)').test(className);
+	},
+
+	// @function addClass(el: HTMLElement, name: String)
+	// Adds `name` to the element's class attribute.
+	addClass: function (el, name) {
+		if (el.classList !== undefined) {
+			var classes = L.Util.splitWords(name);
+			for (var i = 0, len = classes.length; i < len; i++) {
+				el.classList.add(classes[i]);
+			}
+		} else if (!L.DomUtil.hasClass(el, name)) {
+			var className = L.DomUtil.getClass(el);
+			L.DomUtil.setClass(el, (className ? className + ' ' : '') + name);
+		}
+	},
+
+	// @function removeClass(el: HTMLElement, name: String)
+	// Removes `name` from the element's class attribute.
+	removeClass: function (el, name) {
+		if (el.classList !== undefined) {
+			el.classList.remove(name);
+		} else {
+			L.DomUtil.setClass(el, L.Util.trim((' ' + L.DomUtil.getClass(el) + ' ').replace(' ' + name + ' ', ' ')));
+		}
+	},
+
+	// @function setClass(el: HTMLElement, name: String)
+	// Sets the element's class.
+	setClass: function (el, name) {
+		if (el.className.baseVal === undefined) {
+			el.className = name;
+		} else {
+			// in case of SVG element
+			el.className.baseVal = name;
+		}
+	},
+
+	// @function getClass(el: HTMLElement): String
+	// Returns the element's class.
+	getClass: function (el) {
+		return el.className.baseVal === undefined ? el.className : el.className.baseVal;
+	},
+
+	// @function setOpacity(el: HTMLElement, opacity: Number)
+	// Set the opacity of an element (including old IE support).
+	// `opacity` must be a number from `0` to `1`.
+	setOpacity: function (el, value) {
+
+		if ('opacity' in el.style) {
+			el.style.opacity = value;
+
+		} else if ('filter' in el.style) {
+			L.DomUtil._setOpacityIE(el, value);
+		}
+	},
+
+	_setOpacityIE: function (el, value) {
+		var filter = false,
+		    filterName = 'DXImageTransform.Microsoft.Alpha';
+
+		// filters collection throws an error if we try to retrieve a filter that doesn't exist
+		try {
+			filter = el.filters.item(filterName);
+		} catch (e) {
+			// don't set opacity to 1 if we haven't already set an opacity,
+			// it isn't needed and breaks transparent pngs.
+			if (value === 1) { return; }
+		}
+
+		value = Math.round(value * 100);
+
+		if (filter) {
+			filter.Enabled = (value !== 100);
+			filter.Opacity = value;
+		} else {
+			el.style.filter += ' progid:' + filterName + '(opacity=' + value + ')';
+		}
+	},
+
+	// @function testProp(props: String[]): String|false
+	// Goes through the array of style names and returns the first name
+	// that is a valid style name for an element. If no such name is found,
+	// it returns false. Useful for vendor-prefixed styles like `transform`.
+	testProp: function (props) {
+
+		var style = document.documentElement.style;
+
+		for (var i = 0; i < props.length; i++) {
+			if (props[i] in style) {
+				return props[i];
+			}
+		}
+		return false;
+	},
+
+	// @function setTransform(el: HTMLElement, offset: Point, scale?: Number)
+	// Resets the 3D CSS transform of `el` so it is translated by `offset` pixels
+	// and optionally scaled by `scale`. Does not have an effect if the
+	// browser doesn't support 3D CSS transforms.
+	setTransform: function (el, offset, scale) {
+		var pos = offset || new L.Point(0, 0);
+
+		el.style[L.DomUtil.TRANSFORM] =
+			(L.Browser.ie3d ?
+				'translate(' + pos.x + 'px,' + pos.y + 'px)' :
+				'translate3d(' + pos.x + 'px,' + pos.y + 'px,0)') +
+			(scale ? ' scale(' + scale + ')' : '');
+	},
+
+	// @function setPosition(el: HTMLElement, position: Point)
+	// Sets the position of `el` to coordinates specified by `position`,
+	// using CSS translate or top/left positioning depending on the browser
+	// (used by Leaflet internally to position its layers).
+	setPosition: function (el, point) { // (HTMLElement, Point[, Boolean])
+
+		/*eslint-disable */
+		el._leaflet_pos = point;
+		/*eslint-enable */
+
+		if (L.Browser.any3d) {
+			L.DomUtil.setTransform(el, point);
+		} else {
+			el.style.left = point.x + 'px';
+			el.style.top = point.y + 'px';
+		}
+	},
+
+	// @function getPosition(el: HTMLElement): Point
+	// Returns the coordinates of an element previously positioned with setPosition.
+	getPosition: function (el) {
+		// this method is only used for elements previously positioned using setPosition,
+		// so it's safe to cache the position for performance
+
+		return el._leaflet_pos || new L.Point(0, 0);
+	}
+};
+
+
+(function () {
+	// prefix style property names
+
+	// @property TRANSFORM: String
+	// Vendor-prefixed fransform style name (e.g. `'webkitTransform'` for WebKit).
+	L.DomUtil.TRANSFORM = L.DomUtil.testProp(
+			['transform', 'WebkitTransform', 'OTransform', 'MozTransform', 'msTransform']);
+
+
+	// webkitTransition comes first because some browser versions that drop vendor prefix don't do
+	// the same for the transitionend event, in particular the Android 4.1 stock browser
+
+	// @property TRANSITION: String
+	// Vendor-prefixed transform style name.
+	var transition = L.DomUtil.TRANSITION = L.DomUtil.testProp(
+			['webkitTransition', 'transition', 'OTransition', 'MozTransition', 'msTransition']);
+
+	L.DomUtil.TRANSITION_END =
+			transition === 'webkitTransition' || transition === 'OTransition' ? transition + 'End' : 'transitionend';
+
+	// @function disableTextSelection()
+	// Prevents the user from generating `selectstart` DOM events, usually generated
+	// when the user drags the mouse through a page with text. Used internally
+	// by Leaflet to override the behaviour of any click-and-drag interaction on
+	// the map. Affects drag interactions on the whole document.
+
+	// @function enableTextSelection()
+	// Cancels the effects of a previous [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection).
+	if ('onselectstart' in document) {
+		L.DomUtil.disableTextSelection = function () {
+			L.DomEvent.on(window, 'selectstart', L.DomEvent.preventDefault);
+		};
+		L.DomUtil.enableTextSelection = function () {
+			L.DomEvent.off(window, 'selectstart', L.DomEvent.preventDefault);
+		};
+
+	} else {
+		var userSelectProperty = L.DomUtil.testProp(
+			['userSelect', 'WebkitUserSelect', 'OUserSelect', 'MozUserSelect', 'msUserSelect']);
+
+		L.DomUtil.disableTextSelection = function () {
+			if (userSelectProperty) {
+				var style = document.documentElement.style;
+				this._userSelect = style[userSelectProperty];
+				style[userSelectProperty] = 'none';
+			}
+		};
+		L.DomUtil.enableTextSelection = function () {
+			if (userSelectProperty) {
+				document.documentElement.style[userSelectProperty] = this._userSelect;
+				delete this._userSelect;
+			}
+		};
+	}
+
+	// @function disableImageDrag()
+	// As [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection), but
+	// for `dragstart` DOM events, usually generated when the user drags an image.
+	L.DomUtil.disableImageDrag = function () {
+		L.DomEvent.on(window, 'dragstart', L.DomEvent.preventDefault);
+	};
+
+	// @function enableImageDrag()
+	// Cancels the effects of a previous [`L.DomUtil.disableImageDrag`](#domutil-disabletextselection).
+	L.DomUtil.enableImageDrag = function () {
+		L.DomEvent.off(window, 'dragstart', L.DomEvent.preventDefault);
+	};
+
+	// @function preventOutline(el: HTMLElement)
+	// Makes the [outline](https://developer.mozilla.org/docs/Web/CSS/outline)
+	// of the element `el` invisible. Used internally by Leaflet to prevent
+	// focusable elements from displaying an outline when the user performs a
+	// drag interaction on them.
+	L.DomUtil.preventOutline = function (element) {
+		while (element.tabIndex === -1) {
+			element = element.parentNode;
+		}
+		if (!element || !element.style) { return; }
+		L.DomUtil.restoreOutline();
+		this._outlineElement = element;
+		this._outlineStyle = element.style.outline;
+		element.style.outline = 'none';
+		L.DomEvent.on(window, 'keydown', L.DomUtil.restoreOutline, this);
+	};
+
+	// @function restoreOutline()
+	// Cancels the effects of a previous [`L.DomUtil.preventOutline`]().
+	L.DomUtil.restoreOutline = function () {
+		if (!this._outlineElement) { return; }
+		this._outlineElement.style.outline = this._outlineStyle;
+		delete this._outlineElement;
+		delete this._outlineStyle;
+		L.DomEvent.off(window, 'keydown', L.DomUtil.restoreOutline, this);
+	};
+})();
+
+
+
+/* @class LatLng
+ * @aka L.LatLng
+ *
+ * Represents a geographical point with a certain latitude and longitude.
+ *
+ * @example
+ *
+ * ```
+ * var latlng = L.latLng(50.5, 30.5);
+ * ```
+ *
+ * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent:
+ *
+ * ```
+ * map.panTo([50, 30]);
+ * map.panTo({lon: 30, lat: 50});
+ * map.panTo({lat: 50, lng: 30});
+ * map.panTo(L.latLng(50, 30));
+ * ```
+ */
+
+L.LatLng = function (lat, lng, alt) {
+	if (isNaN(lat) || isNaN(lng)) {
+		throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')');
+	}
+
+	// @property lat: Number
+	// Latitude in degrees
+	this.lat = +lat;
+
+	// @property lng: Number
+	// Longitude in degrees
+	this.lng = +lng;
+
+	// @property alt: Number
+	// Altitude in meters (optional)
+	if (alt !== undefined) {
+		this.alt = +alt;
+	}
+};
+
+L.LatLng.prototype = {
+	// @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean
+	// Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overriden by setting `maxMargin` to a small number.
+	equals: function (obj, maxMargin) {
+		if (!obj) { return false; }
+
+		obj = L.latLng(obj);
+
+		var margin = Math.max(
+		        Math.abs(this.lat - obj.lat),
+		        Math.abs(this.lng - obj.lng));
+
+		return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin);
+	},
+
+	// @method toString(): String
+	// Returns a string representation of the point (for debugging purposes).
+	toString: function (precision) {
+		return 'LatLng(' +
+		        L.Util.formatNum(this.lat, precision) + ', ' +
+		        L.Util.formatNum(this.lng, precision) + ')';
+	},
+
+	// @method distanceTo(otherLatLng: LatLng): Number
+	// Returns the distance (in meters) to the given `LatLng` calculated using the [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula).
+	distanceTo: function (other) {
+		return L.CRS.Earth.distance(this, L.latLng(other));
+	},
+
+	// @method wrap(): LatLng
+	// Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees.
+	wrap: function () {
+		return L.CRS.Earth.wrapLatLng(this);
+	},
+
+	// @method toBounds(sizeInMeters: Number): LatLngBounds
+	// Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters` meters apart from the `LatLng`.
+	toBounds: function (sizeInMeters) {
+		var latAccuracy = 180 * sizeInMeters / 40075017,
+		    lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat);
+
+		return L.latLngBounds(
+		        [this.lat - latAccuracy, this.lng - lngAccuracy],
+		        [this.lat + latAccuracy, this.lng + lngAccuracy]);
+	},
+
+	clone: function () {
+		return new L.LatLng(this.lat, this.lng, this.alt);
+	}
+};
+
+
+
+// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng
+// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude).
+
+// @alternative
+// @factory L.latLng(coords: Array): LatLng
+// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead.
+
+// @alternative
+// @factory L.latLng(coords: Object): LatLng
+// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead.
+
+L.latLng = function (a, b, c) {
+	if (a instanceof L.LatLng) {
+		return a;
+	}
+	if (L.Util.isArray(a) && typeof a[0] !== 'object') {
+		if (a.length === 3) {
+			return new L.LatLng(a[0], a[1], a[2]);
+		}
+		if (a.length === 2) {
+			return new L.LatLng(a[0], a[1]);
+		}
+		return null;
+	}
+	if (a === undefined || a === null) {
+		return a;
+	}
+	if (typeof a === 'object' && 'lat' in a) {
+		return new L.LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt);
+	}
+	if (b === undefined) {
+		return null;
+	}
+	return new L.LatLng(a, b, c);
+};
+
+
+
+/*
+ * @class LatLngBounds
+ * @aka L.LatLngBounds
+ *
+ * Represents a rectangular geographical area on a map.
+ *
+ * @example
+ *
+ * ```js
+ * var southWest = L.latLng(40.712, -74.227),
+ * northEast = L.latLng(40.774, -74.125),
+ * bounds = L.latLngBounds(southWest, northEast);
+ * ```
+ *
+ * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this:
+ *
+ * ```js
+ * map.fitBounds([
+ * 	[40.712, -74.227],
+ * 	[40.774, -74.125]
+ * ]);
+ * ```
+ */
+
+L.LatLngBounds = function (southWest, northEast) { // (LatLng, LatLng) or (LatLng[])
+	if (!southWest) { return; }
+
+	var latlngs = northEast ? [southWest, northEast] : southWest;
+
+	for (var i = 0, len = latlngs.length; i < len; i++) {
+		this.extend(latlngs[i]);
+	}
+};
+
+L.LatLngBounds.prototype = {
+
+	// @method extend(latlng: LatLng): this
+	// Extend the bounds to contain the given point
+
+	// @alternative
+	// @method extend(otherBounds: LatLngBounds): this
+	// Extend the bounds to contain the given bounds
+	extend: function (obj) {
+		var sw = this._southWest,
+		    ne = this._northEast,
+		    sw2, ne2;
+
+		if (obj instanceof L.LatLng) {
+			sw2 = obj;
+			ne2 = obj;
+
+		} else if (obj instanceof L.LatLngBounds) {
+			sw2 = obj._southWest;
+			ne2 = obj._northEast;
+
+			if (!sw2 || !ne2) { return this; }
+
+		} else {
+			return obj ? this.extend(L.latLng(obj) || L.latLngBounds(obj)) : this;
+		}
+
+		if (!sw && !ne) {
+			this._southWest = new L.LatLng(sw2.lat, sw2.lng);
+			this._northEast = new L.LatLng(ne2.lat, ne2.lng);
+		} else {
+			sw.lat = Math.min(sw2.lat, sw.lat);
+			sw.lng = Math.min(sw2.lng, sw.lng);
+			ne.lat = Math.max(ne2.lat, ne.lat);
+			ne.lng = Math.max(ne2.lng, ne.lng);
+		}
+
+		return this;
+	},
+
+	// @method pad(bufferRatio: Number): LatLngBounds
+	// Returns bigger bounds created by extending the current bounds by a given percentage in each direction.
+	pad: function (bufferRatio) {
+		var sw = this._southWest,
+		    ne = this._northEast,
+		    heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio,
+		    widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio;
+
+		return new L.LatLngBounds(
+		        new L.LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer),
+		        new L.LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer));
+	},
+
+	// @method getCenter(): LatLng
+	// Returns the center point of the bounds.
+	getCenter: function () {
+		return new L.LatLng(
+		        (this._southWest.lat + this._northEast.lat) / 2,
+		        (this._southWest.lng + this._northEast.lng) / 2);
+	},
+
+	// @method getSouthWest(): LatLng
+	// Returns the south-west point of the bounds.
+	getSouthWest: function () {
+		return this._southWest;
+	},
+
+	// @method getNorthEast(): LatLng
+	// Returns the north-east point of the bounds.
+	getNorthEast: function () {
+		return this._northEast;
+	},
+
+	// @method getNorthWest(): LatLng
+	// Returns the north-west point of the bounds.
+	getNorthWest: function () {
+		return new L.LatLng(this.getNorth(), this.getWest());
+	},
+
+	// @method getSouthEast(): LatLng
+	// Returns the south-east point of the bounds.
+	getSouthEast: function () {
+		return new L.LatLng(this.getSouth(), this.getEast());
+	},
+
+	// @method getWest(): Number
+	// Returns the west longitude of the bounds
+	getWest: function () {
+		return this._southWest.lng;
+	},
+
+	// @method getSouth(): Number
+	// Returns the south latitude of the bounds
+	getSouth: function () {
+		return this._southWest.lat;
+	},
+
+	// @method getEast(): Number
+	// Returns the east longitude of the bounds
+	getEast: function () {
+		return this._northEast.lng;
+	},
+
+	// @method getNorth(): Number
+	// Returns the north latitude of the bounds
+	getNorth: function () {
+		return this._northEast.lat;
+	},
+
+	// @method contains(otherBounds: LatLngBounds): Boolean
+	// Returns `true` if the rectangle contains the given one.
+
+	// @alternative
+	// @method contains (latlng: LatLng): Boolean
+	// Returns `true` if the rectangle contains the given point.
+	contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean
+		if (typeof obj[0] === 'number' || obj instanceof L.LatLng) {
+			obj = L.latLng(obj);
+		} else {
+			obj = L.latLngBounds(obj);
+		}
+
+		var sw = this._southWest,
+		    ne = this._northEast,
+		    sw2, ne2;
+
+		if (obj instanceof L.LatLngBounds) {
+			sw2 = obj.getSouthWest();
+			ne2 = obj.getNorthEast();
+		} else {
+			sw2 = ne2 = obj;
+		}
+
+		return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) &&
+		       (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng);
+	},
+
+	// @method intersects(otherBounds: LatLngBounds): Boolean
+	// Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common.
+	intersects: function (bounds) {
+		bounds = L.latLngBounds(bounds);
+
+		var sw = this._southWest,
+		    ne = this._northEast,
+		    sw2 = bounds.getSouthWest(),
+		    ne2 = bounds.getNorthEast(),
+
+		    latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat),
+		    lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng);
+
+		return latIntersects && lngIntersects;
+	},
+
+	// @method overlaps(otherBounds: Bounds): Boolean
+	// Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area.
+	overlaps: function (bounds) {
+		bounds = L.latLngBounds(bounds);
+
+		var sw = this._southWest,
+		    ne = this._northEast,
+		    sw2 = bounds.getSouthWest(),
+		    ne2 = bounds.getNorthEast(),
+
+		    latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat),
+		    lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng);
+
+		return latOverlaps && lngOverlaps;
+	},
+
+	// @method toBBoxString(): String
+	// Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data.
+	toBBoxString: function () {
+		return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(',');
+	},
+
+	// @method equals(otherBounds: LatLngBounds): Boolean
+	// Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds.
+	equals: function (bounds) {
+		if (!bounds) { return false; }
+
+		bounds = L.latLngBounds(bounds);
+
+		return this._southWest.equals(bounds.getSouthWest()) &&
+		       this._northEast.equals(bounds.getNorthEast());
+	},
+
+	// @method isValid(): Boolean
+	// Returns `true` if the bounds are properly initialized.
+	isValid: function () {
+		return !!(this._southWest && this._northEast);
+	}
+};
+
+// TODO International date line?
+
+// @factory L.latLngBounds(southWest: LatLng, northEast: LatLng)
+// Creates a `LatLngBounds` object by defining south-west and north-east corners of the rectangle.
+
+// @alternative
+// @factory L.latLngBounds(latlngs: LatLng[])
+// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds).
+L.latLngBounds = function (a, b) {
+	if (a instanceof L.LatLngBounds) {
+		return a;
+	}
+	return new L.LatLngBounds(a, b);
+};
+
+
+
+/*
+ * @namespace Projection
+ * @section
+ * Leaflet comes with a set of already defined Projections out of the box:
+ *
+ * @projection L.Projection.LonLat
+ *
+ * Equirectangular, or Plate Carree projection — the most simple projection,
+ * mostly used by GIS enthusiasts. Directly maps `x` as longitude, and `y` as
+ * latitude. Also suitable for flat worlds, e.g. game maps. Used by the
+ * `EPSG:3395` and `Simple` CRS.
+ */
+
+L.Projection = {};
+
+L.Projection.LonLat = {
+	project: function (latlng) {
+		return new L.Point(latlng.lng, latlng.lat);
+	},
+
+	unproject: function (point) {
+		return new L.LatLng(point.y, point.x);
+	},
+
+	bounds: L.bounds([-180, -90], [180, 90])
+};
+
+
+
+/*
+ * @namespace Projection
+ * @projection L.Projection.SphericalMercator
+ *
+ * Spherical Mercator projection — the most common projection for online maps,
+ * used by almost all free and commercial tile providers. Assumes that Earth is
+ * a sphere. Used by the `EPSG:3857` CRS.
+ */
+
+L.Projection.SphericalMercator = {
+
+	R: 6378137,
+	MAX_LATITUDE: 85.0511287798,
+
+	project: function (latlng) {
+		var d = Math.PI / 180,
+		    max = this.MAX_LATITUDE,
+		    lat = Math.max(Math.min(max, latlng.lat), -max),
+		    sin = Math.sin(lat * d);
+
+		return new L.Point(
+				this.R * latlng.lng * d,
+				this.R * Math.log((1 + sin) / (1 - sin)) / 2);
+	},
+
+	unproject: function (point) {
+		var d = 180 / Math.PI;
+
+		return new L.LatLng(
+			(2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d,
+			point.x * d / this.R);
+	},
+
+	bounds: (function () {
+		var d = 6378137 * Math.PI;
+		return L.bounds([-d, -d], [d, d]);
+	})()
+};
+
+
+
+/*
+ * @class CRS
+ * @aka L.CRS
+ * Abstract class that defines coordinate reference systems for projecting
+ * geographical points into pixel (screen) coordinates and back (and to
+ * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See
+ * [spatial reference system](http://en.wikipedia.org/wiki/Coordinate_reference_system).
+ *
+ * Leaflet defines the most usual CRSs by default. If you want to use a
+ * CRS not defined by default, take a look at the
+ * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin.
+ */
+
+L.CRS = {
+	// @method latLngToPoint(latlng: LatLng, zoom: Number): Point
+	// Projects geographical coordinates into pixel coordinates for a given zoom.
+	latLngToPoint: function (latlng, zoom) {
+		var projectedPoint = this.projection.project(latlng),
+		    scale = this.scale(zoom);
+
+		return this.transformation._transform(projectedPoint, scale);
+	},
+
+	// @method pointToLatLng(point: Point, zoom: Number): LatLng
+	// The inverse of `latLngToPoint`. Projects pixel coordinates on a given
+	// zoom into geographical coordinates.
+	pointToLatLng: function (point, zoom) {
+		var scale = this.scale(zoom),
+		    untransformedPoint = this.transformation.untransform(point, scale);
+
+		return this.projection.unproject(untransformedPoint);
+	},
+
+	// @method project(latlng: LatLng): Point
+	// Projects geographical coordinates into coordinates in units accepted for
+	// this CRS (e.g. meters for EPSG:3857, for passing it to WMS services).
+	project: function (latlng) {
+		return this.projection.project(latlng);
+	},
+
+	// @method unproject(point: Point): LatLng
+	// Given a projected coordinate returns the corresponding LatLng.
+	// The inverse of `project`.
+	unproject: function (point) {
+		return this.projection.unproject(point);
+	},
+
+	// @method scale(zoom: Number): Number
+	// Returns the scale used when transforming projected coordinates into
+	// pixel coordinates for a particular zoom. For example, it returns
+	// `256 * 2^zoom` for Mercator-based CRS.
+	scale: function (zoom) {
+		return 256 * Math.pow(2, zoom);
+	},
+
+	// @method zoom(scale: Number): Number
+	// Inverse of `scale()`, returns the zoom level corresponding to a scale
+	// factor of `scale`.
+	zoom: function (scale) {
+		return Math.log(scale / 256) / Math.LN2;
+	},
+
+	// @method getProjectedBounds(zoom: Number): Bounds
+	// Returns the projection's bounds scaled and transformed for the provided `zoom`.
+	getProjectedBounds: function (zoom) {
+		if (this.infinite) { return null; }
+
+		var b = this.projection.bounds,
+		    s = this.scale(zoom),
+		    min = this.transformation.transform(b.min, s),
+		    max = this.transformation.transform(b.max, s);
+
+		return L.bounds(min, max);
+	},
+
+	// @method distance(latlng1: LatLng, latlng2: LatLng): Number
+	// Returns the distance between two geographical coordinates.
+
+	// @property code: String
+	// Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`)
+	//
+	// @property wrapLng: Number[]
+	// An array of two numbers defining whether the longitude (horizontal) coordinate
+	// axis wraps around a given range and how. Defaults to `[-180, 180]` in most
+	// geographical CRSs. If `undefined`, the longitude axis does not wrap around.
+	//
+	// @property wrapLat: Number[]
+	// Like `wrapLng`, but for the latitude (vertical) axis.
+
+	// wrapLng: [min, max],
+	// wrapLat: [min, max],
+
+	// @property infinite: Boolean
+	// If true, the coordinate space will be unbounded (infinite in both axes)
+	infinite: false,
+
+	// @method wrapLatLng(latlng: LatLng): LatLng
+	// Returns a `LatLng` where lat and lng has been wrapped according to the
+	// CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds.
+	wrapLatLng: function (latlng) {
+		var lng = this.wrapLng ? L.Util.wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng,
+		    lat = this.wrapLat ? L.Util.wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat,
+		    alt = latlng.alt;
+
+		return L.latLng(lat, lng, alt);
+	}
+};
+
+
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.Simple
+ *
+ * A simple CRS that maps longitude and latitude into `x` and `y` directly.
+ * May be used for maps of flat surfaces (e.g. game maps). Note that the `y`
+ * axis should still be inverted (going from bottom to top). `distance()` returns
+ * simple euclidean distance.
+ */
+
+L.CRS.Simple = L.extend({}, L.CRS, {
+	projection: L.Projection.LonLat,
+	transformation: new L.Transformation(1, 0, -1, 0),
+
+	scale: function (zoom) {
+		return Math.pow(2, zoom);
+	},
+
+	zoom: function (scale) {
+		return Math.log(scale) / Math.LN2;
+	},
+
+	distance: function (latlng1, latlng2) {
+		var dx = latlng2.lng - latlng1.lng,
+		    dy = latlng2.lat - latlng1.lat;
+
+		return Math.sqrt(dx * dx + dy * dy);
+	},
+
+	infinite: true
+});
+
+
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.Earth
+ *
+ * Serves as the base for CRS that are global such that they cover the earth.
+ * Can only be used as the base for other CRS and cannot be used directly,
+ * since it does not have a `code`, `projection` or `transformation`. `distance()` returns
+ * meters.
+ */
+
+L.CRS.Earth = L.extend({}, L.CRS, {
+	wrapLng: [-180, 180],
+
+	// Mean Earth Radius, as recommended for use by
+	// the International Union of Geodesy and Geophysics,
+	// see http://rosettacode.org/wiki/Haversine_formula
+	R: 6371000,
+
+	// distance between two geographical points using spherical law of cosines approximation
+	distance: function (latlng1, latlng2) {
+		var rad = Math.PI / 180,
+		    lat1 = latlng1.lat * rad,
+		    lat2 = latlng2.lat * rad,
+		    a = Math.sin(lat1) * Math.sin(lat2) +
+		        Math.cos(lat1) * Math.cos(lat2) * Math.cos((latlng2.lng - latlng1.lng) * rad);
+
+		return this.R * Math.acos(Math.min(a, 1));
+	}
+});
+
+
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.EPSG3857
+ *
+ * The most common CRS for online maps, used by almost all free and commercial
+ * tile providers. Uses Spherical Mercator projection. Set in by default in
+ * Map's `crs` option.
+ */
+
+L.CRS.EPSG3857 = L.extend({}, L.CRS.Earth, {
+	code: 'EPSG:3857',
+	projection: L.Projection.SphericalMercator,
+
+	transformation: (function () {
+		var scale = 0.5 / (Math.PI * L.Projection.SphericalMercator.R);
+		return new L.Transformation(scale, 0.5, -scale, 0.5);
+	}())
+});
+
+L.CRS.EPSG900913 = L.extend({}, L.CRS.EPSG3857, {
+	code: 'EPSG:900913'
+});
+
+
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.EPSG4326
+ *
+ * A common CRS among GIS enthusiasts. Uses simple Equirectangular projection.
+ */
+
+L.CRS.EPSG4326 = L.extend({}, L.CRS.Earth, {
+	code: 'EPSG:4326',
+	projection: L.Projection.LonLat,
+	transformation: new L.Transformation(1 / 180, 1, -1 / 180, 0.5)
+});
+
+
+
+/*
+ * @class Map
+ * @aka L.Map
+ * @inherits Evented
+ *
+ * The central class of the API — it is used to create a map on a page and manipulate it.
+ *
+ * @example
+ *
+ * ```js
+ * // initialize the map on the "map" div with a given center and zoom
+ * var map = L.map('map', {
+ * 	center: [51.505, -0.09],
+ * 	zoom: 13
+ * });
+ * ```
+ *
+ */
+
+L.Map = L.Evented.extend({
+
+	options: {
+		// @section Map State Options
+		// @option crs: CRS = L.CRS.EPSG3857
+		// The [Coordinate Reference System](#crs) to use. Don't change this if you're not
+		// sure what it means.
+		crs: L.CRS.EPSG3857,
+
+		// @option center: LatLng = undefined
+		// Initial geographic center of the map
+		center: undefined,
+
+		// @option zoom: Number = undefined
+		// Initial map zoom level
+		zoom: undefined,
+
+		// @option minZoom: Number = undefined
+		// Minimum zoom level of the map. Overrides any `minZoom` option set on map layers.
+		minZoom: undefined,
+
+		// @option maxZoom: Number = undefined
+		// Maximum zoom level of the map. Overrides any `maxZoom` option set on map layers.
+		maxZoom: undefined,
+
+		// @option layers: Layer[] = []
+		// Array of layers that will be added to the map initially
+		layers: [],
+
+		// @option maxBounds: LatLngBounds = null
+		// When this option is set, the map restricts the view to the given
+		// geographical bounds, bouncing the user back when he tries to pan
+		// outside the view. To set the restriction dynamically, use
+		// [`setMaxBounds`](#map-setmaxbounds) method.
+		maxBounds: undefined,
+
+		// @option renderer: Renderer = *
+		// The default method for drawing vector layers on the map. `L.SVG`
+		// or `L.Canvas` by default depending on browser support.
+		renderer: undefined,
+
+
+		// @section Animation Options
+		// @option fadeAnimation: Boolean = true
+		// Whether the tile fade animation is enabled. By default it's enabled
+		// in all browsers that support CSS3 Transitions except Android.
+		fadeAnimation: true,
+
+		// @option markerZoomAnimation: Boolean = true
+		// Whether markers animate their zoom with the zoom animation, if disabled
+		// they will disappear for the length of the animation. By default it's
+		// enabled in all browsers that support CSS3 Transitions except Android.
+		markerZoomAnimation: true,
+
+		// @option transform3DLimit: Number = 2^23
+		// Defines the maximum size of a CSS translation transform. The default
+		// value should not be changed unless a web browser positions layers in
+		// the wrong place after doing a large `panBy`.
+		transform3DLimit: 8388608, // Precision limit of a 32-bit float
+
+		// @section Interaction Options
+		// @option zoomSnap: Number = 1
+		// Forces the map's zoom level to always be a multiple of this, particularly
+		// right after a [`fitBounds()`](#map-fitbounds) or a pinch-zoom.
+		// By default, the zoom level snaps to the nearest integer; lower values
+		// (e.g. `0.5` or `0.1`) allow for greater granularity. A value of `0`
+		// means the zoom level will not be snapped after `fitBounds` or a pinch-zoom.
+		zoomSnap: 1,
+
+		// @option zoomDelta: Number = 1
+		// Controls how much the map's zoom level will change after a
+		// [`zoomIn()`](#map-zoomin), [`zoomOut()`](#map-zoomout), pressing `+`
+		// or `-` on the keyboard, or using the [zoom controls](#control-zoom).
+		// Values smaller than `1` (e.g. `0.5`) allow for greater granularity.
+		zoomDelta: 1,
+
+		// @option trackResize: Boolean = true
+		// Whether the map automatically handles browser window resize to update itself.
+		trackResize: true
+	},
+
+	initialize: function (id, options) { // (HTMLElement or String, Object)
+		options = L.setOptions(this, options);
+
+		this._initContainer(id);
+		this._initLayout();
+
+		// hack for https://github.com/Leaflet/Leaflet/issues/1980
+		this._onResize = L.bind(this._onResize, this);
+
+		this._initEvents();
+
+		if (options.maxBounds) {
+			this.setMaxBounds(options.maxBounds);
+		}
+
+		if (options.zoom !== undefined) {
+			this._zoom = this._limitZoom(options.zoom);
+		}
+
+		if (options.center && options.zoom !== undefined) {
+			this.setView(L.latLng(options.center), options.zoom, {reset: true});
+		}
+
+		this._handlers = [];
+		this._layers = {};
+		this._zoomBoundLayers = {};
+		this._sizeChanged = true;
+
+		this.callInitHooks();
+
+		this._addLayers(this.options.layers);
+	},
+
+
+	// @section Methods for modifying map state
+
+	// @method setView(center: LatLng, zoom: Number, options?: Zoom/pan options): this
+	// Sets the view of the map (geographical center and zoom) with the given
+	// animation options.
+	setView: function (center, zoom) {
+		// replaced by animation-powered implementation in Map.PanAnimation.js
+		zoom = zoom === undefined ? this.getZoom() : zoom;
+		this._resetView(L.latLng(center), zoom);
+		return this;
+	},
+
+	// @method setZoom(zoom: Number, options: Zoom/pan options): this
+	// Sets the zoom of the map.
+	setZoom: function (zoom, options) {
+		if (!this._loaded) {
+			this._zoom = zoom;
+			return this;
+		}
+		return this.setView(this.getCenter(), zoom, {zoom: options});
+	},
+
+	// @method zoomIn(delta?: Number, options?: Zoom options): this
+	// Increases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default).
+	zoomIn: function (delta, options) {
+		delta = delta || (L.Browser.any3d ? this.options.zoomDelta : 1);
+		return this.setZoom(this._zoom + delta, options);
+	},
+
+	// @method zoomOut(delta?: Number, options?: Zoom options): this
+	// Decreases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default).
+	zoomOut: function (delta, options) {
+		delta = delta || (L.Browser.any3d ? this.options.zoomDelta : 1);
+		return this.setZoom(this._zoom - delta, options);
+	},
+
+	// @method setZoomAround(latlng: LatLng, zoom: Number, options: Zoom options): this
+	// Zooms the map while keeping a specified geographical point on the map
+	// stationary (e.g. used internally for scroll zoom and double-click zoom).
+	// @alternative
+	// @method setZoomAround(offset: Point, zoom: Number, options: Zoom options): this
+	// Zooms the map while keeping a specified pixel on the map (relative to the top-left corner) stationary.
+	setZoomAround: function (latlng, zoom, options) {
+		var scale = this.getZoomScale(zoom),
+		    viewHalf = this.getSize().divideBy(2),
+		    containerPoint = latlng instanceof L.Point ? latlng : this.latLngToContainerPoint(latlng),
+
+		    centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale),
+		    newCenter = this.containerPointToLatLng(viewHalf.add(centerOffset));
+
+		return this.setView(newCenter, zoom, {zoom: options});
+	},
+
+	_getBoundsCenterZoom: function (bounds, options) {
+
+		options = options || {};
+		bounds = bounds.getBounds ? bounds.getBounds() : L.latLngBounds(bounds);
+
+		var paddingTL = L.point(options.paddingTopLeft || options.padding || [0, 0]),
+		    paddingBR = L.point(options.paddingBottomRight || options.padding || [0, 0]),
+
+		    zoom = this.getBoundsZoom(bounds, false, paddingTL.add(paddingBR));
+
+		zoom = (typeof options.maxZoom === 'number') ? Math.min(options.maxZoom, zoom) : zoom;
+
+		var paddingOffset = paddingBR.subtract(paddingTL).divideBy(2),
+
+		    swPoint = this.project(bounds.getSouthWest(), zoom),
+		    nePoint = this.project(bounds.getNorthEast(), zoom),
+		    center = this.unproject(swPoint.add(nePoint).divideBy(2).add(paddingOffset), zoom);
+
+		return {
+			center: center,
+			zoom: zoom
+		};
+	},
+
+	// @method fitBounds(bounds: LatLngBounds, options: fitBounds options): this
+	// Sets a map view that contains the given geographical bounds with the
+	// maximum zoom level possible.
+	fitBounds: function (bounds, options) {
+
+		bounds = L.latLngBounds(bounds);
+
+		if (!bounds.isValid()) {
+			throw new Error('Bounds are not valid.');
+		}
+
+		var target = this._getBoundsCenterZoom(bounds, options);
+		return this.setView(target.center, target.zoom, options);
+	},
+
+	// @method fitWorld(options?: fitBounds options): this
+	// Sets a map view that mostly contains the whole world with the maximum
+	// zoom level possible.
+	fitWorld: function (options) {
+		return this.fitBounds([[-90, -180], [90, 180]], options);
+	},
+
+	// @method panTo(latlng: LatLng, options?: Pan options): this
+	// Pans the map to a given center.
+	panTo: function (center, options) { // (LatLng)
+		return this.setView(center, this._zoom, {pan: options});
+	},
+
+	// @method panBy(offset: Point): this
+	// Pans the map by a given number of pixels (animated).
+	panBy: function (offset) { // (Point)
+		// replaced with animated panBy in Map.PanAnimation.js
+		this.fire('movestart');
+
+		this._rawPanBy(L.point(offset));
+
+		this.fire('move');
+		return this.fire('moveend');
+	},
+
+	// @method setMaxBounds(bounds: Bounds): this
+	// Restricts the map view to the given bounds (see the [maxBounds](#map-maxbounds) option).
+	setMaxBounds: function (bounds) {
+		bounds = L.latLngBounds(bounds);
+
+		if (!bounds.isValid()) {
+			this.options.maxBounds = null;
+			return this.off('moveend', this._panInsideMaxBounds);
+		} else if (this.options.maxBounds) {
+			this.off('moveend', this._panInsideMaxBounds);
+		}
+
+		this.options.maxBounds = bounds;
+
+		if (this._loaded) {
+			this._panInsideMaxBounds();
+		}
+
+		return this.on('moveend', this._panInsideMaxBounds);
+	},
+
+	// @method setMinZoom(zoom: Number): this
+	// Sets the lower limit for the available zoom levels (see the [minZoom](#map-minzoom) option).
+	setMinZoom: function (zoom) {
+		this.options.minZoom = zoom;
+
+		if (this._loaded && this.getZoom() < this.options.minZoom) {
+			return this.setZoom(zoom);
+		}
+
+		return this;
+	},
+
+	// @method setMaxZoom(zoom: Number): this
+	// Sets the upper limit for the available zoom levels (see the [maxZoom](#map-maxzoom) option).
+	setMaxZoom: function (zoom) {
+		this.options.maxZoom = zoom;
+
+		if (this._loaded && (this.getZoom() > this.options.maxZoom)) {
+			return this.setZoom(zoom);
+		}
+
+		return this;
+	},
+
+	// @method panInsideBounds(bounds: LatLngBounds, options?: Pan options): this
+	// Pans the map to the closest view that would lie inside the given bounds (if it's not already), controlling the animation using the options specific, if any.
+	panInsideBounds: function (bounds, options) {
+		this._enforcingBounds = true;
+		var center = this.getCenter(),
+		    newCenter = this._limitCenter(center, this._zoom, L.latLngBounds(bounds));
+
+		if (!center.equals(newCenter)) {
+			this.panTo(newCenter, options);
+		}
+
+		this._enforcingBounds = false;
+		return this;
+	},
+
+	// @method invalidateSize(options: Zoom/Pan options): this
+	// Checks if the map container size changed and updates the map if so —
+	// call it after you've changed the map size dynamically, also animating
+	// pan by default. If `options.pan` is `false`, panning will not occur.
+	// If `options.debounceMoveend` is `true`, it will delay `moveend` event so
+	// that it doesn't happen often even if the method is called many
+	// times in a row.
+
+	// @alternative
+	// @method invalidateSize(animate: Boolean): this
+	// Checks if the map container size changed and updates the map if so —
+	// call it after you've changed the map size dynamically, also animating
+	// pan by default.
+	invalidateSize: function (options) {
+		if (!this._loaded) { return this; }
+
+		options = L.extend({
+			animate: false,
+			pan: true
+		}, options === true ? {animate: true} : options);
+
+		var oldSize = this.getSize();
+		this._sizeChanged = true;
+		this._lastCenter = null;
+
+		var newSize = this.getSize(),
+		    oldCenter = oldSize.divideBy(2).round(),
+		    newCenter = newSize.divideBy(2).round(),
+		    offset = oldCenter.subtract(newCenter);
+
+		if (!offset.x && !offset.y) { return this; }
+
+		if (options.animate && options.pan) {
+			this.panBy(offset);
+
+		} else {
+			if (options.pan) {
+				this._rawPanBy(offset);
+			}
+
+			this.fire('move');
+
+			if (options.debounceMoveend) {
+				clearTimeout(this._sizeTimer);
+				this._sizeTimer = setTimeout(L.bind(this.fire, this, 'moveend'), 200);
+			} else {
+				this.fire('moveend');
+			}
+		}
+
+		// @section Map state change events
+		// @event resize: ResizeEvent
+		// Fired when the map is resized.
+		return this.fire('resize', {
+			oldSize: oldSize,
+			newSize: newSize
+		});
+	},
+
+	// @section Methods for modifying map state
+	// @method stop(): this
+	// Stops the currently running `panTo` or `flyTo` animation, if any.
+	stop: function () {
+		this.setZoom(this._limitZoom(this._zoom));
+		if (!this.options.zoomSnap) {
+			this.fire('viewreset');
+		}
+		return this._stop();
+	},
+
+
+	// TODO handler.addTo
+	// TODO Appropiate docs section?
+	// @section Other Methods
+	// @method addHandler(name: String, HandlerClass: Function): this
+	// Adds a new `Handler` to the map, given its name and constructor function.
+	addHandler: function (name, HandlerClass) {
+		if (!HandlerClass) { return this; }
+
+		var handler = this[name] = new HandlerClass(this);
+
+		this._handlers.push(handler);
+
+		if (this.options[name]) {
+			handler.enable();
+		}
+
+		return this;
+	},
+
+	// @method remove(): this
+	// Destroys the map and clears all related event listeners.
+	remove: function () {
+
+		this._initEvents(true);
+
+		if (this._containerId !== this._container._leaflet_id) {
+			throw new Error('Map container is being reused by another instance');
+		}
+
+		try {
+			// throws error in IE6-8
+			delete this._container._leaflet_id;
+			delete this._containerId;
+		} catch (e) {
+			/*eslint-disable */
+			this._container._leaflet_id = undefined;
+			/*eslint-enable */
+			this._containerId = undefined;
+		}
+
+		L.DomUtil.remove(this._mapPane);
+
+		if (this._clearControlPos) {
+			this._clearControlPos();
+		}
+
+		this._clearHandlers();
+
+		if (this._loaded) {
+			// @section Map state change events
+			// @event unload: Event
+			// Fired when the map is destroyed with [remove](#map-remove) method.
+			this.fire('unload');
+		}
+
+		for (var i in this._layers) {
+			this._layers[i].remove();
+		}
+
+		return this;
+	},
+
+	// @section Other Methods
+	// @method createPane(name: String, container?: HTMLElement): HTMLElement
+	// Creates a new [map pane](#map-pane) with the given name if it doesn't exist already,
+	// then returns it. The pane is created as a children of `container`, or
+	// as a children of the main map pane if not set.
+	createPane: function (name, container) {
+		var className = 'leaflet-pane' + (name ? ' leaflet-' + name.replace('Pane', '') + '-pane' : ''),
+		    pane = L.DomUtil.create('div', className, container || this._mapPane);
+
+		if (name) {
+			this._panes[name] = pane;
+		}
+		return pane;
+	},
+
+	// @section Methods for Getting Map State
+
+	// @method getCenter(): LatLng
+	// Returns the geographical center of the map view
+	getCenter: function () {
+		this._checkIfLoaded();
+
+		if (this._lastCenter && !this._moved()) {
+			return this._lastCenter;
+		}
+		return this.layerPointToLatLng(this._getCenterLayerPoint());
+	},
+
+	// @method getZoom(): Number
+	// Returns the current zoom level of the map view
+	getZoom: function () {
+		return this._zoom;
+	},
+
+	// @method getBounds(): LatLngBounds
+	// Returns the geographical bounds visible in the current map view
+	getBounds: function () {
+		var bounds = this.getPixelBounds(),
+		    sw = this.unproject(bounds.getBottomLeft()),
+		    ne = this.unproject(bounds.getTopRight());
+
+		return new L.LatLngBounds(sw, ne);
+	},
+
+	// @method getMinZoom(): Number
+	// Returns the minimum zoom level of the map (if set in the `minZoom` option of the map or of any layers), or `0` by default.
+	getMinZoom: function () {
+		return this.options.minZoom === undefined ? this._layersMinZoom || 0 : this.options.minZoom;
+	},
+
+	// @method getMaxZoom(): Number
+	// Returns the maximum zoom level of the map (if set in the `maxZoom` option of the map or of any layers).
+	getMaxZoom: function () {
+		return this.options.maxZoom === undefined ?
+			(this._layersMaxZoom === undefined ? Infinity : this._layersMaxZoom) :
+			this.options.maxZoom;
+	},
+
+	// @method getBoundsZoom(bounds: LatLngBounds, inside?: Boolean): Number
+	// Returns the maximum zoom level on which the given bounds fit to the map
+	// view in its entirety. If `inside` (optional) is set to `true`, the method
+	// instead returns the minimum zoom level on which the map view fits into
+	// the given bounds in its entirety.
+	getBoundsZoom: function (bounds, inside, padding) { // (LatLngBounds[, Boolean, Point]) -> Number
+		bounds = L.latLngBounds(bounds);
+		padding = L.point(padding || [0, 0]);
+
+		var zoom = this.getZoom() || 0,
+		    min = this.getMinZoom(),
+		    max = this.getMaxZoom(),
+		    nw = bounds.getNorthWest(),
+		    se = bounds.getSouthEast(),
+		    size = this.getSize().subtract(padding),
+		    boundsSize = this.project(se, zoom).subtract(this.project(nw, zoom)),
+		    snap = L.Browser.any3d ? this.options.zoomSnap : 1;
+
+		var scale = Math.min(size.x / boundsSize.x, size.y / boundsSize.y);
+		zoom = this.getScaleZoom(scale, zoom);
+
+		if (snap) {
+			zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level
+			zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap;
+		}
+
+		return Math.max(min, Math.min(max, zoom));
+	},
+
+	// @method getSize(): Point
+	// Returns the current size of the map container (in pixels).
+	getSize: function () {
+		if (!this._size || this._sizeChanged) {
+			this._size = new L.Point(
+				this._container.clientWidth,
+				this._container.clientHeight);
+
+			this._sizeChanged = false;
+		}
+		return this._size.clone();
+	},
+
+	// @method getPixelBounds(): Bounds
+	// Returns the bounds of the current map view in projected pixel
+	// coordinates (sometimes useful in layer and overlay implementations).
+	getPixelBounds: function (center, zoom) {
+		var topLeftPoint = this._getTopLeftPoint(center, zoom);
+		return new L.Bounds(topLeftPoint, topLeftPoint.add(this.getSize()));
+	},
+
+	// TODO: Check semantics - isn't the pixel origin the 0,0 coord relative to
+	// the map pane? "left point of the map layer" can be confusing, specially
+	// since there can be negative offsets.
+	// @method getPixelOrigin(): Point
+	// Returns the projected pixel coordinates of the top left point of
+	// the map layer (useful in custom layer and overlay implementations).
+	getPixelOrigin: function () {
+		this._checkIfLoaded();
+		return this._pixelOrigin;
+	},
+
+	// @method getPixelWorldBounds(zoom?: Number): Bounds
+	// Returns the world's bounds in pixel coordinates for zoom level `zoom`.
+	// If `zoom` is omitted, the map's current zoom level is used.
+	getPixelWorldBounds: function (zoom) {
+		return this.options.crs.getProjectedBounds(zoom === undefined ? this.getZoom() : zoom);
+	},
+
+	// @section Other Methods
+
+	// @method getPane(pane: String|HTMLElement): HTMLElement
+	// Returns a [map pane](#map-pane), given its name or its HTML element (its identity).
+	getPane: function (pane) {
+		return typeof pane === 'string' ? this._panes[pane] : pane;
+	},
+
+	// @method getPanes(): Object
+	// Returns a plain object containing the names of all [panes](#map-pane) as keys and
+	// the panes as values.
+	getPanes: function () {
+		return this._panes;
+	},
+
+	// @method getContainer: HTMLElement
+	// Returns the HTML element that contains the map.
+	getContainer: function () {
+		return this._container;
+	},
+
+
+	// @section Conversion Methods
+
+	// @method getZoomScale(toZoom: Number, fromZoom: Number): Number
+	// Returns the scale factor to be applied to a map transition from zoom level
+	// `fromZoom` to `toZoom`. Used internally to help with zoom animations.
+	getZoomScale: function (toZoom, fromZoom) {
+		// TODO replace with universal implementation after refactoring projections
+		var crs = this.options.crs;
+		fromZoom = fromZoom === undefined ? this._zoom : fromZoom;
+		return crs.scale(toZoom) / crs.scale(fromZoom);
+	},
+
+	// @method getScaleZoom(scale: Number, fromZoom: Number): Number
+	// Returns the zoom level that the map would end up at, if it is at `fromZoom`
+	// level and everything is scaled by a factor of `scale`. Inverse of
+	// [`getZoomScale`](#map-getZoomScale).
+	getScaleZoom: function (scale, fromZoom) {
+		var crs = this.options.crs;
+		fromZoom = fromZoom === undefined ? this._zoom : fromZoom;
+		var zoom = crs.zoom(scale * crs.scale(fromZoom));
+		return isNaN(zoom) ? Infinity : zoom;
+	},
+
+	// @method project(latlng: LatLng, zoom: Number): Point
+	// Projects a geographical coordinate `LatLng` according to the projection
+	// of the map's CRS, then scales it according to `zoom` and the CRS's
+	// `Transformation`. The result is pixel coordinate relative to
+	// the CRS origin.
+	project: function (latlng, zoom) {
+		zoom = zoom === undefined ? this._zoom : zoom;
+		return this.options.crs.latLngToPoint(L.latLng(latlng), zoom);
+	},
+
+	// @method unproject(point: Point, zoom: Number): LatLng
+	// Inverse of [`project`](#map-project).
+	unproject: function (point, zoom) {
+		zoom = zoom === undefined ? this._zoom : zoom;
+		return this.options.crs.pointToLatLng(L.point(point), zoom);
+	},
+
+	// @method layerPointToLatLng(point: Point): LatLng
+	// Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
+	// returns the corresponding geographical coordinate (for the current zoom level).
+	layerPointToLatLng: function (point) {
+		var projectedPoint = L.point(point).add(this.getPixelOrigin());
+		return this.unproject(projectedPoint);
+	},
+
+	// @method latLngToLayerPoint(latlng: LatLng): Point
+	// Given a geographical coordinate, returns the corresponding pixel coordinate
+	// relative to the [origin pixel](#map-getpixelorigin).
+	latLngToLayerPoint: function (latlng) {
+		var projectedPoint = this.project(L.latLng(latlng))._round();
+		return projectedPoint._subtract(this.getPixelOrigin());
+	},
+
+	// @method wrapLatLng(latlng: LatLng): LatLng
+	// Returns a `LatLng` where `lat` and `lng` has been wrapped according to the
+	// map's CRS's `wrapLat` and `wrapLng` properties, if they are outside the
+	// CRS's bounds.
+	// By default this means longitude is wrapped around the dateline so its
+	// value is between -180 and +180 degrees.
+	wrapLatLng: function (latlng) {
+		return this.options.crs.wrapLatLng(L.latLng(latlng));
+	},
+
+	// @method distance(latlng1: LatLng, latlng2: LatLng): Number
+	// Returns the distance between two geographical coordinates according to
+	// the map's CRS. By default this measures distance in meters.
+	distance: function (latlng1, latlng2) {
+		return this.options.crs.distance(L.latLng(latlng1), L.latLng(latlng2));
+	},
+
+	// @method containerPointToLayerPoint(point: Point): Point
+	// Given a pixel coordinate relative to the map container, returns the corresponding
+	// pixel coordinate relative to the [origin pixel](#map-getpixelorigin).
+	containerPointToLayerPoint: function (point) { // (Point)
+		return L.point(point).subtract(this._getMapPanePos());
+	},
+
+	// @method layerPointToContainerPoint(point: Point): Point
+	// Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
+	// returns the corresponding pixel coordinate relative to the map container.
+	layerPointToContainerPoint: function (point) { // (Point)
+		return L.point(point).add(this._getMapPanePos());
+	},
+
+	// @method containerPointToLatLng(point: Point): Point
+	// Given a pixel coordinate relative to the map container, returns
+	// the corresponding geographical coordinate (for the current zoom level).
+	containerPointToLatLng: function (point) {
+		var layerPoint = this.containerPointToLayerPoint(L.point(point));
+		return this.layerPointToLatLng(layerPoint);
+	},
+
+	// @method latLngToContainerPoint(latlng: LatLng): Point
+	// Given a geographical coordinate, returns the corresponding pixel coordinate
+	// relative to the map container.
+	latLngToContainerPoint: function (latlng) {
+		return this.layerPointToContainerPoint(this.latLngToLayerPoint(L.latLng(latlng)));
+	},
+
+	// @method mouseEventToContainerPoint(ev: MouseEvent): Point
+	// Given a MouseEvent object, returns the pixel coordinate relative to the
+	// map container where the event took place.
+	mouseEventToContainerPoint: function (e) {
+		return L.DomEvent.getMousePosition(e, this._container);
+	},
+
+	// @method mouseEventToLayerPoint(ev: MouseEvent): Point
+	// Given a MouseEvent object, returns the pixel coordinate relative to
+	// the [origin pixel](#map-getpixelorigin) where the event took place.
+	mouseEventToLayerPoint: function (e) {
+		return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e));
+	},
+
+	// @method mouseEventToLatLng(ev: MouseEvent): LatLng
+	// Given a MouseEvent object, returns geographical coordinate where the
+	// event took place.
+	mouseEventToLatLng: function (e) { // (MouseEvent)
+		return this.layerPointToLatLng(this.mouseEventToLayerPoint(e));
+	},
+
+
+	// map initialization methods
+
+	_initContainer: function (id) {
+		var container = this._container = L.DomUtil.get(id);
+
+		if (!container) {
+			throw new Error('Map container not found.');
+		} else if (container._leaflet_id) {
+			throw new Error('Map container is already initialized.');
+		}
+
+		L.DomEvent.addListener(container, 'scroll', this._onScroll, this);
+		this._containerId = L.Util.stamp(container);
+	},
+
+	_initLayout: function () {
+		var container = this._container;
+
+		this._fadeAnimated = this.options.fadeAnimation && L.Browser.any3d;
+
+		L.DomUtil.addClass(container, 'leaflet-container' +
+			(L.Browser.touch ? ' leaflet-touch' : '') +
+			(L.Browser.retina ? ' leaflet-retina' : '') +
+			(L.Browser.ielt9 ? ' leaflet-oldie' : '') +
+			(L.Browser.safari ? ' leaflet-safari' : '') +
+			(this._fadeAnimated ? ' leaflet-fade-anim' : ''));
+
+		var position = L.DomUtil.getStyle(container, 'position');
+
+		if (position !== 'absolute' && position !== 'relative' && position !== 'fixed') {
+			container.style.position = 'relative';
+		}
+
+		this._initPanes();
+
+		if (this._initControlPos) {
+			this._initControlPos();
+		}
+	},
+
+	_initPanes: function () {
+		var panes = this._panes = {};
+		this._paneRenderers = {};
+
+		// @section
+		//
+		// Panes are DOM elements used to control the ordering of layers on the map. You
+		// can access panes with [`map.getPane`](#map-getpane) or
+		// [`map.getPanes`](#map-getpanes) methods. New panes can be created with the
+		// [`map.createPane`](#map-createpane) method.
+		//
+		// Every map has the following default panes that differ only in zIndex.
+		//
+		// @pane mapPane: HTMLElement = 'auto'
+		// Pane that contains all other map panes
+
+		this._mapPane = this.createPane('mapPane', this._container);
+		L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
+
+		// @pane tilePane: HTMLElement = 200
+		// Pane for `GridLayer`s and `TileLayer`s
+		this.createPane('tilePane');
+		// @pane overlayPane: HTMLElement = 400
+		// Pane for vector overlays (`Path`s), like `Polyline`s and `Polygon`s
+		this.createPane('shadowPane');
+		// @pane shadowPane: HTMLElement = 500
+		// Pane for overlay shadows (e.g. `Marker` shadows)
+		this.createPane('overlayPane');
+		// @pane markerPane: HTMLElement = 600
+		// Pane for `Icon`s of `Marker`s
+		this.createPane('markerPane');
+		// @pane tooltipPane: HTMLElement = 650
+		// Pane for tooltip.
+		this.createPane('tooltipPane');
+		// @pane popupPane: HTMLElement = 700
+		// Pane for `Popup`s.
+		this.createPane('popupPane');
+
+		if (!this.options.markerZoomAnimation) {
+			L.DomUtil.addClass(panes.markerPane, 'leaflet-zoom-hide');
+			L.DomUtil.addClass(panes.shadowPane, 'leaflet-zoom-hide');
+		}
+	},
+
+
+	// private methods that modify map state
+
+	// @section Map state change events
+	_resetView: function (center, zoom) {
+		L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
+
+		var loading = !this._loaded;
+		this._loaded = true;
+		zoom = this._limitZoom(zoom);
+
+		this.fire('viewprereset');
+
+		var zoomChanged = this._zoom !== zoom;
+		this
+			._moveStart(zoomChanged)
+			._move(center, zoom)
+			._moveEnd(zoomChanged);
+
+		// @event viewreset: Event
+		// Fired when the map needs to redraw its content (this usually happens
+		// on map zoom or load). Very useful for creating custom overlays.
+		this.fire('viewreset');
+
+		// @event load: Event
+		// Fired when the map is initialized (when its center and zoom are set
+		// for the first time).
+		if (loading) {
+			this.fire('load');
+		}
+	},
+
+	_moveStart: function (zoomChanged) {
+		// @event zoomstart: Event
+		// Fired when the map zoom is about to change (e.g. before zoom animation).
+		// @event movestart: Event
+		// Fired when the view of the map starts changing (e.g. user starts dragging the map).
+		if (zoomChanged) {
+			this.fire('zoomstart');
+		}
+		return this.fire('movestart');
+	},
+
+	_move: function (center, zoom, data) {
+		if (zoom === undefined) {
+			zoom = this._zoom;
+		}
+		var zoomChanged = this._zoom !== zoom;
+
+		this._zoom = zoom;
+		this._lastCenter = center;
+		this._pixelOrigin = this._getNewPixelOrigin(center);
+
+		// @event zoom: Event
+		// Fired repeatedly during any change in zoom level, including zoom
+		// and fly animations.
+		if (zoomChanged || (data && data.pinch)) {	// Always fire 'zoom' if pinching because #3530
+			this.fire('zoom', data);
+		}
+
+		// @event move: Event
+		// Fired repeatedly during any movement of the map, including pan and
+		// fly animations.
+		return this.fire('move', data);
+	},
+
+	_moveEnd: function (zoomChanged) {
+		// @event zoomend: Event
+		// Fired when the map has changed, after any animations.
+		if (zoomChanged) {
+			this.fire('zoomend');
+		}
+
+		// @event moveend: Event
+		// Fired when the center of the map stops changing (e.g. user stopped
+		// dragging the map).
+		return this.fire('moveend');
+	},
+
+	_stop: function () {
+		L.Util.cancelAnimFrame(this._flyToFrame);
+		if (this._panAnim) {
+			this._panAnim.stop();
+		}
+		return this;
+	},
+
+	_rawPanBy: function (offset) {
+		L.DomUtil.setPosition(this._mapPane, this._getMapPanePos().subtract(offset));
+	},
+
+	_getZoomSpan: function () {
+		return this.getMaxZoom() - this.getMinZoom();
+	},
+
+	_panInsideMaxBounds: function () {
+		if (!this._enforcingBounds) {
+			this.panInsideBounds(this.options.maxBounds);
+		}
+	},
+
+	_checkIfLoaded: function () {
+		if (!this._loaded) {
+			throw new Error('Set map center and zoom first.');
+		}
+	},
+
+	// DOM event handling
+
+	// @section Interaction events
+	_initEvents: function (remove) {
+		if (!L.DomEvent) { return; }
+
+		this._targets = {};
+		this._targets[L.stamp(this._container)] = this;
+
+		var onOff = remove ? 'off' : 'on';
+
+		// @event click: MouseEvent
+		// Fired when the user clicks (or taps) the map.
+		// @event dblclick: MouseEvent
+		// Fired when the user double-clicks (or double-taps) the map.
+		// @event mousedown: MouseEvent
+		// Fired when the user pushes the mouse button on the map.
+		// @event mouseup: MouseEvent
+		// Fired when the user releases the mouse button on the map.
+		// @event mouseover: MouseEvent
+		// Fired when the mouse enters the map.
+		// @event mouseout: MouseEvent
+		// Fired when the mouse leaves the map.
+		// @event mousemove: MouseEvent
+		// Fired while the mouse moves over the map.
+		// @event contextmenu: MouseEvent
+		// Fired when the user pushes the right mouse button on the map, prevents
+		// default browser context menu from showing if there are listeners on
+		// this event. Also fired on mobile when the user holds a single touch
+		// for a second (also called long press).
+		// @event keypress: KeyboardEvent
+		// Fired when the user presses a key from the keyboard while the map is focused.
+		L.DomEvent[onOff](this._container, 'click dblclick mousedown mouseup ' +
+			'mouseover mouseout mousemove contextmenu keypress', this._handleDOMEvent, this);
+
+		if (this.options.trackResize) {
+			L.DomEvent[onOff](window, 'resize', this._onResize, this);
+		}
+
+		if (L.Browser.any3d && this.options.transform3DLimit) {
+			this[onOff]('moveend', this._onMoveEnd);
+		}
+	},
+
+	_onResize: function () {
+		L.Util.cancelAnimFrame(this._resizeRequest);
+		this._resizeRequest = L.Util.requestAnimFrame(
+		        function () { this.invalidateSize({debounceMoveend: true}); }, this);
+	},
+
+	_onScroll: function () {
+		this._container.scrollTop  = 0;
+		this._container.scrollLeft = 0;
+	},
+
+	_onMoveEnd: function () {
+		var pos = this._getMapPanePos();
+		if (Math.max(Math.abs(pos.x), Math.abs(pos.y)) >= this.options.transform3DLimit) {
+			// https://bugzilla.mozilla.org/show_bug.cgi?id=1203873 but Webkit also have
+			// a pixel offset on very high values, see: http://jsfiddle.net/dg6r5hhb/
+			this._resetView(this.getCenter(), this.getZoom());
+		}
+	},
+
+	_findEventTargets: function (e, type) {
+		var targets = [],
+		    target,
+		    isHover = type === 'mouseout' || type === 'mouseover',
+		    src = e.target || e.srcElement,
+		    dragging = false;
+
+		while (src) {
+			target = this._targets[L.stamp(src)];
+			if (target && (type === 'click' || type === 'preclick') && !e._simulated && this._draggableMoved(target)) {
+				// Prevent firing click after you just dragged an object.
+				dragging = true;
+				break;
+			}
+			if (target && target.listens(type, true)) {
+				if (isHover && !L.DomEvent._isExternalTarget(src, e)) { break; }
+				targets.push(target);
+				if (isHover) { break; }
+			}
+			if (src === this._container) { break; }
+			src = src.parentNode;
+		}
+		if (!targets.length && !dragging && !isHover && L.DomEvent._isExternalTarget(src, e)) {
+			targets = [this];
+		}
+		return targets;
+	},
+
+	_handleDOMEvent: function (e) {
+		if (!this._loaded || L.DomEvent._skipped(e)) { return; }
+
+		var type = e.type === 'keypress' && e.keyCode === 13 ? 'click' : e.type;
+
+		if (type === 'mousedown') {
+			// prevents outline when clicking on keyboard-focusable element
+			L.DomUtil.preventOutline(e.target || e.srcElement);
+		}
+
+		this._fireDOMEvent(e, type);
+	},
+
+	_fireDOMEvent: function (e, type, targets) {
+
+		if (e.type === 'click') {
+			// Fire a synthetic 'preclick' event which propagates up (mainly for closing popups).
+			// @event preclick: MouseEvent
+			// Fired before mouse click on the map (sometimes useful when you
+			// want something to happen on click before any existing click
+			// handlers start running).
+			var synth = L.Util.extend({}, e);
+			synth.type = 'preclick';
+			this._fireDOMEvent(synth, synth.type, targets);
+		}
+
+		if (e._stopped) { return; }
+
+		// Find the layer the event is propagating from and its parents.
+		targets = (targets || []).concat(this._findEventTargets(e, type));
+
+		if (!targets.length) { return; }
+
+		var target = targets[0];
+		if (type === 'contextmenu' && target.listens(type, true)) {
+			L.DomEvent.preventDefault(e);
+		}
+
+		var data = {
+			originalEvent: e
+		};
+
+		if (e.type !== 'keypress') {
+			var isMarker = target instanceof L.Marker;
+			data.containerPoint = isMarker ?
+					this.latLngToContainerPoint(target.getLatLng()) : this.mouseEventToContainerPoint(e);
+			data.layerPoint = this.containerPointToLayerPoint(data.containerPoint);
+			data.latlng = isMarker ? target.getLatLng() : this.layerPointToLatLng(data.layerPoint);
+		}
+
+		for (var i = 0; i < targets.length; i++) {
+			targets[i].fire(type, data, true);
+			if (data.originalEvent._stopped ||
+				(targets[i].options.nonBubblingEvents && L.Util.indexOf(targets[i].options.nonBubblingEvents, type) !== -1)) { return; }
+		}
+	},
+
+	_draggableMoved: function (obj) {
+		obj = obj.dragging && obj.dragging.enabled() ? obj : this;
+		return (obj.dragging && obj.dragging.moved()) || (this.boxZoom && this.boxZoom.moved());
+	},
+
+	_clearHandlers: function () {
+		for (var i = 0, len = this._handlers.length; i < len; i++) {
+			this._handlers[i].disable();
+		}
+	},
+
+	// @section Other Methods
+
+	// @method whenReady(fn: Function, context?: Object): this
+	// Runs the given function `fn` when the map gets initialized with
+	// a view (center and zoom) and at least one layer, or immediately
+	// if it's already initialized, optionally passing a function context.
+	whenReady: function (callback, context) {
+		if (this._loaded) {
+			callback.call(context || this, {target: this});
+		} else {
+			this.on('load', callback, context);
+		}
+		return this;
+	},
+
+
+	// private methods for getting map state
+
+	_getMapPanePos: function () {
+		return L.DomUtil.getPosition(this._mapPane) || new L.Point(0, 0);
+	},
+
+	_moved: function () {
+		var pos = this._getMapPanePos();
+		return pos && !pos.equals([0, 0]);
+	},
+
+	_getTopLeftPoint: function (center, zoom) {
+		var pixelOrigin = center && zoom !== undefined ?
+			this._getNewPixelOrigin(center, zoom) :
+			this.getPixelOrigin();
+		return pixelOrigin.subtract(this._getMapPanePos());
+	},
+
+	_getNewPixelOrigin: function (center, zoom) {
+		var viewHalf = this.getSize()._divideBy(2);
+		return this.project(center, zoom)._subtract(viewHalf)._add(this._getMapPanePos())._round();
+	},
+
+	_latLngToNewLayerPoint: function (latlng, zoom, center) {
+		var topLeft = this._getNewPixelOrigin(center, zoom);
+		return this.project(latlng, zoom)._subtract(topLeft);
+	},
+
+	// layer point of the current center
+	_getCenterLayerPoint: function () {
+		return this.containerPointToLayerPoint(this.getSize()._divideBy(2));
+	},
+
+	// offset of the specified place to the current center in pixels
+	_getCenterOffset: function (latlng) {
+		return this.latLngToLayerPoint(latlng).subtract(this._getCenterLayerPoint());
+	},
+
+	// adjust center for view to get inside bounds
+	_limitCenter: function (center, zoom, bounds) {
+
+		if (!bounds) { return center; }
+
+		var centerPoint = this.project(center, zoom),
+		    viewHalf = this.getSize().divideBy(2),
+		    viewBounds = new L.Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)),
+		    offset = this._getBoundsOffset(viewBounds, bounds, zoom);
+
+		// If offset is less than a pixel, ignore.
+		// This prevents unstable projections from getting into
+		// an infinite loop of tiny offsets.
+		if (offset.round().equals([0, 0])) {
+			return center;
+		}
+
+		return this.unproject(centerPoint.add(offset), zoom);
+	},
+
+	// adjust offset for view to get inside bounds
+	_limitOffset: function (offset, bounds) {
+		if (!bounds) { return offset; }
+
+		var viewBounds = this.getPixelBounds(),
+		    newBounds = new L.Bounds(viewBounds.min.add(offset), viewBounds.max.add(offset));
+
+		return offset.add(this._getBoundsOffset(newBounds, bounds));
+	},
+
+	// returns offset needed for pxBounds to get inside maxBounds at a specified zoom
+	_getBoundsOffset: function (pxBounds, maxBounds, zoom) {
+		var projectedMaxBounds = L.bounds(
+		        this.project(maxBounds.getNorthEast(), zoom),
+		        this.project(maxBounds.getSouthWest(), zoom)
+		    ),
+		    minOffset = projectedMaxBounds.min.subtract(pxBounds.min),
+		    maxOffset = projectedMaxBounds.max.subtract(pxBounds.max),
+
+		    dx = this._rebound(minOffset.x, -maxOffset.x),
+		    dy = this._rebound(minOffset.y, -maxOffset.y);
+
+		return new L.Point(dx, dy);
+	},
+
+	_rebound: function (left, right) {
+		return left + right > 0 ?
+			Math.round(left - right) / 2 :
+			Math.max(0, Math.ceil(left)) - Math.max(0, Math.floor(right));
+	},
+
+	_limitZoom: function (zoom) {
+		var min = this.getMinZoom(),
+		    max = this.getMaxZoom(),
+		    snap = L.Browser.any3d ? this.options.zoomSnap : 1;
+		if (snap) {
+			zoom = Math.round(zoom / snap) * snap;
+		}
+		return Math.max(min, Math.min(max, zoom));
+	}
+});
+
+// @section
+
+// @factory L.map(id: String, options?: Map options)
+// Instantiates a map object given the DOM ID of a `<div>` element
+// and optionally an object literal with `Map options`.
+//
+// @alternative
+// @factory L.map(el: HTMLElement, options?: Map options)
+// Instantiates a map object given an instance of a `<div>` HTML element
+// and optionally an object literal with `Map options`.
+L.map = function (id, options) {
+	return new L.Map(id, options);
+};
+
+
+
+
+/*
+ * @class Layer
+ * @inherits Evented
+ * @aka L.Layer
+ * @aka ILayer
+ *
+ * A set of methods from the Layer base class that all Leaflet layers use.
+ * Inherits all methods, options and events from `L.Evented`.
+ *
+ * @example
+ *
+ * ```js
+ * var layer = L.Marker(latlng).addTo(map);
+ * layer.addTo(map);
+ * layer.remove();
+ * ```
+ *
+ * @event add: Event
+ * Fired after the layer is added to a map
+ *
+ * @event remove: Event
+ * Fired after the layer is removed from a map
+ */
+
+
+L.Layer = L.Evented.extend({
+
+	// Classes extending `L.Layer` will inherit the following options:
+	options: {
+		// @option pane: String = 'overlayPane'
+		// By default the layer will be added to the map's [overlay pane](#map-overlaypane). Overriding this option will cause the layer to be placed on another pane by default.
+		pane: 'overlayPane',
+		nonBubblingEvents: []  // Array of events that should not be bubbled to DOM parents (like the map)
+	},
+
+	/* @section
+	 * Classes extending `L.Layer` will inherit the following methods:
+	 *
+	 * @method addTo(map: Map): this
+	 * Adds the layer to the given map
+	 */
+	addTo: function (map) {
+		map.addLayer(this);
+		return this;
+	},
+
+	// @method remove: this
+	// Removes the layer from the map it is currently active on.
+	remove: function () {
+		return this.removeFrom(this._map || this._mapToAdd);
+	},
+
+	// @method removeFrom(map: Map): this
+	// Removes the layer from the given map
+	removeFrom: function (obj) {
+		if (obj) {
+			obj.removeLayer(this);
+		}
+		return this;
+	},
+
+	// @method getPane(name? : String): HTMLElement
+	// Returns the `HTMLElement` representing the named pane on the map. If `name` is omitted, returns the pane for this layer.
+	getPane: function (name) {
+		return this._map.getPane(name ? (this.options[name] || name) : this.options.pane);
+	},
+
+	addInteractiveTarget: function (targetEl) {
+		this._map._targets[L.stamp(targetEl)] = this;
+		return this;
+	},
+
+	removeInteractiveTarget: function (targetEl) {
+		delete this._map._targets[L.stamp(targetEl)];
+		return this;
+	},
+
+	_layerAdd: function (e) {
+		var map = e.target;
+
+		// check in case layer gets added and then removed before the map is ready
+		if (!map.hasLayer(this)) { return; }
+
+		this._map = map;
+		this._zoomAnimated = map._zoomAnimated;
+
+		if (this.getEvents) {
+			var events = this.getEvents();
+			map.on(events, this);
+			this.once('remove', function () {
+				map.off(events, this);
+			}, this);
+		}
+
+		this.onAdd(map);
+
+		if (this.getAttribution && this._map.attributionControl) {
+			this._map.attributionControl.addAttribution(this.getAttribution());
+		}
+
+		this.fire('add');
+		map.fire('layeradd', {layer: this});
+	}
+});
+
+/* @section Extension methods
+ * @uninheritable
+ *
+ * Every layer should extend from `L.Layer` and (re-)implement the following methods.
+ *
+ * @method onAdd(map: Map): this
+ * Should contain code that creates DOM elements for the layer, adds them to `map panes` where they should belong and puts listeners on relevant map events. Called on [`map.addLayer(layer)`](#map-addlayer).
+ *
+ * @method onRemove(map: Map): this
+ * Should contain all clean up code that removes the layer's elements from the DOM and removes listeners previously added in [`onAdd`](#layer-onadd). Called on [`map.removeLayer(layer)`](#map-removelayer).
+ *
+ * @method getEvents(): Object
+ * This optional method should return an object like `{ viewreset: this._reset }` for [`addEventListener`](#evented-addeventlistener). The event handlers in this object will be automatically added and removed from the map with your layer.
+ *
+ * @method getAttribution(): String
+ * This optional method should return a string containing HTML to be shown on the `Attribution control` whenever the layer is visible.
+ *
+ * @method beforeAdd(map: Map): this
+ * Optional method. Called on [`map.addLayer(layer)`](#map-addlayer), before the layer is added to the map, before events are initialized, without waiting until the map is in a usable state. Use for early initialization only.
+ */
+
+
+/* @namespace Map
+ * @section Layer events
+ *
+ * @event layeradd: LayerEvent
+ * Fired when a new layer is added to the map.
+ *
+ * @event layerremove: LayerEvent
+ * Fired when some layer is removed from the map
+ *
+ * @section Methods for Layers and Controls
+ */
+L.Map.include({
+	// @method addLayer(layer: Layer): this
+	// Adds the given layer to the map
+	addLayer: function (layer) {
+		var id = L.stamp(layer);
+		if (this._layers[id]) { return this; }
+		this._layers[id] = layer;
+
+		layer._mapToAdd = this;
+
+		if (layer.beforeAdd) {
+			layer.beforeAdd(this);
+		}
+
+		this.whenReady(layer._layerAdd, layer);
+
+		return this;
+	},
+
+	// @method removeLayer(layer: Layer): this
+	// Removes the given layer from the map.
+	removeLayer: function (layer) {
+		var id = L.stamp(layer);
+
+		if (!this._layers[id]) { return this; }
+
+		if (this._loaded) {
+			layer.onRemove(this);
+		}
+
+		if (layer.getAttribution && this.attributionControl) {
+			this.attributionControl.removeAttribution(layer.getAttribution());
+		}
+
+		delete this._layers[id];
+
+		if (this._loaded) {
+			this.fire('layerremove', {layer: layer});
+			layer.fire('remove');
+		}
+
+		layer._map = layer._mapToAdd = null;
+
+		return this;
+	},
+
+	// @method hasLayer(layer: Layer): Boolean
+	// Returns `true` if the given layer is currently added to the map
+	hasLayer: function (layer) {
+		return !!layer && (L.stamp(layer) in this._layers);
+	},
+
+	/* @method eachLayer(fn: Function, context?: Object): this
+	 * Iterates over the layers of the map, optionally specifying context of the iterator function.
+	 * ```
+	 * map.eachLayer(function(layer){
+	 *     layer.bindPopup('Hello');
+	 * });
+	 * ```
+	 */
+	eachLayer: function (method, context) {
+		for (var i in this._layers) {
+			method.call(context, this._layers[i]);
+		}
+		return this;
+	},
+
+	_addLayers: function (layers) {
+		layers = layers ? (L.Util.isArray(layers) ? layers : [layers]) : [];
+
+		for (var i = 0, len = layers.length; i < len; i++) {
+			this.addLayer(layers[i]);
+		}
+	},
+
+	_addZoomLimit: function (layer) {
+		if (isNaN(layer.options.maxZoom) || !isNaN(layer.options.minZoom)) {
+			this._zoomBoundLayers[L.stamp(layer)] = layer;
+			this._updateZoomLevels();
+		}
+	},
+
+	_removeZoomLimit: function (layer) {
+		var id = L.stamp(layer);
+
+		if (this._zoomBoundLayers[id]) {
+			delete this._zoomBoundLayers[id];
+			this._updateZoomLevels();
+		}
+	},
+
+	_updateZoomLevels: function () {
+		var minZoom = Infinity,
+		    maxZoom = -Infinity,
+		    oldZoomSpan = this._getZoomSpan();
+
+		for (var i in this._zoomBoundLayers) {
+			var options = this._zoomBoundLayers[i].options;
+
+			minZoom = options.minZoom === undefined ? minZoom : Math.min(minZoom, options.minZoom);
+			maxZoom = options.maxZoom === undefined ? maxZoom : Math.max(maxZoom, options.maxZoom);
+		}
+
+		this._layersMaxZoom = maxZoom === -Infinity ? undefined : maxZoom;
+		this._layersMinZoom = minZoom === Infinity ? undefined : minZoom;
+
+		// @section Map state change events
+		// @event zoomlevelschange: Event
+		// Fired when the number of zoomlevels on the map is changed due
+		// to adding or removing a layer.
+		if (oldZoomSpan !== this._getZoomSpan()) {
+			this.fire('zoomlevelschange');
+		}
+	}
+});
+
+
+
+/*
+ * @namespace Projection
+ * @projection L.Projection.Mercator
+ *
+ * Elliptical Mercator projection — more complex than Spherical Mercator. Takes into account that Earth is a geoid, not a perfect sphere. Used by the EPSG:3395 CRS.
+ */
+
+L.Projection.Mercator = {
+	R: 6378137,
+	R_MINOR: 6356752.314245179,
+
+	bounds: L.bounds([-20037508.34279, -15496570.73972], [20037508.34279, 18764656.23138]),
+
+	project: function (latlng) {
+		var d = Math.PI / 180,
+		    r = this.R,
+		    y = latlng.lat * d,
+		    tmp = this.R_MINOR / r,
+		    e = Math.sqrt(1 - tmp * tmp),
+		    con = e * Math.sin(y);
+
+		var ts = Math.tan(Math.PI / 4 - y / 2) / Math.pow((1 - con) / (1 + con), e / 2);
+		y = -r * Math.log(Math.max(ts, 1E-10));
+
+		return new L.Point(latlng.lng * d * r, y);
+	},
+
+	unproject: function (point) {
+		var d = 180 / Math.PI,
+		    r = this.R,
+		    tmp = this.R_MINOR / r,
+		    e = Math.sqrt(1 - tmp * tmp),
+		    ts = Math.exp(-point.y / r),
+		    phi = Math.PI / 2 - 2 * Math.atan(ts);
+
+		for (var i = 0, dphi = 0.1, con; i < 15 && Math.abs(dphi) > 1e-7; i++) {
+			con = e * Math.sin(phi);
+			con = Math.pow((1 - con) / (1 + con), e / 2);
+			dphi = Math.PI / 2 - 2 * Math.atan(ts * con) - phi;
+			phi += dphi;
+		}
+
+		return new L.LatLng(phi * d, point.x * d / r);
+	}
+};
+
+
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.EPSG3395
+ *
+ * Rarely used by some commercial tile providers. Uses Elliptical Mercator projection.
+ */
+
+L.CRS.EPSG3395 = L.extend({}, L.CRS.Earth, {
+	code: 'EPSG:3395',
+	projection: L.Projection.Mercator,
+
+	transformation: (function () {
+		var scale = 0.5 / (Math.PI * L.Projection.Mercator.R);
+		return new L.Transformation(scale, 0.5, -scale, 0.5);
+	}())
+});
+
+
+
+/*
+ * @class GridLayer
+ * @inherits Layer
+ * @aka L.GridLayer
+ *
+ * Generic class for handling a tiled grid of HTML elements. This is the base class for all tile layers and replaces `TileLayer.Canvas`.
+ * GridLayer can be extended to create a tiled grid of HTML elements like `<canvas>`, `<img>` or `<div>`. GridLayer will handle creating and animating these DOM elements for you.
+ *
+ *
+ * @section Synchronous usage
+ * @example
+ *
+ * To create a custom layer, extend GridLayer and implement the `createTile()` method, which will be passed a `Point` object with the `x`, `y`, and `z` (zoom level) coordinates to draw your tile.
+ *
+ * ```js
+ * var CanvasLayer = L.GridLayer.extend({
+ *     createTile: function(coords){
+ *         // create a <canvas> element for drawing
+ *         var tile = L.DomUtil.create('canvas', 'leaflet-tile');
+ *
+ *         // setup tile width and height according to the options
+ *         var size = this.getTileSize();
+ *         tile.width = size.x;
+ *         tile.height = size.y;
+ *
+ *         // get a canvas context and draw something on it using coords.x, coords.y and coords.z
+ *         var ctx = tile.getContext('2d');
+ *
+ *         // return the tile so it can be rendered on screen
+ *         return tile;
+ *     }
+ * });
+ * ```
+ *
+ * @section Asynchronous usage
+ * @example
+ *
+ * Tile creation can also be asynchronous, this is useful when using a third-party drawing library. Once the tile is finished drawing it can be passed to the `done()` callback.
+ *
+ * ```js
+ * var CanvasLayer = L.GridLayer.extend({
+ *     createTile: function(coords, done){
+ *         var error;
+ *
+ *         // create a <canvas> element for drawing
+ *         var tile = L.DomUtil.create('canvas', 'leaflet-tile');
+ *
+ *         // setup tile width and height according to the options
+ *         var size = this.getTileSize();
+ *         tile.width = size.x;
+ *         tile.height = size.y;
+ *
+ *         // draw something asynchronously and pass the tile to the done() callback
+ *         setTimeout(function() {
+ *             done(error, tile);
+ *         }, 1000);
+ *
+ *         return tile;
+ *     }
+ * });
+ * ```
+ *
+ * @section
+ */
+
+
+L.GridLayer = L.Layer.extend({
+
+	// @section
+	// @aka GridLayer options
+	options: {
+		// @option tileSize: Number|Point = 256
+		// Width and height of tiles in the grid. Use a number if width and height are equal, or `L.point(width, height)` otherwise.
+		tileSize: 256,
+
+		// @option opacity: Number = 1.0
+		// Opacity of the tiles. Can be used in the `createTile()` function.
+		opacity: 1,
+
+		// @option updateWhenIdle: Boolean = depends
+		// If `false`, new tiles are loaded during panning, otherwise only after it (for better performance). `true` by default on mobile browsers, otherwise `false`.
+		updateWhenIdle: L.Browser.mobile,
+
+		// @option updateWhenZooming: Boolean = true
+		// By default, a smooth zoom animation (during a [touch zoom](#map-touchzoom) or a [`flyTo()`](#map-flyto)) will update grid layers every integer zoom level. Setting this option to `false` will update the grid layer only when the smooth animation ends.
+		updateWhenZooming: true,
+
+		// @option updateInterval: Number = 200
+		// Tiles will not update more than once every `updateInterval` milliseconds when panning.
+		updateInterval: 200,
+
+		// @option attribution: String = null
+		// String to be shown in the attribution control, describes the layer data, e.g. "© Mapbox".
+		attribution: null,
+
+		// @option zIndex: Number = 1
+		// The explicit zIndex of the tile layer.
+		zIndex: 1,
+
+		// @option bounds: LatLngBounds = undefined
+		// If set, tiles will only be loaded inside the set `LatLngBounds`.
+		bounds: null,
+
+		// @option minZoom: Number = 0
+		// The minimum zoom level that tiles will be loaded at. By default the entire map.
+		minZoom: 0,
+
+		// @option maxZoom: Number = undefined
+		// The maximum zoom level that tiles will be loaded at.
+		maxZoom: undefined,
+
+		// @option noWrap: Boolean = false
+		// Whether the layer is wrapped around the antimeridian. If `true`, the
+		// GridLayer will only be displayed once at low zoom levels. Has no
+		// effect when the [map CRS](#map-crs) doesn't wrap around.
+		noWrap: false,
+
+		// @option pane: String = 'tilePane'
+		// `Map pane` where the grid layer will be added.
+		pane: 'tilePane',
+
+		// @option className: String = ''
+		// A custom class name to assign to the tile layer. Empty by default.
+		className: '',
+
+		// @option keepBuffer: Number = 2
+		// When panning the map, keep this many rows and columns of tiles before unloading them.
+		keepBuffer: 2
+	},
+
+	initialize: function (options) {
+		L.setOptions(this, options);
+	},
+
+	onAdd: function () {
+		this._initContainer();
+
+		this._levels = {};
+		this._tiles = {};
+
+		this._resetView();
+		this._update();
+	},
+
+	beforeAdd: function (map) {
+		map._addZoomLimit(this);
+	},
+
+	onRemove: function (map) {
+		this._removeAllTiles();
+		L.DomUtil.remove(this._container);
+		map._removeZoomLimit(this);
+		this._container = null;
+		this._tileZoom = null;
+	},
+
+	// @method bringToFront: this
+	// Brings the tile layer to the top of all tile layers.
+	bringToFront: function () {
+		if (this._map) {
+			L.DomUtil.toFront(this._container);
+			this._setAutoZIndex(Math.max);
+		}
+		return this;
+	},
+
+	// @method bringToBack: this
+	// Brings the tile layer to the bottom of all tile layers.
+	bringToBack: function () {
+		if (this._map) {
+			L.DomUtil.toBack(this._container);
+			this._setAutoZIndex(Math.min);
+		}
+		return this;
+	},
+
+	// @method getAttribution: String
+	// Used by the `attribution control`, returns the [attribution option](#gridlayer-attribution).
+	getAttribution: function () {
+		return this.options.attribution;
+	},
+
+	// @method getContainer: HTMLElement
+	// Returns the HTML element that contains the tiles for this layer.
+	getContainer: function () {
+		return this._container;
+	},
+
+	// @method setOpacity(opacity: Number): this
+	// Changes the [opacity](#gridlayer-opacity) of the grid layer.
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+		this._updateOpacity();
+		return this;
+	},
+
+	// @method setZIndex(zIndex: Number): this
+	// Changes the [zIndex](#gridlayer-zindex) of the grid layer.
+	setZIndex: function (zIndex) {
+		this.options.zIndex = zIndex;
+		this._updateZIndex();
+
+		return this;
+	},
+
+	// @method isLoading: Boolean
+	// Returns `true` if any tile in the grid layer has not finished loading.
+	isLoading: function () {
+		return this._loading;
+	},
+
+	// @method redraw: this
+	// Causes the layer to clear all the tiles and request them again.
+	redraw: function () {
+		if (this._map) {
+			this._removeAllTiles();
+			this._update();
+		}
+		return this;
+	},
+
+	getEvents: function () {
+		var events = {
+			viewprereset: this._invalidateAll,
+			viewreset: this._resetView,
+			zoom: this._resetView,
+			moveend: this._onMoveEnd
+		};
+
+		if (!this.options.updateWhenIdle) {
+			// update tiles on move, but not more often than once per given interval
+			if (!this._onMove) {
+				this._onMove = L.Util.throttle(this._onMoveEnd, this.options.updateInterval, this);
+			}
+
+			events.move = this._onMove;
+		}
+
+		if (this._zoomAnimated) {
+			events.zoomanim = this._animateZoom;
+		}
+
+		return events;
+	},
+
+	// @section Extension methods
+	// Layers extending `GridLayer` shall reimplement the following method.
+	// @method createTile(coords: Object, done?: Function): HTMLElement
+	// Called only internally, must be overriden by classes extending `GridLayer`.
+	// Returns the `HTMLElement` corresponding to the given `coords`. If the `done` callback
+	// is specified, it must be called when the tile has finished loading and drawing.
+	createTile: function () {
+		return document.createElement('div');
+	},
+
+	// @section
+	// @method getTileSize: Point
+	// Normalizes the [tileSize option](#gridlayer-tilesize) into a point. Used by the `createTile()` method.
+	getTileSize: function () {
+		var s = this.options.tileSize;
+		return s instanceof L.Point ? s : new L.Point(s, s);
+	},
+
+	_updateZIndex: function () {
+		if (this._container && this.options.zIndex !== undefined && this.options.zIndex !== null) {
+			this._container.style.zIndex = this.options.zIndex;
+		}
+	},
+
+	_setAutoZIndex: function (compare) {
+		// go through all other layers of the same pane, set zIndex to max + 1 (front) or min - 1 (back)
+
+		var layers = this.getPane().children,
+		    edgeZIndex = -compare(-Infinity, Infinity); // -Infinity for max, Infinity for min
+
+		for (var i = 0, len = layers.length, zIndex; i < len; i++) {
+
+			zIndex = layers[i].style.zIndex;
+
+			if (layers[i] !== this._container && zIndex) {
+				edgeZIndex = compare(edgeZIndex, +zIndex);
+			}
+		}
+
+		if (isFinite(edgeZIndex)) {
+			this.options.zIndex = edgeZIndex + compare(-1, 1);
+			this._updateZIndex();
+		}
+	},
+
+	_updateOpacity: function () {
+		if (!this._map) { return; }
+
+		// IE doesn't inherit filter opacity properly, so we're forced to set it on tiles
+		if (L.Browser.ielt9) { return; }
+
+		L.DomUtil.setOpacity(this._container, this.options.opacity);
+
+		var now = +new Date(),
+		    nextFrame = false,
+		    willPrune = false;
+
+		for (var key in this._tiles) {
+			var tile = this._tiles[key];
+			if (!tile.current || !tile.loaded) { continue; }
+
+			var fade = Math.min(1, (now - tile.loaded) / 200);
+
+			L.DomUtil.setOpacity(tile.el, fade);
+			if (fade < 1) {
+				nextFrame = true;
+			} else {
+				if (tile.active) { willPrune = true; }
+				tile.active = true;
+			}
+		}
+
+		if (willPrune && !this._noPrune) { this._pruneTiles(); }
+
+		if (nextFrame) {
+			L.Util.cancelAnimFrame(this._fadeFrame);
+			this._fadeFrame = L.Util.requestAnimFrame(this._updateOpacity, this);
+		}
+	},
+
+	_initContainer: function () {
+		if (this._container) { return; }
+
+		this._container = L.DomUtil.create('div', 'leaflet-layer ' + (this.options.className || ''));
+		this._updateZIndex();
+
+		if (this.options.opacity < 1) {
+			this._updateOpacity();
+		}
+
+		this.getPane().appendChild(this._container);
+	},
+
+	_updateLevels: function () {
+
+		var zoom = this._tileZoom,
+		    maxZoom = this.options.maxZoom;
+
+		if (zoom === undefined) { return undefined; }
+
+		for (var z in this._levels) {
+			if (this._levels[z].el.children.length || z === zoom) {
+				this._levels[z].el.style.zIndex = maxZoom - Math.abs(zoom - z);
+			} else {
+				L.DomUtil.remove(this._levels[z].el);
+				this._removeTilesAtZoom(z);
+				delete this._levels[z];
+			}
+		}
+
+		var level = this._levels[zoom],
+		    map = this._map;
+
+		if (!level) {
+			level = this._levels[zoom] = {};
+
+			level.el = L.DomUtil.create('div', 'leaflet-tile-container leaflet-zoom-animated', this._container);
+			level.el.style.zIndex = maxZoom;
+
+			level.origin = map.project(map.unproject(map.getPixelOrigin()), zoom).round();
+			level.zoom = zoom;
+
+			this._setZoomTransform(level, map.getCenter(), map.getZoom());
+
+			// force the browser to consider the newly added element for transition
+			L.Util.falseFn(level.el.offsetWidth);
+		}
+
+		this._level = level;
+
+		return level;
+	},
+
+	_pruneTiles: function () {
+		if (!this._map) {
+			return;
+		}
+
+		var key, tile;
+
+		var zoom = this._map.getZoom();
+		if (zoom > this.options.maxZoom ||
+			zoom < this.options.minZoom) {
+			this._removeAllTiles();
+			return;
+		}
+
+		for (key in this._tiles) {
+			tile = this._tiles[key];
+			tile.retain = tile.current;
+		}
+
+		for (key in this._tiles) {
+			tile = this._tiles[key];
+			if (tile.current && !tile.active) {
+				var coords = tile.coords;
+				if (!this._retainParent(coords.x, coords.y, coords.z, coords.z - 5)) {
+					this._retainChildren(coords.x, coords.y, coords.z, coords.z + 2);
+				}
+			}
+		}
+
+		for (key in this._tiles) {
+			if (!this._tiles[key].retain) {
+				this._removeTile(key);
+			}
+		}
+	},
+
+	_removeTilesAtZoom: function (zoom) {
+		for (var key in this._tiles) {
+			if (this._tiles[key].coords.z !== zoom) {
+				continue;
+			}
+			this._removeTile(key);
+		}
+	},
+
+	_removeAllTiles: function () {
+		for (var key in this._tiles) {
+			this._removeTile(key);
+		}
+	},
+
+	_invalidateAll: function () {
+		for (var z in this._levels) {
+			L.DomUtil.remove(this._levels[z].el);
+			delete this._levels[z];
+		}
+		this._removeAllTiles();
+
+		this._tileZoom = null;
+	},
+
+	_retainParent: function (x, y, z, minZoom) {
+		var x2 = Math.floor(x / 2),
+		    y2 = Math.floor(y / 2),
+		    z2 = z - 1,
+		    coords2 = new L.Point(+x2, +y2);
+		coords2.z = +z2;
+
+		var key = this._tileCoordsToKey(coords2),
+		    tile = this._tiles[key];
+
+		if (tile && tile.active) {
+			tile.retain = true;
+			return true;
+
+		} else if (tile && tile.loaded) {
+			tile.retain = true;
+		}
+
+		if (z2 > minZoom) {
+			return this._retainParent(x2, y2, z2, minZoom);
+		}
+
+		return false;
+	},
+
+	_retainChildren: function (x, y, z, maxZoom) {
+
+		for (var i = 2 * x; i < 2 * x + 2; i++) {
+			for (var j = 2 * y; j < 2 * y + 2; j++) {
+
+				var coords = new L.Point(i, j);
+				coords.z = z + 1;
+
+				var key = this._tileCoordsToKey(coords),
+				    tile = this._tiles[key];
+
+				if (tile && tile.active) {
+					tile.retain = true;
+					continue;
+
+				} else if (tile && tile.loaded) {
+					tile.retain = true;
+				}
+
+				if (z + 1 < maxZoom) {
+					this._retainChildren(i, j, z + 1, maxZoom);
+				}
+			}
+		}
+	},
+
+	_resetView: function (e) {
+		var animating = e && (e.pinch || e.flyTo);
+		this._setView(this._map.getCenter(), this._map.getZoom(), animating, animating);
+	},
+
+	_animateZoom: function (e) {
+		this._setView(e.center, e.zoom, true, e.noUpdate);
+	},
+
+	_setView: function (center, zoom, noPrune, noUpdate) {
+		var tileZoom = Math.round(zoom);
+		if ((this.options.maxZoom !== undefined && tileZoom > this.options.maxZoom) ||
+		    (this.options.minZoom !== undefined && tileZoom < this.options.minZoom)) {
+			tileZoom = undefined;
+		}
+
+		var tileZoomChanged = this.options.updateWhenZooming && (tileZoom !== this._tileZoom);
+
+		if (!noUpdate || tileZoomChanged) {
+
+			this._tileZoom = tileZoom;
+
+			if (this._abortLoading) {
+				this._abortLoading();
+			}
+
+			this._updateLevels();
+			this._resetGrid();
+
+			if (tileZoom !== undefined) {
+				this._update(center);
+			}
+
+			if (!noPrune) {
+				this._pruneTiles();
+			}
+
+			// Flag to prevent _updateOpacity from pruning tiles during
+			// a zoom anim or a pinch gesture
+			this._noPrune = !!noPrune;
+		}
+
+		this._setZoomTransforms(center, zoom);
+	},
+
+	_setZoomTransforms: function (center, zoom) {
+		for (var i in this._levels) {
+			this._setZoomTransform(this._levels[i], center, zoom);
+		}
+	},
+
+	_setZoomTransform: function (level, center, zoom) {
+		var scale = this._map.getZoomScale(zoom, level.zoom),
+		    translate = level.origin.multiplyBy(scale)
+		        .subtract(this._map._getNewPixelOrigin(center, zoom)).round();
+
+		if (L.Browser.any3d) {
+			L.DomUtil.setTransform(level.el, translate, scale);
+		} else {
+			L.DomUtil.setPosition(level.el, translate);
+		}
+	},
+
+	_resetGrid: function () {
+		var map = this._map,
+		    crs = map.options.crs,
+		    tileSize = this._tileSize = this.getTileSize(),
+		    tileZoom = this._tileZoom;
+
+		var bounds = this._map.getPixelWorldBounds(this._tileZoom);
+		if (bounds) {
+			this._globalTileRange = this._pxBoundsToTileRange(bounds);
+		}
+
+		this._wrapX = crs.wrapLng && !this.options.noWrap && [
+			Math.floor(map.project([0, crs.wrapLng[0]], tileZoom).x / tileSize.x),
+			Math.ceil(map.project([0, crs.wrapLng[1]], tileZoom).x / tileSize.y)
+		];
+		this._wrapY = crs.wrapLat && !this.options.noWrap && [
+			Math.floor(map.project([crs.wrapLat[0], 0], tileZoom).y / tileSize.x),
+			Math.ceil(map.project([crs.wrapLat[1], 0], tileZoom).y / tileSize.y)
+		];
+	},
+
+	_onMoveEnd: function () {
+		if (!this._map || this._map._animatingZoom) { return; }
+
+		this._update();
+	},
+
+	_getTiledPixelBounds: function (center) {
+		var map = this._map,
+		    mapZoom = map._animatingZoom ? Math.max(map._animateToZoom, map.getZoom()) : map.getZoom(),
+		    scale = map.getZoomScale(mapZoom, this._tileZoom),
+		    pixelCenter = map.project(center, this._tileZoom).floor(),
+		    halfSize = map.getSize().divideBy(scale * 2);
+
+		return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
+	},
+
+	// Private method to load tiles in the grid's active zoom level according to map bounds
+	_update: function (center) {
+		var map = this._map;
+		if (!map) { return; }
+		var zoom = map.getZoom();
+
+		if (center === undefined) { center = map.getCenter(); }
+		if (this._tileZoom === undefined) { return; }	// if out of minzoom/maxzoom
+
+		var pixelBounds = this._getTiledPixelBounds(center),
+		    tileRange = this._pxBoundsToTileRange(pixelBounds),
+		    tileCenter = tileRange.getCenter(),
+		    queue = [],
+		    margin = this.options.keepBuffer,
+		    noPruneRange = new L.Bounds(tileRange.getBottomLeft().subtract([margin, -margin]),
+		                              tileRange.getTopRight().add([margin, -margin]));
+
+		for (var key in this._tiles) {
+			var c = this._tiles[key].coords;
+			if (c.z !== this._tileZoom || !noPruneRange.contains(L.point(c.x, c.y))) {
+				this._tiles[key].current = false;
+			}
+		}
+
+		// _update just loads more tiles. If the tile zoom level differs too much
+		// from the map's, let _setView reset levels and prune old tiles.
+		if (Math.abs(zoom - this._tileZoom) > 1) { this._setView(center, zoom); return; }
+
+		// create a queue of coordinates to load tiles from
+		for (var j = tileRange.min.y; j <= tileRange.max.y; j++) {
+			for (var i = tileRange.min.x; i <= tileRange.max.x; i++) {
+				var coords = new L.Point(i, j);
+				coords.z = this._tileZoom;
+
+				if (!this._isValidTile(coords)) { continue; }
+
+				var tile = this._tiles[this._tileCoordsToKey(coords)];
+				if (tile) {
+					tile.current = true;
+				} else {
+					queue.push(coords);
+				}
+			}
+		}
+
+		// sort tile queue to load tiles in order of their distance to center
+		queue.sort(function (a, b) {
+			return a.distanceTo(tileCenter) - b.distanceTo(tileCenter);
+		});
+
+		if (queue.length !== 0) {
+			// if it's the first batch of tiles to load
+			if (!this._loading) {
+				this._loading = true;
+				// @event loading: Event
+				// Fired when the grid layer starts loading tiles.
+				this.fire('loading');
+			}
+
+			// create DOM fragment to append tiles in one batch
+			var fragment = document.createDocumentFragment();
+
+			for (i = 0; i < queue.length; i++) {
+				this._addTile(queue[i], fragment);
+			}
+
+			this._level.el.appendChild(fragment);
+		}
+	},
+
+	_isValidTile: function (coords) {
+		var crs = this._map.options.crs;
+
+		if (!crs.infinite) {
+			// don't load tile if it's out of bounds and not wrapped
+			var bounds = this._globalTileRange;
+			if ((!crs.wrapLng && (coords.x < bounds.min.x || coords.x > bounds.max.x)) ||
+			    (!crs.wrapLat && (coords.y < bounds.min.y || coords.y > bounds.max.y))) { return false; }
+		}
+
+		if (!this.options.bounds) { return true; }
+
+		// don't load tile if it doesn't intersect the bounds in options
+		var tileBounds = this._tileCoordsToBounds(coords);
+		return L.latLngBounds(this.options.bounds).overlaps(tileBounds);
+	},
+
+	_keyToBounds: function (key) {
+		return this._tileCoordsToBounds(this._keyToTileCoords(key));
+	},
+
+	// converts tile coordinates to its geographical bounds
+	_tileCoordsToBounds: function (coords) {
+
+		var map = this._map,
+		    tileSize = this.getTileSize(),
+
+		    nwPoint = coords.scaleBy(tileSize),
+		    sePoint = nwPoint.add(tileSize),
+
+		    nw = map.unproject(nwPoint, coords.z),
+		    se = map.unproject(sePoint, coords.z);
+
+		if (!this.options.noWrap) {
+			nw = map.wrapLatLng(nw);
+			se = map.wrapLatLng(se);
+		}
+
+		return new L.LatLngBounds(nw, se);
+	},
+
+	// converts tile coordinates to key for the tile cache
+	_tileCoordsToKey: function (coords) {
+		return coords.x + ':' + coords.y + ':' + coords.z;
+	},
+
+	// converts tile cache key to coordinates
+	_keyToTileCoords: function (key) {
+		var k = key.split(':'),
+		    coords = new L.Point(+k[0], +k[1]);
+		coords.z = +k[2];
+		return coords;
+	},
+
+	_removeTile: function (key) {
+		var tile = this._tiles[key];
+		if (!tile) { return; }
+
+		L.DomUtil.remove(tile.el);
+
+		delete this._tiles[key];
+
+		// @event tileunload: TileEvent
+		// Fired when a tile is removed (e.g. when a tile goes off the screen).
+		this.fire('tileunload', {
+			tile: tile.el,
+			coords: this._keyToTileCoords(key)
+		});
+	},
+
+	_initTile: function (tile) {
+		L.DomUtil.addClass(tile, 'leaflet-tile');
+
+		var tileSize = this.getTileSize();
+		tile.style.width = tileSize.x + 'px';
+		tile.style.height = tileSize.y + 'px';
+
+		tile.onselectstart = L.Util.falseFn;
+		tile.onmousemove = L.Util.falseFn;
+
+		// update opacity on tiles in IE7-8 because of filter inheritance problems
+		if (L.Browser.ielt9 && this.options.opacity < 1) {
+			L.DomUtil.setOpacity(tile, this.options.opacity);
+		}
+
+		// without this hack, tiles disappear after zoom on Chrome for Android
+		// https://github.com/Leaflet/Leaflet/issues/2078
+		if (L.Browser.android && !L.Browser.android23) {
+			tile.style.WebkitBackfaceVisibility = 'hidden';
+		}
+	},
+
+	_addTile: function (coords, container) {
+		var tilePos = this._getTilePos(coords),
+		    key = this._tileCoordsToKey(coords);
+
+		var tile = this.createTile(this._wrapCoords(coords), L.bind(this._tileReady, this, coords));
+
+		this._initTile(tile);
+
+		// if createTile is defined with a second argument ("done" callback),
+		// we know that tile is async and will be ready later; otherwise
+		if (this.createTile.length < 2) {
+			// mark tile as ready, but delay one frame for opacity animation to happen
+			L.Util.requestAnimFrame(L.bind(this._tileReady, this, coords, null, tile));
+		}
+
+		L.DomUtil.setPosition(tile, tilePos);
+
+		// save tile in cache
+		this._tiles[key] = {
+			el: tile,
+			coords: coords,
+			current: true
+		};
+
+		container.appendChild(tile);
+		// @event tileloadstart: TileEvent
+		// Fired when a tile is requested and starts loading.
+		this.fire('tileloadstart', {
+			tile: tile,
+			coords: coords
+		});
+	},
+
+	_tileReady: function (coords, err, tile) {
+		if (!this._map) { return; }
+
+		if (err) {
+			// @event tileerror: TileErrorEvent
+			// Fired when there is an error loading a tile.
+			this.fire('tileerror', {
+				error: err,
+				tile: tile,
+				coords: coords
+			});
+		}
+
+		var key = this._tileCoordsToKey(coords);
+
+		tile = this._tiles[key];
+		if (!tile) { return; }
+
+		tile.loaded = +new Date();
+		if (this._map._fadeAnimated) {
+			L.DomUtil.setOpacity(tile.el, 0);
+			L.Util.cancelAnimFrame(this._fadeFrame);
+			this._fadeFrame = L.Util.requestAnimFrame(this._updateOpacity, this);
+		} else {
+			tile.active = true;
+			this._pruneTiles();
+		}
+
+		if (!err) {
+			L.DomUtil.addClass(tile.el, 'leaflet-tile-loaded');
+
+			// @event tileload: TileEvent
+			// Fired when a tile loads.
+			this.fire('tileload', {
+				tile: tile.el,
+				coords: coords
+			});
+		}
+
+		if (this._noTilesToLoad()) {
+			this._loading = false;
+			// @event load: Event
+			// Fired when the grid layer loaded all visible tiles.
+			this.fire('load');
+
+			if (L.Browser.ielt9 || !this._map._fadeAnimated) {
+				L.Util.requestAnimFrame(this._pruneTiles, this);
+			} else {
+				// Wait a bit more than 0.2 secs (the duration of the tile fade-in)
+				// to trigger a pruning.
+				setTimeout(L.bind(this._pruneTiles, this), 250);
+			}
+		}
+	},
+
+	_getTilePos: function (coords) {
+		return coords.scaleBy(this.getTileSize()).subtract(this._level.origin);
+	},
+
+	_wrapCoords: function (coords) {
+		var newCoords = new L.Point(
+			this._wrapX ? L.Util.wrapNum(coords.x, this._wrapX) : coords.x,
+			this._wrapY ? L.Util.wrapNum(coords.y, this._wrapY) : coords.y);
+		newCoords.z = coords.z;
+		return newCoords;
+	},
+
+	_pxBoundsToTileRange: function (bounds) {
+		var tileSize = this.getTileSize();
+		return new L.Bounds(
+			bounds.min.unscaleBy(tileSize).floor(),
+			bounds.max.unscaleBy(tileSize).ceil().subtract([1, 1]));
+	},
+
+	_noTilesToLoad: function () {
+		for (var key in this._tiles) {
+			if (!this._tiles[key].loaded) { return false; }
+		}
+		return true;
+	}
+});
+
+// @factory L.gridLayer(options?: GridLayer options)
+// Creates a new instance of GridLayer with the supplied options.
+L.gridLayer = function (options) {
+	return new L.GridLayer(options);
+};
+
+
+
+/*
+ * @class TileLayer
+ * @inherits GridLayer
+ * @aka L.TileLayer
+ * Used to load and display tile layers on the map. Extends `GridLayer`.
+ *
+ * @example
+ *
+ * ```js
+ * L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png?{foo}', {foo: 'bar'}).addTo(map);
+ * ```
+ *
+ * @section URL template
+ * @example
+ *
+ * A string of the following form:
+ *
+ * ```
+ * 'http://{s}.somedomain.com/blabla/{z}/{x}/{y}{r}.png'
+ * ```
+ *
+ * `{s}` means one of the available subdomains (used sequentially to help with browser parallel requests per domain limitation; subdomain values are specified in options; `a`, `b` or `c` by default, can be omitted), `{z}` — zoom level, `{x}` and `{y}` — tile coordinates. `{r}` can be used to add @2x to the URL to load retina tiles.
+ *
+ * You can use custom keys in the template, which will be [evaluated](#util-template) from TileLayer options, like this:
+ *
+ * ```
+ * L.tileLayer('http://{s}.somedomain.com/{foo}/{z}/{x}/{y}.png', {foo: 'bar'});
+ * ```
+ */
+
+
+L.TileLayer = L.GridLayer.extend({
+
+	// @section
+	// @aka TileLayer options
+	options: {
+		// @option minZoom: Number = 0
+		// Minimum zoom number.
+		minZoom: 0,
+
+		// @option maxZoom: Number = 18
+		// Maximum zoom number.
+		maxZoom: 18,
+
+		// @option maxNativeZoom: Number = null
+		// Maximum zoom number the tile source has available. If it is specified,
+		// the tiles on all zoom levels higher than `maxNativeZoom` will be loaded
+		// from `maxNativeZoom` level and auto-scaled.
+		maxNativeZoom: null,
+
+		// @option subdomains: String|String[] = 'abc'
+		// Subdomains of the tile service. Can be passed in the form of one string (where each letter is a subdomain name) or an array of strings.
+		subdomains: 'abc',
+
+		// @option errorTileUrl: String = ''
+		// URL to the tile image to show in place of the tile that failed to load.
+		errorTileUrl: '',
+
+		// @option zoomOffset: Number = 0
+		// The zoom number used in tile URLs will be offset with this value.
+		zoomOffset: 0,
+
+		// @option tms: Boolean = false
+		// If `true`, inverses Y axis numbering for tiles (turn this on for [TMS](https://en.wikipedia.org/wiki/Tile_Map_Service) services).
+		tms: false,
+
+		// @option zoomReverse: Boolean = false
+		// If set to true, the zoom number used in tile URLs will be reversed (`maxZoom - zoom` instead of `zoom`)
+		zoomReverse: false,
+
+		// @option detectRetina: Boolean = false
+		// If `true` and user is on a retina display, it will request four tiles of half the specified size and a bigger zoom level in place of one to utilize the high resolution.
+		detectRetina: false,
+
+		// @option crossOrigin: Boolean = false
+		// If true, all tiles will have their crossOrigin attribute set to ''. This is needed if you want to access tile pixel data.
+		crossOrigin: false
+	},
+
+	initialize: function (url, options) {
+
+		this._url = url;
+
+		options = L.setOptions(this, options);
+
+		// detecting retina displays, adjusting tileSize and zoom levels
+		if (options.detectRetina && L.Browser.retina && options.maxZoom > 0) {
+
+			options.tileSize = Math.floor(options.tileSize / 2);
+
+			if (!options.zoomReverse) {
+				options.zoomOffset++;
+				options.maxZoom--;
+			} else {
+				options.zoomOffset--;
+				options.minZoom++;
+			}
+
+			options.minZoom = Math.max(0, options.minZoom);
+		}
+
+		if (typeof options.subdomains === 'string') {
+			options.subdomains = options.subdomains.split('');
+		}
+
+		// for https://github.com/Leaflet/Leaflet/issues/137
+		if (!L.Browser.android) {
+			this.on('tileunload', this._onTileRemove);
+		}
+	},
+
+	// @method setUrl(url: String, noRedraw?: Boolean): this
+	// Updates the layer's URL template and redraws it (unless `noRedraw` is set to `true`).
+	setUrl: function (url, noRedraw) {
+		this._url = url;
+
+		if (!noRedraw) {
+			this.redraw();
+		}
+		return this;
+	},
+
+	// @method createTile(coords: Object, done?: Function): HTMLElement
+	// Called only internally, overrides GridLayer's [`createTile()`](#gridlayer-createtile)
+	// to return an `<img>` HTML element with the appropiate image URL given `coords`. The `done`
+	// callback is called when the tile has been loaded.
+	createTile: function (coords, done) {
+		var tile = document.createElement('img');
+
+		L.DomEvent.on(tile, 'load', L.bind(this._tileOnLoad, this, done, tile));
+		L.DomEvent.on(tile, 'error', L.bind(this._tileOnError, this, done, tile));
+
+		if (this.options.crossOrigin) {
+			tile.crossOrigin = '';
+		}
+
+		/*
+		 Alt tag is set to empty string to keep screen readers from reading URL and for compliance reasons
+		 http://www.w3.org/TR/WCAG20-TECHS/H67
+		*/
+		tile.alt = '';
+
+		tile.src = this.getTileUrl(coords);
+
+		return tile;
+	},
+
+	// @section Extension methods
+	// @uninheritable
+	// Layers extending `TileLayer` might reimplement the following method.
+	// @method getTileUrl(coords: Object): String
+	// Called only internally, returns the URL for a tile given its coordinates.
+	// Classes extending `TileLayer` can override this function to provide custom tile URL naming schemes.
+	getTileUrl: function (coords) {
+		var data = {
+			r: L.Browser.retina ? '@2x' : '',
+			s: this._getSubdomain(coords),
+			x: coords.x,
+			y: coords.y,
+			z: this._getZoomForUrl()
+		};
+		if (this._map && !this._map.options.crs.infinite) {
+			var invertedY = this._globalTileRange.max.y - coords.y;
+			if (this.options.tms) {
+				data['y'] = invertedY;
+			}
+			data['-y'] = invertedY;
+		}
+
+		return L.Util.template(this._url, L.extend(data, this.options));
+	},
+
+	_tileOnLoad: function (done, tile) {
+		// For https://github.com/Leaflet/Leaflet/issues/3332
+		if (L.Browser.ielt9) {
+			setTimeout(L.bind(done, this, null, tile), 0);
+		} else {
+			done(null, tile);
+		}
+	},
+
+	_tileOnError: function (done, tile, e) {
+		var errorUrl = this.options.errorTileUrl;
+		if (errorUrl) {
+			tile.src = errorUrl;
+		}
+		done(e, tile);
+	},
+
+	getTileSize: function () {
+		var map = this._map,
+		    tileSize = L.GridLayer.prototype.getTileSize.call(this),
+		    zoom = this._tileZoom + this.options.zoomOffset,
+		    zoomN = this.options.maxNativeZoom;
+
+		// increase tile size when overscaling
+		return zoomN !== null && zoom > zoomN ?
+				tileSize.divideBy(map.getZoomScale(zoomN, zoom)).round() :
+				tileSize;
+	},
+
+	_onTileRemove: function (e) {
+		e.tile.onload = null;
+	},
+
+	_getZoomForUrl: function () {
+
+		var options = this.options,
+		    zoom = this._tileZoom;
+
+		if (options.zoomReverse) {
+			zoom = options.maxZoom - zoom;
+		}
+
+		zoom += options.zoomOffset;
+
+		return options.maxNativeZoom !== null ? Math.min(zoom, options.maxNativeZoom) : zoom;
+	},
+
+	_getSubdomain: function (tilePoint) {
+		var index = Math.abs(tilePoint.x + tilePoint.y) % this.options.subdomains.length;
+		return this.options.subdomains[index];
+	},
+
+	// stops loading all tiles in the background layer
+	_abortLoading: function () {
+		var i, tile;
+		for (i in this._tiles) {
+			if (this._tiles[i].coords.z !== this._tileZoom) {
+				tile = this._tiles[i].el;
+
+				tile.onload = L.Util.falseFn;
+				tile.onerror = L.Util.falseFn;
+
+				if (!tile.complete) {
+					tile.src = L.Util.emptyImageUrl;
+					L.DomUtil.remove(tile);
+				}
+			}
+		}
+	}
+});
+
+
+// @factory L.tilelayer(urlTemplate: String, options?: TileLayer options)
+// Instantiates a tile layer object given a `URL template` and optionally an options object.
+
+L.tileLayer = function (url, options) {
+	return new L.TileLayer(url, options);
+};
+
+
+
+/*
+ * @class TileLayer.WMS
+ * @inherits TileLayer
+ * @aka L.TileLayer.WMS
+ * Used to display [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services as tile layers on the map. Extends `TileLayer`.
+ *
+ * @example
+ *
+ * ```js
+ * var nexrad = L.tileLayer.wms("http://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi", {
+ * 	layers: 'nexrad-n0r-900913',
+ * 	format: 'image/png',
+ * 	transparent: true,
+ * 	attribution: "Weather data © 2012 IEM Nexrad"
+ * });
+ * ```
+ */
+
+L.TileLayer.WMS = L.TileLayer.extend({
+
+	// @section
+	// @aka TileLayer.WMS options
+	// If any custom options not documented here are used, they will be sent to the
+	// WMS server as extra parameters in each request URL. This can be useful for
+	// [non-standard vendor WMS parameters](http://docs.geoserver.org/stable/en/user/services/wms/vendor.html).
+	defaultWmsParams: {
+		service: 'WMS',
+		request: 'GetMap',
+
+		// @option layers: String = ''
+		// **(required)** Comma-separated list of WMS layers to show.
+		layers: '',
+
+		// @option styles: String = ''
+		// Comma-separated list of WMS styles.
+		styles: '',
+
+		// @option format: String = 'image/jpeg'
+		// WMS image format (use `'image/png'` for layers with transparency).
+		format: 'image/jpeg',
+
+		// @option transparent: Boolean = false
+		// If `true`, the WMS service will return images with transparency.
+		transparent: false,
+
+		// @option version: String = '1.1.1'
+		// Version of the WMS service to use
+		version: '1.1.1'
+	},
+
+	options: {
+		// @option crs: CRS = null
+		// Coordinate Reference System to use for the WMS requests, defaults to
+		// map CRS. Don't change this if you're not sure what it means.
+		crs: null,
+
+		// @option uppercase: Boolean = false
+		// If `true`, WMS request parameter keys will be uppercase.
+		uppercase: false
+	},
+
+	initialize: function (url, options) {
+
+		this._url = url;
+
+		var wmsParams = L.extend({}, this.defaultWmsParams);
+
+		// all keys that are not TileLayer options go to WMS params
+		for (var i in options) {
+			if (!(i in this.options)) {
+				wmsParams[i] = options[i];
+			}
+		}
+
+		options = L.setOptions(this, options);
+
+		wmsParams.width = wmsParams.height = options.tileSize * (options.detectRetina && L.Browser.retina ? 2 : 1);
+
+		this.wmsParams = wmsParams;
+	},
+
+	onAdd: function (map) {
+
+		this._crs = this.options.crs || map.options.crs;
+		this._wmsVersion = parseFloat(this.wmsParams.version);
+
+		var projectionKey = this._wmsVersion >= 1.3 ? 'crs' : 'srs';
+		this.wmsParams[projectionKey] = this._crs.code;
+
+		L.TileLayer.prototype.onAdd.call(this, map);
+	},
+
+	getTileUrl: function (coords) {
+
+		var tileBounds = this._tileCoordsToBounds(coords),
+		    nw = this._crs.project(tileBounds.getNorthWest()),
+		    se = this._crs.project(tileBounds.getSouthEast()),
+
+		    bbox = (this._wmsVersion >= 1.3 && this._crs === L.CRS.EPSG4326 ?
+			    [se.y, nw.x, nw.y, se.x] :
+			    [nw.x, se.y, se.x, nw.y]).join(','),
+
+		    url = L.TileLayer.prototype.getTileUrl.call(this, coords);
+
+		return url +
+			L.Util.getParamString(this.wmsParams, url, this.options.uppercase) +
+			(this.options.uppercase ? '&BBOX=' : '&bbox=') + bbox;
+	},
+
+	// @method setParams(params: Object, noRedraw?: Boolean): this
+	// Merges an object with the new parameters and re-requests tiles on the current screen (unless `noRedraw` was set to true).
+	setParams: function (params, noRedraw) {
+
+		L.extend(this.wmsParams, params);
+
+		if (!noRedraw) {
+			this.redraw();
+		}
+
+		return this;
+	}
+});
+
+
+// @factory L.tileLayer.wms(baseUrl: String, options: TileLayer.WMS options)
+// Instantiates a WMS tile layer object given a base URL of the WMS service and a WMS parameters/options object.
+L.tileLayer.wms = function (url, options) {
+	return new L.TileLayer.WMS(url, options);
+};
+
+
+
+/*
+ * @class ImageOverlay
+ * @aka L.ImageOverlay
+ * @inherits Interactive layer
+ *
+ * Used to load and display a single image over specific bounds of the map. Extends `Layer`.
+ *
+ * @example
+ *
+ * ```js
+ * var imageUrl = 'http://www.lib.utexas.edu/maps/historical/newark_nj_1922.jpg',
+ * 	imageBounds = [[40.712216, -74.22655], [40.773941, -74.12544]];
+ * L.imageOverlay(imageUrl, imageBounds).addTo(map);
+ * ```
+ */
+
+L.ImageOverlay = L.Layer.extend({
+
+	// @section
+	// @aka ImageOverlay options
+	options: {
+		// @option opacity: Number = 1.0
+		// The opacity of the image overlay.
+		opacity: 1,
+
+		// @option alt: String = ''
+		// Text for the `alt` attribute of the image (useful for accessibility).
+		alt: '',
+
+		// @option interactive: Boolean = false
+		// If `true`, the image overlay will emit [mouse events](#interactive-layer) when clicked or hovered.
+		interactive: false,
+
+		// @option attribution: String = null
+		// An optional string containing HTML to be shown on the `Attribution control`
+		attribution: null,
+
+		// @option crossOrigin: Boolean = false
+		// If true, the image will have its crossOrigin attribute set to ''. This is needed if you want to access image pixel data.
+		crossOrigin: false
+	},
+
+	initialize: function (url, bounds, options) { // (String, LatLngBounds, Object)
+		this._url = url;
+		this._bounds = L.latLngBounds(bounds);
+
+		L.setOptions(this, options);
+	},
+
+	onAdd: function () {
+		if (!this._image) {
+			this._initImage();
+
+			if (this.options.opacity < 1) {
+				this._updateOpacity();
+			}
+		}
+
+		if (this.options.interactive) {
+			L.DomUtil.addClass(this._image, 'leaflet-interactive');
+			this.addInteractiveTarget(this._image);
+		}
+
+		this.getPane().appendChild(this._image);
+		this._reset();
+	},
+
+	onRemove: function () {
+		L.DomUtil.remove(this._image);
+		if (this.options.interactive) {
+			this.removeInteractiveTarget(this._image);
+		}
+	},
+
+	// @method setOpacity(opacity: Number): this
+	// Sets the opacity of the overlay.
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+
+		if (this._image) {
+			this._updateOpacity();
+		}
+		return this;
+	},
+
+	setStyle: function (styleOpts) {
+		if (styleOpts.opacity) {
+			this.setOpacity(styleOpts.opacity);
+		}
+		return this;
+	},
+
+	// @method bringToFront(): this
+	// Brings the layer to the top of all overlays.
+	bringToFront: function () {
+		if (this._map) {
+			L.DomUtil.toFront(this._image);
+		}
+		return this;
+	},
+
+	// @method bringToBack(): this
+	// Brings the layer to the bottom of all overlays.
+	bringToBack: function () {
+		if (this._map) {
+			L.DomUtil.toBack(this._image);
+		}
+		return this;
+	},
+
+	// @method setUrl(url: String): this
+	// Changes the URL of the image.
+	setUrl: function (url) {
+		this._url = url;
+
+		if (this._image) {
+			this._image.src = url;
+		}
+		return this;
+	},
+
+	setBounds: function (bounds) {
+		this._bounds = bounds;
+
+		if (this._map) {
+			this._reset();
+		}
+		return this;
+	},
+
+	getAttribution: function () {
+		return this.options.attribution;
+	},
+
+	getEvents: function () {
+		var events = {
+			zoom: this._reset,
+			viewreset: this._reset
+		};
+
+		if (this._zoomAnimated) {
+			events.zoomanim = this._animateZoom;
+		}
+
+		return events;
+	},
+
+	getBounds: function () {
+		return this._bounds;
+	},
+
+	getElement: function () {
+		return this._image;
+	},
+
+	_initImage: function () {
+		var img = this._image = L.DomUtil.create('img',
+				'leaflet-image-layer ' + (this._zoomAnimated ? 'leaflet-zoom-animated' : ''));
+
+		img.onselectstart = L.Util.falseFn;
+		img.onmousemove = L.Util.falseFn;
+
+		img.onload = L.bind(this.fire, this, 'load');
+
+		if (this.options.crossOrigin) {
+			img.crossOrigin = '';
+		}
+
+		img.src = this._url;
+		img.alt = this.options.alt;
+	},
+
+	_animateZoom: function (e) {
+		var scale = this._map.getZoomScale(e.zoom),
+		    offset = this._map._latLngToNewLayerPoint(this._bounds.getNorthWest(), e.zoom, e.center);
+
+		L.DomUtil.setTransform(this._image, offset, scale);
+	},
+
+	_reset: function () {
+		var image = this._image,
+		    bounds = new L.Bounds(
+		        this._map.latLngToLayerPoint(this._bounds.getNorthWest()),
+		        this._map.latLngToLayerPoint(this._bounds.getSouthEast())),
+		    size = bounds.getSize();
+
+		L.DomUtil.setPosition(image, bounds.min);
+
+		image.style.width  = size.x + 'px';
+		image.style.height = size.y + 'px';
+	},
+
+	_updateOpacity: function () {
+		L.DomUtil.setOpacity(this._image, this.options.opacity);
+	}
+});
+
+// @factory L.imageOverlay(imageUrl: String, bounds: LatLngBounds, options?: ImageOverlay options)
+// Instantiates an image overlay object given the URL of the image and the
+// geographical bounds it is tied to.
+L.imageOverlay = function (url, bounds, options) {
+	return new L.ImageOverlay(url, bounds, options);
+};
+
+
+
+/*
+ * @class Icon
+ * @aka L.Icon
+ * @inherits Layer
+ *
+ * Represents an icon to provide when creating a marker.
+ *
+ * @example
+ *
+ * ```js
+ * var myIcon = L.icon({
+ *     iconUrl: 'my-icon.png',
+ *     iconRetinaUrl: 'my-icon@2x.png',
+ *     iconSize: [38, 95],
+ *     iconAnchor: [22, 94],
+ *     popupAnchor: [-3, -76],
+ *     shadowUrl: 'my-icon-shadow.png',
+ *     shadowRetinaUrl: 'my-icon-shadow@2x.png',
+ *     shadowSize: [68, 95],
+ *     shadowAnchor: [22, 94]
+ * });
+ *
+ * L.marker([50.505, 30.57], {icon: myIcon}).addTo(map);
+ * ```
+ *
+ * `L.Icon.Default` extends `L.Icon` and is the blue icon Leaflet uses for markers by default.
+ *
+ */
+
+L.Icon = L.Class.extend({
+
+	/* @section
+	 * @aka Icon options
+	 *
+	 * @option iconUrl: String = null
+	 * **(required)** The URL to the icon image (absolute or relative to your script path).
+	 *
+	 * @option iconRetinaUrl: String = null
+	 * The URL to a retina sized version of the icon image (absolute or relative to your
+	 * script path). Used for Retina screen devices.
+	 *
+	 * @option iconSize: Point = null
+	 * Size of the icon image in pixels.
+	 *
+	 * @option iconAnchor: Point = null
+	 * The coordinates of the "tip" of the icon (relative to its top left corner). The icon
+	 * will be aligned so that this point is at the marker's geographical location. Centered
+	 * by default if size is specified, also can be set in CSS with negative margins.
+	 *
+	 * @option popupAnchor: Point = null
+	 * The coordinates of the point from which popups will "open", relative to the icon anchor.
+	 *
+	 * @option shadowUrl: String = null
+	 * The URL to the icon shadow image. If not specified, no shadow image will be created.
+	 *
+	 * @option shadowRetinaUrl: String = null
+	 *
+	 * @option shadowSize: Point = null
+	 * Size of the shadow image in pixels.
+	 *
+	 * @option shadowAnchor: Point = null
+	 * The coordinates of the "tip" of the shadow (relative to its top left corner) (the same
+	 * as iconAnchor if not specified).
+	 *
+	 * @option className: String = ''
+	 * A custom class name to assign to both icon and shadow images. Empty by default.
+	 */
+
+	initialize: function (options) {
+		L.setOptions(this, options);
+	},
+
+	// @method createIcon(oldIcon?: HTMLElement): HTMLElement
+	// Called internally when the icon has to be shown, returns a `<img>` HTML element
+	// styled according to the options.
+	createIcon: function (oldIcon) {
+		return this._createIcon('icon', oldIcon);
+	},
+
+	// @method createShadow(oldIcon?: HTMLElement): HTMLElement
+	// As `createIcon`, but for the shadow beneath it.
+	createShadow: function (oldIcon) {
+		return this._createIcon('shadow', oldIcon);
+	},
+
+	_createIcon: function (name, oldIcon) {
+		var src = this._getIconUrl(name);
+
+		if (!src) {
+			if (name === 'icon') {
+				throw new Error('iconUrl not set in Icon options (see the docs).');
+			}
+			return null;
+		}
+
+		var img = this._createImg(src, oldIcon && oldIcon.tagName === 'IMG' ? oldIcon : null);
+		this._setIconStyles(img, name);
+
+		return img;
+	},
+
+	_setIconStyles: function (img, name) {
+		var options = this.options;
+		var sizeOption = options[name + 'Size'];
+
+		if (typeof sizeOption === 'number') {
+			sizeOption = [sizeOption, sizeOption];
+		}
+
+		var size = L.point(sizeOption),
+		    anchor = L.point(name === 'shadow' && options.shadowAnchor || options.iconAnchor ||
+		            size && size.divideBy(2, true));
+
+		img.className = 'leaflet-marker-' + name + ' ' + (options.className || '');
+
+		if (anchor) {
+			img.style.marginLeft = (-anchor.x) + 'px';
+			img.style.marginTop  = (-anchor.y) + 'px';
+		}
+
+		if (size) {
+			img.style.width  = size.x + 'px';
+			img.style.height = size.y + 'px';
+		}
+	},
+
+	_createImg: function (src, el) {
+		el = el || document.createElement('img');
+		el.src = src;
+		return el;
+	},
+
+	_getIconUrl: function (name) {
+		return L.Browser.retina && this.options[name + 'RetinaUrl'] || this.options[name + 'Url'];
+	}
+});
+
+
+// @factory L.icon(options: Icon options)
+// Creates an icon instance with the given options.
+L.icon = function (options) {
+	return new L.Icon(options);
+};
+
+
+
+/*
+ * @miniclass Icon.Default (Icon)
+ * @aka L.Icon.Default
+ * @section
+ *
+ * A trivial subclass of `Icon`, represents the icon to use in `Marker`s when
+ * no icon is specified. Points to the blue marker image distributed with Leaflet
+ * releases.
+ *
+ * In order to change the default icon, just change the properties of `L.Icon.Default.prototype.options`
+ * (which is a set of `Icon options`).
+ */
+
+L.Icon.Default = L.Icon.extend({
+
+	options: {
+		iconUrl:       'marker-icon.png',
+		iconRetinaUrl: 'marker-icon-2x.png',
+		shadowUrl:     'marker-shadow.png',
+		iconSize:    [25, 41],
+		iconAnchor:  [12, 41],
+		popupAnchor: [1, -34],
+		tooltipAnchor: [16, -28],
+		shadowSize:  [41, 41]
+	},
+
+	_getIconUrl: function (name) {
+		if (!L.Icon.Default.imagePath) {	// Deprecated, backwards-compatibility only
+			L.Icon.Default.imagePath = this._detectIconPath();
+		}
+
+		// @option imagePath: String
+		// `L.Icon.Default` will try to auto-detect the absolute location of the
+		// blue icon images. If you are placing these images in a non-standard
+		// way, set this option to point to the right absolute path.
+		return (this.options.imagePath || L.Icon.Default.imagePath) + L.Icon.prototype._getIconUrl.call(this, name);
+	},
+
+	_detectIconPath: function () {
+		var el = L.DomUtil.create('div',  'leaflet-default-icon-path', document.body);
+		var path = L.DomUtil.getStyle(el, 'background-image') ||
+		           L.DomUtil.getStyle(el, 'backgroundImage');	// IE8
+
+		document.body.removeChild(el);
+
+		return path.indexOf('url') === 0 ?
+			path.replace(/^url\([\"\']?/, '').replace(/marker-icon\.png[\"\']?\)$/, '') : '';
+	}
+});
+
+
+
+/*
+ * @class Marker
+ * @inherits Interactive layer
+ * @aka L.Marker
+ * L.Marker is used to display clickable/draggable icons on the map. Extends `Layer`.
+ *
+ * @example
+ *
+ * ```js
+ * L.marker([50.5, 30.5]).addTo(map);
+ * ```
+ */
+
+L.Marker = L.Layer.extend({
+
+	// @section
+	// @aka Marker options
+	options: {
+		// @option icon: Icon = *
+		// Icon class to use for rendering the marker. See [Icon documentation](#L.Icon) for details on how to customize the marker icon. If not specified, a new `L.Icon.Default` is used.
+		icon: new L.Icon.Default(),
+
+		// Option inherited from "Interactive layer" abstract class
+		interactive: true,
+
+		// @option draggable: Boolean = false
+		// Whether the marker is draggable with mouse/touch or not.
+		draggable: false,
+
+		// @option keyboard: Boolean = true
+		// Whether the marker can be tabbed to with a keyboard and clicked by pressing enter.
+		keyboard: true,
+
+		// @option title: String = ''
+		// Text for the browser tooltip that appear on marker hover (no tooltip by default).
+		title: '',
+
+		// @option alt: String = ''
+		// Text for the `alt` attribute of the icon image (useful for accessibility).
+		alt: '',
+
+		// @option zIndexOffset: Number = 0
+		// By default, marker images zIndex is set automatically based on its latitude. Use this option if you want to put the marker on top of all others (or below), specifying a high value like `1000` (or high negative value, respectively).
+		zIndexOffset: 0,
+
+		// @option opacity: Number = 1.0
+		// The opacity of the marker.
+		opacity: 1,
+
+		// @option riseOnHover: Boolean = false
+		// If `true`, the marker will get on top of others when you hover the mouse over it.
+		riseOnHover: false,
+
+		// @option riseOffset: Number = 250
+		// The z-index offset used for the `riseOnHover` feature.
+		riseOffset: 250,
+
+		// @option pane: String = 'markerPane'
+		// `Map pane` where the markers icon will be added.
+		pane: 'markerPane',
+
+		// FIXME: shadowPane is no longer a valid option
+		nonBubblingEvents: ['click', 'dblclick', 'mouseover', 'mouseout', 'contextmenu']
+	},
+
+	/* @section
+	 *
+	 * In addition to [shared layer methods](#Layer) like `addTo()` and `remove()` and [popup methods](#Popup) like bindPopup() you can also use the following methods:
+	 */
+
+	initialize: function (latlng, options) {
+		L.setOptions(this, options);
+		this._latlng = L.latLng(latlng);
+	},
+
+	onAdd: function (map) {
+		this._zoomAnimated = this._zoomAnimated && map.options.markerZoomAnimation;
+
+		if (this._zoomAnimated) {
+			map.on('zoomanim', this._animateZoom, this);
+		}
+
+		this._initIcon();
+		this.update();
+	},
+
+	onRemove: function (map) {
+		if (this.dragging && this.dragging.enabled()) {
+			this.options.draggable = true;
+			this.dragging.removeHooks();
+		}
+
+		if (this._zoomAnimated) {
+			map.off('zoomanim', this._animateZoom, this);
+		}
+
+		this._removeIcon();
+		this._removeShadow();
+	},
+
+	getEvents: function () {
+		return {
+			zoom: this.update,
+			viewreset: this.update
+		};
+	},
+
+	// @method getLatLng: LatLng
+	// Returns the current geographical position of the marker.
+	getLatLng: function () {
+		return this._latlng;
+	},
+
+	// @method setLatLng(latlng: LatLng): this
+	// Changes the marker position to the given point.
+	setLatLng: function (latlng) {
+		var oldLatLng = this._latlng;
+		this._latlng = L.latLng(latlng);
+		this.update();
+
+		// @event move: Event
+		// Fired when the marker is moved via [`setLatLng`](#marker-setlatlng) or by [dragging](#marker-dragging). Old and new coordinates are included in event arguments as `oldLatLng`, `latlng`.
+		return this.fire('move', {oldLatLng: oldLatLng, latlng: this._latlng});
+	},
+
+	// @method setZIndexOffset(offset: Number): this
+	// Changes the [zIndex offset](#marker-zindexoffset) of the marker.
+	setZIndexOffset: function (offset) {
+		this.options.zIndexOffset = offset;
+		return this.update();
+	},
+
+	// @method setIcon(icon: Icon): this
+	// Changes the marker icon.
+	setIcon: function (icon) {
+
+		this.options.icon = icon;
+
+		if (this._map) {
+			this._initIcon();
+			this.update();
+		}
+
+		if (this._popup) {
+			this.bindPopup(this._popup, this._popup.options);
+		}
+
+		return this;
+	},
+
+	getElement: function () {
+		return this._icon;
+	},
+
+	update: function () {
+
+		if (this._icon) {
+			var pos = this._map.latLngToLayerPoint(this._latlng).round();
+			this._setPos(pos);
+		}
+
+		return this;
+	},
+
+	_initIcon: function () {
+		var options = this.options,
+		    classToAdd = 'leaflet-zoom-' + (this._zoomAnimated ? 'animated' : 'hide');
+
+		var icon = options.icon.createIcon(this._icon),
+		    addIcon = false;
+
+		// if we're not reusing the icon, remove the old one and init new one
+		if (icon !== this._icon) {
+			if (this._icon) {
+				this._removeIcon();
+			}
+			addIcon = true;
+
+			if (options.title) {
+				icon.title = options.title;
+			}
+			if (options.alt) {
+				icon.alt = options.alt;
+			}
+		}
+
+		L.DomUtil.addClass(icon, classToAdd);
+
+		if (options.keyboard) {
+			icon.tabIndex = '0';
+		}
+
+		this._icon = icon;
+
+		if (options.riseOnHover) {
+			this.on({
+				mouseover: this._bringToFront,
+				mouseout: this._resetZIndex
+			});
+		}
+
+		var newShadow = options.icon.createShadow(this._shadow),
+		    addShadow = false;
+
+		if (newShadow !== this._shadow) {
+			this._removeShadow();
+			addShadow = true;
+		}
+
+		if (newShadow) {
+			L.DomUtil.addClass(newShadow, classToAdd);
+		}
+		this._shadow = newShadow;
+
+
+		if (options.opacity < 1) {
+			this._updateOpacity();
+		}
+
+
+		if (addIcon) {
+			this.getPane().appendChild(this._icon);
+		}
+		this._initInteraction();
+		if (newShadow && addShadow) {
+			this.getPane('shadowPane').appendChild(this._shadow);
+		}
+	},
+
+	_removeIcon: function () {
+		if (this.options.riseOnHover) {
+			this.off({
+				mouseover: this._bringToFront,
+				mouseout: this._resetZIndex
+			});
+		}
+
+		L.DomUtil.remove(this._icon);
+		this.removeInteractiveTarget(this._icon);
+
+		this._icon = null;
+	},
+
+	_removeShadow: function () {
+		if (this._shadow) {
+			L.DomUtil.remove(this._shadow);
+		}
+		this._shadow = null;
+	},
+
+	_setPos: function (pos) {
+		L.DomUtil.setPosition(this._icon, pos);
+
+		if (this._shadow) {
+			L.DomUtil.setPosition(this._shadow, pos);
+		}
+
+		this._zIndex = pos.y + this.options.zIndexOffset;
+
+		this._resetZIndex();
+	},
+
+	_updateZIndex: function (offset) {
+		this._icon.style.zIndex = this._zIndex + offset;
+	},
+
+	_animateZoom: function (opt) {
+		var pos = this._map._latLngToNewLayerPoint(this._latlng, opt.zoom, opt.center).round();
+
+		this._setPos(pos);
+	},
+
+	_initInteraction: function () {
+
+		if (!this.options.interactive) { return; }
+
+		L.DomUtil.addClass(this._icon, 'leaflet-interactive');
+
+		this.addInteractiveTarget(this._icon);
+
+		if (L.Handler.MarkerDrag) {
+			var draggable = this.options.draggable;
+			if (this.dragging) {
+				draggable = this.dragging.enabled();
+				this.dragging.disable();
+			}
+
+			this.dragging = new L.Handler.MarkerDrag(this);
+
+			if (draggable) {
+				this.dragging.enable();
+			}
+		}
+	},
+
+	// @method setOpacity(opacity: Number): this
+	// Changes the opacity of the marker.
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+		if (this._map) {
+			this._updateOpacity();
+		}
+
+		return this;
+	},
+
+	_updateOpacity: function () {
+		var opacity = this.options.opacity;
+
+		L.DomUtil.setOpacity(this._icon, opacity);
+
+		if (this._shadow) {
+			L.DomUtil.setOpacity(this._shadow, opacity);
+		}
+	},
+
+	_bringToFront: function () {
+		this._updateZIndex(this.options.riseOffset);
+	},
+
+	_resetZIndex: function () {
+		this._updateZIndex(0);
+	}
+});
+
+
+// factory L.marker(latlng: LatLng, options? : Marker options)
+
+// @factory L.marker(latlng: LatLng, options? : Marker options)
+// Instantiates a Marker object given a geographical point and optionally an options object.
+L.marker = function (latlng, options) {
+	return new L.Marker(latlng, options);
+};
+
+
+
+/*
+ * @class DivIcon
+ * @aka L.DivIcon
+ * @inherits Icon
+ *
+ * Represents a lightweight icon for markers that uses a simple `<div>`
+ * element instead of an image. Inherits from `Icon` but ignores the `iconUrl` and shadow options.
+ *
+ * @example
+ * ```js
+ * var myIcon = L.divIcon({className: 'my-div-icon'});
+ * // you can set .my-div-icon styles in CSS
+ *
+ * L.marker([50.505, 30.57], {icon: myIcon}).addTo(map);
+ * ```
+ *
+ * By default, it has a 'leaflet-div-icon' CSS class and is styled as a little white square with a shadow.
+ */
+
+L.DivIcon = L.Icon.extend({
+	options: {
+		// @section
+		// @aka DivIcon options
+		iconSize: [12, 12], // also can be set through CSS
+
+		// iconAnchor: (Point),
+		// popupAnchor: (Point),
+
+		// @option html: String = ''
+		// Custom HTML code to put inside the div element, empty by default.
+		html: false,
+
+		// @option bgPos: Point = [0, 0]
+		// Optional relative position of the background, in pixels
+		bgPos: null,
+
+		className: 'leaflet-div-icon'
+	},
+
+	createIcon: function (oldIcon) {
+		var div = (oldIcon && oldIcon.tagName === 'DIV') ? oldIcon : document.createElement('div'),
+		    options = this.options;
+
+		div.innerHTML = options.html !== false ? options.html : '';
+
+		if (options.bgPos) {
+			var bgPos = L.point(options.bgPos);
+			div.style.backgroundPosition = (-bgPos.x) + 'px ' + (-bgPos.y) + 'px';
+		}
+		this._setIconStyles(div, 'icon');
+
+		return div;
+	},
+
+	createShadow: function () {
+		return null;
+	}
+});
+
+// @factory L.divIcon(options: DivIcon options)
+// Creates a `DivIcon` instance with the given options.
+L.divIcon = function (options) {
+	return new L.DivIcon(options);
+};
+
+
+
+/*
+ * @class DivOverlay
+ * @inherits Layer
+ * @aka L.DivOverlay
+ * Base model for L.Popup and L.Tooltip. Inherit from it for custom popup like plugins.
+ */
+
+// @namespace DivOverlay
+L.DivOverlay = L.Layer.extend({
+
+	// @section
+	// @aka DivOverlay options
+	options: {
+		// @option offset: Point = Point(0, 7)
+		// The offset of the popup position. Useful to control the anchor
+		// of the popup when opening it on some overlays.
+		offset: [0, 7],
+
+		// @option className: String = ''
+		// A custom CSS class name to assign to the popup.
+		className: '',
+
+		// @option pane: String = 'popupPane'
+		// `Map pane` where the popup will be added.
+		pane: 'popupPane'
+	},
+
+	initialize: function (options, source) {
+		L.setOptions(this, options);
+
+		this._source = source;
+	},
+
+	onAdd: function (map) {
+		this._zoomAnimated = map._zoomAnimated;
+
+		if (!this._container) {
+			this._initLayout();
+		}
+
+		if (map._fadeAnimated) {
+			L.DomUtil.setOpacity(this._container, 0);
+		}
+
+		clearTimeout(this._removeTimeout);
+		this.getPane().appendChild(this._container);
+		this.update();
+
+		if (map._fadeAnimated) {
+			L.DomUtil.setOpacity(this._container, 1);
+		}
+
+		this.bringToFront();
+	},
+
+	onRemove: function (map) {
+		if (map._fadeAnimated) {
+			L.DomUtil.setOpacity(this._container, 0);
+			this._removeTimeout = setTimeout(L.bind(L.DomUtil.remove, L.DomUtil, this._container), 200);
+		} else {
+			L.DomUtil.remove(this._container);
+		}
+	},
+
+	// @namespace Popup
+	// @method getLatLng: LatLng
+	// Returns the geographical point of popup.
+	getLatLng: function () {
+		return this._latlng;
+	},
+
+	// @method setLatLng(latlng: LatLng): this
+	// Sets the geographical point where the popup will open.
+	setLatLng: function (latlng) {
+		this._latlng = L.latLng(latlng);
+		if (this._map) {
+			this._updatePosition();
+			this._adjustPan();
+		}
+		return this;
+	},
+
+	// @method getContent: String|HTMLElement
+	// Returns the content of the popup.
+	getContent: function () {
+		return this._content;
+	},
+
+	// @method setContent(htmlContent: String|HTMLElement|Function): this
+	// Sets the HTML content of the popup. If a function is passed the source layer will be passed to the function. The function should return a `String` or `HTMLElement` to be used in the popup.
+	setContent: function (content) {
+		this._content = content;
+		this.update();
+		return this;
+	},
+
+	// @method getElement: String|HTMLElement
+	// Alias for [getContent()](#popup-getcontent)
+	getElement: function () {
+		return this._container;
+	},
+
+	// @method update: null
+	// Updates the popup content, layout and position. Useful for updating the popup after something inside changed, e.g. image loaded.
+	update: function () {
+		if (!this._map) { return; }
+
+		this._container.style.visibility = 'hidden';
+
+		this._updateContent();
+		this._updateLayout();
+		this._updatePosition();
+
+		this._container.style.visibility = '';
+
+		this._adjustPan();
+	},
+
+	getEvents: function () {
+		var events = {
+			zoom: this._updatePosition,
+			viewreset: this._updatePosition
+		};
+
+		if (this._zoomAnimated) {
+			events.zoomanim = this._animateZoom;
+		}
+		return events;
+	},
+
+	// @method isOpen: Boolean
+	// Returns `true` when the popup is visible on the map.
+	isOpen: function () {
+		return !!this._map && this._map.hasLayer(this);
+	},
+
+	// @method bringToFront: this
+	// Brings this popup in front of other popups (in the same map pane).
+	bringToFront: function () {
+		if (this._map) {
+			L.DomUtil.toFront(this._container);
+		}
+		return this;
+	},
+
+	// @method bringToBack: this
+	// Brings this popup to the back of other popups (in the same map pane).
+	bringToBack: function () {
+		if (this._map) {
+			L.DomUtil.toBack(this._container);
+		}
+		return this;
+	},
+
+	_updateContent: function () {
+		if (!this._content) { return; }
+
+		var node = this._contentNode;
+		var content = (typeof this._content === 'function') ? this._content(this._source || this) : this._content;
+
+		if (typeof content === 'string') {
+			node.innerHTML = content;
+		} else {
+			while (node.hasChildNodes()) {
+				node.removeChild(node.firstChild);
+			}
+			node.appendChild(content);
+		}
+		this.fire('contentupdate');
+	},
+
+	_updatePosition: function () {
+		if (!this._map) { return; }
+
+		var pos = this._map.latLngToLayerPoint(this._latlng),
+		    offset = L.point(this.options.offset),
+		    anchor = this._getAnchor();
+
+		if (this._zoomAnimated) {
+			L.DomUtil.setPosition(this._container, pos.add(anchor));
+		} else {
+			offset = offset.add(pos).add(anchor);
+		}
+
+		var bottom = this._containerBottom = -offset.y,
+		    left = this._containerLeft = -Math.round(this._containerWidth / 2) + offset.x;
+
+		// bottom position the popup in case the height of the popup changes (images loading etc)
+		this._container.style.bottom = bottom + 'px';
+		this._container.style.left = left + 'px';
+	},
+
+	_getAnchor: function () {
+		return [0, 0];
+	}
+
+});
+
+
+
+/*
+ * @class Popup
+ * @inherits DivOverlay
+ * @aka L.Popup
+ * Used to open popups in certain places of the map. Use [Map.openPopup](#map-openpopup) to
+ * open popups while making sure that only one popup is open at one time
+ * (recommended for usability), or use [Map.addLayer](#map-addlayer) to open as many as you want.
+ *
+ * @example
+ *
+ * If you want to just bind a popup to marker click and then open it, it's really easy:
+ *
+ * ```js
+ * marker.bindPopup(popupContent).openPopup();
+ * ```
+ * Path overlays like polylines also have a `bindPopup` method.
+ * Here's a more complicated way to open a popup on a map:
+ *
+ * ```js
+ * var popup = L.popup()
+ * 	.setLatLng(latlng)
+ * 	.setContent('<p>Hello world!<br />This is a nice popup.</p>')
+ * 	.openOn(map);
+ * ```
+ */
+
+
+// @namespace Popup
+L.Popup = L.DivOverlay.extend({
+
+	// @section
+	// @aka Popup options
+	options: {
+		// @option maxWidth: Number = 300
+		// Max width of the popup, in pixels.
+		maxWidth: 300,
+
+		// @option minWidth: Number = 50
+		// Min width of the popup, in pixels.
+		minWidth: 50,
+
+		// @option maxHeight: Number = null
+		// If set, creates a scrollable container of the given height
+		// inside a popup if its content exceeds it.
+		maxHeight: null,
+
+		// @option autoPan: Boolean = true
+		// Set it to `false` if you don't want the map to do panning animation
+		// to fit the opened popup.
+		autoPan: true,
+
+		// @option autoPanPaddingTopLeft: Point = null
+		// The margin between the popup and the top left corner of the map
+		// view after autopanning was performed.
+		autoPanPaddingTopLeft: null,
+
+		// @option autoPanPaddingBottomRight: Point = null
+		// The margin between the popup and the bottom right corner of the map
+		// view after autopanning was performed.
+		autoPanPaddingBottomRight: null,
+
+		// @option autoPanPadding: Point = Point(5, 5)
+		// Equivalent of setting both top left and bottom right autopan padding to the same value.
+		autoPanPadding: [5, 5],
+
+		// @option keepInView: Boolean = false
+		// Set it to `true` if you want to prevent users from panning the popup
+		// off of the screen while it is open.
+		keepInView: false,
+
+		// @option closeButton: Boolean = true
+		// Controls the presence of a close button in the popup.
+		closeButton: true,
+
+		// @option autoClose: Boolean = true
+		// Set it to `false` if you want to override the default behavior of
+		// the popup closing when user clicks the map (set globally by
+		// the Map's [closePopupOnClick](#map-closepopuponclick) option).
+		autoClose: true,
+
+		// @option className: String = ''
+		// A custom CSS class name to assign to the popup.
+		className: ''
+	},
+
+	// @namespace Popup
+	// @method openOn(map: Map): this
+	// Adds the popup to the map and closes the previous one. The same as `map.openPopup(popup)`.
+	openOn: function (map) {
+		map.openPopup(this);
+		return this;
+	},
+
+	onAdd: function (map) {
+		L.DivOverlay.prototype.onAdd.call(this, map);
+
+		// @namespace Map
+		// @section Popup events
+		// @event popupopen: PopupEvent
+		// Fired when a popup is opened in the map
+		map.fire('popupopen', {popup: this});
+
+		if (this._source) {
+			// @namespace Layer
+			// @section Popup events
+			// @event popupopen: PopupEvent
+			// Fired when a popup bound to this layer is opened
+			this._source.fire('popupopen', {popup: this}, true);
+			// For non-path layers, we toggle the popup when clicking
+			// again the layer, so prevent the map to reopen it.
+			if (!(this._source instanceof L.Path)) {
+				this._source.on('preclick', L.DomEvent.stopPropagation);
+			}
+		}
+	},
+
+	onRemove: function (map) {
+		L.DivOverlay.prototype.onRemove.call(this, map);
+
+		// @namespace Map
+		// @section Popup events
+		// @event popupclose: PopupEvent
+		// Fired when a popup in the map is closed
+		map.fire('popupclose', {popup: this});
+
+		if (this._source) {
+			// @namespace Layer
+			// @section Popup events
+			// @event popupclose: PopupEvent
+			// Fired when a popup bound to this layer is closed
+			this._source.fire('popupclose', {popup: this}, true);
+			if (!(this._source instanceof L.Path)) {
+				this._source.off('preclick', L.DomEvent.stopPropagation);
+			}
+		}
+	},
+
+	getEvents: function () {
+		var events = L.DivOverlay.prototype.getEvents.call(this);
+
+		if ('closeOnClick' in this.options ? this.options.closeOnClick : this._map.options.closePopupOnClick) {
+			events.preclick = this._close;
+		}
+
+		if (this.options.keepInView) {
+			events.moveend = this._adjustPan;
+		}
+
+		return events;
+	},
+
+	_close: function () {
+		if (this._map) {
+			this._map.closePopup(this);
+		}
+	},
+
+	_initLayout: function () {
+		var prefix = 'leaflet-popup',
+		    container = this._container = L.DomUtil.create('div',
+			prefix + ' ' + (this.options.className || '') +
+			' leaflet-zoom-animated');
+
+		if (this.options.closeButton) {
+			var closeButton = this._closeButton = L.DomUtil.create('a', prefix + '-close-button', container);
+			closeButton.href = '#close';
+			closeButton.innerHTML = '&#215;';
+
+			L.DomEvent.on(closeButton, 'click', this._onCloseButtonClick, this);
+		}
+
+		var wrapper = this._wrapper = L.DomUtil.create('div', prefix + '-content-wrapper', container);
+		this._contentNode = L.DomUtil.create('div', prefix + '-content', wrapper);
+
+		L.DomEvent
+			.disableClickPropagation(wrapper)
+			.disableScrollPropagation(this._contentNode)
+			.on(wrapper, 'contextmenu', L.DomEvent.stopPropagation);
+
+		this._tipContainer = L.DomUtil.create('div', prefix + '-tip-container', container);
+		this._tip = L.DomUtil.create('div', prefix + '-tip', this._tipContainer);
+	},
+
+	_updateLayout: function () {
+		var container = this._contentNode,
+		    style = container.style;
+
+		style.width = '';
+		style.whiteSpace = 'nowrap';
+
+		var width = container.offsetWidth;
+		width = Math.min(width, this.options.maxWidth);
+		width = Math.max(width, this.options.minWidth);
+
+		style.width = (width + 1) + 'px';
+		style.whiteSpace = '';
+
+		style.height = '';
+
+		var height = container.offsetHeight,
+		    maxHeight = this.options.maxHeight,
+		    scrolledClass = 'leaflet-popup-scrolled';
+
+		if (maxHeight && height > maxHeight) {
+			style.height = maxHeight + 'px';
+			L.DomUtil.addClass(container, scrolledClass);
+		} else {
+			L.DomUtil.removeClass(container, scrolledClass);
+		}
+
+		this._containerWidth = this._container.offsetWidth;
+	},
+
+	_animateZoom: function (e) {
+		var pos = this._map._latLngToNewLayerPoint(this._latlng, e.zoom, e.center),
+		    anchor = this._getAnchor();
+		L.DomUtil.setPosition(this._container, pos.add(anchor));
+	},
+
+	_adjustPan: function () {
+		if (!this.options.autoPan || (this._map._panAnim && this._map._panAnim._inProgress)) { return; }
+
+		var map = this._map,
+		    marginBottom = parseInt(L.DomUtil.getStyle(this._container, 'marginBottom'), 10) || 0,
+		    containerHeight = this._container.offsetHeight + marginBottom,
+		    containerWidth = this._containerWidth,
+		    layerPos = new L.Point(this._containerLeft, -containerHeight - this._containerBottom);
+
+		layerPos._add(L.DomUtil.getPosition(this._container));
+
+		var containerPos = map.layerPointToContainerPoint(layerPos),
+		    padding = L.point(this.options.autoPanPadding),
+		    paddingTL = L.point(this.options.autoPanPaddingTopLeft || padding),
+		    paddingBR = L.point(this.options.autoPanPaddingBottomRight || padding),
+		    size = map.getSize(),
+		    dx = 0,
+		    dy = 0;
+
+		if (containerPos.x + containerWidth + paddingBR.x > size.x) { // right
+			dx = containerPos.x + containerWidth - size.x + paddingBR.x;
+		}
+		if (containerPos.x - dx - paddingTL.x < 0) { // left
+			dx = containerPos.x - paddingTL.x;
+		}
+		if (containerPos.y + containerHeight + paddingBR.y > size.y) { // bottom
+			dy = containerPos.y + containerHeight - size.y + paddingBR.y;
+		}
+		if (containerPos.y - dy - paddingTL.y < 0) { // top
+			dy = containerPos.y - paddingTL.y;
+		}
+
+		// @namespace Map
+		// @section Popup events
+		// @event autopanstart: Event
+		// Fired when the map starts autopanning when opening a popup.
+		if (dx || dy) {
+			map
+			    .fire('autopanstart')
+			    .panBy([dx, dy]);
+		}
+	},
+
+	_onCloseButtonClick: function (e) {
+		this._close();
+		L.DomEvent.stop(e);
+	},
+
+	_getAnchor: function () {
+		// Where should we anchor the popup on the source layer?
+		return L.point(this._source && this._source._getPopupAnchor ? this._source._getPopupAnchor() : [0, 0]);
+	}
+
+});
+
+// @namespace Popup
+// @factory L.popup(options?: Popup options, source?: Layer)
+// Instantiates a `Popup` object given an optional `options` object that describes its appearance and location and an optional `source` object that is used to tag the popup with a reference to the Layer to which it refers.
+L.popup = function (options, source) {
+	return new L.Popup(options, source);
+};
+
+
+/* @namespace Map
+ * @section Interaction Options
+ * @option closePopupOnClick: Boolean = true
+ * Set it to `false` if you don't want popups to close when user clicks the map.
+ */
+L.Map.mergeOptions({
+	closePopupOnClick: true
+});
+
+
+// @namespace Map
+// @section Methods for Layers and Controls
+L.Map.include({
+	// @method openPopup(popup: Popup): this
+	// Opens the specified popup while closing the previously opened (to make sure only one is opened at one time for usability).
+	// @alternative
+	// @method openPopup(content: String|HTMLElement, latlng: LatLng, options?: Popup options): this
+	// Creates a popup with the specified content and options and opens it in the given point on a map.
+	openPopup: function (popup, latlng, options) {
+		if (!(popup instanceof L.Popup)) {
+			popup = new L.Popup(options).setContent(popup);
+		}
+
+		if (latlng) {
+			popup.setLatLng(latlng);
+		}
+
+		if (this.hasLayer(popup)) {
+			return this;
+		}
+
+		if (this._popup && this._popup.options.autoClose) {
+			this.closePopup();
+		}
+
+		this._popup = popup;
+		return this.addLayer(popup);
+	},
+
+	// @method closePopup(popup?: Popup): this
+	// Closes the popup previously opened with [openPopup](#map-openpopup) (or the given one).
+	closePopup: function (popup) {
+		if (!popup || popup === this._popup) {
+			popup = this._popup;
+			this._popup = null;
+		}
+		if (popup) {
+			this.removeLayer(popup);
+		}
+		return this;
+	}
+});
+
+
+
+/*
+ * @namespace Layer
+ * @section Popup methods example
+ *
+ * All layers share a set of methods convenient for binding popups to it.
+ *
+ * ```js
+ * var layer = L.Polygon(latlngs).bindPopup('Hi There!').addTo(map);
+ * layer.openPopup();
+ * layer.closePopup();
+ * ```
+ *
+ * Popups will also be automatically opened when the layer is clicked on and closed when the layer is removed from the map or another popup is opened.
+ */
+
+// @section Popup methods
+L.Layer.include({
+
+	// @method bindPopup(content: String|HTMLElement|Function|Popup, options?: Popup options): this
+	// Binds a popup to the layer with the passed `content` and sets up the
+	// neccessary event listeners. If a `Function` is passed it will receive
+	// the layer as the first argument and should return a `String` or `HTMLElement`.
+	bindPopup: function (content, options) {
+
+		if (content instanceof L.Popup) {
+			L.setOptions(content, options);
+			this._popup = content;
+			content._source = this;
+		} else {
+			if (!this._popup || options) {
+				this._popup = new L.Popup(options, this);
+			}
+			this._popup.setContent(content);
+		}
+
+		if (!this._popupHandlersAdded) {
+			this.on({
+				click: this._openPopup,
+				remove: this.closePopup,
+				move: this._movePopup
+			});
+			this._popupHandlersAdded = true;
+		}
+
+		return this;
+	},
+
+	// @method unbindPopup(): this
+	// Removes the popup previously bound with `bindPopup`.
+	unbindPopup: function () {
+		if (this._popup) {
+			this.off({
+				click: this._openPopup,
+				remove: this.closePopup,
+				move: this._movePopup
+			});
+			this._popupHandlersAdded = false;
+			this._popup = null;
+		}
+		return this;
+	},
+
+	// @method openPopup(latlng?: LatLng): this
+	// Opens the bound popup at the specificed `latlng` or at the default popup anchor if no `latlng` is passed.
+	openPopup: function (layer, latlng) {
+		if (!(layer instanceof L.Layer)) {
+			latlng = layer;
+			layer = this;
+		}
+
+		if (layer instanceof L.FeatureGroup) {
+			for (var id in this._layers) {
+				layer = this._layers[id];
+				break;
+			}
+		}
+
+		if (!latlng) {
+			latlng = layer.getCenter ? layer.getCenter() : layer.getLatLng();
+		}
+
+		if (this._popup && this._map) {
+			// set popup source to this layer
+			this._popup._source = layer;
+
+			// update the popup (content, layout, ect...)
+			this._popup.update();
+
+			// open the popup on the map
+			this._map.openPopup(this._popup, latlng);
+		}
+
+		return this;
+	},
+
+	// @method closePopup(): this
+	// Closes the popup bound to this layer if it is open.
+	closePopup: function () {
+		if (this._popup) {
+			this._popup._close();
+		}
+		return this;
+	},
+
+	// @method togglePopup(): this
+	// Opens or closes the popup bound to this layer depending on its current state.
+	togglePopup: function (target) {
+		if (this._popup) {
+			if (this._popup._map) {
+				this.closePopup();
+			} else {
+				this.openPopup(target);
+			}
+		}
+		return this;
+	},
+
+	// @method isPopupOpen(): boolean
+	// Returns `true` if the popup bound to this layer is currently open.
+	isPopupOpen: function () {
+		return this._popup.isOpen();
+	},
+
+	// @method setPopupContent(content: String|HTMLElement|Popup): this
+	// Sets the content of the popup bound to this layer.
+	setPopupContent: function (content) {
+		if (this._popup) {
+			this._popup.setContent(content);
+		}
+		return this;
+	},
+
+	// @method getPopup(): Popup
+	// Returns the popup bound to this layer.
+	getPopup: function () {
+		return this._popup;
+	},
+
+	_openPopup: function (e) {
+		var layer = e.layer || e.target;
+
+		if (!this._popup) {
+			return;
+		}
+
+		if (!this._map) {
+			return;
+		}
+
+		// prevent map click
+		L.DomEvent.stop(e);
+
+		// if this inherits from Path its a vector and we can just
+		// open the popup at the new location
+		if (layer instanceof L.Path) {
+			this.openPopup(e.layer || e.target, e.latlng);
+			return;
+		}
+
+		// otherwise treat it like a marker and figure out
+		// if we should toggle it open/closed
+		if (this._map.hasLayer(this._popup) && this._popup._source === layer) {
+			this.closePopup();
+		} else {
+			this.openPopup(layer, e.latlng);
+		}
+	},
+
+	_movePopup: function (e) {
+		this._popup.setLatLng(e.latlng);
+	}
+});
+
+
+
+/*
+ * Popup extension to L.Marker, adding popup-related methods.
+ */
+
+L.Marker.include({
+	_getPopupAnchor: function () {
+		return this.options.icon.options.popupAnchor || [0, 0];
+	}
+});
+
+
+
+/*
+ * @class Tooltip
+ * @inherits DivOverlay
+ * @aka L.Tooltip
+ * Used to display small texts on top of map layers.
+ *
+ * @example
+ *
+ * ```js
+ * marker.bindTooltip("my tooltip text").openTooltip();
+ * ```
+ * Note about tooltip offset. Leaflet takes two options in consideration
+ * for computing tooltip offseting:
+ * - the `offset` Tooltip option: it defaults to [0, 0], and it's specific to one tooltip.
+ *   Add a positive x offset to move the tooltip to the right, and a positive y offset to
+ *   move it to the bottom. Negatives will move to the left and top.
+ * - the `tooltipAnchor` Icon option: this will only be considered for Marker. You
+ *   should adapt this value if you use a custom icon.
+ */
+
+
+// @namespace Tooltip
+L.Tooltip = L.DivOverlay.extend({
+
+	// @section
+	// @aka Tooltip options
+	options: {
+		// @option pane: String = 'tooltipPane'
+		// `Map pane` where the tooltip will be added.
+		pane: 'tooltipPane',
+
+		// @option offset: Point = Point(0, 0)
+		// Optional offset of the tooltip position.
+		offset: [0, 0],
+
+		// @option direction: String = 'auto'
+		// Direction where to open the tooltip. Possible values are: `right`, `left`,
+		// `top`, `bottom`, `center`, `auto`.
+		// `auto` will dynamicaly switch between `right` and `left` according to the tooltip
+		// position on the map.
+		direction: 'auto',
+
+		// @option permanent: Boolean = false
+		// Whether to open the tooltip permanently or only on mouseover.
+		permanent: false,
+
+		// @option sticky: Boolean = false
+		// If true, the tooltip will follow the mouse instead of being fixed at the feature center.
+		sticky: false,
+
+		// @option interactive: Boolean = false
+		// If true, the tooltip will listen to the feature events.
+		interactive: false,
+
+		// @option opacity: Number = 0.9
+		// Tooltip container opacity.
+		opacity: 0.9
+	},
+
+	onAdd: function (map) {
+		L.DivOverlay.prototype.onAdd.call(this, map);
+		this.setOpacity(this.options.opacity);
+
+		// @namespace Map
+		// @section Tooltip events
+		// @event tooltipopen: TooltipEvent
+		// Fired when a tooltip is opened in the map.
+		map.fire('tooltipopen', {tooltip: this});
+
+		if (this._source) {
+			// @namespace Layer
+			// @section Tooltip events
+			// @event tooltipopen: TooltipEvent
+			// Fired when a tooltip bound to this layer is opened.
+			this._source.fire('tooltipopen', {tooltip: this}, true);
+		}
+	},
+
+	onRemove: function (map) {
+		L.DivOverlay.prototype.onRemove.call(this, map);
+
+		// @namespace Map
+		// @section Tooltip events
+		// @event tooltipclose: TooltipEvent
+		// Fired when a tooltip in the map is closed.
+		map.fire('tooltipclose', {tooltip: this});
+
+		if (this._source) {
+			// @namespace Layer
+			// @section Tooltip events
+			// @event tooltipclose: TooltipEvent
+			// Fired when a tooltip bound to this layer is closed.
+			this._source.fire('tooltipclose', {tooltip: this}, true);
+		}
+	},
+
+	getEvents: function () {
+		var events = L.DivOverlay.prototype.getEvents.call(this);
+
+		if (L.Browser.touch && !this.options.permanent) {
+			events.preclick = this._close;
+		}
+
+		return events;
+	},
+
+	_close: function () {
+		if (this._map) {
+			this._map.closeTooltip(this);
+		}
+	},
+
+	_initLayout: function () {
+		var prefix = 'leaflet-tooltip',
+		    className = prefix + ' ' + (this.options.className || '') + ' leaflet-zoom-' + (this._zoomAnimated ? 'animated' : 'hide');
+
+		this._contentNode = this._container = L.DomUtil.create('div', className);
+	},
+
+	_updateLayout: function () {},
+
+	_adjustPan: function () {},
+
+	_setPosition: function (pos) {
+		var map = this._map,
+		    container = this._container,
+		    centerPoint = map.latLngToContainerPoint(map.getCenter()),
+		    tooltipPoint = map.layerPointToContainerPoint(pos),
+		    direction = this.options.direction,
+		    tooltipWidth = container.offsetWidth,
+		    tooltipHeight = container.offsetHeight,
+		    offset = L.point(this.options.offset),
+		    anchor = this._getAnchor();
+
+		if (direction === 'top') {
+			pos = pos.add(L.point(-tooltipWidth / 2 + offset.x, -tooltipHeight + offset.y + anchor.y));
+		} else if (direction === 'bottom') {
+			pos = pos.subtract(L.point(tooltipWidth / 2 - offset.x, -offset.y));
+		} else if (direction === 'center') {
+			pos = pos.subtract(L.point(tooltipWidth / 2 + offset.x, tooltipHeight / 2 - anchor.y + offset.y));
+		} else if (direction === 'right' || direction === 'auto' && tooltipPoint.x < centerPoint.x) {
+			direction = 'right';
+			pos = pos.add([offset.x + anchor.x, anchor.y - tooltipHeight / 2 + offset.y]);
+		} else {
+			direction = 'left';
+			pos = pos.subtract(L.point(tooltipWidth + anchor.x - offset.x, tooltipHeight / 2 - anchor.y - offset.y));
+		}
+
+		L.DomUtil.removeClass(container, 'leaflet-tooltip-right');
+		L.DomUtil.removeClass(container, 'leaflet-tooltip-left');
+		L.DomUtil.removeClass(container, 'leaflet-tooltip-top');
+		L.DomUtil.removeClass(container, 'leaflet-tooltip-bottom');
+		L.DomUtil.addClass(container, 'leaflet-tooltip-' + direction);
+		L.DomUtil.setPosition(container, pos);
+	},
+
+	_updatePosition: function () {
+		var pos = this._map.latLngToLayerPoint(this._latlng);
+		this._setPosition(pos);
+	},
+
+	setOpacity: function (opacity) {
+		this.options.opacity = opacity;
+
+		if (this._container) {
+			L.DomUtil.setOpacity(this._container, opacity);
+		}
+	},
+
+	_animateZoom: function (e) {
+		var pos = this._map._latLngToNewLayerPoint(this._latlng, e.zoom, e.center);
+		this._setPosition(pos);
+	},
+
+	_getAnchor: function () {
+		// Where should we anchor the tooltip on the source layer?
+		return L.point(this._source && this._source._getTooltipAnchor && !this.options.sticky ? this._source._getTooltipAnchor() : [0, 0]);
+	}
+
+});
+
+// @namespace Tooltip
+// @factory L.tooltip(options?: Tooltip options, source?: Layer)
+// Instantiates a Tooltip object given an optional `options` object that describes its appearance and location and an optional `source` object that is used to tag the tooltip with a reference to the Layer to which it refers.
+L.tooltip = function (options, source) {
+	return new L.Tooltip(options, source);
+};
+
+// @namespace Map
+// @section Methods for Layers and Controls
+L.Map.include({
+
+	// @method openTooltip(tooltip: Tooltip): this
+	// Opens the specified tooltip.
+	// @alternative
+	// @method openTooltip(content: String|HTMLElement, latlng: LatLng, options?: Tooltip options): this
+	// Creates a tooltip with the specified content and options and open it.
+	openTooltip: function (tooltip, latlng, options) {
+		if (!(tooltip instanceof L.Tooltip)) {
+			tooltip = new L.Tooltip(options).setContent(tooltip);
+		}
+
+		if (latlng) {
+			tooltip.setLatLng(latlng);
+		}
+
+		if (this.hasLayer(tooltip)) {
+			return this;
+		}
+
+		return this.addLayer(tooltip);
+	},
+
+	// @method closeTooltip(tooltip?: Tooltip): this
+	// Closes the tooltip given as parameter.
+	closeTooltip: function (tooltip) {
+		if (tooltip) {
+			this.removeLayer(tooltip);
+		}
+		return this;
+	}
+
+});
+
+
+
+/*
+ * @namespace Layer
+ * @section Tooltip methods example
+ *
+ * All layers share a set of methods convenient for binding tooltips to it.
+ *
+ * ```js
+ * var layer = L.Polygon(latlngs).bindTooltip('Hi There!').addTo(map);
+ * layer.openTooltip();
+ * layer.closeTooltip();
+ * ```
+ */
+
+// @section Tooltip methods
+L.Layer.include({
+
+	// @method bindTooltip(content: String|