Archiv rubriky: nette

Fotoalbum v NETTE (flickr style) – jak obejít databázi

Dva body, které mi vadili na starém albu:

  • složité nahrávání fotek (přes již prastarou aplikaci Olympus Camedia Photoalbum)
  • proprietální databáze Olympus Camedia Photoalbum postavená na MS ACCESS 1997, bez rozumné možnosti rozšíření

A jeden bod, který jsem měl jako požadavek na album nové:

  • minimální závislost na databázi

Když jsem psal tyto řádky netušil jsem, že se databáze budu schopen zbavit úplně (alespoň na straně uživatele). Došlo mi to ve chvíli, kdy jsem nahrával první album a následně jsem se musel přepínat do prostředí databáze a tam zadávat údaje (jméno, cesta, parent_id, …). Čím to nahradit?

INI souborem. Výhoda je, že je to i pro laika pochopitelné. Stačí do přidávané složky nahrát soubor album.ini a v něm mít následující obsah:

[album]
nazev="Vildštejn - Mohyly"
dukaz=1753
datum="5. - 6. 10. 2013"
 
[nezobrazovat]
fotka[]=DSC_9317.JPG
fotka[]=DSC_9318.JPG

Toto nepotřebuje delšího komentáře. V oddíle je zvykem mít název tvořen ve tvaru „1753 – Vildštejn – Mohyly 5. – 6. 10. 2013“ a obsah souboru je pochopitelný i pro laika.

Ještě drobné zabezpečení v .htaccess.

RewriteRule (.*)album.ini$ - [F]

Práce se souborem album.ini:
Načítání (zatím i s databází):

	public function getNameFromPath($path) {
		$row = $this->connection->fetchColumn('SELECT `desc` FROM `I_dir` WHERE `webalize-path`=? LIMIT 1', strtr($path, '/', '\\'));
		if ($row['desc'] == '') {
			$ini_array = @parse_ini_file(ALBUM_PATH . $path . 'album.ini');
			if (!isset($ini_array['nazev'])) return 'Album nemá vyplněno název';
			return iconv('WINDOWS-1250', 'UTF-8', "{$ini_array['dukaz']} - {$ini_array['nazev']} {$ini_array['datum']}");
		} else {
			return $row;
		}
	}

Ukládání:
Bude potřeba celou databázi převést na album.ini soubory a dále ve webovém rozhraní opatřit admin módem, aby šlo z rozhraní označit fotky, které nemají jít vidět směrem ven. Na webu jsem nenašel rozumnou komplexní funkci/třídu pro zápis do tohoto formátu, tak jsem si ji napsal sám.

Funkce dostane cestu k souboru a pole, které uloží do struktury. Tedy nejen že uloží, ale umí do souboru i data přidávat ne jen přepisovat.

	/**
	 *	přidá do existujícího INI souboru hodnoty ve tvaru $arr['klic']['podklic'] = $hodnota
	 *
	 */	 	 	 	
	public function saveIni($array, $path) {
		$ini_array = parse_ini_file(ALBUM_PATH . $path . 'album.ini', true);
 
		$res = array();
		foreach($ini_array as $key => $val) {
		    if(is_array($val)) {
		        $res[] = "[$key]";
		        foreach($val as $skey => $sval) {
		        	if(is_array($sval)) {
		        		foreach($sval as $skey1 => $sval1) {
							$res[] = "{$skey}[] = ".(is_numeric($sval1) ? $sval1 : '"'.$sval1.'"');
						}
					} else {
						$res[] = "$skey = ".(is_numeric($sval) ? $sval : '"'.$sval.'"');
					}
				}
				if (array_key_exists($key, $array)) {
					foreach($array[$key] as $skey => $sval) {
						if(is_array($sval)) {
			        		foreach($sval as $skey1 => $sval1) {
								$res[] = "{$skey}[] = ".(is_numeric($sval1) ? $sval1 : '"'.$sval1.'"');
							}
						} else {
							$res[] = "$skey = ".(is_numeric($sval) ? $sval : '"'.$sval.'"');
						}
					}
					unset($array[$key]);
				}
				$res[] = '';
		    } else {
				$res[] = "$key = ".(is_numeric($val) ? $val : '"'.$val.'"');
				if (array_key_exists($key, $array)) {
					$res[] = "$array[$key] = ".(is_numeric($array[$key]) ? $array[$key] : '"'.$array[$key].'"');
					unset($array[$key]);
				}
			}
		}
		foreach($array as $key => $val) {
			if(is_array($val)) {
		        $res[] = "[$key]";
		        foreach($val as $skey => $sval) {
		        	if(is_array($sval)) {
		        		foreach($sval as $skey1 => $sval1) {
							$res[] = "{$skey}[] = ".(is_numeric($sval1) ? $sval1 : '"'.$sval1.'"');
						}
					} else {
						$res[] = "$skey = ".(is_numeric($sval) ? $sval : '"'.$sval.'"');
					}
				}
				$res[] = '';
		    } else {
				$res[] = "$key = ".(is_numeric($val) ? $val : '"'.$val.'"');
			}
		}
 
		file_put_contents('safe://' . ALBUM_PATH . $path . 'album.ini', implode("\r\n", $res));
	}

Jediné co mi nyní na ni vadí je standardní funkce parse_ini_file(), která nenačte komentáře a tak o ně tímto přijdeme.

Fotoalbum v NETTE (flickr style) – práce s fotkami

Myšlena práce s fotkami na straně serveru…

