1<?php
2
3
4/**
5 * Database interface implementation
6 *
7 * This file is part of DokuWiki plugin database2 and is available under
8 * GPL version 2. See the following URL for a copy of this license!
9 *
10 * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
11 *
12 *
13 * @author Thomas Urban <soletan@nihilum.de>
14 * @version 0.2
15 * @copyright GPLv2
16 *
17 */
18
19
20if ( !defined( 'DB2_PATH' ) )
21	define( 'DB2_PATH', 'lib/plugins/database2/' );
22
23
24/**
25 * Implementation of database access feature.
26 *
27 * This class is integrated into DokuWiki syntax extension in file syntax.php
28 * and provides the plugin's actual features like database interaction, table
29 * browsing, data lookup and input ...
30 *
31 * @author Thomas Urban <soletan@nihilum.de>
32 * @version 0.2
33 * @copyright GPLv2
34 *
35 */
36
37class Database2
38{
39
40	const maxSurroundingPagesCount = 3;
41
42	const maxUploadSize = 2097152;	// 2 MiB
43
44
45	protected static $ioIndices = array();
46
47
48	/**
49	 * Renderer for producing any output.
50	 *
51	 * @var Doku_Renderer_xhtml
52	 */
53
54	public $renderer;
55
56
57	/**
58	 * Link to database of current namespace.
59	 *
60	 * @var PDO
61	 */
62
63	protected $db;
64
65
66	/**
67	 * Name of driver indicating type of DB currently connected to.
68	 *
69	 * @var string
70	 */
71
72	private $driver;
73
74
75	/**
76	 * Name of table managed by current instance.
77	 *
78	 * @var string
79	 */
80
81	protected $table = null;
82
83
84	/**
85	 * Meta information on/definition of current table as provided in tag value
86	 *
87	 * @var array
88	 */
89
90	protected $meta = array();
91
92
93	/**
94	 * Options additionally provided in opening tag's attributes.
95	 *
96	 * @var array
97	 */
98
99	protected $options = array();
100
101
102	/**
103	 * I/O-Index assigned for use on acting on table managed by current
104	 * instance.
105	 *
106	 * @var integer
107	 */
108
109	protected $ioIndex = null;
110
111
112	/**
113	 * input data
114	 *
115	 * @var array
116	 */
117
118	private $input = null;
119
120
121	/**
122	 * DSN used to connect to database server
123	 *
124	 * @var string
125	 */
126
127	private $dsn = null;
128
129
130	/**
131	 * Name of slot in site configuration containing authentication data.
132	 *
133	 * @var string
134	 */
135
136	private $authSlot = null;
137
138
139	/**
140	 * Reference to database syntax plugin object integrating this instance of
141	 * Database2.
142	 *
143	 * @var DokuWiki_Syntax_Plugin
144	 */
145
146	protected $integrator = null;
147
148
149	/**
150	 * Page ID explicitly selected to use in current instance.
151	 *
152	 * This is used to supercede ID returned by DokuWiki's getID() and is
153	 * required in media.php using this class in a faked context.
154	 */
155
156	protected $explicitPageID = null;
157
158
159
160	public function __construct( Doku_Renderer $renderer,
161								 DokuWiki_Syntax_Plugin $integrator )
162	{
163
164		$this->renderer   = $renderer;
165		$this->db         = null;
166		$this->integrator = $integrator;
167
168		$this->renderer->nocache();
169
170	}
171
172
173	/**
174	 * Detects whether provided name is a valid name for a table or column.
175	 *
176	 * @param string $in name to test
177	 * @return boolean true if provided name might be used for tables/columns
178	 */
179
180	public static function isValidName( $in )
181	{
182		return preg_match( '/^[_a-z][_a-z0-9]+$/i', $in );
183	}
184
185
186	/**
187	 * Retrieves open link to current database file or null if not connected.
188	 *
189	 * @return PDO
190	 */
191
192	public function getLink()
193	{
194		return $this->db;
195	}
196
197
198	/**
199	 * Retrieves configuration setting using integrator's interface.
200	 *
201	 * @param string $name name of setting to retrieve
202	 * @return mixed retrieved configuration setting
203	 */
204
205	public function getConf( $name )
206	{
207		global $conf;
208
209
210		if ( $this->integrator instanceof DokuWiki_Syntax_Plugin )
211		{
212
213			$value = $this->integrator->getConf( $name, null );
214			if ( is_null( $value ) )
215				if ( !is_null( $conf[$name] ) )
216					$value = $conf[$name];
217
218			return $value;
219
220		}
221
222
223		// fix for accessing configuration in media.php
224		if ( isset( $conf['plugin']['database2'][$name] ) )
225			return $conf['plugin']['database2'][$name];
226
227		return $conf[$name];
228
229	}
230
231
232	/**
233	 * Retrieves localized string.
234	 *
235	 * @param string $name name of localized string
236	 * @return mixed retrieved localized string
237	 */
238
239	public function getLang( $name )
240	{
241
242		if ( $this->integrator instanceof DokuWiki_Syntax_Plugin )
243			return $this->integrator->getLang( $name );
244
245
246		// fix for accessing strings in media.php
247		if ( !is_array( $this->integrator ) )
248		{
249
250			$lang = array();
251
252			@include( dirname( __FILE__ ) . '/lang/en/lang.php' );
253			if ( $GLOBALS['conf']['lang'] != 'en' )
254				@include( dirname( __FILE__ ) . '/lang/' .
255						  $GLOBALS['conf']['lang'] . '/lang.php' );
256
257			$this->integrator = $lang;
258
259		}
260
261
262		return $this->integrator[$name];
263
264	}
265
266
267	/**
268	 * Retrieves index to be used for parameters/fields passed in I/O on
269	 * currently processed integration of a table in current page.
270	 *
271	 * The current method is quite unstable, at least on editing arrangement
272	 * of database2 instances in a page, however it should work in a production
273	 * environment.
274	 *
275	 * @return integer numeric index
276	 */
277
278	protected function getIndex()
279	{
280
281		if ( is_null( $this->table ) )
282			throw new Exception( 'getIndex: missing name of managed table' );
283
284		if ( is_null( $this->ioIndex ) )
285			$this->ioIndex = self::$ioIndices[$tableName]++;
286
287		return $this->ioIndex;
288
289	}
290
291
292	/**
293	 * Retrieves ID of current DokuWiki page.
294	 *
295	 * @return string
296	 */
297
298	protected function getPageID()
299	{
300
301		if ( !is_null( $this->explicitPageID ) )
302			return $this->explicitPageID;
303
304		return getID();
305
306	}
307
308
309	/**
310	 * Allocates separate section in session data for state of current
311	 * table instance.
312	 *
313	 * @return array
314	 */
315
316	protected function &getSession()
317	{
318		if ( !is_array( $_SESSION['database2'] ) )
319			$_SESSION['database2'] = array();
320
321		$id = $this->getPageID();
322
323
324		// if current page's source has changed ...
325		$dates = p_get_metadata( $id, 'date' );
326		if ( is_array( $_SESSION['database2'][$id] ) )
327			if ( $_SESSION['database2'][$id]['onRevision'] != $dates['modified'] )
328				// ... it's related session-based data is dropped
329				unset( $_SESSION['database2'][$id] );
330
331
332		if ( !is_array( $_SESSION['database2'][$id] ) )
333			$_SESSION['database2'][$id] = array(
334												'onRevision' => $dates['modified'],
335												'tables'     => array(),
336												);
337
338		$index = $this->getIndex();
339		if ( !is_array( $_SESSION['database2'][$id]['tables'][$index] ) )
340			$_SESSION['database2'][$id]['tables'][$index] = array();
341
342
343		return $_SESSION['database2'][$id]['tables'][$index];
344
345	}
346
347
348	/**
349	 * Allocates separate section in session data for temporary content of
350	 * single-record editor.
351	 *
352	 * @return array
353	 */
354
355	protected function &getEditorSession()
356	{
357
358		$session =& $this->getSession();
359
360		if ( !is_array( $session['editors'] ) )
361			$session['editors'] = array();
362
363		return $session['editors'];
364
365	}
366
367
368	/**
369	 * Renders provided HTML code replacing database tag in current Wiki page.
370	 *
371	 * @param string $code HTML code to render
372	 */
373
374	protected function render( $code )
375	{
376		$this->renderer->doc .= strval( $code );
377	}
378
379
380	/**
381	 * Connects to database (external server, local SQLite DB file).
382	 *
383	 * @param string $dbPath database selector
384	 * @param string $authConfigSlot name of slot in site config containing
385	 *                               authentication data
386	 * @return boolean true on success, false on failure
387	 */
388
389	public function connect( $dbPath, $authConfigSlot = null  )
390	{
391
392		$dbPath = trim( $dbPath );
393		if ( $dbPath[0] == '@' )
394			$dsn = substr( $dbPath, 1 );
395		else if ( ( $dbPath[0] == '/' ) && !self::getConf( 'useslash' ) &&
396				  is_dir( dirname( $dbPath ) ) &&
397				  !preg_match( '#(\.\.)|(^\/(etc)\/)#', $dbPath ) )
398			$dsn = 'sqlite:' . $dbPath;
399		else
400			$dsn = 'sqlite:' . metaFN( $dbPath, '.db' );
401
402
403		try
404		{
405
406			// read username/password for authentication from optionally
407			// selected slot in site's configuration
408
409			if ( $authConfigSlot )
410			{
411
412				$username = $password = '';
413
414				foreach ( explode( "\n", $this->getConf( 'authSlots' ) ) as $line )
415				{
416
417					$line = trim( $line );
418
419					if ( ( $line[0] == '#' ) || ( ( $line[0] == '/' ) &&
420						 ( $line[1] == '/' ) ) || ( $line === '' ) )
421						// skip comments and empty lines
422						continue;
423
424
425					// parse assignment
426					$pos  = 0;
427					$temp = self::parseAssignment( $line, $pos );
428
429					if ( !is_array( $temp ) )
430						continue;
431
432					list( $name, $value ) = $temp;
433
434					if ( strcasecmp( $name, $authConfigSlot ) )
435						// not related to current authentication slot
436						continue;
437
438
439					// split value into username and password
440					$value = trim( $value );
441					$sep   = strcspn( $value, ':' );
442
443					$username = trim( substr( $value, 0, $sep ) );
444					$password = trim( substr( $value, $sep + 1 ) );
445
446					// done ...
447					break;
448
449				}
450
451
452				if ( $username === '' )
453					unset( $username, $password );
454				else if ( $password === '' )
455					unset( $password );
456
457			}
458			else
459				unset( $username, $password );
460
461
462			// connect to database
463			$this->db = new PDO( $dsn, $username, $password );
464
465			// request throwing exceptions on failure
466			$this->db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
467
468			// cache used driver name
469			$this->driver = strtolower( trim( $this->db->getAttribute(
470													PDO::ATTR_DRIVER_NAME ) ) );
471
472
473			if ( strpos( $this->driver, 'mysql' ) !== false )
474				// ensure to use proper encoding on talking to MySQL RDBMSs
475				// NOTE: according to server setup this may result in UTF-8 bytes
476				//       being UTF-8 encoded, thus resulting in usual "garbage"
477				// TODO: add option for selecting whether using UTF8-mapping here
478				$this->db->query( 'SET NAMES UTF8' );
479
480
481			// store how to connect to database for integrated retrieval of files
482			$this->dsn      = $dsn;
483			$this->authSlot = $authConfigSlot;
484
485
486			return true;
487
488		}
489		catch ( PDOException $e )
490		{
491
492			$this->render( sprintf( $this->getLang( 'nodblink' ), $e->getMessage() ) );
493
494			$this->db = $this->driver = null;
495
496			return false;
497
498		}
499	}
500
501
502	/**
503	 * Reads all available input data extracting values related to this plugin.
504	 *
505	 * @return array set of input data related to current plugin
506	 */
507
508	protected function getInput()
509	{
510
511		if ( is_null( $this->input ) )
512		{
513
514			$index = $this->getIndex();
515
516			$this->input = array();
517
518			$matchingSecTok = ( $_REQUEST['sectok'] == getSecurityToken() );
519
520			foreach ( $_REQUEST as $name => $value )
521				if ( preg_match( '/^db2do(.+?)(_[xy])?$/i', $name, $matches ) )
522					if ( $matchingSecTok || ( $_GET[$name] && preg_match( '/^(cmd|opt)/i', $matches[1] ) ) )
523						if ( is_null( $this->input[$matches[1]] ) )
524							if ( !is_array( $value ) || !is_null( $value[$index] ) )
525								$this->input[$matches[1]] = is_array( $value ) ? $value[$index] : $value;
526
527		}
528
529
530		return $this->input;
531
532	}
533
534
535	/**
536	 * Retrieves meta information on columns.
537	 *
538	 * @return array
539	 */
540
541	public function getColumnsMeta( $ignoreMissingMeta = false )
542	{
543
544		if ( !is_array( $this->meta ) || empty( $this->meta ) )
545		{
546
547			$session =& self::getSession();
548			if ( is_array( $session['definition'] ) )
549				$this->meta = $session['definition'];
550			else if ( $ignoreMissingMeta )
551				return array();	// don't store permanently ...
552			else
553				throw new Exception( $this->getLang( 'nocolmeta' ) );
554
555		}
556
557
558		return $this->meta;
559
560	}
561
562
563	/**
564	 * Renders name of form element to conform with input parser above.
565	 *
566	 * @param string $name internal name of element
567	 * @param index $rowid optional rowid element is related to
568	 * @return string external name of form element
569	 */
570
571	protected function varname( $name, $rowid = null )
572	{
573		return 'db2do' . $name . ( $rowid ? $rowid : '' ) . '[' .
574				$this->getIndex() . ']';
575	}
576
577
578	/**
579	 * Processes occurrence of database tag in a wiki page.
580	 *
581	 * The tag's value (stuff between opening and closing tag) is passed in
582	 * $code.
583	 *
584	 * @param string $table name of table to work with
585	 * @param string $code code found between opening and closing tag
586	 * @param array $options additional options provided in tag attributes
587	 */
588
589	public function process( $table, $code, $options )
590	{
591
592		// wrap all action on database in one exception handler
593		try
594		{
595
596			// check whether or not database tags are enabled on this page
597			if ( !$this->getConf( 'enableallpages' ) )
598			{
599
600				$patterns = explode( "\n", trim( $this->getConf( 'enablepages' ) ) );
601				$enabled  = false;
602
603				$pageID   = $this->getPageID();
604
605				foreach ( $patterns as $pattern )
606				{
607
608					$pattern = trim( $pattern );
609					if ( preg_match( '#^/.+/\w*$#', $pattern ) )
610						$match = preg_match( $pattern, $pageID );
611					else
612						$match = fnmatch( $pattern, $pageID );
613
614					if ( $match )
615					{
616						$enabled = true;
617						break;
618					}
619				}
620
621				if ( !$enabled )
622				{
623					// use of database tag is disabled
624
625					$this->render( '<div class="database2-disabled">' .
626								   $this->getLang( 'tagdisabled' ) . '</div>' );
627
628					return;
629
630				}
631			}
632
633
634
635			// normalize/validate table name
636			$table = preg_replace( '/[^\w]/', '_', trim( $table ) );
637
638			if ( in_array( $table, array( '__keys', '__locks', '__log', ) ) )
639				throw new Exception( $this->getLang( 'restabnames' ) );
640
641			$this->table = $table;
642
643			// select subset of input parameters
644			$this->ioIndex = null;		// drop to re-obtain new I/O index next
645			$index = $this->getIndex();	// but ensure to obtain at all ...
646
647			// install set of options
648			$this->options = is_array( $options ) ? $options : array();
649
650			if ( trim( $this->options['mayview'] ) === '' )
651				$this->options['mayview'] = '@ALL';
652
653			if ( trim( $this->options['mayinspect'] ) === '' )
654				$this->options['mayinspect'] = '@ALL';
655
656			$this->options['view'] = trim( $this->options['view'] );
657			if ( !$this->getConf( 'customviews' ) )
658				$this->options['view'] = '';
659			else if ( !preg_match( '/^SELECT\s/i', trim( $this->options['view'] ) ) )
660				$this->options['view'] = '';
661
662			$this->options['wikimarkup'] = self::asBool( $this->options['wikimarkup'] );
663			$this->options['simplenav']  = self::asBool( $this->options['simplenav'] );
664
665			if ( ctype_digit( trim( $this->options['rowsperpage'] ) ) )
666			{
667
668				$state =& $this->getSession();
669				if ( !is_integer( $state['num'] ) )
670					$state['num'] = intval( $this->options['rowsperpage'] );
671
672			}
673
674			// parse code for contained definitions
675			$this->parseDefinition( $code );
676
677
678			try
679			{
680
681				// look for available action to perform on selected table
682
683				// support preventing CSRF ...
684				foreach ( $this->getInput() as $key => $dummy )
685				{
686
687					if ( !preg_match( '/^cmd([a-z]+)(\d*)(_x)?$/i', $key, $matches ) )
688						continue;
689
690					$action = strtolower( trim( $matches[1] ) );
691
692					$rowid = intval( $matches[2] );
693					if ( $rowid )
694						$rowACL = $this->getRowACL( $rowid );
695					else
696						$rowid = $rowACL = null;
697
698
699					if ( $action === 'reset' )
700					{
701						$state =& $this->getSession();
702						$state = array();
703						continue;
704					}
705
706
707					if ( !$this->isAuthorizedMulti( $rowACL, $this->options, 'may'.$action ) )
708					{
709						// user isn't authorized to perform this action
710						$this->render( '<div class="error">' .
711									   sprintf( $this->getLang( 'accessdenied' ),
712									   $action, $this->table ) . '</div>' );
713						continue;
714					}
715
716
717					if ( ( $this->getSingleNumericPrimaryKey() !== false ) ||
718						 in_array( $action, array( 'drop', ) ) )
719						// perform optionally requested action
720						switch ( $action )
721						{
722
723							case 'inspect' :// show record details (read-only)
724							case 'insert' :	// show record editor to insert
725							case 'edit' :	// show record editor to adjust
726								do
727								{
728
729									if ( ( $action == 'insert' ) && $rowid )
730									{
731										// insert record starting with duplicate
732										// of existing one ...
733
734										if ( !$this->isAuthorizedMulti( $rowACL, $this->options, 'mayinspect' ) )
735										{
736											// user isn't authorized to perform this action
737											$this->render( '<div class="error">' .
738														   sprintf( $this->getLang( 'accessdenied' ),
739														   $action, $this->table ) . '</div>' );
740											break;
741										}
742
743										// use duplicate of selected record
744										$duplicateRowID = $rowid;
745
746										// but don't overwrite it!
747										$rowid = null;
748
749									}
750									else
751										$duplicateRowID = null;
752
753
754									// invoke editor/single record view
755									$readonly = ( $action == 'inspect' );
756									$result   = $this->editRecord( $rowid, $readonly,
757																   $duplicateRowID,
758																   $rowACL );
759									if ( !$result )
760										// skip rendering table, rendered single
761										return;
762
763									if ( is_integer( $result ) )
764									{
765										// switch to selected record
766										$rowid = $result;
767										continue;
768									}
769
770									break;
771
772								}
773								while ( true );
774
775								break;
776
777							case 'delete' :
778								$this->deleteRecord( $rowid );
779								break;
780
781							case 'drop' :
782								$this->dropTable();
783								break;
784
785							default :
786								$method = array( &$this, '__handle_'.$action );
787								if ( is_callable( $method ) )
788									if ( !call_user_func( $method, $rowid ) )
789										return;
790
791						}
792				}
793
794
795				if ( !$this->exists( $this->table ) )
796					// (re-)create table as it is missing (e.g. after dropping)
797					$this->createTable();
798
799
800
801				/*
802				 * finally render table
803				 */
804
805				// check user's authorization to view table
806				if ( $this->isAuthorized( $this->options['mayview'] ) )
807					// user may view table
808					$this->showTable( true, false, false, $this->options['view'] );
809
810			}
811			catch ( PDOException $e )
812			{
813				throw new Exception( sprintf( $this->getLang( 'badinteraction' ),
814									 $e->getMessage(), $e->getLine() ) );
815			}
816
817		}
818		catch ( Exception $e )
819		{
820
821			$this->render( '<div class="error">' .
822							sprintf( $this->getLang( 'deferror' ),
823							$e->getMessage(), $e->getLine(), $e->getFile() ) .
824							'</div>' );
825
826			$resetCmd = $this->varname( 'cmdreset' );
827			$viewCmd  = $this->varname( 'view' );
828
829			$btnSession = $this->getLang( 'btnResetSession' );
830			$btnTable   = $this->getLang( 'btnViewTable' );
831
832			$this->render( $this->wrapInForm( <<<EOT
833<input type="submit" name="$resetCmd" value="$btnSession" />
834<input type="submit" name="$viewCmd" value="$btnTable" />
835EOT
836							) );
837
838		}
839	}
840
841
842	protected function getACLCol()
843	{
844
845		foreach ( $this->meta as $name => $def )
846			if ( $def['isColumn'] && ( $def['format'] == 'acl' ) )
847				return $name;
848
849		return null;
850
851	}
852
853
854	protected function getRowACL( $rowid )
855	{
856
857		$session =& $this->getSession();
858
859		if ( !is_array( $session['rowACLs'] ) )
860			$session['rowACLs'] = array();
861
862		if ( !isset( $session['rowACLs'][$rowid] ) )
863		{
864
865			$aclName  = $this->getACLCol();
866			$idColumn = $this->getSingleNumericPrimaryKey();
867
868			if ( $aclName && $idColumn )
869			{
870
871				$sql = sprintf( 'SELECT %s FROM %s WHERE %s=?', $aclName,
872								$this->table, $idColumn );
873
874				$st = $this->db->prepare( $sql );
875				if ( !$st )
876					throw new PDOException( $this->getLang( 'aclprepare' ) );
877
878				if ( !$st->execute( array( $rowid ) ) )
879					throw new PDOException( $this->getLang( 'aclexecute' ) );
880
881				$row = $st->fetch( PDO::FETCH_NUM );
882
883				$st->closeCursor();
884
885
886				$session['rowACLs'][$rowid] = trim( $row[0] );
887
888			}
889		}
890
891
892		return $session['rowACLs'][$rowid] ? $session['rowACLs'][$rowid] : null;
893
894	}
895
896
897	protected function dropRowACL( $rowid )
898	{
899
900		$session =& $this->getSession();
901
902		if ( $session['rowACLs'][$rowid] )
903			unset( $session['rowACLs'] );
904
905	}
906
907
908	/**
909	 * Creates managed table on demand.
910	 *
911	 */
912
913	protected function createTable()
914	{
915
916		if ( empty( $this->meta ) )
917			throw new Exception( $this->getLang( 'defmissing' ) );
918
919		// extract all column definitions
920		$cols = array_map( create_function( '$a','return $a[definition];' ),
921						   $this->meta );
922
923		// compile CREATE TABLE-statement using linebreaks as some versions
924		// of SQLite engines cache it for schema representation, thus improving
925		// human-readability ...
926		$sql = "CREATE TABLE {$this->table}\n(\n\t" . implode( ",\n\t", $cols ).
927				"\n)";
928
929
930		if ( $this->db->query( $sql ) === false )
931			throw new PDOException( sprintf( $this->getLang( 'nocreatetable' ),
932									$this->table ) );
933
934
935		$this->log( 'create', $this->table );
936
937	}
938
939
940	/**
941	 * Renders single record for editing (or inspecting if $readOnly is true).
942	 *
943	 * @param integer $rowid unique numeric ID of record to edit/inspect
944	 * @param boolean $readOnly if true, the record is rendered read-only
945	 * @param integer $duplicateOf unique numeric ID of record to duplicate
946	 * @return boolean if true, the table/list shouldn't be rendered
947	 */
948
949	protected function editRecord( $rowid, $readOnly, $duplicateOf = null,
950								   $rowACL = null )
951	{
952
953		$ioIndex  = $this->getIndex();
954		$input    = $this->getInput();
955		$idColumn = $this->getSingleNumericPrimaryKey();
956
957		$isNew    = !$rowid || $duplicateOf;
958
959
960
961		/**
962		 * Obtain lock for exclusively accessing selected record
963		 */
964
965		if ( $rowid && !$readOnly && !$this->obtainLock( $this->table, $rowid ) )
966		{
967			$this->render( '<div class="error">' . $this->getLang( 'reclocked' ) . '</div>' );
968			return true;
969		}
970
971
972
973		/*
974		 * prepare session to contain data specific to this editor
975		 */
976
977		$state =& $this->getSession();
978		$store =& $this->getEditorSession();
979
980		$errors = array();
981
982
983
984		/*
985		 * process input data updating record and handle contained commands
986		 */
987
988		if ( $input && ( $input['____single'] === md5( $rowid ) ) )
989		{
990
991			// select result to return depending on selected navigation mode
992			if ( $input['____nav'] )
993			{
994
995				if ( $input['____nav'][0] == 'P' )
996					$state['nav'] = 'previous';
997				else
998					$state['nav'] = 'next';
999
1000				$result = intval( substr( $input['____nav'], 1 ) );
1001				if ( !$result )
1002					$result = true;
1003
1004			}
1005			else
1006			{
1007				unset( $state['nav'] );
1008				$result = true;
1009			}
1010
1011
1012
1013			if ( $input['____cancel'] )
1014			{
1015				// cancel editing record
1016
1017				if ( $rowid && !$readOnly && !$this->releaseLock( $this->table, $rowid ) )
1018					$this->render( '<div class="error">' . $this->getLang( 'editnorelease' ) . '</div>' );
1019
1020				// drop content of current editor session
1021				$store = array();
1022
1023				return $result;
1024
1025			}
1026
1027
1028
1029			/*
1030			 * validate input data and store in session
1031			 */
1032
1033			if ( !$readOnly )
1034				foreach ( $this->meta as $column => $def )
1035					if ( $def['isColumn'] && ( $column != $idColumn ) )
1036					{
1037
1038						$mayEdit = !$def['options']['mayedit'] || $this->isAuthorizedMulti( $rowACL, $def['options'], 'mayedit' );
1039						if ( !$mayEdit )
1040							continue;
1041
1042						// user may edit this column ...
1043						$mayView = !$def['options']['mayview'] || $this->isAuthorizedMulti( $rowACL, $def['options'], 'mayview' );
1044						if ( !$mayView )
1045							// ... but mustn't view it ...
1046							if ( $rowid )
1047								// it's an existing record -> reject editing field
1048								continue;
1049							// ELSE: editing new records doesn't actually imply
1050							//       viewing something existing in this field
1051
1052						$error = $this->checkValue( $rowid, $column, $input['data'.$column], $store[$column], $def );
1053						if ( $error && $column )
1054						{
1055							// something's wrong, but if it's a typo it's better
1056							// user may change his previous input next rather
1057							// than starting it all over again ...
1058							// --> store even malformed input in editor session
1059							$store[$column]  = $input['data'.$column];
1060							$errors[$column] = $error;
1061						}
1062
1063					}
1064
1065
1066
1067			if ( !$readOnly && empty( $errors ) && $input['____save'] )
1068			{
1069
1070				/*
1071				 * write changed record to database
1072				 */
1073
1074				if ( !$this->db->beginTransaction() )
1075					$this->render( '<div class="error">' . $this->getLang( 'editnotransact' ) . '</div>' );
1076				else try
1077				{
1078
1079					// convert record to be written to database next
1080					$record = array();
1081					foreach ( $store as $column => $value )
1082						if ( $column !== $idColumn )
1083							if ( $this->meta[$column]['isColumn'] &&
1084								 !is_string( $this->meta[$column]['options']['aliasing'] ) )
1085							{
1086
1087								$value = $this->valueToDB( $rowid, $column, $value, $this->meta[$column] );
1088
1089								if ( $value !== false )
1090									$record[$column] = $value;
1091
1092							}
1093
1094
1095					if ( $isNew )
1096					{
1097
1098						if ( !( $record[$idColumn] = $this->nextID( $this->table, true ) ) )
1099							throw new PDOException( $this->getLang( 'editnoid' ) );
1100
1101						$sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)',
1102										$this->table,
1103										implode( ',', array_keys( $record ) ),
1104										implode( ',', array_pad( array(),
1105												 count( $record ), '?' ) ) );
1106
1107						$log = array( $record[$idColumn], 'insert' );
1108
1109					}
1110					else
1111					{
1112
1113						$assignments = array();
1114						foreach ( array_keys( $record ) as $column )
1115							$assignments[] = $column . '=?';
1116
1117						$sql = sprintf( 'UPDATE %s SET %s WHERE %s=?',
1118										$this->table, implode( ',', $assignments ),
1119										$idColumn );
1120
1121						$record[$idColumn] = $rowid;
1122
1123						$log = array( $rowid, 'update' );
1124
1125					}
1126
1127
1128					$st = $this->db->prepare( $sql );
1129					if ( !$st )
1130						throw new PDOException( $this->getLang( 'editprepare' ) );
1131
1132					if ( !$st->execute( array_values( $record ) ) )
1133						throw new PDOException( $this->getLang( 'editexecute' ) );
1134
1135
1136					$this->log( $log[1], $this->table, $log[0] );
1137
1138
1139					if ( !$this->db->commit() )
1140						throw new PDOException( $this->getLang( 'editcommit' ) );
1141
1142
1143
1144					/*
1145					 * release lock on record
1146					 */
1147
1148					if ( $rowid && !$readOnly && !$this->releaseLock( $this->table, $rowid, true ) )
1149						throw new PDOException( $this->getLang( 'editnorelease' ) );
1150
1151					$store = array();
1152
1153					return $result;
1154
1155				}
1156				catch ( PDOException $e )
1157				{
1158
1159					$this->render( '<div class="error">' .
1160									sprintf( $this->getLang( 'editcantsave' ),
1161									$e->getMessage() ) . '</div>' );
1162
1163					if ( !$this->db->rollBack() )
1164						$this->render( '<div class="error">' .
1165										$this->getLang( 'editrollback' ) .
1166										'</div>' );
1167
1168
1169				}
1170			}
1171		}
1172		else
1173		{
1174
1175			/*
1176			 * editor started ... load from DB or initialize
1177			 */
1178
1179			if ( $isNew )
1180				if ( $readOnly )
1181					return;
1182
1183
1184
1185			if ( $isNew && !$duplicateOf )
1186			{
1187
1188				$store = array();
1189
1190				foreach ( $this->meta as $column => $def )
1191					if ( $def['isColumn'] && ( $column != $idColumn ) )
1192						$store[$column] = $this->getInitialValue( $column, $def );
1193
1194			}
1195			else
1196			{
1197				// load record from table
1198
1199				$cols = $this->__columnsList( false );
1200				$cols = implode( ',', $cols );
1201
1202				//  - get raw record
1203				if ( $this->options['view'] )
1204					$sql = sprintf( '%s WHERE %s=?',
1205									$this->options['view'], $idColumn );
1206				else
1207					$sql = sprintf( 'SELECT %s FROM %s WHERE %s=?',
1208									$cols, $this->table, $idColumn );
1209
1210				$st = $this->db->prepare( $sql );
1211				if ( !$st )
1212					throw new PDOException( $this->getLang( 'editloadprepare' ) );
1213
1214				if ( !$st->execute( array( $duplicateOf ? $duplicateOf : $rowid ) ) )
1215					throw new PDOException( $this->getLang( 'editloadexecute' ) );
1216
1217				$record = $st->fetch( PDO::FETCH_ASSOC );
1218				if ( !is_array( $record ) || empty( $record ) )
1219					throw new PDOException( $this->getLang( 'notarecord' ) );
1220
1221				$st->closeCursor();
1222
1223
1224				// drop contained ID column
1225				unset( $record[$idColumn] );
1226
1227
1228				// on duplicating record reset some of the original record's
1229				// values current user isn't authorized to view
1230				foreach ( $record as $name => $value )
1231					if ( !$this->isAuthorizedMulti( $rowACL, $this->meta[$name]['options'], 'mayview', null, true ) )
1232						// user mustn't view this value of original record
1233						// --> reset to defined default value
1234						$record[$name] = $this->getInitialValue( $column, $def );
1235
1236
1237
1238
1239				// transfer to temporary storage converting accordingly
1240				$store = $this->__sortRecord( $record );
1241
1242
1243				// convert values from DB format to internal one
1244				foreach ( $store as $column => $value )
1245					$store[$column] = $this->valueFromDB( $rowid, $column, $value, $this->meta[$column] );
1246
1247			}
1248		}
1249
1250
1251
1252		/*
1253		 * prepare to support navigation
1254		 */
1255
1256		$nav = array();
1257
1258		if ( !$isNew )
1259		{
1260
1261			if ( !is_integer( $input['____idx'] ) )
1262				$input['____idx'] = $this->recordI2X( $rowid );
1263
1264
1265			if ( $input['____idx'] )
1266				$nav[] = array( 'P' . $this->recordX2I( $input['____idx'] - 1 ),
1267								$this->getLang( 'navprevious' ),
1268								( $state['nav'] === 'previous' ) );
1269
1270			$nextID = $this->recordX2I( $input['____idx'] + 1 );
1271			if ( $nextID )
1272				$nav[] = array( 'N' . $nextID, $this->getLang( 'navnext' ),
1273								( $state['nav'] === 'next' ) );
1274
1275			if ( count( $nav ) )
1276				array_unshift( $nav, array( 0, $this->getLang( 'navreturn' ) ) );
1277
1278		}
1279
1280		if ( empty( $nav ) )
1281			$nav = null;
1282
1283
1284
1285		/*
1286		 * Render single editor
1287		 */
1288
1289		// compile form
1290		$elements = array();
1291
1292		$elements[] = $this->renderField( true, null, null, array(), null, $readOnly, $rowACL );
1293
1294		foreach ( $store as $column => $value )
1295			$elements[] = $this->renderField( $rowid, $column, $value,
1296											  $this->meta[$column],
1297											  $errors[$column], $readOnly, $rowACL );
1298
1299		$elements[] = $this->renderField( false, $nav, null, array(), null, $readOnly, $rowACL );
1300
1301
1302		if ( $readOnly && $rowid )
1303			$cmdName = 'inspect' . $rowid;
1304		else if ( $rowid )
1305			$cmdName = 'edit' . $rowid;
1306		else
1307			$cmdName = 'insert0';
1308
1309		// ensure to come back here on submitting form data
1310		$this->render( $this->wrapInForm( implode( '', $elements ), array(
1311							$this->varname( 'cmd' . $cmdName ) => '1',
1312							$this->varname( '____single' ) => md5( $rowid ),
1313							$this->varname( '____idx' ) => $input['____idx'],
1314							), self::maxUploadSize, true ) );
1315
1316
1317		// return and mark to prevent rendering data list
1318		return false;
1319
1320	}
1321
1322
1323	/**
1324	 * Sorts record according to tabindex order provided in definition.
1325	 *
1326	 * @param array $record unsorted record
1327	 * @return array sorted record
1328	 */
1329
1330	private function __sortRecord( $record )
1331	{
1332
1333		$in    = $record;
1334		$index = array();
1335
1336		foreach ( $in as $column => $value )
1337		{
1338
1339			$tabindex = $this->meta[$column]['options']['tabindex'];
1340			if ( $tabindex > 0 )
1341			{
1342				$index[$column] = intval( $tabindex );
1343				unset( $in[$column] );
1344			}
1345		}
1346
1347		foreach ( $in as $column => $value )
1348			$index[$column] = empty( $index ) ? 1 : ( max( $index ) + 1 );
1349
1350
1351		// sort columns according to explicit/implicit tabindex
1352		asort( $index );
1353
1354
1355		// sort record according to that index
1356		$out = array();
1357		foreach ( $index as $column => $dummy )
1358			if ( $this->meta[$column]['isColumn'] )
1359				$out[$column] = $record[$column];
1360
1361
1362		return $out;
1363
1364	}
1365
1366
1367	/**
1368	 * Deletes selected record from managed table.
1369	 *
1370	 * @param integer $rowid ID of row to delete
1371	 */
1372
1373	protected function deleteRecord( $rowid )
1374	{
1375
1376		if ( !$rowid || !ctype_digit( trim( $rowid ) ) )
1377			throw new Exception( $this->getLang( 'notarecord' ) );
1378
1379
1380		if ( !$this->db->beginTransaction() )
1381			throw new PDOException( $this->getLang( 'notransact' ) );
1382
1383		try
1384		{
1385
1386			if ( !$this->obtainLock( $this->table, $rowid, true, true ) )
1387			{
1388				$this->render( '<div class="error">' . $this->getLang( 'reclocked' ) . '</div>' );
1389				$this->db->rollback();
1390				return true;
1391			}
1392
1393
1394			$idColumn = $this->getSingleNumericPrimaryKey();
1395
1396			$st = $this->db->prepare( 'DELETE FROM ' . $this->table . ' WHERE '.
1397									  $idColumn . '=?' );
1398			if ( !$st )
1399				throw new PDOException( $this->getLang( 'delprepare' ) );
1400
1401			if ( !$st->execute( array( $rowid ) ) )
1402				throw new PDOException( $this->getLang( 'delexecute' ) );
1403
1404			$this->log( 'delete', $this->table, $rowid );
1405
1406
1407			if ( !$this->db->commit() )
1408				throw new PDOException( $this->getLang( 'delcommit' ) );
1409
1410		}
1411		catch ( PDOException $e )
1412		{
1413
1414			$this->db->rollback();
1415
1416			throw $e;
1417
1418		}
1419	}
1420
1421
1422	/**
1423	 * Drops whole table.
1424	 *
1425	 */
1426
1427	protected function dropTable()
1428	{
1429
1430		if ( !$this->db->beginTransaction() )
1431			throw new PDOException( $this->getLang( 'notransact' ) );
1432
1433		try
1434		{
1435
1436			if ( !$this->obtainLock( $this->table, null, true, true ) )
1437			{
1438				$this->render( '<div class="error">' . $this->getLang( 'tablelocked' ) . '</div>' );
1439				$this->db->rollback();
1440				return true;
1441			}
1442
1443
1444			if ( $this->db->query( 'DROP TABLE ' . $this->table ) === false )
1445				throw new PDOException( sprintf( $this->getLang( 'nodrop' ), $this->table ) );
1446
1447			$this->log( 'drop', $this->table );
1448
1449
1450			if ( !$this->db->commit() )
1451				throw new PDOException( $this->getLang( 'dropcommit' ) );
1452
1453		}
1454		catch ( PDOException $e )
1455		{
1456
1457			$this->db->rollback();
1458
1459			throw $e;
1460
1461		}
1462	}
1463
1464
1465	/**
1466	 * Retrieves list of columns (optionally reduced to the set marked as
1467	 * visible) in current table.
1468	 *
1469	 * @param boolean $visibleOnly if true return visible columns, only
1470	 * @param boolean $printable if true, obey marks on columns being printable
1471	 * @return array list of columns' names
1472	 */
1473
1474	protected function __columnsList( $visibleOnly = true, $printable = false )
1475	{
1476
1477		$meta  = $this->getColumnsMeta();
1478
1479		$idCol = $this->getSingleNumericPrimaryKey();
1480		$cols  = array();
1481
1482		if ( $visibleOnly )
1483			foreach ( $meta as $colName => $def )
1484				if ( $def['isColumn'] )
1485				{
1486
1487					if ( $def['options']['visible'] === 1 )
1488						$use = true;
1489					else if ( $printable )
1490						$use = $def['options']['print'] ||
1491							 ( $def['options']['visible'] &&
1492							  !$def['options']['noprint'] );
1493					else
1494						$use = $def['options']['visible'];
1495
1496					if ( $use )
1497						$cols[] = $colName;
1498
1499				}
1500
1501		if ( !$visibleOnly || empty( $cols ) )
1502			foreach ( $meta as $colName => $def )
1503				if ( $def['isColumn'] )
1504					$cols[] = $colName;
1505
1506		foreach ( $cols as $index => $name )
1507			if ( is_string( $meta[$name]['options']['aliasing'] ) )
1508				$cols[$index] = $meta[$name]['options']['aliasing'] . ' AS ' .
1509								$name;
1510
1511
1512		if ( $idCol )
1513			array_unshift( $cols, $idCol );
1514
1515
1516		return $cols;
1517
1518	}
1519
1520
1521	/**
1522	 * Retrieves count of records in table obeying current state of filter.
1523	 *
1524	 * @return integer number of available records in table matching filter
1525	 */
1526
1527	protected function __recordsCount( $customQuery = null )
1528	{
1529
1530		// get compiled filter
1531		list( $filter, $parameters ) = $this->getFilter();
1532
1533
1534		$customQuery = trim( $customQuery );
1535		if ( $customQuery === '' )
1536			$query = "SELECT COUNT(*) FROM {$this->table}";
1537		else
1538		{
1539
1540			$query = preg_replace( '/^SELECT .+ (FROM .+)$/i',
1541								   'SELECT COUNT(*) \1', $customQuery );
1542
1543			if ( stripos( $query, ' WHERE ' ) !== false )
1544			{
1545				$filter      = '';
1546				$parameters = array();
1547			}
1548		}
1549
1550		$st = $this->db->prepare( $query . $filter );
1551		if ( !$st )
1552			throw new PDOException( $this->getLang( 'countprepare' ) );
1553
1554		if ( !$st->execute( $parameters ) )
1555			throw new PDOException( $this->getLang( 'countexecute' ) );
1556
1557		$count = $st->fetch( PDO::FETCH_NUM );
1558
1559		$st->closeCursor();
1560
1561
1562		return intval( array_shift( $count ) );
1563
1564	}
1565
1566
1567	/**
1568	 * Retrieves records from table.
1569	 *
1570	 * @param array/string $columns list of records to retrieve, "*" for all
1571	 * @param boolean $obeyFilter if true, current state of filter is used to
1572	 *                            retrieve matching records, only
1573	 * @param string $sortColumn name of column to optionally sort by
1574	 * @param boolean $sortAscendingly if true, request to sort ascendingly
1575	 * @param integer $offset optional number of matching records to skip
1576	 * @param integer $limit maximum number of matching records to retrieve
1577	 * @return array excerpt of matching records according to given parameters
1578	 */
1579
1580	public function __recordsList( $columns = '*', $obeyFilter = true,
1581//								   $sortColumn = null, $sortAscendingly = true,
1582								   $offset = 0, $limit = null, $customQuery = null )
1583	{
1584
1585		$config = $this->__configureSelect();
1586		if ( is_array( $config ) )
1587		{
1588
1589			list( $filter, $parameters, $order, $cols ) = $config;
1590
1591		}
1592		else
1593		{
1594
1595/*
1596			// prepare filter
1597			if ( $obeyFilter )
1598				list( $filter, $parameters ) = $this->getFilter();
1599			else
1600			{
1601				$filter     = '';
1602				$parameters = array();
1603			}
1604
1605			// prepare sorting
1606			if ( $sortColumn )
1607				if ( !$this->meta[$sortColumn]['isColumn'] )
1608					$sortColumn = null;
1609
1610			if ( $sortColumn )
1611				$order = ' ORDER BY ' . $sortColumn .
1612						 ( $sortAscendingly ? ' ASC' : ' DESC' );
1613			else
1614				$order = '';
1615
1616
1617*/
1618
1619			$cols       = array();
1620			$filter     = '';
1621			$parameters = array();
1622			$order      = '';
1623
1624		}
1625
1626
1627		// prepare limits
1628		if ( ( $offset > 0 ) || ( $limit > 0 ) )
1629		{
1630
1631			$limit = ' LIMIT ' . ( ( $limit > 0 ) ? $limit : '10' );
1632
1633			if ( $offset > 0 )
1634				$limit .= ' OFFSET ' . $offset;
1635
1636		}
1637
1638		// prepare columns selected for retrieval
1639		if ( is_array( $columns ) )
1640			$cols   = array_merge( $columns, $cols );
1641		else
1642			$cols[] = $columns;
1643
1644		$columns = implode( ',', $cols );
1645
1646
1647		if ( trim( $customQuery ) === '' )
1648			$query = 'SELECT ' . $columns . ' FROM ' . $this->table;
1649		else
1650		{
1651
1652			$query = $customQuery;
1653
1654			if ( stripos( $query, ' WHERE ' ) !== false )
1655			{
1656				$filter     = '';
1657				$parameters = array();
1658			}
1659
1660		}
1661
1662
1663		// query for records returning whole resultset
1664		$st = $this->db->prepare( $query . $filter . $order . $limit );
1665		if ( !$st )
1666			throw new PDOException( $this->getLang( 'listprepare' ) );
1667
1668		if ( !$st->execute( $parameters ) )
1669			throw new PDOException( $this->getLang( 'listexecute' ) );
1670
1671		return $st->fetchAll( PDO::FETCH_ASSOC );
1672
1673	}
1674
1675
1676	/**
1677	 * Renders (excerpt of) managed table.
1678	 *
1679	 * @param boolean $expectInput if true, input is processed and controls
1680	 *                             (filter+commands) are rendered
1681	 * @param boolean $returnOutput if true, rendered table is returned rather
1682	 *                              than enqueued for rendering in DokuWiki page
1683	 * @param boolean $listAll if true, paging is disabled and all available/
1684	 *                         matching records are rendered
1685	 * @param string $customQuery a custom query to use instead of managed one
1686	 */
1687
1688	protected function showTable( $expectInput = true, $returnOutput = false,
1689								  $listAll = false, $customQuery = null,
1690								  $isPrintVersion = false )
1691	{
1692
1693		$customQuery = trim( $customQuery );
1694
1695		$meta = $this->getColumnsMeta( ( $customQuery !== '' ) );
1696
1697
1698
1699		/*
1700		 * count all matching records in table
1701		 */
1702
1703		$count = $this->__recordsCount( $customQuery );
1704
1705
1706
1707		/*
1708		 * update view state according to available input data
1709		 */
1710
1711		$state =& $this->getSession();
1712
1713		if ( trim( $state['sort'] ) === '' )
1714			$state['sort'] = $this->options['sort'];
1715
1716
1717		$updated = array(
1718						'skip' => intval( $state['skip'] ),
1719						'num'  => intval( $state['num'] ),
1720						'sort' => trim( $state['sort'] ),
1721						);
1722
1723		if ( $expectInput )
1724		{
1725
1726			$input = $this->getInput();
1727
1728			foreach ( $input as $name => $dummy )
1729				if ( preg_match( '/^(skip|num|sort)(.+)$/i', $name, $m ) )
1730				{
1731
1732					$name = strtolower( $m[1] );
1733
1734					$updated[$name] = ( $name == 'sort' ) ? trim( $m[2] )
1735														  : intval( $m[2] );
1736
1737				}
1738		}
1739
1740
1741		// keep values in range
1742		$updated['num']  = max( 10, $updated['num'] );
1743		$updated['skip'] = max( 0, min( $count - $updated['num'], $updated['skip'] ) );
1744
1745		// save updated view state in session
1746		$state = array_merge( $state, $updated );
1747
1748		// load view state values for easier access
1749		extract( $updated );
1750
1751
1752
1753		/*
1754		 * prepare information on requested sorting
1755		 */
1756
1757/*
1758		if ( $sort )
1759		{
1760
1761			$sortDescendingly = ( $sort[0] == '!' );
1762			$sortCol = $sortDescendingly ? strtok( substr( $sort, 1 ), ',' )
1763										 : $sort;
1764			$sortCol = trim( $sortCol );
1765
1766		}
1767
1768*/
1769
1770
1771		/*
1772		 * query to list matching records
1773		 */
1774
1775		if ( $listAll )
1776			unset( $skip, $num );
1777
1778		$cols  = $this->__columnsList( true, $isPrintVersion );
1779		$rows  = $this->__recordsList( $cols, true, $skip, $num, $customQuery );
1780
1781
1782		$idCol = $this->getSingleNumericPrimaryKey();
1783		$code  = $this->__renderTable( $idCol, $cols, $rows, $count, $num,
1784									   $skip, $sort, $meta, $expectInput,
1785									   $listAll );
1786
1787		if ( $returnOutput )
1788			return $code;
1789
1790		$this->render( $code );
1791
1792	}
1793
1794
1795	public function __renderTable( $idCol, $cols, $rows, $count, $num, $skip,
1796								   $sort, $meta, $expectInput, $listAll )
1797	{
1798
1799		// required to check whether $idCol column has to be rendered or not
1800		$visibleIDCol = null;
1801
1802		foreach ( $meta as $colName => $def )
1803			if ( $def['isColumn'] )
1804			{
1805
1806				if ( is_null( $visibleIDCol ) && $def['options']['visible'] )
1807					$visibleIDCol = ( $colName == $idCol );
1808				else if ( $colName == $idCol )
1809					$visibleIDCol = ( $def['options']['visible'] != false );
1810
1811			}
1812
1813		if ( is_null( $visibleIDCol ) )
1814			$visibleIDCol = $meta[$idCol] && !$meta[$idCol]['auto_id'];
1815
1816
1817
1818		/*
1819		 * - collect header row according to listed rows
1820		 * - transform all listed rows to properly defined HTML table cells
1821		 */
1822
1823		$headers = array();
1824		$counter = $skip;
1825
1826		foreach ( $rows as $nr => $row )
1827		{
1828
1829			// get record's rowid
1830			$rowid = $idCol ? intval( $row[$idCol] ) : 0;
1831
1832			if ( !$visibleIDCol )
1833				unset( $row[$idCol] );
1834
1835
1836			// convert all values in current row to table cells
1837			$i = 0;
1838
1839			if ( $this->options['aclColumn'] )
1840			{
1841				$rowACL = $row[$this->options['aclColumn']];
1842				if ( $meta[$this->options['aclColumn']]['options']['visible'] === 1 )
1843					unset( $row[$this->options['aclColumn']] );
1844			}
1845			else
1846				$rowACL = null;
1847
1848
1849			$clicks = array();
1850
1851			foreach ( $row as $column => $value )
1852			{
1853
1854				if ( !is_array( $meta[$column] ) )
1855					$meta[$column] = array(
1856										'readonly' => true,
1857										'isColumn' => true,
1858										'format'   => 'text',
1859										'label'    => $column,
1860										);
1861
1862
1863				$def = $meta[$column];
1864
1865				$headers[$column] = $def['label']  ? $def['label']  : $column;
1866				$class            = $def['format'] ? $def['format'] : 'na';
1867				$class           .= ' col' . ++$i;
1868
1869				$value = $this->valueFromDB( $rowid, $column, $value, $def );
1870
1871				$cell = $this->renderValue( $rowid, $column, $value, $def,
1872											false, false, $rowACL );
1873
1874				switch ( $clickAction = $def['options']['onclick'] )
1875				{
1876
1877					case 'edit' :
1878						if ( $this->options['view'] ||
1879							 !$this->isAuthorizedMulti( $rowACL, $this->options,
1880													   'may' . $clickAction ) )
1881							$clickAction = 'inspect';
1882
1883					case 'inspect' :
1884						if ( $this->isAuthorizedMulti( $rowACL, $this->options,
1885													   'may' . $clickAction ) )
1886						{
1887							$cell = '<a href="#" onclick="return !!document.getElementById(\'' .
1888									$this->varname( 'cmd' . $clickAction, $rowid ) .
1889									'\').click();">' . $cell . '</a>';
1890							$clicks[] = $clickAction;
1891						}
1892						break;
1893
1894					default :
1895						$cell = $this->convertToLink( $clickAction, $cell,
1896													  array( 'value' => $cell ) );
1897
1898				}
1899
1900				$row[$column] = "<td class=\"$class\">" . $cell . "</td>\n";
1901
1902			}
1903
1904			// prepend cell for counter
1905			array_unshift( $row, '<td class="counter col0 rightalign">'.++$counter."</td>\n" );
1906
1907			if ( $expectInput )
1908				// append cell for row-related commands
1909				$row[] = '<td class="commands col' . ++$i . '">' .
1910						 $this->getRecordCommands( $rowid, $rowACL, $clicks ) .
1911						 '</td>';
1912
1913
1914			// convert set of values into HTML table row
1915			$classes = array();
1916
1917			if ( $nr == 0 )
1918				$classes[] = 'first';
1919			if ( $nr == count( $rows ) - 1 )
1920				$classes[] = 'last';
1921
1922			$classes[] = ( $nr % 2 ) ? 'even' : 'odd';
1923			$classes[] = 'row' . ( $nr + 1 );
1924
1925			$classes = implode( ' ', $classes );
1926
1927			$rows[$nr] = '<tr class="' . $classes . '">'.
1928						 implode( '', $row ) . "</tr>\n";
1929
1930		}
1931
1932		// finally convert all HTML table rows into single HTML table body
1933		$rows = implode( '', $rows );
1934
1935
1936
1937		/*
1938		 * compile header row
1939		 */
1940
1941		// ensure to have row of headers (missing on an empty list of rows)
1942		if ( empty( $headers ) )
1943			foreach ( $cols as $column )
1944			{
1945
1946				unset( $def );
1947
1948				if ( is_array( $meta[$column] ) )
1949					$def = $meta[$column];
1950				else
1951				{
1952					// missing meta information on current "column name"
1953					// --> might be an alias definition
1954					//     --> extract originally selected column name from that
1955
1956					$pos = strripos( $column, ' AS ' );
1957					if ( $pos !== false )
1958					{
1959
1960						$temp = substr( $column, $pos + 4 );
1961						if ( $meta[$temp] )
1962						{
1963							// found definition on extracted column name
1964
1965							$def    = $meta[$temp];
1966							$column = $temp;
1967
1968						}
1969					}
1970				}
1971
1972
1973				$headers[$column] = $def['label'] ? $def['label'] : $column;
1974
1975			}
1976
1977
1978		// next transform headers into table header cells including proper
1979		// controls for sorting etc.
1980		$sortDescendingly = ( $sort[0] == '!' );
1981		if ( $sortDescendingly )
1982			$sort = substr( $sort, 1 );
1983
1984		$sort = trim( strtok( $sort, ',' ) );
1985
1986
1987		foreach ( $headers as $column => $label )
1988		{
1989
1990			if ( $meta[$column]['options']['headerlabel'] )
1991				$label = trim( $meta[$column]['options']['headerlabel'] );
1992
1993			if ( ( $href = trim( $meta[$column]['options']['headerlink'] ) ) !== '' )
1994				$label = $this->convertToLink( $href, $label );
1995
1996
1997			if ( ( $sort == $column ) && $sortDescendingly )
1998			{
1999				$name = $column;
2000				$icon = 'down';
2001				$title = $this->getLang( 'hintsortasc' );
2002			}
2003			else if ( $sort == $column )
2004			{
2005				$name = '!' . $column;
2006				$icon = 'up';
2007				$title = $this->getLang( 'hintsortdesc' );
2008			}
2009			else
2010			{
2011				$name = $column;
2012				$icon = 'none';
2013				$title = $this->getLang( 'hintsortasc' );
2014			}
2015
2016			if ( $expectInput )
2017				$sorter = "&nbsp;<input " .
2018								'type="image" name="' . $this->varname( 'sort' . $name ).
2019								'" src="' . DOKU_BASE . DB2_PATH .
2020								"icons/$icon.gif\" title=\"$title\"/>";
2021			else
2022				$sorter = '';
2023
2024			$headers[$column] = "<th class=\"label\">$label$sorter</th>\n";
2025
2026		}
2027
2028		// compile row of header cells
2029		$headers = implode( '', $headers );
2030
2031
2032
2033		/*
2034		 * check for available filter
2035		 */
2036
2037		if ( $this->isAuthorized( $this->options['mayfilter'] ) && $expectInput )
2038		{
2039
2040			$filter = $this->renderFilter();
2041			if ( $filter != '' )
2042				$filter = '<tr class="filter"><td colspan="3">' . $filter . '</td></tr>';
2043
2044		}
2045		else
2046			$filter = '';
2047
2048
2049
2050		/*
2051		 * compile pager
2052		 */
2053
2054		list( $flipDown, $flipUp, $pages, $sizes, $stat ) = $this->getPagerElements( $skip, $num, $count );
2055
2056		if ( !$expectInput )
2057			unset( $sizes );
2058
2059		$sepStat = $sizes ? ' &mdash; ' . $stat : $stat;
2060
2061
2062
2063		/*
2064		 * retrieve all available commands operating on whole table
2065		 */
2066
2067		$globalCmds = $expectInput ? $this->getGlobalCommands() : '';
2068
2069
2070		/*
2071		 * render list of rows as HTML table
2072		 */
2073
2074		$width     = intval( $this->options['width'] ) ? ' width="' . $this->options['width'] . '"' : '';
2075		$cmdHeader = $expectInput ? '<th class="commands"></th>' : '';
2076
2077		$trClass    = $this->options['wikistyle'] ? '' : ' class="data-list"';
2078		$tableClass = $this->options['wikistyle'] ? ' class="inline"' : '';
2079
2080
2081		$table = <<<EOT
2082   <table width="100%"$tableClass>
2083    <thead>
2084     <tr class="row0">
2085      <th class="counter"></th>
2086      $headers
2087      $cmdHeader
2088     </tr>
2089    </thead>
2090    <tbody>
2091     $rows
2092    </tbody>
2093    <caption>
2094     $sizes$sepStat
2095    </caption>
2096   </table>
2097EOT;
2098
2099		if ( $expectInput || !$listAll )
2100			$table     = <<<EOT
2101<table class="database2"$width>
2102 <tbody>
2103  $filter
2104  <tr class="upper-navigation">
2105   <td align="left" width="33.3%">
2106    $flipDown
2107   </td>
2108   <td align="center" width="33.3%">
2109    $pages
2110   </td>
2111   <td align="right" width="33.3%">
2112    $flipUp
2113   </td>
2114  </tr>
2115  <tr$trClass>
2116   <td colspan="3">
2117    $table
2118   </td>
2119  </tr>
2120  <tr class="lower-navigation">
2121   <td colspan="3">
2122    $globalCmds
2123   </td>
2124  </tr>
2125 </tbody>
2126</table>
2127EOT;
2128		else
2129			$table     = <<<EOT
2130<table class="database2"$width>
2131 <tbody>
2132  <tr$trClass>
2133   <td>
2134    $table
2135   </td>
2136  </tr>
2137 </tbody>
2138</table>
2139EOT;
2140
2141		return $expectInput ? $this->wrapInForm( $table ) : $table;
2142
2143	}
2144
2145
2146	public function __csvLine( $fields )
2147	{
2148
2149		foreach ( $fields as &$field )
2150			$field = '"' . strtr( $field, array( '"' => '""' ) ) . '"';
2151
2152		return implode( ';', $fields ) . "\n";
2153
2154	}
2155
2156
2157	protected function button( $name, $label )
2158	{
2159
2160		$args = func_get_args();
2161		$args = array_filter( array_slice( $args, 2 ), create_function( '$a', 'return trim($a)!=="";' ) );
2162
2163		$disabled = in_array( 'disabled', $args ) ? ' disabled="disabled"' : '';
2164
2165		if ( !$icon )
2166			$args[] = 'pure-text';
2167
2168		$classes = implode( ' ', $args );
2169
2170		return '<input type="submit" name="' . $this->varname( $name ) .
2171				'" value="' . htmlentities( $label ) . '" class="' .
2172				$classes . '"' . $disabled . ' />';
2173
2174	}
2175
2176	protected function imgbutton( $name, $label, $icon )
2177	{
2178
2179		$args = func_get_args();
2180		$args = array_filter( array_slice( $args, 3 ), create_function( '$a', 'return trim($a)!=="";' ) );
2181
2182		$disabled = in_array( 'disabled', $args ) ? ' disabled="disabled"' : '';
2183
2184		$classes = implode( ' ', $args );
2185
2186		return '<input type="image" name="' . $this->varname( $name ) .
2187				'" title="' . htmlentities( $label, ENT_COMPAT, 'UTF-8' ) .
2188				'" class="' . $classes . '"' . $disabled .' src="' .
2189				DOKU_BASE . DB2_PATH . 'icons/' . $icon . '.gif" />';
2190
2191	}
2192
2193
2194	/**
2195	 * Compiles elements for flipping/selecting page and number of records per
2196	 * page in listing table according to current context.
2197	 *
2198	 * @param integer $skip number of records to skip on listing
2199	 * @param integer $num number of records to list per page at most
2200	 * @param integer $count number of records in table
2201	 * @return array five-element array containing buttons for flipping down,
2202	 *               flipping up, selecting page, selecting number of records
2203	 *               per page and for showing number of records and pages.
2204	 */
2205
2206	protected function getPagerElements( $skip, $num, $count )
2207	{
2208
2209		// build list of skip-values for all pages
2210		$skips = array();
2211
2212		if ( $num > 0 )
2213		{
2214
2215			for ( $i = $skip; $i > 0; $i -= $num )
2216				array_unshift( $skips, $i );
2217
2218			array_unshift( $skips, 0 );
2219
2220			for ( $i = $skip + $num; $i < $count - $num; $i += $num )
2221				array_push( $skips, $i );
2222
2223			if ( $i < $count )
2224				array_push( $skips, $count - $num );
2225
2226		}
2227
2228
2229		// detect index of currently visible page
2230		$page = array_search( $skip, $skips );
2231
2232		$minPage = max( 0, $page - self::maxSurroundingPagesCount );
2233		$maxPage = min( count( $skips ), $page + self::maxSurroundingPagesCount );
2234
2235
2236
2237		/*
2238		 * compile pager elements for ...
2239		 */
2240
2241		if ( count( $skips ) <= 1 )
2242			$backward = $forward = $pages = '';
2243		else
2244		{
2245
2246			// ... flipping down
2247			$backward = $pages = $forward = array();
2248
2249			if ( $page > 1 )
2250				$backward[] = $this->imgbutton( 'skip0', $this->getLang( 'hintflipfirst' ), 'first' );
2251
2252			if ( $page > 0 )
2253				$backward[] = $this->imgbutton( 'skip' . $skips[$page-1], $this->getLang( 'hintflipprevious' ), 'previous' );
2254
2255			$backward = implode( "\n", $backward );
2256
2257
2258			// ... switching to near page
2259			for ( $i = $minPage; $i < $maxPage; $i++ )
2260				$pages[] = $this->button( 'skip' . $skips[$i], $i + 1,
2261										  ( $i == $page ? 'selected' : '' ) );
2262
2263			if ( $minPage > 0 )
2264				array_unshift( $pages, '...' );
2265
2266			if ( $maxPage < count( $skips ) - 1 )
2267				array_push( $pages, '...' );
2268
2269			$pages = implode( "\n", $pages );
2270
2271
2272			// ... flipping up
2273			if ( $page < count( $skips ) - 1 )
2274				$forward[] = $this->imgbutton( 'skip' . $skips[$page+1], $this->getLang( 'hintflipnext' ), 'next' );
2275
2276			if ( $page < count( $skips ) - 2 )
2277				$forward[] = $this->imgbutton( 'skip' . ( $count - $num ), $this->getLang( 'hintfliplast' ), 'last' );
2278
2279			$forward = implode( "\n", $forward );
2280
2281		}
2282
2283
2284		// ... showing number of records/pages
2285		if ( $count === 1 )
2286			$stat = sprintf( $this->getLang( 'recnumsingle' ), $count );
2287		else if ( count( $skips ) <= 1 )
2288			$stat = sprintf( $this->getLang( 'recnummulti' ), $count );
2289		else
2290			$stat = sprintf( $this->getLang( 'recnummultipage' ), $count, count( $skips ) );
2291
2292
2293		// ... selecting number of records per page
2294		foreach ( array( 10, 20, 50, 100, 200 ) as $size )
2295		{
2296			$sizes[] = $this->button( 'num' . $size, $size,
2297									  ( $size == $num ? 'selected' : '' ) );
2298			if ( $size >= $count )
2299				break;
2300		}
2301
2302		$sizes = count( $sizes ) == 1 ? '' : implode( "\n", $sizes );
2303
2304
2305
2306		// return set of pager elements to caller
2307		return array( $backward, $forward, $pages, $sizes, $stat );
2308
2309	}
2310
2311
2312	/**
2313	 * Wraps provided HTML code in a form sending form data to current wiki
2314	 * page.
2315	 *
2316	 * @param string $code HTML code to embed
2317	 * @param array $hiddens set of hidden values to be added
2318	 * @param integer $maxUploadSize maximum size of supported uploads in bytes
2319	 * @param boolean $isSingle set true on calling to render single-record editor
2320	 * @return string
2321	 */
2322
2323	protected function wrapInForm( $code, $hiddens = null,
2324									$maxUploadSize = null, $isSingle = false )
2325	{
2326		include_once( DOKU_INC . 'inc/form.php' );
2327
2328		ob_start();
2329
2330		$id   = 'database2_' . ( $isSingle ? 'single' : 'table' ) . '_' .
2331				$this->table . '_' . $this->getIndex();
2332
2333		if ( $maxUploadSize > 0 )
2334		{
2335			$form = new Doku_Form( $id, false, 'POST', 'multipart/form-data' );
2336			$form->addHidden( 'MAX_FILE_SIZE', intval( $maxUploadSize ) );
2337		}
2338		else
2339			$form = new Doku_Form( $id );
2340
2341		$form->addHidden( 'id', $this->getPageID() );
2342
2343		if ( is_array( $hiddens ) )
2344			foreach ( $hiddens as $name => $value )
2345				$form->addHidden( $name, $value );
2346
2347		$form->addElement( $code );
2348
2349		$form->printForm();
2350
2351
2352		return ob_get_clean();
2353
2354	}
2355
2356
2357	/**
2358	 * Checks if current user is authorized according to given rule.
2359	 *
2360	 * The rule is a comma-separated list of usernames and groups (after
2361	 * preceeding @ character), e.g.
2362	 *
2363	 *   admin,@user
2364	 *
2365	 * authorizing user admin and every user in group "user".
2366	 *
2367	 * @param string $rule rule describing authorizations
2368	 * @return boolean true if current user is authorized, false otherwise
2369	 */
2370
2371	protected function isAuthorized( $rule )
2372	{
2373		global $USERINFO;
2374
2375
2376		if ( auth_isadmin() )
2377			return true;
2378
2379		if ( $rule )
2380		{
2381
2382			$granted = true;
2383
2384			foreach ( explode( ',', $rule ) as $role )
2385			{
2386
2387				$role = trim( $role );
2388				if ( $role === '' )
2389					continue;
2390
2391				if ( !strcasecmp( $role, '@ALL' ) )
2392					return true;
2393
2394				if ( !strcasecmp( $role, '@NONE' ) )
2395					return false;
2396
2397
2398				if ( $_SERVER['REMOTE_USER'] )
2399				{
2400
2401					if ( $role[0] == '!' )
2402					{
2403						$role  = substr( $role, 1 );
2404						$match = false;
2405					}
2406					else
2407						$match = true;
2408
2409					if ( $role[0] == '@' )
2410					{
2411						if ( in_array( substr( $role, 1 ), $USERINFO['grps'] ) )
2412						{
2413							if ( $match && $granted )
2414								return true;
2415							if ( !$match )
2416								$granted = false;
2417						}
2418					}
2419					else if ( $role == $_SERVER['REMOTE_USER'] )
2420					{
2421						if ( $match && $granted )
2422							return true;
2423						if ( !$match )
2424							$granted = false;
2425					}
2426
2427				}
2428			}
2429		}
2430
2431
2432		return false;
2433
2434	}
2435
2436
2437	/**
2438	 * Takes ACL rule set related to a single row and related to whole table
2439	 * testing them either of them preferring the first one for authorizing
2440	 * current user to do one of up to two sorts of access preferring the first
2441	 * one again.
2442	 *
2443	 * @param string|array $rowACL row-related ACL rule set
2444	 * @param array $tableACL table-related ACL rule set, used if $rowACL isn't
2445	 *                        managing any of the given sorts of access
2446	 * @param string $ruleName preferred sort of access to authorize for
2447	 * @param string $optRuleName optional fallback sort of access if first
2448	 *                            isn't managed in selected rule set
2449	 * @param boolean $defaultGrant true to select granting access if neither
2450	 *                              rule set is managing any selected sort of access
2451	 * @return boolean true if user is authorized, false otherwise
2452	 */
2453
2454	protected function isAuthorizedMulti( &$rowACL, $tableACL, $ruleName,
2455										  $optRuleName = null,
2456										  $defaultGrant = false )
2457	{
2458
2459		if ( is_string( $rowACL ) )
2460			$rowACL = $this->parseACLRule( $rowACL, false, true );
2461
2462		// use row-related rule set if it's managing any given sort of access
2463		if ( $rowACL[$ruleName] )
2464			$rule = $rowACL[$ruleName];
2465		else if ( $rowACL[$optRuleName] )
2466			$rule = $rowACL[$optRuleName];
2467		else
2468			$rule = null;
2469
2470		if ( $rule )
2471			return $this->isAuthorized( $rule );
2472
2473
2474		// use table-related rule set if it's managin any given sort of access
2475		if ( $tableACL[$ruleName] )
2476			$rule = $tableACL[$ruleName];
2477		else if ( $tableACL[$optRuleName] )
2478			$rule = $tableACL[$optRuleName];
2479		else
2480			// neither of them is managing given sorts of access
2481			// --> grant or revoke access depending on fifth argument
2482			if ( $defaultGrant )
2483				return true;
2484			else
2485				$rule = '';
2486
2487
2488		return $this->isAuthorized( $rule );
2489
2490	}
2491
2492
2493	/**
2494	 * Gets HTML code of global commands current user is authorized to invoke.
2495	 *
2496	 * @return string HTML code
2497	 */
2498
2499	protected function getGlobalCommands()
2500	{
2501
2502		if ( $this->options['view'] )
2503			return '';
2504
2505
2506		$globalCmds = array(
2507							'insert'     => array( 'add', $this->getLang( 'cmdadd' ) ),
2508							'drop'       => array( 'drop', $this->getLang( 'cmddrop' ), true ),
2509							'print'      => array( 'print', $this->getLang( 'cmdprint' ), 'print', true, 'print' ),
2510							'export.csv' => array( 'exportcsv', $this->getLang( 'cmdcsv' ), 'csv' ),
2511//							'export.xml' => array( 'exportxml', $this->getLang( 'cmdxml' ), 'xml' ),
2512							'viewlog'    => array( 'exportlog', $this->getLang( 'cmdlog' ), 'log' ),
2513							);
2514
2515
2516		if ( !$this->getSingleNumericPrimaryKey() )
2517			unset( $globalCmds['insert'] );
2518
2519
2520		foreach ( $globalCmds as $name => $def )
2521			if ( $this->isAuthorized( $this->options['may'.($authz=strtok( $name, '.' ))] ) )
2522			{
2523
2524				if ( is_string( $def[2] ) )
2525				{
2526
2527					$href = $this->attachmentLink( $def[2], $authz, !$def[3] );
2528					$globalCmds[$name] = '<a href="' . $href . '" class="icon-cmd" target="' . $def[4] .
2529										 '"><img src="' .
2530										 DOKU_BASE . DB2_PATH . 'icons/' . $def[0] .
2531										 '.gif" title="' . $def[1] . '" alt="' .
2532										 $def[1] . '" /></a>';
2533
2534				}
2535				else
2536					$globalCmds[$name] = '<input type="image" class="icon-cmd" name="' .
2537										 $this->varname( 'cmd' . $name ) .
2538										 '" title="' . $def[1] . '" src="' .
2539										 DOKU_BASE . DB2_PATH . 'icons/' . $def[0] .
2540										 '.gif" onclick="' . ( $def[2] ? "return confirm('" . $this->getLang( 'confirmdrop' ) . "');" : '' ) . '" />';
2541
2542			}
2543			else
2544				unset( $globalCmds[$name] );
2545
2546
2547		return implode( "\n", $globalCmds );
2548
2549	}
2550
2551
2552	/**
2553	 * Gets HTML code of commands related to selected record current user is
2554	 * authorized to invoke.
2555	 *
2556	 * @param integer $rowid ID of record
2557	 * @return string HTML code
2558	 */
2559
2560	protected function getRecordCommands( $rowid, $rowACL = null, $clickActions = null )
2561	{
2562
2563		$rowid = intval( $rowid );
2564		if ( !$rowid )
2565			// don't provide record management on complex/non-integer
2566			// primary keys
2567			return '';
2568
2569
2570		if ( !$this->getSingleNumericPrimaryKey() )
2571			return '';
2572
2573
2574		$recordCmds = array(
2575							'inspect' => array( 'view', $this->getLang( 'cmdview' ) ),
2576							'edit'    => array( 'edit', $this->getLang( 'cmdedit' ) ),
2577							'insert'  => array( 'copy', $this->getLang( 'cmdcopy' ) ),
2578//							'insert'  => array( 'insert', $this->getLang( 'cmdinsert' ) ),
2579							'delete'  => array( 'delete', $this->getLang( 'cmddelete' ), true ),
2580							);
2581
2582
2583		if ( $this->options['view'] )
2584			// it's a read-only view thus exclude commands for adjusting data
2585			unset( $recordCmds['edit'], $recordCmds['insert'],
2586				   $recordCmds['delete'] );
2587
2588
2589		if ( !is_array( $clickActions ) )
2590			$clickActions = array();
2591
2592		foreach ( $recordCmds as $name => $def )
2593			if ( $this->isAuthorizedMulti( $rowACL, $this->options, 'may' . $name ) )
2594			{
2595
2596				$idName = $this->varname( 'cmd' . $name, $rowid );
2597				$class  = ( !in_array( $name, $clickActions ) ||
2598							$this->options['addonclick'] ) ? '' : ' hidden';
2599
2600				$recordCmds[$name] = '<input type="image" class="icon-cmd' .
2601									 $class . '" name="' . $idName . '" id="' .
2602									 $idName . '" title="' . $def[1] . '" src="' .
2603									 DOKU_BASE . DB2_PATH . 'icons/' . $def[0] .
2604									 '.gif" onclick="' . ( $def[2] ? "return confirm('" . $this->getLang( 'confirmdelete' ) . "');" : '' ) . '" />';
2605
2606			}
2607			else
2608				unset( $recordCmds[$name] );
2609
2610
2611		return implode( "\n", $recordCmds );
2612
2613	}
2614
2615
2616	protected function __configureSelect()
2617	{
2618
2619		$idCol = $this->getSingleNumericPrimaryKey();
2620		if ( $idCol === false )
2621			return false;
2622
2623
2624		$cols = array( $idCol );
2625
2626
2627		// prepare filter
2628		list( $filter, $parameters ) = $this->getFilter();
2629
2630
2631		// prepare sorting
2632		$state =& $this->getSession();
2633
2634		$sort  = preg_split( '/[,\s]+/', trim( $state['sort'] ) );
2635
2636		foreach ( $sort as $key => $desc )
2637		{
2638
2639			$dir = ( $desc[0] == '!' );
2640
2641			$col = $dir ? substr( $desc, 1 ) : $desc;
2642			$col = trim( $col );
2643
2644			if ( $this->meta[$col]['isColumn'] )
2645			{
2646
2647				if ( is_string( $this->meta[$col]['options']['aliasing'] ) )
2648					$cols[] = $this->meta[$col]['options']['aliasing'] . ' AS ' . $col;
2649				else
2650					$cols[] = $col;
2651
2652				$sort[$key] = $col . ( $dir ? ' DESC' : ' ASC' );
2653
2654			}
2655			else
2656				unset( $sort[$key] );
2657
2658		}
2659
2660		$order = count( $sort ) ? ' ORDER BY ' . implode( ', ', $sort ) : '';
2661
2662
2663
2664		return array( $filter, $parameters, $order, $cols );
2665
2666	}
2667
2668	protected function recordI2X( $rowid )
2669	{
2670
2671		$config = $this->__configureSelect();
2672		if ( !is_array( $config ) )
2673			return false;
2674
2675		list( $filter, $parameters, $order, $cols ) = $config;
2676
2677
2678		// query for records returning whole resultset
2679		$st = $this->db->prepare( 'SELECT ' . implode( ',', $cols ) .
2680								  ' FROM ' . $this->table . $filter . $order );
2681		if ( !$st )
2682			throw new PDOException( $this->getLang( 'listprepare' ) );
2683
2684		if ( !$st->execute( $parameters ) )
2685			throw new PDOException( $this->getLang( 'listexecute' ) );
2686
2687
2688		$index = 0;
2689		while ( ( $record = $st->fetch( PDO::FETCH_NUM ) ) !== false )
2690			if ( $record[0] == $rowid )
2691			{
2692				$st->closeCursor();
2693				return $index;
2694			}
2695			else
2696				$index++;
2697
2698
2699		return null;
2700
2701	}
2702
2703
2704	protected function recordX2I( $index )
2705	{
2706
2707		if ( !is_integer( $index ) || ( $index < 0 ) )
2708			return false;
2709
2710
2711		$config = $this->__configureSelect();
2712		if ( !is_array( $config ) )
2713			return false;
2714
2715		list( $filter, $parameters, $order, $cols ) = $config;
2716
2717
2718		// query for records returning whole resultset
2719		$st = $this->db->prepare( 'SELECT ' . implode( ',', $cols ) .
2720								  ' FROM ' . $this->table . $filter . $order .
2721								  ' LIMIT 1 OFFSET ' . $index );
2722		if ( !$st )
2723			throw new PDOException( $this->getLang( 'listprepare' ) );
2724
2725		if ( !$st->execute( $parameters ) )
2726			throw new PDOException( $this->getLang( 'listexecute' ) );
2727
2728
2729		$record = $st->fetch( PDO::FETCH_NUM );
2730
2731		if ( is_array( $record ) && count( $record ) )
2732			return intval( $record[0] );
2733
2734		return null;
2735
2736	}
2737
2738
2739	/**
2740	 * Parses provided string for filter description consisting of one or more
2741	 * components.
2742	 *
2743	 * @param string $in string containing filter definition
2744	 * @return array list of successfully parsed filter components
2745	 */
2746
2747	protected function parseFilterCode( $in )
2748	{
2749
2750		$in       = trim( $in );
2751		$out      = array();
2752
2753		$prevMode = false;
2754
2755		while ( $in !== '' )
2756		{
2757
2758			if ( preg_match( '/^(\w+)\s+(\w+)(.*)$/i', $in, $matches ) )
2759			{
2760
2761				// extract argument to current filter rule
2762				$tail = trim( $matches[3] );
2763				if ( ( $tail[0] == '"' ) || ( $tail[0] == "'" ) )
2764				{
2765					// argument is enclosed in quotes
2766
2767					$pos      = 0;
2768					$argument = $this->parseString( $tail, $pos );
2769
2770				}
2771				else
2772				{
2773					// argument take everything up to next space or separator
2774
2775					$pos = strcspn( $tail, " \r\n\t\f&|" );
2776					if ( $pos )
2777						$argument = trim( substr( $tail, 0, $pos ) );
2778					else
2779						$argument = '';
2780
2781				}
2782
2783
2784				$new = array(
2785							'col' => $matches[1],
2786							'op'  => $matches[2],
2787							'arg' => $this->replaceMarkup( $argument ),
2788							);
2789
2790				if ( $prevMode === '&' )
2791					$new['mode'] = 'AND';
2792				else if ( $prevMode === '|' )
2793					$new['mode'] = 'OR';
2794				else if ( $prevMode !== false )
2795					// invalid pattern separator --> break parsing filter code
2796					break;
2797
2798				$out[] = $new;
2799
2800				$in    = ltrim( substr( $tail, $pos ) );
2801
2802			}
2803			// invalid filter element --> break parsing filter code
2804			else break;
2805
2806
2807			$prevMode = $in[0];
2808			$in       = substr( $in, 1 );
2809
2810		}
2811
2812
2813		return $out;
2814
2815	}
2816
2817
2818	/**
2819	 * Manages current state of table's filter in session processing optional
2820	 * modifications in current input data.
2821	 *
2822	 * @return array description of current state of filter
2823	 */
2824
2825	protected function getFilterInput()
2826	{
2827
2828		$input   =  $this->getInput();
2829		$session =& $this->getSession();
2830
2831
2832		if ( $input['searchdrop'] )
2833			$session['search'] = array();
2834		else
2835		{
2836
2837			if ( !is_array( $session['search'] ) )
2838			{
2839
2840				$session['search'] = array();
2841
2842				if ( $this->options['basefilter'] )
2843					// initialize filter using provided code
2844					return ( $session['search'] = $this->parseFilterCode( $this->options['basefilter'] ) );
2845
2846			}
2847
2848
2849			// parse filter input and transfer it to session
2850			foreach ( $input as $key => $value )
2851				if ( preg_match( '/^search(col|op|arg)(\d*)$/', $key, $matches ) )
2852				{
2853
2854					$index = intval( $matches[2] );
2855
2856					if ( !is_array( $session['search'][$index] ) )
2857						$session['search'][$index] = array();
2858
2859					$session['search'][$index][$matches[1]] = $value;
2860
2861				}
2862
2863
2864			// drop incomplete filter components
2865			foreach ( $session['search'] as $index => $filter )
2866				if ( !is_string( $filter['col'] ) ||
2867					 !is_string( $filter['op'] ) ||
2868					 !is_string( $filter['arg'] ) )
2869					unset( $session['search'][$index] );
2870				else
2871					if ( !in_array( $filter['mode'], array( 'AND', 'OR' ) ) )
2872						if ( $index )
2873							unset( $session['search'][$index] );
2874
2875		}
2876
2877
2878		return $session['search'];
2879
2880	}
2881
2882
2883	/**
2884	 * Gets WHERE clause and contained parameters to filter records in table.
2885	 *
2886	 * @return array two-element array, SQL-WHERE-clause with initial WHERE and
2887	 *               array with all values to be bound as parameters to clause
2888	 */
2889
2890	protected function getFilter()
2891	{
2892
2893		$filters   = $this->getFilterInput();
2894		$opMap     = array(
2895							'like'    => ' %s ( %s like ? )',
2896							'nlike'   => ' %s ( %s not like ? )',
2897							'lt'      => ' %s ( %s < ? )',
2898							'eq'      => ' %s ( %s = ? )',
2899							'gt'      => ' %s ( %s > ? )',
2900							'ne'      => ' %s ( %s <> ? )',
2901							'le'      => ' %s ( %s <= ? )',
2902							'ge'      => ' %s ( %s >= ? )',
2903							'isset'   => ' %s ( ( %2$s = ? ) AND %2$s IS NOT NULL )',
2904							'isclear' => ' %s ( ( %2$s <> ? ) OR %2$s IS NULL )',
2905							);
2906
2907
2908		$meta = $this->getColumnsMeta();
2909		$out  = array( '', null );
2910
2911		foreach ( $filters as $index => $filter )
2912		{
2913
2914			$mode   = ( $out[0] !== '' ) ? $filter['mode'] : 'WHERE';
2915
2916			$column = $meta[$filter['col']];
2917			if ( $column && $column['isColumn'] )
2918			{
2919				// 1) filter operates on valid column
2920
2921				if ( $column['type'] == 'bool' )
2922				{
2923
2924					if ( in_array( $filter['op'], array( 'like', 'eq', 'le', 'ge', 'isset' ) ) )
2925						$filter['op'] = 'isset';
2926					else
2927						$filter['op'] = 'isclear';
2928
2929					switch ( $column['options']['booltype'] )
2930					{
2931						case 'xmark' : $argument = 'x'; break;
2932						case 'yesno' : $argument = 'y'; break;
2933						case 'int'   : $argument = '1'; break;
2934					}
2935				}
2936				else
2937					$argument = trim( $filter['arg'] );
2938
2939				if ( $argument !== '' )
2940					// 2) filter operates with non-empty argument
2941					if ( $opMap[$filter['op']] )
2942					{
2943						// 3) filter uses valid operation
2944						// ----> include it.
2945
2946						if ( in_array( $filter['op'], array( 'like', 'nlike' ) ) )
2947							if ( strpos( $argument, '%' ) === false )
2948								$argument = '%' . $argument . '%';
2949
2950						$out[0] .= sprintf( $opMap[$filter['op']], $mode,
2951											$filter['col'] );
2952
2953						if ( is_array( $out[1] ) )
2954							$out[1][] = $argument;
2955						else
2956							$out[1]   = array( $argument );
2957
2958					}
2959			}
2960		}
2961
2962
2963		return $out;
2964
2965	}
2966
2967
2968	/**
2969	 * Renders filter on current table.
2970	 *
2971	 * @return string HTML-code representing filter on selected table
2972	 */
2973
2974	protected function renderFilter()
2975	{
2976
2977		/*
2978		 * prepare entries for selecting column
2979		 */
2980
2981		$meta    = $this->getColumnsMeta();
2982
2983		$columns = array();
2984		$mapType = array(
2985						'integer' => 'numeric',
2986						'real'    => 'numeric',
2987						'decimal' => 'numeric',
2988						'text'    => 'text',
2989						'date'    => 'date',
2990						'enum'    => 'enum',
2991						'bool'    => 'bool',
2992						);
2993
2994		$allVisible = true;
2995		foreach ( $meta as $column => $def )
2996			if ( !is_null( $def['options']['visible'] ) ||
2997				 !is_null( $def['options']['filter'] ) )
2998			{
2999				$allVisible = false;
3000				break;
3001			}
3002
3003		foreach ( $meta as $column => $def )
3004			if ( $def['isColumn'] && ( $allVisible || $def['options']['visible'] || $def['options']['filter'] ) )
3005			{
3006
3007				if ( $def['format'] == 'acl' )
3008					continue;
3009
3010				$class = $mapType[$def['type']];
3011				if ( !$class )
3012					continue;
3013
3014				$label = $def['label'] ? $def['label'] : $column;
3015				$label = strtr( $label, array( '<' => '&lt;' ) );
3016
3017				$head  = "<option value=\"$column\" class=\"$class\"";
3018				$tail  = ">$label</option>";
3019
3020				$columns[$column] = strtr( $head, array( '%' => '%%' ) ) . '%s'.
3021									strtr( $tail, array( '%' => '%%' ) );
3022
3023			}
3024
3025		if ( empty( $columns ) )
3026			// not supporting to filter any column -> don't render filter at all
3027			return '';
3028
3029
3030
3031		/*
3032		 * prepare entries for selecting operator
3033		 */
3034
3035		$operators = array(
3036						'like'    => array( $this->getLang( 'oplike' ), 'text', ),
3037						'nlike'   => array( $this->getLang( 'opnotlike' ), 'text', ),
3038						'lt'      => array( '<', 'text', 'numeric', 'date', ),
3039						'le'      => array( '<=', 'text', 'numeric', 'date', ),
3040						'eq'      => array( '=', 'text', 'numeric', 'date', 'enum', ),
3041						'ne'      => array( '<>', 'text', 'numeric', 'date', 'enum', ),
3042						'ge'      => array( '>=', 'text', 'numeric', 'date', ),
3043						'gt'      => array( '>', 'text', 'numeric', 'date', ),
3044						'isset'	  => array( $this->getLang( 'opset' ), 'bool', ),
3045						'isclear' => array( $this->getLang( 'opclear' ), 'bool', ),
3046						);
3047
3048		foreach ( $operators as $op => $def )
3049		{
3050
3051			$label = array_shift( $def );
3052			$class = implode( ' ', $def );
3053
3054			$head  = "<option value=\"$op\" class=\"$class\"";
3055			$tail  = ">$label</option>";
3056
3057			$operators[$op] = strtr( $head, array( '%' => '%%' ) ) . '%s' .
3058							  strtr( $tail, array( '%' => '%%' ) );
3059
3060		}
3061
3062
3063
3064		/*
3065		 * separately render used filter components
3066		 */
3067
3068		$filters = $this->getFilterInput();
3069		$input   = $this->getInput();
3070
3071		$modeMap = array(
3072						'AND' => '<span class="mode-and">' . $this->getLang( 'opand' ) . '</span>',
3073						'OR'  => '<span class="mode-or">' . $this->getLang( 'opor' ) . '</span>',
3074						);
3075
3076		if ( empty( $filters ) || $input['searchand'] || $input['searchor'] )
3077		{
3078			// add new empty filter rule
3079
3080			$newFilter = array(
3081								'col' => '',
3082								'op'  => '',
3083								'arg' => '',
3084								);
3085
3086			if ( $input['searchand'] )
3087				$newFilter['mode'] = 'AND';
3088			else if ( $input['searchor'] )
3089				$newFilter['mode'] = 'OR';
3090
3091			$session             =& $this->getSession();
3092			$session['search'][] = $newFilter;
3093			$filters[]           = $newFilter;
3094
3095		}
3096
3097
3098		foreach ( $filters as $index => $filter )
3099		{
3100
3101			// update columns selector entries marking currently selected one
3102			$optColumns   = $columns;
3103			foreach ( $optColumns as $column => $code )
3104				$optColumns[$column] = sprintf( $code, ( $column == $filter['col'] ) ? ' selected="selected"' : '' );
3105
3106			// update operators selector entries marking currently selected one
3107			$optOperators = $operators;
3108			foreach ( $optOperators as $operator => $code )
3109				$optOperators[$operator] = sprintf( $code, ( $operator == $filter['op'] ) ? ' selected="selected"' : '' );
3110
3111
3112			// prepare stuff for rendering code
3113			$optColumns   = implode( "\n", $optColumns );
3114			$optOperators = implode( "\n", $optOperators );
3115
3116			$argument = strtr( $filter['arg'], array( '"' => '&quot;' ) );
3117
3118			$colname  = $this->varname( 'searchcol' . $index );
3119			$opname   = $this->varname( 'searchop' . $index );
3120			$argname  = $this->varname( 'searcharg' . $index );
3121
3122			$mode     = $index ? $modeMap[$filter['mode']] : '';
3123
3124			$mark     = ( trim( $filter['arg'] ) === '' ) ? ' unused' : '';
3125
3126
3127			// render code for single filter component
3128			$filters[$index] = <<<EOT
3129<span class="filter-component$mark">
3130 $mode
3131 <select name="$colname" class="column" onchange="return database2_searchCol(this);">$optColumns</select>
3132 <select name="$opname" class="operator">$optOperators</select>
3133 <input type="text" name="$argname" size="10" value="$argument" class="argument text numeric date enum" />
3134</span>
3135EOT;
3136
3137		}
3138
3139
3140
3141		$cmds = array();
3142
3143		$cmds[] = $this->imgbutton( 'searchgo', $this->getLang( 'cmdfilterapply' ), 'filter' );
3144		$cmds[] = $this->imgbutton( 'searchand', $this->getLang( 'cmdfilterintersect' ), 'filter-and' );
3145		$cmds[] = $this->imgbutton( 'searchor', $this->getLang( 'cmdfilterunion' ), 'filter-or' );
3146
3147		if ( ( count( $filters ) > 1 ) || ( trim( $argument ) !== '' ) ||
3148			 ( $filter['op'] && ( $filter['op'] !== 'like' ) ) )
3149			$cmds[] = $this->imgbutton( 'searchdrop', 'Reset filter',
3150										'filter-drop' );
3151
3152		$commands = '<span class="commands">' . implode( "\n", $cmds ) . '</span>';
3153
3154
3155		$class = ( count( $filters ) > 1 ) ? 'multi-filter' : 'single-filter';
3156
3157
3158		return '<div class="' . $class . '">' .
3159			   implode( "\n", $filters ) . $commands . '</div>';
3160
3161	}
3162
3163
3164	/**
3165	 * Extracts quoted string starting with arbitrary quoting character at given
3166	 * index.
3167	 *
3168	 * The provided index in $first is updated on return to point to first
3169	 * character after extracted string.
3170	 *
3171	 * @param string $in haystack containing quoted string
3172	 * @param integer $first index of character starting quoted string
3173	 * @return string extracted string on success, false on error
3174	 */
3175
3176	public static function parseString( $in, &$first )
3177	{
3178
3179		$pos = $first;
3180
3181		do
3182		{
3183
3184			// find next matching quote character marking end of string
3185			$end = strpos( $in, $in[$first], $pos + 1 );
3186			if ( $end === false )
3187				// didn't find any --> malformed string
3188				return false;
3189
3190			$count = 0;
3191			for ( $idx = $end - 1; $idx > $pos; $idx-- )
3192				if ( $in[$idx] == '\\' )
3193					$count++;
3194				else
3195					break;
3196
3197			if ( $count & 1 )
3198				$pos = $end;
3199			else
3200			{
3201
3202				$string = substr( $in, $first + 1, $end - $first - 1 );
3203
3204				$first  = $end + 1;
3205
3206				return stripcslashes( $string );
3207
3208			}
3209
3210		}
3211		while ( true );
3212
3213	}
3214
3215
3216	public static function parseAssignment( $in, &$first )
3217	{
3218
3219		$pos  = $first;
3220
3221
3222		// skip any leading whitespace
3223		$pos += strspn( $in, " \t", $pos );
3224
3225
3226
3227		// read and normalize name
3228		$end  = $pos + strcspn( $in, " \t=", $pos );
3229		$name = substr( $in, $pos, $end - $pos );
3230
3231		if ( $name === '' )
3232			// there is no (further) assignment in $in
3233			return null;
3234
3235		if ( ctype_digit( $name ) )
3236			$name = intval( $name );
3237
3238
3239
3240		// skip any whitespace found between name and assignment operator
3241		$pos = $end + strspn( $in, " \t", $end );
3242
3243		if ( $in[$pos] !== '=' )
3244			// option does not use assignment operator
3245			// --> it's a "shortcut option"
3246			$value = true;
3247		else
3248		{
3249			// expecting assigned value next
3250
3251
3252			// skip whitespace between assignment operator and value
3253			$pos += strspn( $in, " \t", $pos + 1 ) + 1;
3254			$end  = $pos;
3255
3256			if ( $in[$pos] === '"' )
3257			{
3258				// value is enclosed in quotes
3259
3260				$temp = self::parseString( $in, $end );
3261				if ( $temp === false )
3262					return false;
3263
3264				$value = $temp;
3265
3266			}
3267			else
3268			{
3269
3270				$end  += strcspn( $in, " \t", $end );
3271
3272				$value = substr( $in, $pos, $end - $pos );
3273
3274			}
3275		}
3276
3277		$first = $end;
3278
3279
3280		return array( $name, $value );
3281
3282	}
3283
3284
3285	public static function stripTags( $in, $tags = null )
3286	{
3287
3288		if ( !is_array( $tags ) )
3289			$tags = array( 'script', 'form', 'link', 'html', 'body', 'head', );
3290
3291
3292		$pos = 0;
3293
3294		do
3295		{
3296
3297			// fast search for next opening tag
3298			$tag = strpos( $in, '<', $pos );
3299			if ( $tag === false )
3300				return $in;
3301
3302			if ( $in[$tag+1] == '?' )
3303			{
3304				// detected start of PI ... skip completely
3305
3306				$end = strpos( $in, '?>', $tag + 2 );
3307				if ( $end !== false )
3308					$in = substr_replace( $in, '', $tag, $end - $tag + 2 );
3309				else
3310					$in = substr_replace( $in, '', $tag );
3311
3312				// fix for properly updating $pos below
3313				$tag--;
3314
3315			}
3316			else
3317			{
3318				// got tag ... check its name
3319
3320				$name = strtok( substr( $in, $tag + 1, 20 ), ' >' );
3321				$name = strtolower( trim( $name ) );
3322
3323				if ( array_search( $name, $tags ) !== false )
3324				{
3325					// tag is marked for dropping
3326
3327					// slow, but convenient: find next end of tag and drop everything in between
3328					if ( preg_match( "#.+?</\s*$name\s*>#i", $in, $m, null, $tag ) )
3329						$in = substr_replace( $in, '', $tag, strlen( $m[0] ) );
3330					else
3331						$in = substr_replace( $in, '', $tag );
3332
3333					$tag--;
3334
3335				}
3336			}
3337
3338			// update $pos to omit all previously processed part of $in
3339			$pos = $tag + 1;
3340
3341		}
3342		while ( true );
3343
3344	}
3345
3346
3347	public static function splitDefinitionLine( $line )
3348	{
3349
3350		$line   = trim( $line );
3351
3352		$parts  = array();
3353		$part   = '';
3354
3355		$pos    = 0;
3356		$length = strlen( $line );
3357
3358
3359		while ( ( $pos < $length ) && ( count( $parts ) < 3 ) )
3360		{
3361
3362			$pos += strspn( $line, " \t", $pos );
3363			$end  = $pos;
3364
3365			if ( $line[$pos] === '"' )
3366			{
3367
3368				$temp = self::parseString( $line, $end );
3369				if ( $temp === false )
3370					return false;
3371
3372				if ( $part !== '' )
3373					$part .= ' ';
3374
3375				$part .= $temp;
3376
3377			}
3378			else if ( $line[$pos] === ',' )
3379			{
3380
3381				$parts[] = $part;
3382				$part    = '';
3383
3384				$end++;
3385
3386			}
3387			else
3388			{
3389
3390				$end += strcspn( $line, " \t,", $end );
3391
3392				if ( $part !== '' )
3393					$part .= ' ';
3394
3395				$part .= trim( substr( $line, $pos, $end - $pos ) );
3396
3397			}
3398
3399			$pos = $end;
3400
3401		}
3402
3403
3404		if ( $part !== '' )
3405			$parts[] = $part;
3406
3407
3408		$parts = array_pad( $parts, 3, '' );
3409
3410
3411		$options = array();
3412
3413		if ( $pos < $length )
3414		{
3415
3416			$name    = '';
3417			$value   = '';
3418
3419			while ( $pos < $length )
3420			{
3421
3422				$temp = self::parseAssignment( $line, $pos );
3423				if ( $temp === false )
3424					return false;
3425
3426				if ( is_null( $temp ) )
3427					break;
3428
3429				list( $name, $value ) = $temp;
3430
3431				$options[$name] = $value;
3432
3433			}
3434		}
3435
3436		$parts[] = $options;
3437
3438
3439		return $parts;
3440
3441	}
3442
3443
3444	protected function convertToLink( $href, $label, $varspace = array() )
3445	{
3446
3447		$href = trim( $href );
3448		if ( $href === '' )
3449			return $label;
3450
3451
3452		if ( is_array( $varspace ) && count( $varspace ) )
3453			$href = $this->replaceMarkup( $href, $varspace );
3454
3455
3456		if ( strpos( $href, '://' ) !== false )
3457		{
3458			// embed external link in header
3459
3460			// externallink() is adding to renderer->doc() ...
3461			// --> remove from doc afterwards, thus store its length now
3462			$length = strlen( $this->renderer->doc );
3463
3464			$this->renderer->externallink( $href, $label );
3465
3466			// --> now extract rendered link from doc
3467			$label = substr( $this->renderer->doc, $length );
3468			$this->renderer->doc = substr_replace( $this->renderer->doc, '',
3469												   $length );
3470
3471		}
3472		else
3473		{
3474			// embed internal link in header
3475
3476			resolve_pageid( getNS( self::getPageID() ), $href, $exists );
3477			$label = $this->renderer->internallink( $href, $label, NULL, true );
3478
3479		}
3480
3481
3482		return $label;
3483
3484	}
3485
3486
3487	/**
3488	 * Parses the code between opening and closing tag for data definition of
3489	 * table to be managed/provided by tag.
3490	 *
3491	 * @throws Exception
3492	 *
3493	 * @param string $code data definition found in Wiki code
3494	 */
3495
3496	protected function parseDefinition( $code )
3497	{
3498
3499		$failed = $out = $primaries = $uniques = $visibles = array();
3500		$aclColumn = null;
3501
3502		// parse line by line
3503		foreach ( explode( "\n", $code ) as $index => $line )
3504		{
3505
3506			// skip empty lines and comments
3507			$line = trim( $line );
3508			if ( ( $line === '' ) || ( $line[0] == '#' ) ||
3509				 ( ( $line[0] == '/' ) && ( $line[1] == '/' ) ) )
3510				// comment or empty line -> skip
3511				continue;
3512
3513
3514			// split line into at most 4 comma-separated fields with last
3515			// containing optional set of attributes/options
3516			$parsed = $this->splitDefinitionLine( $line );
3517			if ( $parsed === false )
3518				throw new Exception( sprintf( $this->getLang( 'definline' ), $index ) );
3519
3520			list( $colName, $rawType, $label, $attributes ) = $parsed;
3521
3522
3523			// validate and normalize fields
3524			try
3525			{
3526
3527				// ***** 1st field: the column name *****
3528				// normalize column name dropping invalid all invalid characters
3529				$colName = preg_replace( '/[^\w]/', '_', $colName );
3530
3531				if ( $out[$colName] )
3532					throw new Exception( sprintf( $this->getLang( 'defdouble' ), $colName ) );
3533
3534
3535				// ***** 4th field: additional options *****
3536				$options = array();
3537
3538				foreach ( $attributes as $name => $value )
3539				{
3540
3541					// process option
3542					$name = strtolower( $name );
3543					switch ( $name )
3544					{
3545
3546						// marks to demand a non-empty value on editing
3547						// (column in table is defined as NOT NULL)
3548						case 'required' :
3549						case 'req' :
3550							$options['required'] = self::asBool( $value );
3551							break;
3552
3553						// marks column to be included in listing records
3554						// (if no column is marked visible this way, all columns
3555						//  are visible by default)
3556						case 'visible' :
3557							if ( self::asBool( $value ) )
3558							{
3559								$visibles[] = $colName;
3560								$options['visible'] = true;
3561							}
3562							break;
3563
3564						// selects column to be (part of) primary key index
3565						case 'primary' :
3566							if ( self::asBool( $value ) )
3567							{
3568								$primaries[] = $colName;
3569								$options['primary'] = $options['required'] = true;
3570							}
3571							break;
3572
3573						// selects explicit index in order of fields/columns
3574						// (this is used on inspecting/editing records, only)
3575						case 'tabindex' :
3576							if ( ctype_digit( trim( $value ) ) )
3577								$options['tabindex'] = intval( $value );
3578							break;
3579
3580						// selects how to handle this column being defined as
3581						// boolean in 2nd field:
3582						//  yesno - column is CHAR(1) with values 'y' or 'n'
3583						//  xmark - column is CHAR(1) with values 'x' or ' '
3584						//  int   - column is TINYINT with values 1 or 0
3585						// default is "yesno" ... selected below!
3586						case 'booltype' :
3587							$value = strtolower( $value );
3588							if ( !in_array( $value, array( 'yesno', 'int', 'xmark' ) ) )
3589								throw new Exception( $this->getLang( 'invalidbool' ) );
3590							$options['booltype'] = $value;
3591							break;
3592
3593						case 'readonly' :
3594							// mark column as read-only
3595							// (so even admin mustn't edit it)
3596							$options['readonly'] = self::asBool( $value );
3597							break;
3598
3599						case 'aliasing' :
3600							if ( !is_string( $value ) ||
3601								 ( trim( $value ) === '' ) )
3602								throw new Exception( $this->getLang( 'noaliased' ) );
3603
3604							// mark column as read-only
3605							// (so even admin mustn't edit it)
3606							$options['aliasing'] = $value;
3607
3608							// changing aliased term isn't expected to work
3609							// --> so implicitly mark column as read-only
3610							$options['readonly'] = true;
3611							break;
3612
3613						default :
3614							// support shortcurt for tabindex-definition:
3615							// "@<integer>" is same as "tabindex=<integer>"
3616							if ( preg_match( '/^@(\d+)$/', $name, $matches ) )
3617								$options['tabindex'] = intval( $matches[1] );
3618							else if ( substr( $name, 0, 6 ) == 'unique' )
3619							{
3620								// column is (part of) one of several unique
3621								// indices
3622								// --> an optional integer after name "unique"
3623								//     selects group of columns being part of
3624								//     same unique index
3625
3626								$group = trim( substr( $name, 6 ) );
3627								if ( ctype_digit( $group ) || ( $group === '' ))
3628								{
3629
3630									if ( !is_array( $uniques[$group] ) )
3631										$uniques[$group] = array();
3632
3633									$uniques[$group][] = $colName;
3634
3635								}
3636							}
3637							else if ( ctype_digit( trim( $name ) ) )
3638								// raw digits as token are selecting length of
3639								// column (e.g. maximum length of stored text)
3640								$options['length'] = intval( $name );
3641							else
3642								// all else single tokens are handled like
3643								// assigning boolean value true ...
3644								$options[$name] = $value;
3645
3646					}
3647				}
3648
3649				if ( $this->options['view'] )
3650					$options['readonly'] = true;
3651
3652
3653				// ***** 2nd field: the column's type *****
3654				$sqldef = $format = null;
3655
3656				// derive basic column type and its format from type definition
3657				$rawType  = trim( $rawType );
3658				$typeName = strtolower( trim( strtok( $rawType, ' ' ) ) );
3659
3660				switch ( $typeName )
3661				{
3662
3663					case 'int' :
3664					case 'integer' :
3665						$type   = 'integer';
3666						$format = 'integer';
3667						break;
3668
3669					case 'image' :
3670						$type   = 'data';
3671						$format = 'image';
3672						break;
3673
3674					case 'blob' :
3675					case 'binary' :
3676					case 'file' :
3677					case 'data' :
3678						$type   = 'data';
3679						$format = 'file';
3680						break;
3681
3682					case 'real' :
3683					case 'float' :
3684					case 'double' :
3685						$type   = 'real';
3686						$format = 'real';
3687						break;
3688
3689					case 'money' :
3690					case 'monetary' :
3691						$type   = 'decimal';
3692						$format = 'monetary';
3693						break;
3694
3695					case 'numeric' :
3696					case 'decimal' :
3697						$type   = 'decimal';
3698						$format = 'real';
3699						break;
3700
3701					case 'time' :
3702						$type   = $rawType;
3703						$format = $typeName;
3704						break;
3705
3706					case 'date' :
3707					case 'datetime' :
3708						$type   = $options['unixts'] ? 'integer' : $rawType;
3709						$format = $typeName;
3710						break;
3711
3712					case 'url' :
3713					case 'link' :
3714					case 'href' :
3715						$type   = 'text';
3716						$format = 'url';
3717						break;
3718
3719					case 'email' :
3720					case 'mail' :
3721						$type   = 'text';
3722						$format = 'email';
3723						break;
3724
3725					case 'phone' :
3726					case 'fax' :
3727						$type   = 'text';
3728						$format = $typeName;
3729						break;
3730
3731					case '' :
3732					case 'string' :
3733					case 'text' :
3734					case 'name' :
3735					case 'char' :
3736						$type   = 'text';
3737						$format = 'text';
3738						break;
3739
3740					case 'acl' :
3741						if ( !is_null( $aclColumn ) )
3742							throw new Exception( $this->getLang( 'multiacl' ) );
3743
3744						$type      = 'text';
3745						$format    = 'acl';
3746						$aclColumn = $colName;
3747						break;
3748
3749					case 'check' :
3750					case 'mark' :
3751					case 'boolean' :
3752					case 'bool' :
3753						if ( !$options['booltype'] )
3754							$options['booltype'] = 'yesno';
3755
3756						if ( $options['booltype'] == 'int' )
3757						{
3758							$type   = 'integer';
3759							$sqldef = 'tinyint';
3760						}
3761						else
3762						{
3763							$type   = 'bool';
3764							$sqldef = 'char';
3765							$options['length'] = 1;
3766						}
3767
3768						$format = 'bool';
3769						break;
3770
3771					case 'enum' :
3772						// get set of selectable enumeration elements provided
3773						// after type name separated by slash or semicolon
3774						$options['selectables'] = preg_split( '#[/;]+#', strtok( '' ) );
3775
3776						$max = 0;
3777						foreach ( $options['selectables'] as &$selectable )
3778						{
3779
3780							$selectable = trim( $selectable );
3781
3782							$max = max( $max, strlen( $selectable ) );
3783
3784						}
3785
3786						if ( !$max )
3787							throw new Exception( $this->getLang( 'emptyenum' ) );
3788
3789						if ( !isset( $options['length'] ) )
3790							$options['length'] = $max;
3791
3792						$type   = 'enum';
3793						$format = 'enum';
3794						$sqldef = ( $max > 1 ) ? 'varchar' : 'char';
3795						break;
3796
3797					case 'related' :
3798						// get statement for listing selectable options
3799						$readerSQL = trim( strtok( '' ) );
3800						if ( !$this->getConf( 'customviews' ) )
3801							throw new Exception( $this->getLang( 'readerdisabled' ) );
3802						if ( !preg_match( '/^SELECT\s/i', $readerSQL ) )
3803							throw new Exception( $this->getLang( 'invalidreader' ) );
3804
3805
3806						// read selectable options querying provided statement
3807						$selectables = array();
3808
3809						$resultset = $this->db->query( $readerSQL );
3810						if ( $resultset )
3811							while ( is_array( $related = $resultset->fetch( PDO::FETCH_NUM ) ) )
3812							{
3813
3814								if ( !ctype_digit( trim( $related[0] ) ) )
3815									throw new Exception( $this->getLang( 'invalidreader' ) );
3816
3817								$selectables[intval( $related[0] )] = trim( $related[1] );
3818
3819							}
3820
3821						// workaround for bug in PHP prior to 5.2.10
3822						// see http://bugs.php.net/bug.php?id=35793
3823						$resultset->closeCursor();
3824						$resultset = null;
3825
3826						if ( empty( $selectables ) )
3827							throw new Exception( $this->getLang( 'emptyenum' ) );
3828
3829						$options['selectables'] = $selectables;
3830
3831
3832						$type   = 'related';
3833						$format = 'related';
3834						$sqldef = 'integer';
3835						break;
3836
3837					default :
3838						throw new Exception( sprintf( $this->getLang( 'badtype' ),
3839											  $typeName ) );
3840
3841				}
3842
3843				// derive SQL type definition from parsed type of column
3844				switch ( $type )
3845				{
3846
3847					case 'data' :
3848						if ( $options['length'] > 0 )
3849							$sqldef = 'varbinary';
3850						else if ( $this->driver == 'mssql' )
3851							// untested: is this proper name of driver??
3852							$sqldef = 'varbinary';
3853						else
3854							$sqldef = 'longblob';
3855						break;
3856
3857					case 'text' :
3858						$sqldef = ( $options['length'] > 0 ) ? 'varchar' : 'text';
3859						break;
3860
3861					case 'decimal' :
3862						$sqldef = ( $this->driver == 'sqlite' ) ? 'real'
3863																: 'decimal';
3864						break;
3865
3866					case 'date' :
3867					case 'datetime' :
3868					case 'time' :
3869					default :
3870						if ( is_null( $sqldef ) )
3871							$sqldef = $type;
3872
3873				}
3874
3875
3876				$sqldef  = $colName . ' ' . strtoupper( $sqldef );
3877
3878				if ( $options['length'] > 0 )
3879					if ( in_array( $type, array( 'text', 'enum', 'integer', 'related' ) ) )
3880						$sqldef .= '(' . $options['length'] . ')';
3881
3882				$sqldef .= $options['required'] ? ' NOT NULL' : ' NULL';
3883
3884
3885
3886				// add parsed definition to resulting set
3887				if ( $this->getConf( 'aliasing' ) ||
3888					 !is_string( $options['aliasing'] ) )
3889					$out[$colName] = array(
3890											'column'     => trim( $colName ),
3891											'type'       => $type,
3892											'format'     => $format,
3893											'definition' => $sqldef,
3894											'options'    => $options,
3895											'label'      => trim( $label ),
3896											'isColumn'   => true,
3897											);
3898
3899			}
3900			catch ( Exception $e )
3901			{
3902				$failed[] = sprintf( $this->getLang( 'baddef' ),
3903									 $index + 1, $e->getMessage() );
3904			}
3905		}
3906
3907
3908		if ( empty( $failed ) )
3909		{
3910			// post-process column definitions
3911
3912			if ( empty( $out ) )
3913				throw new Exception( $this->getLang( 'emptydef' ) );
3914
3915
3916			if ( empty( $visibles ) )
3917				// no column is explicitly marked visible
3918				// --> make them all visible
3919				foreach ( $out as &$def )
3920					$def['options']['visible'] = ( $def['format'] == 'acl' ) ? 1 : true;
3921
3922
3923			// append primary key - either as defined or automatically
3924			if ( empty( $primaries ) )
3925			{
3926				// missing explicit definition of primary key
3927
3928				if ( $out['id'] )
3929				{
3930					// choose column "id" and turn it into primary key
3931
3932					if ( !$out['id']['options']['required'] )
3933						// declare it as NOT NULL explicitly
3934						$out['id']['definition'] .= ' NOT NULL';
3935
3936					$out['id']['definition'] .= ' PRIMARY KEY';
3937
3938				}
3939				else
3940					// there is no column "id"
3941					// --> PREPEND one automatically
3942					$out = array_merge( array( 'id' => array(
3943											'column'     => 'id',
3944											'type'       => 'integer',
3945											'format'     => 'integer',
3946											'definition' => 'id INTEGER NOT ' .
3947															'NULL PRIMARY KEY',
3948											'options'    => array(),
3949											'label'      => '#',
3950											'isColumn'   => true,
3951											'auto_id'    => true,
3952											) ), $out );
3953
3954			}
3955			else
3956				// append definition of defined primary key index
3957				$out['.PRIMARY_KEYS'] = array(
3958											'definition' => 'PRIMARY KEY ( ' .
3959															implode( ', ',
3960															$primaries ) . ' )',
3961											'primaries'  => $primaries
3962											);
3963
3964
3965			// next ensure to properly include all uniqueness constraints
3966			if ( count( $uniques ) )
3967			{
3968
3969				foreach ( $uniques as $i => $group )
3970					if ( count( $group ) == 1 )
3971					{
3972						// apply uniqueness constraint on single column
3973						$col = array_shift( $group );
3974						$out[$col]['definition'] .= ' UNIQUE';
3975					}
3976					else
3977						// append separate unique index on joined columns
3978						$out['.UNIQUE-' . $i] = array(
3979											'definition' => 'UNIQUE ( ' .
3980															implode( ', ',
3981															$group ) . ' )',
3982											);
3983
3984			}
3985
3986
3987			$this->meta = $out;
3988
3989			$session =& self::getSession();
3990			$session['definition'] = $this->meta;
3991
3992		}
3993		else
3994			// encountered one or more parser errors
3995			// --> throw exception
3996			throw new Exception( implode( "<br />\n", $failed ) );
3997
3998
3999		$this->options['aclColumn'] = $aclColumn;
4000
4001	}
4002
4003
4004	/**
4005	 * Parses provided value for containing some human-readable form of a
4006	 * boolean value.
4007	 *
4008	 * @param mixed $in value to parse
4009	 * @param boolean $nullIfUnparseable if true, method returns null if $in
4010	 *                                   can't be parsed as boolean value
4011	 * @return boolean boolean counterpart of provided value
4012	 */
4013
4014	protected static function asBool( $in, $nullIfUnparseable = false )
4015	{
4016
4017		if ( is_numeric( $in ) )
4018			return ( $in != 0 );
4019
4020		if ( is_string( $in ) )
4021		{
4022
4023			if ( preg_match( '/^(n|no|f|false|off)$/i', trim( $in ) ) )
4024				return false;
4025
4026			if ( preg_match( '/^(y|yes|t|true|on)$/i', trim( $in ) ) )
4027				return true;
4028
4029		}
4030
4031		if ( ctype_digit( trim( $in ) ) )
4032			return ( intval( $in ) != 0 );
4033
4034		if ( ( $in === true ) || ( $in === false ) )
4035			return $in;
4036
4037		return $nullIfUnparseable ? null : (bool) $in;
4038
4039	}
4040
4041
4042	/**
4043	 * Retrieves list of columns included in table's primary key.
4044	 *
4045	 * @return array list of column names
4046	 */
4047
4048	protected function getPrimaryKeyColumns()
4049	{
4050
4051		if ( !$this->meta )
4052			return array();
4053
4054		if ( $this->meta['.PRIMARY_KEYS'] )
4055			return $this->meta['.PRIMARY_KEYS']['primaries'];
4056
4057		return array( 'id' );
4058
4059	}
4060
4061
4062	/**
4063	 * Retrieves column name of single-column integer primary key or false.
4064	 *
4065	 * The method returns false if
4066	 *  - none or multiple columns are set as primary key
4067	 *  - single column isn't of type integer
4068	 *
4069	 * @return string/false name of column, false if condition does not match
4070	 */
4071
4072	protected function getSingleNumericPrimaryKey()
4073	{
4074
4075		$primaries = $this->getPrimaryKeyColumns();
4076
4077		if ( count( $primaries ) != 1 )
4078			return false;
4079
4080		$column = array_shift( $primaries );
4081
4082		if ( isset( $this->meta[$column]['type'] ) )
4083			if ( $this->meta[$column]['type'] != 'integer' )
4084				return false;
4085
4086
4087		return $column;
4088
4089	}
4090
4091
4092	/**
4093	 * Detects if either a table or a single column in a table exists or not.
4094	 *
4095	 * @param string $table name of table to test
4096	 * @param string $column optional name of single column in table to test
4097	 * @return boolean true if test succeeds, false otherwise
4098	 */
4099
4100	protected function exists( $table, $column = null )
4101	{
4102
4103		if ( is_null( $column ) )
4104			$sql  = 'SELECT COUNT(*) FROM ' . $table;
4105		else
4106			$sql  = 'SELECT COUNT(' . $column . ') FROM ' . $table;
4107
4108		try
4109		{
4110
4111			$s = $this->db->query( $sql );
4112
4113			if ( $s instanceof PDOStatement )
4114				$s->closeCursor();
4115
4116			return true;
4117
4118		}
4119		catch ( PDOException $e )
4120		{
4121
4122			if ( in_array( $e->getCode(), array( '42S02' ) ) )
4123				return false;
4124
4125			if ( stripos( $e->getMessage(), 'no such table' ) !== false )
4126				return false;
4127
4128			if ( !is_null( $column ) )
4129				if ( stripos( $e->getMessage(), 'no such column' ) !== false )
4130					return false;
4131
4132			throw $e;
4133
4134		}
4135	}
4136
4137
4138	/**
4139	 * Obtains next ID for use in an "auto-incrementing ID" column.
4140	 *
4141	 * On every call this method provides another, recently unused ID for the
4142	 * given table. This is achieved by using a separate table in current DB.
4143	 *
4144	 * @throws Exception
4145	 *
4146	 * @param string $table name of table
4147	 * @param boolean $nestedTransaction set true, if you call in a transaction
4148	 * @return integer next available ID for assigning
4149	 */
4150
4151	protected function nextID( $table, $nestedTransaction = false )
4152	{
4153
4154		// automatically create pool for tracking auto-incrementing row IDs
4155		if ( !$this->exists( '__keys' ) )
4156			if ( $this->db->query( <<<EOT
4157CREATE TABLE __keys (
4158	tablename CHAR(64) NOT NULL PRIMARY KEY,
4159	recent INTEGER NOT NULL
4160)
4161EOT
4162									) === false )
4163				throw new PDOException( $this->getLang( 'idnotable' ) );
4164
4165
4166
4167		if ( !$nestedTransaction && !$this->db->beginTransaction() )
4168			throw new PDOException( $this->getLang( 'notransact' ) );
4169
4170		try
4171		{
4172
4173			// read recently assigned auto-incrementing row ID on table
4174			$st = $this->db->prepare('SELECT recent FROM __keys WHERE tablename=?');
4175			if ( !$st )
4176				throw new PDOException( $this->getLang( 'idreadprepare' ) );
4177
4178			if ( !$st->execute( array( $table ) ) )
4179				throw new PDOException( $this->getLang( 'idreadexecute' ) );
4180
4181
4182			$row = $st->fetch( PDO::FETCH_NUM );
4183			if ( is_array( $row ) )
4184			{
4185				// got record -> assigned ID before --> increment and update
4186				$sql    = 'UPDATE __keys SET recent=? WHERE tablename=?';
4187				$nextID = ++$row[0];
4188			}
4189			else
4190			{
4191				// no record -> assigning ID for the first time --> start with 1
4192				$sql    = 'INSERT INTO __keys (recent,tablename) VALUES (?,?)';
4193				$nextID = 1;
4194			}
4195
4196			$st->closeCursor();
4197
4198
4199			// write new/updated track of auto-incrementing ID on current table
4200			$st = $this->db->prepare( $sql );
4201			if ( !$st )
4202				throw new PDOException( $this->getLang( 'idwriteprepare' ) );
4203
4204			if ( !$st->execute( array( $nextID, $table ) ) )
4205				throw new PDOException( $this->getLang( 'idwriteexecute' ) );
4206
4207
4208
4209			if ( !$nestedTransaction && !$this->db->commit() )
4210				throw new PDOException( $this->getLang( 'idcommit' ) );
4211
4212
4213			return $nextID;
4214
4215		}
4216		catch ( PDOException $e )
4217		{
4218
4219			if ( !$nestedTransaction && !$this->db->rollBack() )
4220				throw new PDOException( $this->getLang( 'idrollback' ) );
4221
4222			throw new Exception( $this->getLang( 'idnoid' ) );
4223
4224		}
4225	}
4226
4227
4228	/**
4229	 * Retrieves name of current "user" (providing temporary name for guests)
4230	 *
4231	 * @throws Exception
4232	 * @return string
4233	 */
4234
4235	protected static function currentUser()
4236	{
4237
4238		$currentUser = $_SERVER['REMOTE_USER'];
4239		if ( !$currentUser )
4240		{
4241			// there is no authenticated user ...
4242			// --> try using user's sesion ID instead
4243
4244			if ( !session_id() )
4245				throw new Exception( $this->getLang( 'userunknown' ) );
4246
4247			$currentUser = '|' . session_id();
4248
4249		}
4250
4251
4252		return $currentUser;
4253
4254	}
4255
4256
4257	/**
4258	 * Adds entry to log of changes on a table and record.
4259	 *
4260	 * Omit $rowid to mark change of a whole table.
4261	 *
4262	 * @throws Exception
4263	 *
4264	 * @param string $action name of change action
4265	 * @param string $table name of table
4266	 * @param integer $rowid ID of record changed
4267	 */
4268
4269	protected function log( $action, $table, $rowid = null )
4270	{
4271
4272		// automatically create log table in DB
4273		if ( !$this->exists( '__log' ) )
4274			if ( $this->db->query( <<<EOT
4275CREATE TABLE __log (
4276	tablename CHAR(64) NOT NULL,
4277	rowid INTEGER NULL,
4278	action CHAR(8) NOT NULL,
4279	username CHAR(64) NOT NULL,
4280	ctime INTEGER NOT NULL
4281)
4282EOT
4283									) === false )
4284				throw new PDOException( $this->getLang( 'lognotable' ) );
4285
4286
4287
4288		// add entry to log
4289		$st = $this->db->prepare( 'INSERT INTO __log (tablename,rowid,action,' .
4290								  'username,ctime) VALUES (?,?,?,?,?)' );
4291		if ( !$st )
4292			throw new PDOException( $this->getLang( 'logprepare' ) );
4293
4294		if ( !$st->execute( array( $table, intval( $rowid ), $action,
4295								   self::currentUser(), time() ) ) )
4296			throw new PDOException( $this->getLang( 'logexecute' ) );
4297
4298
4299		// in a local SQLite database: drop all log records older than 30 days
4300		if ( $this->driver == 'sqlite' )
4301			$this->db->query( 'DELETE FROM __log WHERE ctime<'.(time()-30*86400));
4302
4303	}
4304
4305
4306	/**
4307	 * Obtains a lock.
4308	 *
4309	 * The lock is either related to a whole table (if $rowid is omitted or
4310	 * null) or a single record in that table selected by its unique (!!)
4311	 * numeric ID. Obtaining record-related lock is rejected if whole table is
4312	 * currently locked by some other user.
4313	 *
4314	 * NOTE! Locking records basically works with unique (!!) numeric IDs, only.
4315	 *
4316	 * @param string $table name of table lock is related to
4317	 * @param integer $rowid unique (!!) ID of record lock is related to, omit
4318	 *                       or set 0/null for a table-related lock
4319	 * @param boolean $inTransaction if true, the caller started transaction
4320	 * @param boolean $checkOnly if true, an available lock isn't obtained
4321	 *                           actually
4322	 * @return boolean true on success, false on failure
4323	 */
4324
4325	protected function obtainLock( $table, $rowid = null, $inTransaction = false, $checkOnly = false, $innerTest = false )
4326	{
4327
4328		// automatically create DB's pool of obtained locks
4329		if ( !$this->exists( '__locks' ) )
4330			if ( $this->db->query( <<<EOT
4331CREATE TABLE __locks (
4332	tablename CHAR(64) NOT NULL,
4333	record INTEGER NOT NULL,
4334	username CHAR(64) NOT NULL,
4335	obtained INTEGER NOT NULL,
4336	PRIMARY KEY ( tablename, record )
4337)
4338EOT
4339								) === false )
4340				return false;
4341
4342
4343
4344		// get "name" of current user (supporting guests as well)
4345		$currentUser = self::currentUser();
4346
4347		// normalize $rowid selecting single record or whole table (==0)
4348		$rowid = intval( $rowid );
4349
4350
4351		if ( !$inTransaction && !$this->db->beginTransaction() )
4352			return false;
4353
4354		try
4355		{
4356
4357			if ( !$innerTest )
4358			{
4359
4360				if ( $rowid )
4361				{
4362					// obtaining lock on record is rejected if whole table is locked
4363
4364					if ( !$this->obtainLock( $table, null, true, true, true ) )
4365						throw new PDOException( $this->getLang( 'locksuperlocked' ) );
4366
4367				}
4368				else
4369				{
4370					// obtaining lock on whole table is rejected if some other user
4371					// has locked at least one record
4372
4373					$st = $this->db->prepare( 'SELECT COUNT(*) FROM __locks ' .
4374											  'WHERE tablename=? AND record<>0 ' .
4375											  'AND username<>?' );
4376					if ( !$st )
4377						throw new PDOException( $this->getLang( 'locksubprepare' ) );
4378
4379					if ( !$st->execute( array( $table, $currentUser ) ) )
4380						throw new PDOException( $this->getLang( 'locksubexecute' ) );
4381
4382
4383					$count = $st->fetch( PDO::FETCH_NUM );
4384					if ( $count && ( $count[0] > 0 ) )
4385						throw new PDOException( $this->getLang( 'locksublocked' ) );
4386
4387					$st->closeCursor();
4388
4389				}
4390			}
4391
4392
4393
4394			// check for existing lock on selected entity
4395			$st = $this->db->prepare( 'SELECT username,obtained FROM __locks ' .
4396									  'WHERE tablename=? AND record=?' );
4397			if ( !$st )
4398				throw new PDOException( $this->getLang( 'lockreadprepare' ) );
4399
4400			if ( !$st->execute( array( $table, $rowid ) ) )
4401				throw new PDOException( $this->getLang( 'lockreadexecute' ) );
4402
4403			$lock = $st->fetchAll( PDO::FETCH_NUM );
4404			if ( is_array( $lock ) && count( $lock ) )
4405			{
4406				// there is a lock
4407
4408				$lock = array_shift( $lock );
4409
4410				$user = trim( $lock[0] );
4411				if ( $user !== $currentUser )
4412				{
4413					// lock is obtained by different user
4414
4415					// - check whether it's outdated (1 hour) or not
4416					if ( time() - intval( $lock[1] ) < $this->getConf( 'locktime' ) )
4417						// no -> reject to obtain
4418						throw new PDOException( $this->getLang( 'locklocked' ) );
4419
4420				}
4421
4422				$sql = 'UPDATE __locks SET username=?,obtained=? ' .
4423						'WHERE tablename=? AND record=?';
4424
4425			}
4426			else
4427				// resource isn't locked -> obtain lock now
4428				$sql = 'INSERT INTO __locks (username,obtained,tablename,record) ' .
4429					   'VALUES (?,?,?,?)';
4430
4431
4432			if ( !$checkOnly )
4433			{
4434
4435				$st = $this->db->prepare( $sql );
4436				if ( !$st )
4437					throw new PDOException( $this->getLang( 'lockwriteprepare' ) );
4438
4439				if ( !$st->execute( array( $currentUser, time(), $table, $rowid ) ))
4440					throw new PDOException( $this->getLang( 'lockwriteexecute' ) );
4441
4442			}
4443
4444
4445			if ( !$inTransaction && !$this->db->commit() )
4446				throw new PDOException( $this->getLang( 'lockcommit' ) );
4447
4448			return true;
4449
4450		}
4451		catch ( PDOException $e )
4452		{
4453
4454			if ( !$inTransaction && !$this->db->rollBack() )
4455				throw new PDOException( $this->getLang( 'lockrollback' ) );
4456
4457			return false;
4458
4459		}
4460	}
4461
4462
4463	/**
4464	 * Releases recently obtained lock.
4465	 *
4466	 * The lock is either related to a whole table (if $rowid is omitted or
4467	 * null) or a single record in that table selected by its numeric ID.
4468	 *
4469	 * NOTE! Locking records basically works with unique (!!) numeric IDs, only.
4470	 *
4471	 * @param string $table name of table lock is related to
4472	 * @param integer $rowid unique (!!) ID of record lock is related to, omit
4473	 *                       or set 0/null for a table-related lock
4474	 * @param boolean $inTransaction if true, the caller started transaction
4475	 * @return boolean true on successfully releasing lock, false on failure
4476	 */
4477
4478	protected function releaseLock( $table, $rowid = null, $inTransaction = false )
4479	{
4480
4481		if ( !$this->exists( '__locks' ) )
4482			// didn't create pool of locks before
4483			// --> succeed to release without hassle
4484			return true;
4485
4486
4487		// get "name" of current user (supporting guests)
4488		$currentUser = self::currentUser();
4489
4490		// $rowid is non-zero or zero for obtaining lock on whole table
4491		$rowid = intval( $rowid );
4492
4493
4494		if ( !$inTransaction && !$this->db->beginTransaction() )
4495			return false;
4496
4497		try
4498		{
4499
4500			if ( !$this->obtainLock( $table, $rowid, true, true, true ) )
4501				// user didn't obtain that lock before ... succeed to release
4502				return true;
4503
4504
4505			// check for existing lock on selected entity
4506			$st = $this->db->prepare( 'DELETE FROM __locks WHERE tablename=? AND ' .
4507									  'record=? AND username=?' );
4508			if ( !$st )
4509				throw new PDOException( $this->getLang( 'releaseprepare' ) );
4510
4511			if ( !$st->execute( array( $table, $rowid, $currentUser ) ) )
4512				throw new PDOException( $this->getLang( 'releaseexecute' ) );
4513
4514
4515			if ( !$inTransaction && !$this->db->commit() )
4516				throw new PDOException( $this->getLang( 'releasecommit' ) );
4517
4518
4519			return true;
4520
4521		}
4522		catch ( PDOException $e )
4523		{
4524
4525			if ( !$inTransaction && !$this->db->rollBack() )
4526				throw new PDOException( $this->getLang( 'releaserollback' ) );
4527
4528			return false;
4529
4530		}
4531	}
4532
4533
4534	/**
4535	 * Provides link for retrieving media data in selected record's column.
4536	 *
4537	 * @throws Exception
4538	 *
4539	 * @param integer $rowid unique numeric ID of selected record
4540	 * @param string $column name of column in record containing media to retrieve
4541	 * @param boolean $forDownload if true the media is requested for download
4542	 * @return string URL for retrieving media
4543	 */
4544
4545	final protected function mediaLink( $rowid, $column, $forDownload = false,
4546										$rowACL = null )
4547	{
4548
4549		// validate media selected for external retrieval
4550		$rowid  = intval( $rowid );
4551		$column = trim( $column );
4552
4553		if ( !$rowid || ( $this->meta[$column]['type'] != 'data' ) )
4554			throw new Exception( sprintf( $this->getLang( 'medianomedia' ), $column ) );
4555
4556
4557		$idColumn = $this->getSingleNumericPrimaryKey();
4558
4559		if ( !$idColumn )
4560			throw new Exception( $this->getLang( 'mediana' ) );
4561
4562
4563		// gain access on pool of hashing salts in session space
4564		$session =& $this->getSession();
4565
4566		if ( !is_array( $session['linkedMediaSalts'] ) )
4567			$session['linkedMediaSalts'] = array();
4568
4569
4570		// compile selector describing media to be retrieved
4571		$selector = array( '@'.$this->dsn, $this->authSlot, $this->table,
4572						   $column, $idColumn, $rowid, $this->getPageID(),
4573						   $this->getIndex(), self::currentUser(),
4574						   $_SERVER['REMOTE_ADDR'] );
4575
4576		// use unsalted hash to find salt in internal pool for salted hash
4577		$hash = sha1( implode( '/', $selector ) );
4578
4579
4580		// check authorization to download file first
4581		if ( !$this->isAuthorizedMulti( $rowACL, $this->options, 'maydownload', 'mayview', true ) )
4582		{
4583			// lacking authorization
4584			// --> drop salt used in media frontend to proof authorization
4585			unset( $session['linkedMediaSalts'][$hash] );
4586			throw new Exception( $this->getLang( 'mediadenied' ) );
4587		}
4588
4589
4590		// create salt on requesting media for the first time ...
4591		if ( !$session['linkedMediaSalts'][$hash] )
4592		{
4593			mt_srand( intval( microtime( true ) * 1000 ) );
4594			$session['linkedMediaSalts'][$hash] = uniqid( mt_rand(), true );
4595		}
4596
4597
4598		// derive URL components
4599		$source = urlencode( base64_encode( gzcompress( $selector = serialize( $selector ) ) ) );
4600		$hash   = urlencode( base64_encode( self::ssha( $selector, $session['linkedMediaSalts'][$hash] ) ) );
4601
4602
4603		// return URL for retrieving media
4604		return DOKU_BASE . DB2_PATH . "media.php?a=$source&b=$hash&d=" . ( $forDownload ? 1 : 0 );
4605
4606	}
4607
4608
4609	/**
4610	 * Provides link for retrieving files virtually attached to table
4611	 * (e.g. CSV exports).
4612	 *
4613	 * @throws Exception
4614	 *
4615	 * @param boolean $forDownload if true the media is requested for download
4616	 * @return string URL for retrieving media
4617	 */
4618
4619	final protected function attachmentLink( $mode, $authorization, $forDownload = true, $rowACL = null )
4620	{
4621
4622		// gain access on pool of hashing salts in session space
4623		$session =& $this->getSession();
4624
4625		if ( !is_array( $session['linkedMediaSalts'] ) )
4626			$session['linkedMediaSalts'] = array();
4627
4628
4629		// compile selector describing media to be retrieved
4630		$selector = array( '@'.$this->dsn, $this->authSlot, $this->table,'fake',
4631						   'id', 1, $this->getPageID(), $this->getIndex(),
4632						   self::currentUser(), $_SERVER['REMOTE_ADDR'] );
4633
4634		// use unsalted hash to find salt in internal pool for salted hash
4635		$hash = sha1( implode( '/', $selector ) );
4636
4637
4638		// check authorization to request attached file
4639		if ( !$this->isAuthorizedMulti( $rowACL, $this->options, 'may' . $authorization ) )
4640		{
4641			// lacking authorization
4642			// --> drop salt used in media frontend to proof authorization
4643			unset( $session['linkedMediaSalts'][$hash] );
4644			throw new Exception( $this->getLang( 'mediadenied' ) );
4645		}
4646
4647
4648		// create salt on requesting media for the first time ...
4649		if ( !$session['linkedMediaSalts'][$hash] )
4650		{
4651			mt_srand( intval( microtime( true ) * 1000 ) );
4652			$session['linkedMediaSalts'][$hash] = uniqid( mt_rand(), true );
4653		}
4654
4655
4656		// derive URL components
4657		$source = urlencode( base64_encode( gzcompress( $selector = serialize( $selector ) ) ) );
4658		$hash   = urlencode( base64_encode( self::ssha( $selector, $session['linkedMediaSalts'][$hash] ) ) );
4659
4660
4661		// return URL for retrieving media
4662		return DOKU_BASE . DB2_PATH . "media.php?a=$source&b=$hash&m=$mode&d=" . ( $forDownload ? 1 : 0 );
4663
4664	}
4665
4666
4667	/**
4668	 * Provides external link for retrieving media in session
4669	 *
4670	 * @param string $sessionFileKey name of section in session containing file
4671	 * @param boolean $forDownload if true, the link requests file for download
4672	 * @return string URL for retrieving file
4673	 */
4674
4675	public function editorSessionMediaLink( $column, $forDownload = false )
4676	{
4677
4678		$session =& $this->getEditorSession();
4679
4680		if ( !is_array( $session[$column] ) )
4681			throw new Exception( $this->getLang( 'medianoeditor' ) );
4682
4683
4684		// compile selector describing media to be retrieved
4685		$selector = array( $this->getPageID(), $this->getIndex(), $column );
4686
4687		// derive URL components
4688		$source = urlencode( base64_encode( serialize( $selector ) ) );
4689
4690
4691		// return URL for retrieving media
4692		return DOKU_BASE . DB2_PATH . "media.php?s=$source&d=" . ( $forDownload ? 1 : 0 );
4693
4694	}
4695
4696
4697	/**
4698	 * Gets "SSHA1" hash without including the salt (in opposition to what is
4699	 * usually done in RFC-conforming SSHA1 algorithm).
4700	 *
4701	 * @param string $data data to hash
4702	 * @param string $salt salt to use on hashing
4703	 * @return string salted hash on $data
4704	 */
4705
4706	final public static function ssha( $data, $salt )
4707	{
4708		return sha1( $salt . $data . sha1( $data . $salt, true ) );
4709	}
4710
4711
4712
4713
4714
4715	/**
4716	 * Parses provided string for SQL-like Date/Time representation.
4717	 *
4718	 * @param string $in representation of Date/Time in SQL format
4719	 * @param boolean $skipTime if true, the time information is dropped/skipped
4720	 * @return integer UNIX timestamp for parsed date/time
4721	 */
4722
4723	protected static function parseDBDateTime( $in, $skipTime = false )
4724	{
4725
4726		list( $date, $time ) = preg_split( '/t|(\s+)/i', trim( $in ) );
4727
4728		list( $year, $month, $day )     = explode( '-', trim( $date ) );
4729		list( $hour, $minute, $second ) = explode( ':', trim( $time ) );
4730
4731		if ( ( intval( $hour ) == 0 ) && ( intval( $minute ) == 0 ) &&
4732			 ( intval( $second ) == 0 ) && ( intval( $year ) == 0 ) &&
4733			 ( intval( $month ) == 0 ) && ( intval( $day ) == 0 ) )
4734			return 0;
4735
4736		if ( $skipTime )
4737		{
4738			$hour   = 12;
4739			$minute = $second = 0;
4740		}
4741
4742		return mktime( intval( $hour ), intval( $minute ), intval( $second ),
4743					   intval( $month ), intval( $day ), intval( $year ) );
4744
4745	}
4746
4747
4748	protected static function parseInternalDate( $in )
4749	{
4750
4751		$formats = array(
4752						'#^(\d{4})/(\d+)/(\d+)$#' => array( 'year', 'month', 'day' ),
4753						'#^(\d+)/(\d+)/(\d+)$#'   => array( 'month', 'day', 'year' ),
4754						'/^(\d+)-(\d+)-(\d+)$/'   => array( 'year', 'month', 'day' ),
4755						'/^(\d+)\.(\d+)\.(\d+)$/' => array( 'day', 'month', 'year' ),
4756						);
4757
4758		$in = preg_replace( '/\s+/', '', $in );
4759
4760		foreach ( $formats as $pattern => $order )
4761			if ( preg_match( $pattern, $in, $matches ) )
4762			{
4763
4764				$out = array();
4765
4766				foreach ( $order as $key => $value )
4767					$out[$value] = intval( $matches[$key+1] );
4768
4769				if ( $out['year'] < 100 )
4770				{
4771					if ( $out['year'] > 40 )
4772						$out['year'] += 1900;
4773					else
4774						$out['year'] += 2000;
4775				}
4776
4777				return $out;
4778
4779			}
4780
4781
4782		return false;
4783
4784	}
4785
4786
4787	protected static function parseInternalTime( $in )
4788	{
4789
4790		$formats = array(
4791						'/^(\d+):(\d+)(:(\d+))?$/'   => array( 'hour', 'minute', 3 => 'second' ),
4792						);
4793
4794		$in = preg_replace( '/\s+/', '', $in );
4795
4796		foreach ( $formats as $pattern => $order )
4797			if ( preg_match( $pattern, $in, $matches ) )
4798			{
4799
4800				$out = array();
4801
4802				foreach ( $order as $key => $value )
4803					$out[$value] = sprintf( '%02d', intval( $matches[$key+1] ) );
4804
4805				return $out;
4806
4807			}
4808
4809
4810		return false;
4811
4812	}
4813
4814
4815	/**
4816	 * Parses provided ACL rules definition returning contained rules as array.
4817	 *
4818	 */
4819
4820	protected function parseACLRule( $in, $mayThrow = false, $useLabels = false )
4821	{
4822
4823		$out = array();
4824
4825		$rules = preg_split( '/\s*;\s*/', trim( $in ) );
4826		foreach ( $rules as $major => $rule )
4827			if ( !preg_match( '/^(may\S+)\s*=\s*(\S.*)$/i', $rule, $matches ) )
4828			{
4829
4830				if ( $mayThrow )
4831					throw new Exception( $this->getLang( 'badaclrule' ) );
4832
4833				continue;
4834
4835			}
4836			else
4837			{
4838
4839				$objects = preg_split( '/\s*,\s*/', trim( $matches[2] ) );
4840
4841				foreach ( $objects as $minor => $object )
4842					if ( !preg_match( '/^(!?)\s*(\S+)$/', $object, $subs ) )
4843					{
4844
4845						if ( $mayThrow )
4846							throw new Exception( $this->getLang( 'badaclrule' ) );
4847
4848						unset( $objects[$minor] );
4849
4850					}
4851					else
4852						$objects[$minor] = $subs[1] . $subs[2];
4853
4854				if ( empty( $objects ) )
4855					unset( $rules[$major] );
4856				else if ( $useLabels )
4857					$out[strtolower($matches[1])] = implode( ',', $objects );
4858				else
4859					$rules[$major] = strtolower( $matches[1] ) . '=' .
4860									 implode( ',', $objects );
4861
4862			}
4863
4864
4865		return $useLabels ? $out : $rules;
4866
4867	}
4868
4869
4870	/**
4871	 * Serves in processing method replaceMarkup by replacing single occurrence
4872	 * of markup sequence.
4873	 *
4874	 * An empty string is returned if markup sequence isn't detected.
4875	 *
4876	 * @internal
4877	 *
4878	 * @param array $matches set of matches according to used PCRE pattern
4879	 * @return string replacement string
4880	 */
4881
4882	public function __replaceMarkupCB( $matches )
4883	{
4884
4885		$keyword = strtolower( $matches[1] );
4886		switch ( $keyword )
4887		{
4888
4889			case 'wiki.user' :
4890				return $_SERVER['REMOTE_USER'];
4891			case 'wiki.groups' :
4892				return implode( ',', $GLOBALS['USERINFO']['grps'] );
4893			case 'wiki.page' :
4894				return $this->getPageID();
4895
4896			default :
4897				$group = trim( strtok( $matches[1], '.' ) );
4898				$arg   = trim( strtok( '' ) );
4899
4900				switch ( strtolower( $group ) )
4901				{
4902
4903					case 'date' :
4904						return ( $arg !== '' ) ? date( $arg ) : '';
4905
4906					default :
4907						if ( is_array( $this->__replaceMarkupVarspace ) )
4908						{
4909
4910							if ( isset( $this->__replaceMarkupVarspace[$keyword] ) )
4911								return $this->__replaceMarkupVarspace[$keyword];
4912
4913							if ( isset( $this->__replaceMarkupVarspace[$group][$arg] ) )
4914								$this->__replaceMarkupVarspace[$group][$arg];
4915
4916						}
4917
4918						return '';
4919
4920				}
4921
4922		}
4923	}
4924
4925
4926	/**
4927	 * Replaces all occurrences of %{whatever} by a value actually related to
4928	 * the internally defined keyword "whatever".
4929	 *
4930	 * @param string $in string to parse for markup sequences to be replaced
4931	 * @return string string with all markup replaced
4932	 */
4933
4934	public function replaceMarkup( $in, $varspace = array() )
4935	{
4936
4937		if ( strpos( $in, '%{' ) !== false )
4938		{
4939
4940			$this->__replaceMarkupVarspace = $varspace;
4941
4942			$in = preg_replace_callback( '/%{([^}]+)}/', array( &$this,
4943										 '__replaceMarkupCB' ), $in );
4944
4945		}
4946
4947		return $in;
4948
4949	}
4950
4951
4952	/**
4953	 * Provides initial value of a column used on creating new record.
4954	 *
4955	 * @param string $column name of column
4956	 * @param array $def definition of column
4957	 * @return mixed value in internal format
4958	 */
4959
4960	protected function getInitialValue( $column, $def )
4961	{
4962
4963		if ( $def['type'] == 'data' )
4964			return null;
4965
4966		if ( $def['options']['nodefault'] )
4967			return null;
4968
4969
4970		$default = $this->replaceMarkup( trim( $def['options']['default'] ) );
4971
4972		switch ( $def['format'] )
4973		{
4974
4975			case 'bool' :
4976				return self::asBool( $default );
4977
4978			case 'enum' :
4979			case 'related' :
4980				$value = array_search( $default, $def['options']['selectables'] );
4981				if ( $value === false )
4982				{
4983
4984					$value = null;
4985
4986					if ( ctype_digit( $default ) )
4987					{
4988
4989						$default = intval( $default );
4990
4991						if ( ( $default > 0 ) && ( $default <= count( $def['options']['selectables'] ) ) )
4992						{
4993
4994							if ( $def['format'] == 'enum' )
4995								$value = $default - 1;
4996							else
4997								$value = $default;
4998
4999						}
5000					}
5001				}
5002
5003				return $value;
5004
5005			default :
5006				try
5007				{
5008					return $this->inputToInternal( $default, $def );
5009				}
5010				catch ( Exception $e )
5011				{
5012					return null;
5013				}
5014
5015		}
5016	}
5017
5018
5019	/**
5020	 * Converts value from format used in DB into format used internally.
5021	 *
5022	 * @param integer $rowid ID of row containing given value
5023	 * @param string $column name of column
5024	 * @param mixed $value value in DB
5025	 * @param array $def definition of column
5026	 * @return mixed value in internal format
5027	 */
5028
5029	protected function valueFromDB( $rowid, $column, $value, $def )
5030	{
5031
5032		switch ( $def['format'] )
5033		{
5034
5035			case 'image' :
5036			case 'file' :
5037				if ( is_null( $value ) )
5038					return null;
5039
5040				if ( $value === '||' )
5041					return null;
5042
5043				// parse file for internally used structure
5044				$a = strpos( $value, '|' );
5045				if ( !$a )
5046					// externally provided file --> don't touch
5047					return ( strlen( $value ) > 0 );
5048
5049				$b = strpos( $value, '|', $a + 1 );
5050				if ( !$b )
5051					// externally provided file --> don't touch
5052					return true;
5053
5054				$temp = array(
5055							'mime' => substr( $value, 0, $a ),
5056							'name' => substr( $value, $a + 1, $b - $a - 1 ),
5057							'file' => substr( $value, $b + 1 ),
5058							);
5059
5060				if ( !preg_match( '#^[a-z0-9-]+/[+a-z0-9-]+$#i', $temp['mime'] ))
5061					// externally provided file --> don't touch
5062					return true;
5063
5064				if ( trim( $temp['name'] ) === '' )
5065					// externally provided file --> don't touch
5066					return true;
5067
5068				return $temp;
5069
5070			case 'date' :
5071				if ( $def['options']['unixts'] )
5072					return $value;
5073
5074				if ( ( trim( $value ) === '' ) || ( $value == '0000-00-00' ) )
5075					return 0;
5076
5077				return self::parseDBDateTime( $value, true );
5078
5079			case 'time' :
5080				return $value;
5081
5082			case 'datetime' :
5083				if ( $def['options']['unixts'] )
5084					return $value;
5085
5086				$value = substr( $value, 0, 19 );
5087				if ( ( trim( $value ) === '' ) ||
5088					 ( $value == '0000-00-00T00:00:00' ) ||
5089					 ( $value == '0000-00-00 00:00:00' ) )
5090					return 0;
5091
5092				return self::parseDBDateTime( $value, false );
5093
5094			case 'bool' :
5095				$value = trim( $value );
5096				switch ( $def['options']['booltype'] )
5097				{
5098
5099					case 'int' :
5100						return ( intval( $value ) != 0 );
5101
5102					case 'xmark' :
5103						return ( strtolower( $value[0] ) == 'x' );
5104
5105					case 'yesno' :
5106					default :
5107						return ( strtolower( $value[0] ) == 'y' );
5108
5109				}
5110
5111			case 'enum' :
5112				$value = trim( $value );
5113				$value = array_search( $value, $def['options']['selectables'] );
5114				if ( $value === false )
5115					$value = null;
5116				else
5117					$value = intval( $value );
5118
5119				break;
5120
5121			case 'related' :
5122				if ( is_numeric( $value ) )
5123					$value = intval( $value );
5124
5125				break;
5126
5127			case 'monetary' :
5128			case 'real' :
5129				/** @todo manage decimal point conversions */
5130
5131			case 'url' :
5132			case 'email' :
5133			case 'phone' :
5134			case 'fax' :
5135			case 'text' :
5136			case 'integer' :
5137			case 'acl' :
5138				// keep value as is ...
5139
5140		}
5141
5142		return $value;
5143
5144	}
5145
5146
5147	/**
5148	 * Converts value from format used internally into format used in DB.
5149	 *
5150	 * @param integer $rowid ID of row containing given value
5151	 * @param string $column name of column
5152	 * @param mixed $value value in internal format
5153	 * @param array $def definition of column
5154	 * @return mixed value in DB format, false to omit this value on writing
5155	 *               back to database, null to store NULL
5156	 */
5157
5158	protected function valueToDB( $rowid, &$column, $value, $def )
5159	{
5160
5161		if ( $def['options']['readonly'] )
5162			// always omit writing columns marked as read-only
5163			return false;
5164
5165
5166		if ( is_null( $value ) && !$def['options']['notnull'] &&
5167			 ( $def['format'] != 'bool' ) )
5168			return null;
5169
5170
5171
5172		switch ( $def['format'] )
5173		{
5174
5175			case 'image' :
5176			case 'file' :
5177				if ( is_bool( $value ) )
5178					// don't change this file ...
5179					return false;
5180
5181				if ( is_string( $value ) || is_null( $value ) )
5182					// got a raw file or nothing ... write as is
5183					return strval( $value );
5184
5185				if ( !is_array( $value ) || !is_string( $value['file'] ) )
5186					throw new Exception( $this->getLang( 'fileinvalid' ) );
5187
5188				// internally managed files are serialized prior to saving
5189				return "$value[mime]|$value[name]|$value[file]";
5190
5191			case 'date' :
5192				if ( !$value )
5193				{
5194
5195					if ( $def['options']['unixts'] )
5196						return 0;
5197
5198					return $def['options']['notnull'] ? '0000-00-00' : null;
5199
5200				}
5201
5202				return $def['options']['unixts'] ? $value : date( 'Y-m-d',intval( $value ) );
5203
5204			case 'time' :
5205				$time = self::parseInternalTime( $value );
5206				return $time ? "$time[hour]:$time[minute]:$time[second]" : '';
5207
5208			case 'datetime' :
5209				if ( !$value )
5210				{
5211
5212					if ( $def['options']['unixts'] )
5213						return 0;
5214
5215					return $def['options']['notnull'] ? '0000-00-00T00:00:00' : null;
5216
5217				}
5218
5219				return $def['options']['unixts'] ? $value : date( 'Y-m-d\TH:i:s', intval( $value ) );
5220
5221			case 'bool' :
5222				switch ( $def['options']['booltype'] )
5223				{
5224
5225					case 'xmark' :
5226						return $value ? 'x' : ' ';
5227
5228					case 'int' :
5229						return $value ? 1 : 0;
5230
5231					case 'yesno' :
5232					default :
5233						return $value ? 'y' : 'n';
5234
5235				}
5236
5237			case 'enum' :
5238				$value = $def['options']['selectables'][$value];
5239				break;
5240
5241			case 'monetary' :
5242			case 'real' :
5243				/** @todo manage decimal point conversions */
5244				if ( is_null( $value ) )
5245					$value = '0.00';
5246				break;
5247
5248			case 'acl' :
5249				$this->dropRowACL( $rowid );
5250
5251			case 'url' :
5252			case 'email' :
5253			case 'phone' :
5254			case 'fax' :
5255			case 'text' :
5256				if ( is_null( $value ) )
5257					$value = '';
5258				break;
5259
5260			case 'related' :
5261			case 'integer' :
5262				$value = intval( $value );
5263				break;
5264
5265		}
5266
5267		return $value;
5268
5269	}
5270
5271
5272	/**
5273	 * Processes and validates input value on selected column.
5274	 *
5275	 * Set $column false to skip transferring this input value into session
5276	 * storage, e.g. to skip overwriting mark on externally provided file for
5277	 * keeping it untouched.
5278	 *
5279	 * @param integer $rowid ID of row containing given value
5280	 * @param string $column name of column
5281	 * @param mixed $value input value, optionally adjusted on return
5282	 * @param mixed $inStore value stored in editor's session
5283	 * @param array $def definition of column
5284	 * @return string error message to be rendered next to field, null if okay
5285	 */
5286
5287	protected function checkValue( $rowid, &$column, $value, &$inStore, $def )
5288	{
5289
5290		// pre-validate some selected formats
5291		switch ( $def['format'] )
5292		{
5293
5294			case 'image' :
5295			case 'file' :
5296				if ( is_bool( $inStore ) )
5297					// don't touch externally provided files
5298					return;
5299
5300				if ( trim( $value ) !== '' )
5301				{
5302					// handle request for dropping file here
5303					// --> reset value in store prior to processing any upload
5304					$inStore = null;
5305					$value   = null;
5306				}
5307
5308
5309				// check for available upload
5310
5311				$upload = $_FILES['db2dodata'.$column];
5312
5313				// reduce array (shared by all currently open single-record editors)
5314				$in  = array();
5315				$idx = $this->getIndex();
5316
5317				if ( is_array( $upload ) )
5318					foreach ( $upload as $key => $list )
5319						if ( is_array( $list ) && isset( $list[$idx] ) )
5320							$in[$key] = $list[$idx];
5321
5322
5323				if ( !is_array( $in ) || ( $in['error'] == UPLOAD_ERR_NO_FILE ) )
5324				{
5325					// there is no upload for current field
5326					// --> keep existing value/state
5327					$value = $inStore;
5328					break;
5329				}
5330
5331				if ( $in['error'] !== UPLOAD_ERR_OK )
5332					return sprintf( $this->getLang( 'filebadupload' ), $in['error'] );
5333
5334				if ( $in['size'] === 0 )
5335					return $this->getLang( 'filenoupload' );
5336
5337				<