Пример доски объявлений на Kohana

Kohana — довольно молодой PHP фреймворк, форк CI, всецело завязанный на ООП. К достоинствам Kohana можно отнести использование всех возможностей PHP5 на 100%, высокую скорость работы, «легковесность» и простоту как использования, так и изучения. Из минусов отчетливо выделяется небольшое комьюнити, как следствие, не шибко качественная документация и небольшое количество модулей и библиотек.
Не так давно своё знакомство с фреймворками я начинал именно с Kohana и, надо сказать, я был удивлен, насколько легко он мне поддался. Думал будет намного сложнее. Но очень сказывалась нехватка документации и примеров кода. Зарывшись в маны и разбор исходников, через некоторое время, я восполнил интересующие меня пробелы относительно Kohana, и поэтому решил написать эту статью, дабы другим хабралюдям, оказавшимся в схожей ситуации не пришлось по ночам не досыпать и лечить головные боли.
Под катом пример доски объявлений, написанный с помощью Kohana, возможно местами он не претендует на рациональность и здравый смысл, но всё-же я надеюсь услышать конструктивную критику.
Статья рассчитана на людей, имеющих понятие об MVC и ООП, но не имевших, либо мало имевших, дело с фреймворками.
Начнем
Недавно передо мной встала задача написать небольшую доску объявлений на базе новостного сайта, где пользователи могли бы оставлять свои объявления о купле, продаже и прочем. Написать очень быстро. Честно говоря, до этого случая весь мой опыт с фреймворками заключался в установке сэндбокса Symfony и последующем его удалением. А всё, что я знал о фреймворках, так это то, что большинство из них «используют MVC» и то, что они очень облегчают жизнь. Т.к. в то время я уже почитывал Хабр, мне почему-то запала в голову одна из публикаций, которая утверждала, что фреймворк Kohana «cоздан быть легким, быстрым и простым в использовании». Думаю именно поэтому я выбрал его. Итак,
Что мы хотим получить в результате?
Мы хотим, чтобы у нас были такие возможности в отношении пользователей:
- авторизация/регистрация пользователей
- просмотр объявлений всеми пользователями;
- добавлять объявления могут только зарегистрированные;
- править/удалять могут только авторы и админы;
Условия для категорий:
- существуют главные категории;
- существуют под-категории;
- под-категории содержат объявления;
С тем, что от нас нужно, мы определились, теперь придумаем, как мы это будем реализовывать.
Для регистрации пользователей будем использовать модуль Auth, который входит в стандартную поставку версии 2.3. Для его использования создадим в базе несколько таблиц:
# в этой таблице мы будем хранить пользователей
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) unsigned NOT NULL auto_increment,
`username` varchar(32) NOT NULL default '',
`password` char(50) NOT NULL default '',
`email` varchar(127) NOT NULL default '',
`join` int(10) unsigned NOT NULL default '0',
`last_login` int(10) unsigned NOT NULL default '0',
`logins` int(10) unsigned NOT NULL default '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_username` (`username`),
UNIQUE KEY `uniq_email` (`email`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;
INSERT INTO `users` (`id`, `username`, `password`, `email`, `join`, `last_login`, `logins`) VALUES (1, 'admin', '098f6bcd4621d373cade4e832627b4f6', 'example@example.com', 1215075372, 0, 0);
# тут у нас будут описания прав пользователей
CREATE TABLE IF NOT EXISTS `roles` (
`id` int(4) unsigned NOT NULL auto_increment,
`name` varchar(32) NOT NULL,
`description` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_name` (`name`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;
INSERT INTO `roles` (`id`, `name`, `description`) VALUES (1, 'login', 'Зарегистрированный');
INSERT INTO `roles` (`id`, `name`, `description`) VALUES (2, 'admin', 'Админ');
# эта таблица будет содержать привязку прав к пользователю
CREATE TABLE IF NOT EXISTS `roles_users` (
`user_id` int(10) unsigned NOT NULL,
`role_id` int(10) unsigned NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
KEY `fk_role_id` (`role_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
INSERT INTO `roles_users` (`user_id`, `role_id`) VALUES (1, 2);
# a эта таблица будет использоваться модулем Auth для хранения залогиненых пользователей
DROP TABLE IF EXISTS `d_user_tokens`;
CREATE TABLE IF NOT EXISTS `user_tokens` (
`id` int(11) unsigned NOT NULL auto_increment,
`user_id` int(11) unsigned NOT NULL,
`user_agent` varchar(40) NOT NULL,
`token` varchar(32) NOT NULL,
`created` int(10) unsigned NOT NULL,
`expires` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_token` (`token`),
KEY `user_id` (`user_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
Дальше создадим таблицы для категорий и объявлений. При этом все категории будут иметь поле parent_id, которое будет указывать на id родительской категории у которых parent_id будет равен 0.
CREATE TABLE IF NOT EXISTS `categories` (
`id` int(10) unsigned NOT NULL auto_increment,
`parent_id` int(10) unsigned NOT NULL default '0',
`name` varchar(150) character set utf8 NOT NULL,
PRIMARY KEY (`id`),
KEY `parent_id` (`parent_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=14;
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (1, 0, 'Недвижимость');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (2, 1, 'Квартиры');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (3, 1, 'Комнаты');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (4, 1, 'Дома, дачи');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (5, 0, 'Авто');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (6, 5, 'Легковые автомобили');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (7, 5, 'Запчасти, аксессуары');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (8, 5, 'Мотоциклы, мопеды');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (9, 0, 'Работа');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (10, 9, 'Админ персонал');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (11, 9, 'ИТ, интернет');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (12, 9, 'Работа на дому');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (13, 9, 'Другие сферы');
CREATE TABLE IF NOT EXISTS `items` (
`id` int(10) unsigned NOT NULL auto_increment,
`title` varchar(250) character set utf8 NOT NULL,
`category_id` int(10) unsigned NOT NULL default '0',
`content` text NOT NULL,
`user_id` int(10) unsigned NOT NULL,
`datepub` int(10) unsigned NOT NULL default '0',
PRIMARY KEY (`id`),
KEY `title` (`title`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=2;
INSERT INTO `items` (`id`, `title`, `category_id`, `content`, `user_id`, `datepub`) VALUES (1, 'Тестовое объявление', 2, 'Это просто объявление. Таких тут вскоре будет много', 1, 1229251545);Модели
Модели — это часть MVC, которая представляет данные и реагирует на запросы, являясь конечной инстанцией между скриптом и БД.
Теперь о моделях, которые мы будем использовать. Для работы с пользователями у нас есть стандартные модели модуля Auth, для обращения к статьям и категориям, создадим свои — создайте файл /application/models/category.php и пропишите в нем следующий класс:
class Category_Model extends ORM_Tree{
protected $children = 'categories';
protected $has_many = array('items');
}
Теперь создайте ещё один файл /application/models/item.php с таким классом:
class Item_Model extends ORM{
protected $has_one = array('user');
protected $belongs_to = array('category');
}Теперь на русском — для модели категорий мы создали расширенный ORM (об этом немного ниже) класс таблицы `categories` с древовидной структкрой (ORM_Tree), у записи которой могут быть потомки в виде записей из этой же таблицы (protected $children = `categories`, по умолчанию родитель — это поле `parent_id`) и каждая запись которой может иметь много вложенных записей таблицы `items`. А для модели объявлений у нас получился простой ORM класс таблицы `items`, которая содержит одну запись из таблицы `users` и пренадлежит таблице `categories`
Думаю, стоит прояснить, что названия моделей должны быть в единичном числе, т.е. если используется таблица items, то название модели должно быть Item_Model. Также дела обстоят и с объявлениями переменных модели, если предполагается множество объектов ($children, $has_many), то аргумент должен быть во множественном числе, если же объект один ($has_one, $belong_to), то и число — единичное.
Контроллеры
С моделями вроде разобрались, теперь о контроллерах — они служат для отображения информации, являясь прослойкой между моделями и видами.
Какие контроллеры нам нужны? Думаю такие:
- для главной страницы, заодно и категорий
- для под-категорий
- для просмотра объявлений
- для входа пользователей
- для их регистрации
Поскольку первый со вторым, также третий с четвертым, являются логически взаимосвязанными, объединим их в один, для каждого типа и в итоге получаем три контроллера — category.php, items.php и user.php.
Дальше много кода, большинство из которого интуитивно понятна, и занакома людям, работавшим с ORM и MVC.
Что такое ORM?
Думаю, что если Вы дочитали до сюда, то вопросов по MVC у Вас возникнуть не должно, а вот в отношении ORM они вполне могут появиться. Википедия нам говорит, что ORM — это технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных». Что это значит? Это значит, что вызвав метод factory класса ORM с параметром, к примеру, 'table', мы получим объект, проекцию таблицы `tables` (о множественных и единичных числах я говорил немного выше), и место того, чтобы делать SQL запросы к базе через расширение, или какой-либо абстрактный класс, мы можем обращаться к методам и свойствам вызваного класса. Т.е. например, выборка записей с id равным 5 из базы произойдет не привычным $db->query('SELECT * FROM `table` WHERE (`id` = 5) '), а обращением к ORM проекции — ORM::factory('table')->where('id', 5)->find_all(). Но в отличии от простого абстрактного доступа, ORM предоставляет массу вкусностей, которые сложно понять, не попробовав. Так, если речь идет именно об id, то запись можно получить ещё проще — ORM::factory('table', 5). На примере наших вышеобозначенных моделей, мы можем получить имя категории в которой лежит объявление с id = 5 вызовом ORM::factory('item', 5)->category->name, а не писать кучу SQL запросов.
category.php:
class Category_Controller extends Template_Controller {
public $template = 'index';
public function index() { // главная страница
$_result = '';
$_cats = array();
$categories = ORM::factory('category')->where('parent_id', 0)->find_all();
foreach($categories as $l) {
$_tmp = new View('category');
$_tmp->id = $l->id;
$_tmp->name = $l->name;
$_tmp->children = $l->children;
$_cats[] = $_tmp;
}
$_result = new View('category');
$_result->cats = $_cats;
$this->template->content = $_result;
}
public function view($params) { // прсмотр категории
$_result = '';
$categories = ORM::factory('category', $params)->children;
foreach($categories as $l) {
foreach ($l->items as $n) {
$_tmp = new View('item');
$_tmp->content = $n;
$_result.= $_tmp;
}
}
$this->template->content = $_result;
}
public function viewsub($params) { // просмотр под-категории
$_result = '';
$categories = ORM::factory('category', $params);
foreach($categories->items as $l) {
$_tmp = new View('item');
$_tmp->content = $l;
$_result.= $_tmp;
}
$this->template->content = $_result;
}
}В методе index мы собираем массив главных категорий с объектами подкатегорий в нем и кол-вом объявлений(count) в подкатегориях и выводим это всё дело через вид category, который будет описан в главе «Виды».
Метод view показывает нам содержимое всех подкатегорий категории с id, переданным в параметре (как он туда попадёт — в главе «Окончательные настройки»). Также работает метод viewsub с разницей в том, что он применим для подкатегорий.
Теперь контроллер объявлений, items.php:
class Items_Controller extends Template_Controller {
public $template = 'index';
public function index() { // редирект на главную, если просто вызван контроллер
url::redirect('/index');
}
public function view($arg) { // отображение статьи с id = $arg
$_item = ORM::factory('item', $arg);
$this->template->content = new View('item');
$this->template->content->content = $_item;
}
public function edit($arg) { // правка статьи c id = $arg
$_tmp = ORM::factory('item', $arg);
if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&
(Auth::instance()->get_user()->id == $_tmp->user_id))) { // продолжать только если пользователь автор или админ
$category = array();
// наполняем список категорий
foreach(ORM::factory('category')->where('parent_id', 0)->find_all() as $l) {
foreach($l->children as $n) {
$category[$l->name][$n->id] = $n->name;
}
}
// создадим класс для работы с формами
$form = new Forge(url::current());
$form->set_attr('method', 'post');
$form->input('title') // создаем тег <input id = 'title' />
->label('Заголовок') // делаем для него <label for='title'>Заголовок</label>
->rules('required|length[3,40]') // правила валидации - обязательное, от 3 до 40 символов
->value($_tmp->title); // и присваиваем ему значение из $_tmp->title
$form->textarea('addtext')
->label('Текст')
->rules('required')
->value($_tmp->content);
$form->dropdown('category')
->label('Категория')
->options($category)
->selected($_tmp->category_id);
$form->submit('Править');
// вот такая вот простая валидация формы
if ($form->validate()) {
// создаем и заполняем объект ORM
$new = ORM::factory('item', $arg);
$new->title = $form->inputs['title']->value;
$new->content = $form->inputs['addtext']->value;
$new->category_id = $form->inputs['category']->value;
// а после - сохраняем
$new->save();
url::redirect('/');
}
// отправка формы виду
$this->template->content = $form->render();
} else {
$this->template->content = "Вы не зарегистрированы";
}
}
public function add() { // добавление статьи
if(Auth::instance()->logged_in()) { // только для зарегистрированых пользователей
$category = array();
foreach(ORM::factory('category')->where('parent_id', 0)->find_all() as $l) {
foreach($l->children as $n) {
$category[$l->name][$n->id] = $n->name;
}
}
$form = new Forge(url::current());
$form->set_attr('method', 'post');
$form->input('title')
->label('Заголовок')
->rules('required|length[3,40]');
$form->textarea('addtext')
->label('Текст')
->rules('required');
$form->dropdown('category')
->label('Категория')
->options($category)
->selected(0);
$form->submit('Добавить');
if ($form->validate()) {
$new = ORM::factory('item');
$new->title = $form->inputs['title']->value;
$new->content = $form->inputs['addtext']->value;
$new->category_id = $form->inputs['category']->value;
// вот так инстанция класса Auth дает доступ к пользовательским данным
$new->user_id = Auth::instance()->get_user()->id;
$new->datepub = time();
$new->save();
url::redirect('/');
}
$this->template->content = $form->render();
} else {
$this->template->content = "Вы не зарегистрированы";
}
}
public function delete($arg) { // удаление статьи
$new = ORM::factory('item', $arg);
if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&
(Auth::instance()->get_user()->id == $new->user_id))) { // только автор или админ
$new->delete();
url::redirect('/');
} else {
$this->template->content = "Вы не зарегистрированы";
}
}
}Думаю, единственный, до этого времени не встречавшийся нам класс — это Forge. Он используется для облегчения работы с формами, но, к сожалению, он исключен из стандартной поставки Kohana. Где его взять, я расскажу ниже.
Ну и контроллер пользователей, user.php:
class User_Controller extends Template_Controller {
public $template = 'index';
public function login() { // вход
if (Auth::instance()->logged_in()) { // если не зарегистрированы, то на главную
url::redirect('/');
} else { // создание формы
$form = new Forge;
$form->set_attr('method', 'post');
$form->input('username')
->label('Логин')
->rules('required|length[4,32]');
$form->password('password')
->label('Пароль')
->rules('required|length[4,40]');
$form->submit('Войти');
if ($form->validate()) {
$user = ORM::factory('user', $form->username->value);
// если вход успешен, то на главную
if (Auth::instance()->login($user, $form->password->value)) {
url::redirect('/');
} else {
// если нет, то вывести ошибку
$form->password->add_error('login_failed', 'Неверное имя пользователя, или пароль.');
}
}
}
$this->template->content = $form->render();
}
public function logout() { // выход
if(Auth::instance()->logged_in()) {
Auth::instance()->logout(TRUE);
}
url::redirect('/');
}
public function register() { // регистрация
if(Auth::instance()->logged_in()) {
url::redirect('/');
} else {
$form = new Forge(url::current(), 'Регистрация');
$form->set_attr('method', 'post');
$form->input('username')
->label('Логин')
// допускается только пароль из латиницы, с цифрами знаками '_' и '-' от 4 до 32 символов
->rules('required|length[4,32]|valid_alpha_dash');
$form->password('password')->label('Пароль');
$form->password('password2')
->label('Опять пароль')
->rules('required|length[6,40]|valid_alpha_dash')
// это поле должно совпадать с полем 'password'
->matches($form->password);
$form->input('email')
->label('E-Mail')
->rules('required|valid_email');
$form->submit('Регистрация');
if ($form->validate()) {
$user = ORM::factory('user', $form->username->value);
// проверяем, нет ли уже такого пользователя
if (!$user->username_exists($form->username->value)) {
$user->username = $form->username->value;
$user->password = $form->password->value;
$user->email = $form->email->value;
// добавляем пользователя и ставим для него права
if($user->save() && $user->add(ORM::factory('role', 'login'))) {
Auth::instance()->login($user, $form->password->value);
url::redirect('/');
}
}
}
}
$this->template->content = $form->render();
}
}Интересный момент при создании формы регистрации, а именно метод matches() объекта, который возвращает метод Forge password. При обращении к нему, параметром нужно указать другой объект password, при несоответствии значений которых скрипт выдаст ошибку.
Виды
Виды (представления), отвечают за отображение информации и являются конечной прослойкой между пользователем и приложением.
Теперь немного о видах. Всего в нашем движке мы использовали три вида, поочередно о каждом:
category.php:
<?php foreach($cats as $content): ?>
<div class="category">
<h3><a href="/category/<?php echo $content->id ?>"><?php echo $content->name ?></a>/h3>
<ul>
<?php foreach($content->children as $l): ?>
<li><a href="/subcategory/<?php echo $l->id ?>"><?php echo $l->name ?></a></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>Как Вы помните, мы ему передавали массив с категориями и подкатегориями, который он и обрабатывает, в цикле выводя их все.
index.php:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Kohana</title>
</head>
<body>
<div id="enter" style="float:right">
<?php if(Auth::instance()->logged_in()) { ?>
Привет, <b><?php echo Auth::instance()->get_user()->username ?></b> • <a href="/logout">Выход</a>
<?php } else { ?>
<a href="/login">Войти</a> • <a href="/register">Регистрация</a>
<?php } ?>
</div>
<div id='links'>
<a href="/">Главная</a>
<a href="/add">Добавить</a>
</div>
<div id='content'>
<?php echo $content ?>
</div>
</body>
</html>Это «обвертка» для всех страниц, которые мы отображаем. В блоке enter у нас стоит такой-себе триггер, который, в зависимости от статуса пользователя отображает или его имя со ссылкой на выход, или ссылки на вход и регистрацию.
item.php:
<div class="item">
<?php if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&
Auth::instance()->get_user()->id == $content->user_id)) { ?>
<span style="float:right">
<a href="/items/edit/<?php echo $content->id ?>">Править</a>
<a href="/items/delete/<?php echo $content->id ?>">Удалить</a>
</span>
<?php } ?>
<h1><?php echo html::anchor('/' . $content->id, html::specialchars($content->title)) ?></h1>
<div class="other">Опубликовано <?php echo date("j-M-Y ", $content->datepub) ?>
пользователем <b><?php echo html::specialchars($content->>user->username) ?></b>
</div>
<div class="news">
<?php
echo text::auto_p($content->content);
?>
</div>
</div>Этот вид показывает нам, само объявление, выводя ссылки на удаление и редактирование в случае, если пользователь админ или автор объявления. Также можно заметить использование двух хелперов — html и text. html::specialchars делает строку «безопасной», html::anchor делает ссылку — первый параметр адрес, второй — текст, ну а text::auto_p автоматически добавляет абзацы к plain text, как сказано в офф. документации «nl2br() on steroids».
Окончательные настройки
Теперь немного по-настраиваем нашу «систему» перед запуском.
Создайте в папке /application/config/ файл database.php и пропишите в нем следуещее:
$config['default']['connection'] = array(
'type' => 'mysql',
'user' => 'пользователь',
'pass' => 'пароль',
'host' => 'сервер',
'database' => 'имя базы',
);Это, как Вы поняли, настройки для MySQL.
Далее, откройте файл config.php в той же папке и приведите массив $config['modules'] к виду
$config['modules'] = array
(
MODPATH.'auth',
MODPATH.'forge',
); Также следует изменить $config['site_domain'] на '/', если директории Kohana лежат у Вас в корне сайта. И настоятельно рекомендую установить значение $config['index_page'] в пустую строку, в ином случае, Kohana будет генерировать относительные ссылки с вставкой /index.php/ в URL (прим.: site.com/index.php/mail).
А теперь создайте файл routes.php и пропишите в нем
$config['_default'] = 'category';
$config['category/([0-9]+)'] = 'category/view/$1';
$config['subcategory/([0-9]+)'] = 'category/viewsub/$1';
$config['add'] = 'items/add';
$config['items/([0-9]+)'] = 'items/view/$1';
$config['edit/([0-9]+)'] = 'items/edit/$1';
$config['delete/([0-9]+)'] = 'items/delete/$1';
$config['login'] = 'user/login';
$config['logout'] = 'user/logout';
$config['register'] = 'user/register';
$config['([0-9]+)'] = 'items/view/$1';Эти настройки объявляют нестандартный роутинг, который мы будем использовать. В объявлении роутинга можно использовать регулярные выражения.
Forge, как я уже говорил выше, является модулем для работы с формами. Не так давно (начиная с 2.2; текущая 2.3) он был исключен из стандартной поставки Kohana, и теперь доступен тут — http://kohanamodules.googlecode.com/svn/tags/2.2/forge/
Также хочу обратить внимание, что у меня возникли проблемы при валидации select в Forge. На багтрэкере Koahna нашел два тикета с этой проблемой, оба помечены, как решенные. Может у меня руки кривые, может у кого-то ещё, но я немного поправил код /modules/forge/libraries/Form_Dropdown.php и ровно на строчке 71 вставил вот такой вот код:
foreach($this->data['options'] as $l) {
if(is_array($l) && (array_key_exists($this->value, $l))) {
return $this->is_valid = TRUE;
}
}
На уникальную панацею не претендует, но для нашей двойной вложенности сойдет.
Ещё раз проверьте правильность расположения наших файлов:
/application/config/config.php
/database.php
/routes.php
/controllers/category.php
/items.php
/user.php
/models/category.php
/item.php
/views/category.php
/index.php
/item.php
/modules/auth/
/forge/
/forge/libraries/Form_Dropdown.php (*)
* — наша, немного подправленная, библиотека
Всё. Спасибо за внимание :)
Готовый код — nergal.org.ua/application.zip
Вместо послесловия
Хочу сказать, что данная статья не есть стопроцентно верным руководством к написанию доски объявлений, это всего лишь пример кода, который в своё время был мне очень нужен, но я не мог его найти. Поэтому, немного разобравшись, я его написал сам и решил поделиться, вдруг кому-либо понадобиться =)
P.S. Не судите слишком строго, это моя первая публикация на Хабре =)
UPD: Убрал недочет с пересчетом объявлений. Обновлю — выложу зановно.
_________
ХабраРедактор



комментарии (82)