Co bylo třeba vyřešit:

  1. zmenšení fotek
  2. záloha velkých originálů
  3. vytvoření náhledů
  4. vytvoření úvodní fotky pro danou složku
  5. (vyplynulo až později) „kešování“ dat, pro dostatečně rychlé procházení fotoalbem

Všechno se děje vlastně sériově. Vždy se ověří, zda danou operaci je nutné dělat a pokud ne, tak se vynechá. Zároveň se dané části kešují, jelikož z praxe vyplynulo, že i když není třeba nic dělat, tak samotné ověřování trvá řády jednotek až desítek sekund. A na to nikdo čekat nechce.

Poznámka: Vím, že kód níže není optimální a zasloužil by jistou refaktorizaci, ale to bych tento článek a především fotoalbum nenapsal ani za rok (Ano píšu ho ve svém volném čase a pro dobro věci. Ne pro peníze.). A taky se omlouvám za ne úplně 100% prezentaci kódu, ale neznám lepší plugin do WordPressu, který by to zvládal dokonale.

# VYTÁHNEMKE SI SLOŽKY A K NIM ÚVODNÍ FOTKU (samozřejmě jen pokud již nejsou v cache)
$value = $cache->load("folders-{$path}");
if ($value === NULL) {
	$i=0;
	foreach (Finder::findDirectories('*')->exclude('[0-9][0-9][0-9]', '_big')->in($path)->orderByName('DESC') as $key => $file) {
		$this->template->dirs[$i]['name'] = $this->I->getNameFromPath(substr($key, strlen(ALBUM_PATH)).'/');
		// vytahneme si small_home
		foreach (Finder::findFiles('_small_home.png')->in($key) as $key_home => $file_home) {
			$this->template->dirs[$i]['small_home'] = ALBUM_DIR . substr($file_home->getPath(), strlen(ALBUM_PATH)) . '/_small_home.png';
		}
		$this->template->dirs[$i++]['pos'] = substr($key, strlen(ALBUM_PATH)+5);
	}
	$cache->save("folders-{$path}", $this->template->dirs);
} else {
	$this->template->dirs = $value;
}
 
# VYTÁHNEMKE SI FOTKY (samozřejmě jen pokud již nejsou v cache)
$value = $cache->load("files-f-{$path}");
if ($value === NULL) {
	# POKUD EXISTUJÍ TAKOVÉ, KTERÉ MAJÍ VĚTŠÍ STRANU VĚTŠÍ NEŽ 1200PX, TAK JE:
	#	- ZMENŠÍME
	# 	- VELKÉ ORIGINÁLY ZAZÁLOHUJEME
	# 	- PŘENESEME EXIF INFORMACE			
	foreach (Finder::findFiles('*.jpg', '*.jpeg')->exclude('*_small*')->dimensions('>1200', '>1200')->in($path) as $key => $file) {
		$image = @Image::fromFile($key); // ini_set(‘gd.jpeg_ignore_warning’, 1);
 
		$image->resize(1200, 1200, Image::SHRINK_ONLY | Image::FIT);
 
		$ex = exif_read_data($key, 'EXIF');
		if(!empty($ex['Orientation'])) {
			switch($ex['Orientation']) {
				case 8:
					$image->rotate(90, 0);
					break;
				case 3:
					$image->rotate(180, 0);
					break;
				case 6:
					$image->rotate(-90, 0);
					break;
			}
		}
 
		$image->sharpen();
 
		$s = $key;
		$d = $file->getPath() . '/_big/' . $file->getFilename();
		$fd = new SplFileInfo($file->getPath() . '/_big/');
		if (!$fd->isDir()) mkdir($file->getPath() . '/_big/');
		rename($s, $d);
		$image->save($key);
 
		// přeuložíme EXIF informace - použita externí knihovna PelJpeg
		$input_jpeg = new PelJpeg($d);
		$exif = $input_jpeg->getExif();
		if ($exif != null) {
			$output_jpeg = new PelJpeg($key);
			$output_jpeg->setExif($exif);
			$output_jpeg->saveFile($key);
		}
	}
 
	# DÁLE VYTVOŘÍME MALÉ NÁHLEDY
	foreach (Finder::findFiles('*.jpg', '*.jpeg')->exclude('*_small*')->exclude($denyFiles)
		->filter(function($file) {
			$file_extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
			$info = new SplFileInfo($file->getPath() . '/' . $file->getBasename('.' . $file_extension) . '_small_500' . '.jpg');
			return !$info->isFile();
		})->in($path) as $key => $file) {
 
		// vytvoření náhledu
		$image = @Image::fromFile($key);
		$image->resize(500, 500, Image::SHRINK_ONLY | Image::FIT);
 
		$image->sharpen();
		$file_extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
		$image->save($file->getPath() . '/' . $file->getBasename('.' . $file_extension) . '_small_500' . '.jpg');
	}
 
	# DÁLE VYTVOŘÍME ÚVODNÍ NÁHLED, KTERÝ SE BUDE ZOBRAZOVAT MÍSTO OBRÁZKU SLOŽKY
	$small_home = Image::fromBlank(360, 360, Image::rgb(255, 255, 255, 127));
	$i=0;
	foreach (Finder::findFiles('*.jpg', '*.jpeg')->exclude('*_small*')->exclude($denyFiles)
		->filter(function($file) {
			$info = new SplFileInfo($file->getPath() . '/_small_home.png');
			return !$info->isFile();
		})->in($path) as $key => $file) {
		$image = @Image::fromFile($key);
		$image->saveAlpha(true);
		$image->resize(250, 250, Image::SHRINK_ONLY | Image::FIT);
		$blank = Image::fromBlank($image->getWidth()+20, $image->getHeight()+20, Image::rgb(255, 255, 255));
		$blank->alphaBlending(true);
		$blank->saveAlpha(true);
 
		$blank->place($image, 10, 10);
 
		$blank->rotate(mt_rand(-60, 60), Image::rgb(255, 255, 255, 127));
		$blank->sharpen();
 
		$small_home->place($blank, 20+mt_rand(-15, 15), 20+mt_rand(-15, 15));
 
		if ($i++ == 5) {
			$small_home->resize(200, 200, Image::SHRINK_ONLY | Image::FIT);
			$small_home->save($file->getPath() . '/_small_home.png', NULL, Image::PNG);
 
			$upPath = substr($file->getPath(), 0, strrpos($file->getPath(), '/')+1);
			$cache->remove("folders-{$upPath}"); // pokud tvoříme náhled, tak smažeme cache předka složky, aby se mi při dalším načtení tato cache obnovila
			break;
		}
	}
 
	# A KDYŽ MÁME VŠECHNO PŘIPRAVENO, TAK SI JEŠTĚ JEDNOU NAČTU KOMPLETNÍ INFO, KTERÉ SI PAK ULOŽÍM DO CACHE
	$i=0;
	foreach (Finder::findFiles('*.jpg', '*.jpeg')->exclude('*_small*')->exclude($denyFiles)->in($path)->orderByEXIFTime() as $key => $file) {
		$this->template->files[$i]['key'] = ALBUM_DIR . substr($key, strlen(ALBUM_PATH));
		$exif_ifd0 = read_exif_data($file, 'IFD0', 0);
		$this->template->files[$i]['file'] = date("d.m.y H:i", strtotime($exif_ifd0['DateTime']));
 
		$file_extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
		$tf = @Image::fromFile($file->getPath() . '/' . $file->getBasename('.' . $file_extension) . '_small_500' . '.jpg');
		$this->template->files[$i]['w'] = $tf->getWidth(); 
		$this->template->files[$i]['h'] = $tf->getHeight(); 
		$this->template->files[$i++]['pos'] = ALBUM_DIR . substr($file->getPath(), strlen(ALBUM_PATH)) . '/' . $file->getBasename('.' . $file_extension) . '_small_500' . '.jpg';
	}
	$cache->save("files-f-{$path}", $this->template->files);
} else {
	$this->template->files = $value;
}

Jednotlivé části dle mě nemá smysl detailněji popisovat. Je třeba se tím prokousat a pochopit. Pokud by byly dotazy nechť poslouží komentáře.

Jedna konstrukce by šla napsat jednodušeji, jen DEBIAN v současnosti neobsahuje správnou verzi PHP a v té co tam je obsahuje známou chybu…

// Takže místo...
$tf = @Image::fromFile($file->getPath() . '/' . $file->getBasename('.' . $file->getExtension()) . '_small_500' . '.jpg');
// musím používat toto...
$file_extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
$tf = @Image::fromFile($file->getPath() . '/' . $file->getBasename('.' . $file_extension) . '_small_500' . '.jpg');

Fotoalbum v NETTE (flickr style) – příprava složek

V této kapitole prezentuji, jak jsem musel připravit stromovou strukturu současného fotoalba.
webalizace – odstranění diakritiky a nahrazení speciálních znaků (např. mezer) pomlčkou

Výchozí stav:
Struktura složek ve formátu /ALBUM/001/001/038/019/. Tuto strukturu jsem měl uloženou v databázi ve formátu (id, parent_id, path, description). Tuto strukturu jsem dále rozšířil o atributy level (hloubka zanoření ve stromu), webalize-desc )(webalizovaný název složky), webalize-path (webalizovaná cesta).

1. krok – vytvoření webalizovaného popisku v databázi

	public function createCoolURL() {
		$this->connection->exec('begin');
		foreach ($this->connection->table('I_dir') as $row) {
			$this->connection->exec('UPDATE `I_dir` SET `webalize-desc`=? WHERE `id`=? LIMIT 1', Strings::webalize($row->desc, '._', FALSE), $row->id);
		}
		$this->connection->exec('commit');
	}

2. krok – vypočtení atributu level

	public function computeLevel() {
		$this->connection->exec('begin');
		foreach ($this->connection->table('I_dir') as $row) {
			$this->connection->exec('UPDATE `I_dir` SET `level`=? WHERE `id`=? LIMIT 1', substr_count ($row->path, '\\')-1, $row->id);
		}
		$this->connection->exec('commit');
	}

3. krok – vytvoření webalizovaného překladu cesty
Tady je třeba se zamyslet co vlastně děláme a proč to níže může vypadat relativně složitě. Výchozí stav je takový, že známe cestu k cílové složce (/001/001/038/019/) a její název. Ale abychom byli schopni univerzálně přejmenovat celou cestu, tak musíme začít od kořene. Tj. první přejmenovat složky level=1 (/001/). Následně level=2 (/001/001/) a to pomocí parent_id, kde již víme, že se ta složka nějak jmenuje a jen připojit webalizovaný název aktuální úrovně.

	public function createWebalizePath() {
		$this->connection->exec('begin');
		foreach ($this->connection->fetchAll('SELECT DISTINCT `level` FROM `I_dir` WHERE `level`>0 ORDER BY `level` ASC') as $l) {
			foreach ($this->connection->fetchAll('SELECT `a`.`id`, `a`.`webalize-desc` AS `wd`, `b`.`webalize-path` AS `path` FROM `I_dir` `a` JOIN `I_dir` `b` ON `a`.`parent_id`=`b`.`id` WHERE `a`.`level`=?', $l->level) as $row) {
				$this->connection->exec('UPDATE `I_dir` SET `webalize-path`=? WHERE `id`=? LIMIT 1', ($row->path.$row->wd.'\\'), $row->id);
			}
		}
		$this->connection->exec('commit');
	}

4. krok – přejmenování samotných složek
Záhadná formule REVERSE(SUBSTRING(REVERSE(`path`), 5))

  • REVERSE(`path`) z „\001\002\“ do „\200\100\“
  • SUBSTRING(…, 5) z „\200\100\“ do „100\“
  • REVERSE(…) z „100\“ do „\001“

Zbytek je snad jasný…

	public function renameFolders() {
		foreach ($this->connection->fetchAll('SELECT `path`, REVERSE(SUBSTRING(REVERSE(`path`), 5)) AS `newPath`, `webalize-desc` AS `wd` FROM `I_dir` WHERE `level`>0 ORDER BY `level` DESC') as $row) {
			@rename(ALBUM_PATH . strtr($row->path, '\\', '/'), ALBUM_PATH . strtr($row->newPath, '\\', '/') . $row->wd);
		}
	}

Zatím tu není řešeno přidání nové složky, ale to bude principiálně stejné. Jen budu muset vyřešit jak dám programu vědět, která složka a kde přibyla.
Nápady – databáze (chci se ji maximálně vyhnout a velká náchylnost na chybu), Nějaký FLAG soubor – zatím se mi jeví jako dobré řešení. Musel bych jen udělat „robota“, který by hledal soubor jistých parametrů a v případě nálezu by danou složku zpracoval a soubor třeba smazal. Ale to předbíhám…

Fotoalbum v NETTE (flickr style)

Již delší dobu plánuji, že přepíšu dávno nevyhovující fotoalbum oddílu BVÚ.

Nevýhody současného alba:
Viditelné:

  • malé náhledy (generuje je aplikace)
  • v případě aktualizace fotek, není dostupné
  • závislost na datumu souboru (pokud se nepovedlo nahrání, bylo tam nesmyslné datum)

Neviditelné:

  • složité nahrávání fotek (přes již prastarou aplikaci Olympus Camedia Photoalbum)
  • proprietální databáze Olympus Camedia Photoalbum postavená na MS ACCESS 1997, bez rozumné možnosti rozšíření
  • nesnadná správa stránek v prostředí MS – ASP (VB SCRIPT)
  • závislost na datumu souboru – musel se dodržovat speciální postup jinak se o datum přišlo, případně se musel synchronizovat s EXIF informacemi

Co čekám od nového alba:
Vyditelné:

  • rozumné rozhraní odpovídající době
  • možnost větší interakce se stránkami – TOPování fotek, komentáře, TAGy, …
  • větší propojení stránek oddílu (www, stream, fotoalbum)

Neviditelné:

  • sjednocení platforem webů
  • moderní programátorské prostředí (NETTE, jQuery)
  • lepší správa, menší závislost na proprietálním prostředí

Požadavky na nové fotoalbum:
Vyditelné:

  • rychlost
  • přístupnost
  • maximální orientace na prohlížení fotek – je to fotoalbum že!

Neviditelné:

  • minimální závislost na databázi
  • maximální jednoduchost nahrávání fotek

Pro představu uvedu postupy nahrávání fotek:
Postaru:

  • stáhnout fotky v něčem co nemění datumy souborů
  • protřídit
  • zmenšit v něčem co nemění datumy souborů
  • zabalit do ZIP archívu – aby při nahrávání na FTP se nezměnily datumy souborů
  • nahrát archiv na FTP
  • přes vzdálenou plochu na serveru v aplikaci Olympus Camedia Photoalbum vytvořit složku – od otevření aplikace je fotoalbum na webu nedostupné, jelikož si aplikace drží výhradní přístup
  • najít na disku vytvořenou složku (stromová struktura má formát: /ALBUM/001/001/038/019/)
  • nakopírovat fotky z archivu
  • otevřít vytvořené album ve Olympus Camedia Photoalbum – dojde k pomalému vytvoření náhledu a indexaci fotek
  • dříve se ještě v rámci speciální aplikace řešily publikované fotky, to již před časem odpadlo…
  • velké originály popsat a zazálohovat
  • Nově:

    • stáhnout fotky
    • protřídit
    • nahrát na FTP do pojmenované složky (tady ještě uvažuji o použití SAMBA protokolu a ukládání fotek na Windows share, který je pro běžné uživatele dostupnější)

    Úspora kroků je zřejmá!

    Na co se můžete těšit v dalších kapitolách?

    • zpracování fotek – od nahrání až po prezentaci
    • prezentace fotek na celou obrazovku prohlížeče – kdo zná flickr ví o čem píšu

    Migrace aplikace z NETTE 0.9x na NETTE 2.0x

    Potřeboval jsem v jedné starší aplikaci použít dynamické snippety. Po tom co jsem delší dobu hledal jak toto vyřešit ve starém NETTE 0.96, tak jsem na tuto myšlenku nakonec rezignoval a rozhodl jsem se přejít na aktuální verzi NETTE 2.0.6. I když ze začátku jsem se toho bál, tak nakonec to nebylo tak hrozné…

    Co jsem tedy měnil…

    bootstrap.php – ten doznal asi největších změn, ale jedná se spíše jen o přepis než o změnu logiky
    OLD:

    < ?php
    require LIBS_DIR . '/Nette/loader.php';
    require LIBS_DIR . '/dibi/dibi.php';
    require LIBS_DIR . '/currency.php';
    require APP_DIR  . '/models/MyAuthenticator.php';
    require APP_DIR  . '/models/FrontAuthenticator.php';
     
    date_default_timezone_set('Europe/Prague');
     
    //$mode = Debug::DEVELOPMENT;
    $mode = Debug::PRODUCTION;
    Debug::enable($mode, NULL, 'mail@na-me.cz');
    Debug::$strictMode = TRUE;
     
    $loader = new RobotLoader();
    $loader->addDirectory(APP_DIR);
    $loader->register();
     
    // nastavime dibi
    dibi::connect(array(
    	'driver'   => 'mysql',
    	'host'     => 'localhost',
    	'username' => 'user',
    	'password' => 'pass',
    	'database' => 'db',
    	'charset'  => 'utf8',
     ));
     
    $application = Environment::getApplication();
    $application->errorPresenter = 'Error';
    $application->catchExceptions = TRUE;
     
    $router = $application->getRouter();
     
    $router[] = new Route('index.php', array(
    	'presenter' => 'Default',
    	'action' => 'default',
    ), Route::ONE_WAY);
     
    $router[] = new Route('/', array(
        'presenter' => 'Default',
        'action' => 'default',
    ));
     
    $router[] = new Route('admin/<action>/', array(
        'presenter' => 'Admin',
        'action' => 'objednavky',
    ));
     
    // další routy
     
    FormContainer::extensionMethod('FormContainer::addCheckboxList', array('CheckboxList', 'addCheckboxList'));
     
    $application->run();

    NEW:

    < ?php
    require LIBS_DIR . '/Nette/loader.php';
    require LIBS_DIR . '/dibi/dibi.php';
    require LIBS_DIR . '/currency.php';
    require APP_DIR  . '/models/MyAuthenticator.php';
    require APP_DIR  . '/models/FrontAuthenticator.php';
     
    date_default_timezone_set('Europe/Prague');
     
    $configurator = new Configurator;
     
    $configurator->setDebugMode(TRUE);
    $configurator->enableDebugger(dirname(__FILE__) . '/log');
     
    // Enable RobotLoader - this will load all classes automatically
    $configurator->setTempDirectory(dirname(__FILE__) . '/temp');
    $configurator->createRobotLoader()
    	->addDirectory(APP_DIR)
    	->addDirectory(LIBS_DIR)
    	->register();
     
    // Create Dependency Injection container from config.neon file
    //$configurator->addConfig(dirname(__FILE__) . '/config/config.neon');
     
    $container = $configurator->createContainer();
     
    // Setup router
    $container->router[] = new Route('index.php', array(
    	'presenter' => 'Default',
    	'action' => 'default',
    ), Route::ONE_WAY);
     
    $container->router[] = new Route('/', array(
        'presenter' => 'Default',
        'action' => 'default',
    ));
     
    $container->router[] = new Route('admin/<action>/', array(
        'presenter' => 'Admin',
        'action' => 'objednavky',
    ));
     
    // další routy
     
    // nastavime dibi
    dibi::connect(array(
    	'driver'   => 'mysql',
    	'host'     => 'localhost',
    	'username' => 'user',
    	'password' => 'pass',
    	'database' => 'db',
    	'charset'  => 'utf8',
    	'profiler' => TRUE
     ));
     
    FormContainer::extensionMethod('FormContainer::addCheckboxList', array('CheckboxList', 'addCheckboxList'));
     
    $container->application->run();

    Největším oříškem pro mě bylo rozchodit LATTE šablony. Ne že by to bylo v novém NETTE složité ale …
    BasePresenter.php
    OLD

    	// v 0.96 si musíme použití šablony zajistit registrací tohoto filtru
    	public function templatePrepareFilters($template) {
    		$template->registerFilter(new CurlyBracketsFilter);
    	}

    NEW

    	// v novém NETTE jsem myslel, že bude stačit
    	// zakomentovat jeden řádek ($template->register...),
    	// ale to nefungovalo a několik minut jsem nemohl přijít na to proč.
    	// Nakonec pomohlo zakomentovat celou tuto funkci.
    	/*public function templatePrepareFilters($template) {
    		$template->registerFilter(new CurlyBracketsFilter);
    	}*/

    Další změny jsou spíše drobnosti a řešil jsem je jak jsem na ně přicházel:
    $form->addFile() -> $form->addUpload
    User::INACTIVITY -> IUserStorage::INACTIVITY
    $user->setAuthenticationHandler(new MyAuthenticator); -> $user->setAuthenticator(new MyAuthenticator);
    $form->onSubmit[] -> $form->onSuccess[]
    skipFirst() -> setPrompt()
    třída String -> Strings (například Strings::webalize())

    Takže nakonec žádné terno… :)

    Jak NETTE šetří práci

    Občas potřebuji udělat jen drbku na webu, který na NETTE neběží a pak nastane otázka, zda to dělat ještě v pure PHP a nebo nějak do toho komponovat NETTE. U mě je odpověď jasná. NETTE!

    Zadání:
    Přidat na stávající web formulář pro odeslání mailů.

    Řešení:
    Kdekoliv je libo (někdo plácá HTML s PHP, někdo typy obsahu dělí mezi šablonu a další soubory, …) si vygenerujeme formulář s plnou podporou NETTE.

    include_once $_SERVER['DOCUMENT_ROOT'] . '/_libs/nette.min.php';
     
    use	Nette\Mail\Message,
    	Nette\Forms\Form;
     
    // vytvoření formuláře		
    $form = new Form;
    $form->addText('jmeno', 'Jméno a příjmení *:')
    	->setRequired('Zadejte prosím jméno');
    $form->addText('mail', 'E-mail:');
    $form->addText('tel', 'Telefon:')
    	->addConditionOn($form['mail'], Form::EQUAL, '')
    		->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů');
     
    $form['mail']->addConditionOn($form['tel'], Form::EQUAL, '')
    	->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů')
    	->addRule(Form::EMAIL, 'E-mail nemá správný formát');
     
    $form->addSelect('zajem', 'Mám zájem o:', array('sádrokartony, rekonstrukce' => 'sádrokartony, rekonstrukce', 'malby, nátěry' => 'malby, nátěry', 'ostatní' => 'ostatní'));
    $form->addTextArea('pozn', 'Poznámka:', 40, 4);
     
    // antispam
    // podrobněji toto řešení rozebírám v jiném článku
    $c1 = date('j')+3;
    $c2 = date('N')+2;
    $s = $c1 + $c2;
    $form->addText('soucet', sprintf('Zadejte součet %s+%s *:', $c1, $c2))
    	->setRequired('Je třeba zadat součet jako ochranu proti SPAMu')
    	->addRule(Form::EQUAL, 'Je třeba zadat součet jako ochranu proti SPAMu', $s);
    $form->addHidden('c1', $c1);
    $form->addHidden('c2', $c2);
    $form->addSubmit('send', 'Odeslat');

    Pak kdekoliv chci formulář zobrazit tak napíši jen:

    echo $form;

    Pro JS validaci (není nutná) stačí nahrát a nalinkovat soubor netteForm.js:

    <script type="text/javascript" src="../_js/netteForms.js"></script>
     
    <script type="text/javascript">
    	// řešení pro antispam popisuji jinde
    	document.getElementById("frm-soucet").value = parseInt(document.getElementById("frm-c1").value) + parseInt(document.getElementById("frm-c2").value);
    	document.getElementById("frm-soucet").parentNode.parentNode.style.display = "none";
    </script>

    Zpracování formuláře a odeslání ho na mail jde stejně jednoduše:

    if ($form->isSuccess()) {
    	$val = $form->getValues();
    	$mail = new Message;
     
    	$from = ($val['mail'] == '') ? 'Z webu <web @nejaky-web.cz>' : $val['jmeno'] . ' < ' . $val['mail'] . '>';
    	$mail->setFrom($from)
    		->addTo('Info <info @nejaky-web.cz>')
    		->setSubject('Poptávka z webu')
    		->setBody(sprintf("Dobrý den,\nJméno: %s\nE-mail: %s\nTelefon: %s\nZájem o: %s\nPoznámka:\n%s", $val['jmeno'], $val['mail'], $val['tel'], $val['zajem'], $val['pozn']))
    		->send();
    }</info></web>

    Jak dlouho byste toto dělali v čistém PHP?

    Jak neotravovat uživatele s antispamem? – realizace s podporou NETTE

    V článku jsem nastínil, jak chránit webový formulář antispamem bez zbytečné interakce uživatelů.

    Jak to ale propojit s NETTE a jeho generací formulářů.

    Příklad je napsán bez použití kompletního frameworku. Používám NETTE jen jako knihovnu.

    PHP:

    include_once $_SERVER['DOCUMENT_ROOT'] . '/_libs/nette.min.php';
     
    use	Nette\Mail\Message,
    	Nette\Forms\Form;
     
    // vytvoření formuláře		
    $form = new Form;
    $form->addText('jmeno', 'Jméno a příjmení *:')
    	->setRequired('Zadejte prosím jméno');
    $form->addText('mail', 'E-mail:');
    $form->addText('tel', 'Telefon:')
    	->addConditionOn($form['mail'], Form::EQUAL, '')
    		->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů');
     
    $form['mail']->addConditionOn($form['tel'], Form::EQUAL, '')
    	->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů')
    	->addRule(Form::EMAIL, 'E-mail nemá správný formát');
     
    $form->addSelect('zajem', 'Mám zájem o:', array('sádrokartony, rekonstrukce' => 'sádrokartony, rekonstrukce', 'malby, nátěry' => 'malby, nátěry', 'ostatní' => 'ostatní'));
    $form->addTextArea('pozn', 'Poznámka:', 40, 4);
     
    // antispam
    // Nelze řešit přes RAND, jelikož po odeslání by se nám hodnoty změnily, toto beru jako zajímavé "konstanty", které mají platnost jeden den.
    // Nápady jak toto lépe řešit uvítám v diskuzi.
    $c1 = date('j')+3;
    $c2 = date('N')+2;
    $s = $c1 + $c2;
    $form->addText('soucet', sprintf('Zadejte součet %s+%s *:', $c1, $c2))
    	->setRequired('Je třeba zadat součet jako ochranu proti SPAMu')
    	->addRule(Form::EQUAL, 'Je třeba zadat součet jako ochranu proti SPAMu', $s);
    $form->addHidden('c1', $c1);
    $form->addHidden('c2', $c2);
    $form->addSubmit('send', 'Odeslat');
     
    // zpracování formuláře po odeslání
    if ($form->isSuccess()) {
    	$val = $form->getValues();
    	$mail = new Message;
     
    	$from = ($val['mail'] == '') ? 'Z webu <web @nejaky-web.cz>' : $val['jmeno'] . ' < ' . $val['mail'] . '>';
    	$mail->setFrom($from)
    		->addTo('Info <info @nejaky-web.cz>')
    		->setSubject('Poptávka z webu')
    		->setBody(sprintf("Dobrý den,\nJméno: %s\nE-mail: %s\nTelefon: %s\nZájem o: %s\nPoznámka:\n%s", $val['jmeno'], $val['mail'], $val['tel'], $val['zajem'], $val['pozn']))
    		->send();
     
    	// nesouvisí s příkladem - vložíme poděkování
    	include $_SERVER['DOCUMENT_ROOT'] . '/templates/formular-odeslan.php';
    } else {
    	echo $form;
    }

    Vypíchnu raději bokem samotnou podstatu antispamového řešení:

    // antispam
    // Nelze řešit přes RAND, jelikož po odeslání by se nám hodnoty změnily, toto beru jako zajímavé "konstanty", které mají platnost jeden den.
    // Nápady jak toto lépe řešit uvítám v diskuzi.
    $c1 = date('j')+3;
    $c2 = date('N')+2;
    $s = $c1 + $c2;
    $form->addText('soucet', sprintf('Zadejte součet %s+%s *:', $c1, $c2))
    	->setRequired('Je třeba zadat součet jako ochranu proti SPAMu')
    	->addRule(Form::EQUAL, 'Je třeba zadat součet jako ochranu proti SPAMu', $s);
    $form->addHidden('c1', $c1);
    $form->addHidden('c2', $c2)

    Obslužný JS, který nám v případě zapnutého JS skryje antispamový prvek

    <script type="text/javascript">
    	document.getElementById("frm-soucet").value = parseInt(document.getElementById("frm-c1").value) + parseInt(document.getElementById("frm-c2").value);
    	document.getElementById("frm-soucet").parentNode.parentNode.style.display = "none"; // musíme se dostat až ke značce TR
    </script>

    Jak na multiselect v NETTE + dibi

    Potřeboval jsem použít multiselectu v e-shopu, který právě dělám, pro určení souvisejícího zboží a narazil jsem na pár zádrhelů, které zde popíši.

    1. Jak naplnit multiselect daty, při použití skupin
    Toto má být výsledek
    Multiselect

    Je třeba dodat data ve formátu:

    array (
    "Bombóny" => array (
       15 => "Hašlerky"
       16 => "Bompary"
    ),
    "Lízátka" => array (
       11 => "Chupachups"
    ),
    "Čokolády" => array (
       12 => "Milka"
       17 => "Orion"
    ),
    "Ostatní" => (
       14 => "Špalet"
       18 => "Pendrek"
    )
    )

    Jak tento formát získat záleží zejména na struktuře databáze. Předpokládám, že pokud je „ideální“, tak by to šlo pomocí vhodné asociace (viz příklad níže). Vzhledem k tomu, že moje databáze má jinou strukturu a kýženému výsledku jsem se musel dobrat mnohem složitěji (iterace nad daty, dotahování dalších dat pomocí dalších selectů, rekurze, …), tak následující příklad je jen myšlenka, jak by to mohlo fungovat (chybí tam například výběr IDcek a následující nutné UNSETy).

       $res = dibi::query('SELECT [nazev_menu], [nazev_produktu] FROM [menu] JOIN [zbozi] USING ([kod_menu]) ORDER BY [nazev_menu], [nazev_produktu]');
       $res->fetchAssoc('nazev_menu', '=', 'nazev_produktu');

    Po získání dat stačí již jednoduše zavolat:

        $form->addMultiSelect('items', 'Související produkty:', $items);

    2. Vybrání default hodnot
    NETTE očekává pro formulářový prvek multiselect formát:

        array(id1, id2, id3);

    Ten získáme malým trikem pomocí fetchPairs (příklad mojí konkrétní funkce z modelu):

        public function getRelated($id)
        {
            return $this->connection->select('id_2')
    	                        ->from(self::TABLE_PAGES_RELATED)
    	                        ->where('id_1=', $id)
    	                        ->fetchPairs('id_2', 'id_2'); // tento řádek je ten důležitý
        }

    A následně v presenteru:

    	$model = new RelatedModel;
    	$items = $model->getRelated($id);
     
    	$form = $this->getComponent('itemsForm');
    	$form->setDefaults(array('items'=> $items));

    3. Uložení získaných dat
    NETTE nám z formuláře vrátí data ve formátu:

    "items" => array (
       0 => "15"
       1 => "11"
    )

    ale takto je nelze předhodit SQL dotazu pro INSERT a musím je tedy přetransformovat do tvaru ID=>ID. Což vypadá ve výsledku takto:

    "items" => array (
       array (
          "id_1" => "14",
          "id_2" => "15"
       ),
       array (
          "id_1" => "14",
          "id_2" => "11"
       )
    )

    kde id_1 je id editovaného záznamu a id_2 jsou související id (ze stejné tabulky jako id_1).
    Takový formát se již dá předhodit dibi. Konkrétní funkce z mého modelu vypadá následovně:

        public function setRelated($id, $data)
        {
    	dibi::query('DELETE FROM [pages_related] WHERE [id_1]=%i', $id); // vyčistíme si data pro dané ID
    	dibi::query('INSERT INTO [pages_related] %ex', $data); // uložíme si nové související položky. %ex provede expanzi pole pro multiinsert		
        }

    Zde je i ukázáno jakým způsobem provést multiinsert dat, aby nebylo nutno iterovat nad daty a provádět jednotlivé inserty.

    A nyní už chybí jen kus kódu, který transformuje data na výše popsaný formát. pro přehlednost ukážu celou metodu pro zpracování formulářových dat.

        public function itemsFormSubmitted($form)
        {
    		$data = $form->getValues();
    		$model = new RelatedModel;
     
    		$arr = array();
    		foreach($data['items'] as $key => $value) {
    			$arr[$key]['id_1'] = $data['id'];
    			$arr[$key]['id_2'] = $value;
    		}
    		$model->setRelated($data['id'], $arr);
     
    		$this->flashMessage('Byly aktualizovány související produkty.', 'info');
    		$this->redirect('this');
    	}

    Vše!

    Autentizace (přihlášení) v NETTE pomocí LDAP – omezení uživatelů

    Navazuji na předchozí návod. Ale navíc jsem řešil, jak co nejjednodušeji zajistit, aby do aplikace mohla jen určitá podmnožina uživatelů z LDAP (u mě konkrétně Active Directory).
    Možnosti:

    • Využít vlastnosti struktury či atributů Active Directory – u mě nepřipadá v úvahu
    • Mít další evidenci v databázi webové aplikace. Například s INDEXem na atribut $username. – Zbytečná evidence navíc, které se musí spravovat.
    • Mít ve výčtu povolené $username.
      	$allow_usernames = array('honza', 'mojmir', 'patrik');

      A dále kontrolovat, zda přihlašovaný je v tomto poli. – Evidence navíc a při změně oprávnění je třeba magické úpravy v aplikační logice.

    • Přidat kontrolní heslo, které budou znát jen administrátoři. – Zdá se, že je zbytečná další vazba na Active Directory, proč prostě nestačí toto admin heslo, ale pokud vypnu uživatele v Active Directory, tak automaticky zamezím přístup i do administrace, ale bez této kombinace by to nešlo.

    Authenticator:

    < ?php
    // pro PHP 5.3
    // use Nette\Object, Nette\Security\IAuthenticator, Nette\Security\AuthenticationException, Nette\Security\Identity;
     
    class MyAuthenticator extends Object implements IAuthenticator {
     
    	public function authenticate(array $credentials) {
    		$username = $credentials[self::USERNAME];
    		$password = $credentials[self::PASSWORD];
    		$control_password = $credentials['extra'];
     
    		$ldap_conn = ldap_connect ('ldap://server.domena.cz');
    		if ($ldap_conn) {
    			$ldapbind = @ldap_bind ($ldap_conn, $username . '@domena.tld', $password);
    			ldap_unbind ($ldap_conn);
    			if ($ldapbind) {
    				if ($control_password === 'nejake_zvolene_kontrolni_heslo') {
    					return new Identity('Správce.', 'Administrátor', $ldapbind);
    				} else {
    					throw new AuthenticationException("Účet nemá dostatečná práva.", self::NOT_APPROVED);
    				}
    			} else {
    				throw new AuthenticationException("Špatné jméno nebo heslo.", self::INVALID_CREDENTIAL);
    			}
    		} else {
    			throw new AuthenticationException("Ověřovací server není dostupný.", self::FAILURE);
    		}
    	}
    }

    Použití v presenteru (zatím ve stínu starého NETTE):

    	protected function createComponentLogin()
    	{
    		$form = new AppForm;
    		$form->addText('login', 'Přihlašovací jméno:')
    			->addRule(Form::FILLED, 'Pole "Přihlašovací jméno" je povinné.');
    		$form->addPassword('password', 'Heslo:')
    			->addRule(Form::FILLED, 'Pole "Heslo" je povinné.');
    		$form->addPassword('control', 'Kontrolní heslo:')
    			->addRule(Form::FILLED, 'Pole "Kontrolní heslo" je povinné.');
            	// Obrana proti CSRF
    		$form->addProtection('Vypršel čas pro přihlášení.');
    		$form->addSubmit('send', ' Přihlásit ');
     
    		$form->onSubmit[] = array($this, 'formLoginSubmitted');
     
    		$this->addComponent($form, "login");
    	}
     
    	function formLoginSubmitted($form) {
    		$user = Environment::getUser();
    		$user->setExpiration('+ 15 minutes');
    		$values = $form->getValues();
     
    		try {
    			// pokusíme se přihlásit uživatele...
    			// tady je to nejdůležitější, třetí parametr LOGINu
    			$user->login($values['login'], $values['password'], $values['control']);
    			// ...a v případě úspěchu přesměrujeme na další stránku
    			$this->flashMessage('Přihlášení proběhlo úspěšně.');
    			$this->redirect('Admin:default');
    		} catch (AuthenticationException $e) {
    			$this->flashMessage($e->getMessage(), 'validation');
    		}
    	}

    Autentizace (přihlášení) v NETTE pomocí LDAP

    Ne, že by to bylo něco složitého, pro někoho kdo má s LDAP již nějaké zkušenosti, ale myslím, že komplexní návod se hodí.

    < ?php
    // pro PHP 5.3
    // use Nette\Object, Nette\Security\IAuthenticator, Nette\Security\AuthenticationException, Nette\Security\Identity;
     
    class MyAuthenticator extends Object implements IAuthenticator {
     
    	public function authenticate(array $credentials) {
    		$username = $credentials[self::USERNAME];
    		$password = $credentials[self::PASSWORD];
     
    		$ldap_conn = ldap_connect ('ldap://overovaci.server.cz');
    		if ($ldap_conn) {
    			$ldapbind = @ldap_bind ($ldap_conn, $username . '@domena.tld', $password);
    			ldap_unbind ($ldap_conn);
    			if ($ldapbind) {
    				return new Identity('Správce.', 'Administrátor', $ldapbind);
    			} else {
    				throw new AuthenticationException("Špatné jméno nebo heslo.", self::INVALID_CREDENTIAL);
    			}
    		} else {
    			throw new AuthenticationException("Ověřovací server není dostupný.", self::FAILURE);
    		}
    	}
    }

    Pro jednoduchost se v tomto příkladu neřeší další správa rolí, nezjišťují se podrobnosti o uživateli atd. Možná jindy…