Введение
Система групповой работы TeamWox изначально предоставляет возможность эффективного и удобного поиска в модулях из стандартной поставки. Но как быть с данными пользовательских модулей? Ответ очевиден - нарастить функциональность своего модуля возможностью поиска с помощью TeamWox API.
Модуль 'Поиск' позволяет искать данные по всем модулям TeamWox. При поиске данных учитываются права доступа пользователя. В этой статье мы рассмотрим работу с API этого модуля. На примере модуля Hello World, вы узнаете как организовать индексирование и поиск данных для пользовательского модуля.
Как работает модуль 'Поиск'
API модуля 'Поиск'
Пример реализации поиска для пользовательского модуля
1. Подключение API модуля 'Поиск' в проект
2. Реализация интерфейса ISearchClient
3. Расширение менеджера модуля
4. Индексирование
4a. Индексирование отдельной записи
4b. Полное реиндексирование модуля
5. Отображение результатов поискового запроса
6. Демонстрация
Как работает модуль 'Поиск'
Поиск информации в системе TeamWox выполняется с помощью модуля Поиск, входящего в стандартную поставку. Этот модуль одинаково работает со всеми другими модулями TeamWox - сначала он индексирует и обрабатывает их данные, после чего пользователи получают возможность искать информацию на отдельное странице, вводя поисковые запросы.
В соответствии с идеологией системы TeamWox, чтобы расширить функционал модуля необходимо реализовать тот или иной интерфейс, доступный в TeamWox API. В случае с поиском, в пользовательском модуле нам потребуется реализовывать интерфейс ISearchClient, предоставляемый API модуля поиска.
Рассмотрим принцип работы модуля 'Поиск' на следующей схеме:
Далее по тексту, для удобства мы будем называть модуль поиска просто Поиск, а пользовательский модуль или модуль из стандартной поставки - просто Модуль.
Существует два режима взаимодействия Поиска и Модуля.
1. Индексирование данных в режиме реального времени. Модуль обращается к Поиску, чтобы проинформировать его об изменениях в данных.
2. Полная реиндексация. Поиск обращается к конкретному Модулю при полной реиндексации, которая выполняется в штатном режиме по расписанию, либо по запросу администратора сервера TeamWox. В этом случае Модуль предоставляет Поиску всю доступную информацию о данных.
Разработчик Модуля сам решает, каким образом хранить данные (в СУБД или файловом хранилище) и какие именно данные предоставлять Поиску для индексации и отображения.
Отображение результатов поиска
В Поиске хранятся только идентификаторы записей Модуля (+ другая служебная информация) в отдельном специализированном хранилище (см. также статью Как ускорить работу TeamWox за счет хранения компонентов на разных дисках). Для отображения результатов поискового запроса Поиск обращается к Модулю, чтобы заполнить информацию о найденной записи. В качестве аргумента передается ее идентификатор.
API модуля 'Поиск'
API модуля 'Поиск' (сам модуль имеет идентификатор TWX_SEARCH
) входит в состав TeamWox SDK. Вы найдете его в файле <папка установки TeamWox SDK>\SDK\API\Search.h
. Рассмотрим интерфейсы API:
ISearchManager
- Это основной интерфейс, через который вызываются функциональные возможности Поиска.ISearchIndexer
- Интерфейс индексации данных.ISearchClient
- Интерфейс, который Модуль должен реализовывать для взаимодействия с Поиском.ISearchResult
- Интерфейс для отображения результатов поиска. Этот объект передается в модуль для заполнения данными. МетодыSet
применяются для заполнения объекта данными, а методыGet
- для их отображения на странице с результатами поиска.ISearchRequest
- Интерфейс запроса с пользовательскими правами доступа.
Полное описание интерфейсов модуля 'Поиск' вы найдете в документации по TeamWox SDK.
Пример реализации поиска для пользовательского модуля
Рассмотрим реализацию поиска для пользовательского модуля на примере учебного модуля Hello World. В качестве отправной точки вы можете загрузить и распаковать архив к предыдущей публикации Настройка окружения пользовательских модулей - Часть 2 из цикла статей по TeamWox SDK. Финальный вариант со всеми описываемыми в этой статье изменениями доступен в виде прикрепленного файла.
На текущий момент для модуля Hello World мы уже реализовали некоторый функционал: добавление записей с их последующим хранением в СУБД и файловом хранилище, а также возможность составления отчетов по этим данным. Следующий этап расширения функциональности - возможность индексирования данных модуля для их поиска, а также поддержание поискового индекса в актуальном состоянии при изменении и удалении данных модуля.
За основу реализации взят функционал модуля Форум, исходный код которого доступен в TeamWox SDK.
1. Подключение API модуля 'Поиск' в проект
1. Откройте проект HelloWorld.sln
, затем в Visual Studio откройте файл stdafx.h
.
2. Подключите файл Search.h
. В нашем примере проект модуля находится в папке <папка установки TeamWox SDK>\Modules\HelloWorld\
, поэтому используем относительный путь на два уровня выше. Скорректируйте его, если распаковали проект в другую папку.
//--- #include "..\..\SDK\API\Core.h" #include "..\..\SDK\API\Search.h" //---
3. Добавьте файл <папка установки TeamWox SDK>\SDK\API\Search.h
в проект, расположив его в папке \Header Files\API\
.
2. Реализация интерфейса ISearchClient
1. В проекте добавьте новый класс клиента поиска, расположив его согласно принятой иерархии в категории менеджеров.
2. Объявите класс CHelloWorldSearch
, реализующий методы интерфейса ISearchClient
. Реализацию метода ISearchClient::Release
запишем сразу же в объявлении класса.
//+------------------------------------------------------------------+ //| TeamWox | //| Copyright 2006-2010, MetaQuotes Software Corp. | //| https://www.metaquotes.net | //+------------------------------------------------------------------+ #pragma once //+------------------------------------------------------------------+ //| Класс для клиента поиска | //+------------------------------------------------------------------+ class CHelloWorldSearch : public ISearchClient { private: IServer *m_server; // интерфейс сервера TeamWox class CHelloWorldManager &m_manager; // наш внутренний интерфейс public: CHelloWorldSearch(IServer *server,class CHelloWorldManager &manager); ~CHelloWorldSearch(); //--- интерфейсные void Release() { delete this; } TWRESULT Reindex(ISearchManager *search_mngr); TWRESULT FillResult(const Context *context, ISearchResult *result); TWRESULT ModifyRequest(const Context *context, ISearchRequest *request); private: void operator=(CHelloWorldSearch&) {} }; //+------------------------------------------------------------------+
3. Перечислите интерфейс ISearchClient
(и реализующий его класс CHelloWorldSearch
) в списке реализуемых интерфейсов модуля Hello World.
//+------------------------------------------------------------------+ //| Получение интерфейсов модуля | //+------------------------------------------------------------------+ TWRESULT CHelloWorldModule::GetInterface(const wchar_t *name, void **iface) { //--- проверка if(name==NULL || iface==NULL) return(RES_E_INVALID_ARGS); //--- if(StringCompareExactly(L"IToolbar",name)) { *iface=&m_toolbar; return(RES_S_OK); } if(StringCompareExactly(L"IModuleMainframeTopBar",name)) { *iface=static_cast<IModuleMainframeTopBar*>(this); return(RES_S_OK); } if(StringCompareExactly(L"IModuleTips", name)) { *iface=static_cast<IModuleTips*>(this); return(RES_S_OK); } if(StringCompareExactly(L"IWidgets",name)) { *iface=&m_widgets; return(RES_S_OK); } if(StringCompareExactly(L"ISearchClient",name)) { *iface=new (std::nothrow) CHelloWorldSearch(m_server,m_manager); if(*iface==NULL) ReturnError(RES_E_OUT_OF_MEMORY); return(RES_S_OK); } //--- return(RES_E_NOT_FOUND); }
4. Реиндексацию и выдачу результатов поиска мы будем фактически реализовывать в менеджере модуля, а в методах ISearchClient::Reindex
и ISearchClient::FillResult
мы просто передадим управление на соответствующие методы менеджера модуля.
ISearchClient::Reindex
//+------------------------------------------------------------------+ //| Полная реиндексация модуля | //+------------------------------------------------------------------+ TWRESULT CHelloWorldSearch::Reindex(ISearchManager *search_manager) { //--- прокидываем дальше, запускаем процесс реиндексации в менеджере return(m_manager.SearchReindex(search_manager)); }
ISearchClient::FillResult
//+------------------------------------------------------------------+ //| Заполнение данных | //+------------------------------------------------------------------+ TWRESULT CHelloWorldSearch::FillResult(const Context *context, ISearchResult *result) { //--- прокидываем дальше return(m_manager.SearchFillResult(context,result)); }
5. В нашем примере нет необходимости реализовывать запрос с пользовательскими правам доступа.
//+------------------------------------------------------------------+ //| Модификация запросов | //+------------------------------------------------------------------+ TWRESULT CHelloWorldSearch::ModifyRequest(const Context* /*context*/, ISearchRequest* /*request*/) { return(RES_E_NOT_IMPLEMENTED); }
3. Расширение менеджера модуля
В менеджере модуля нам потребуется реализовать упомянутые ранее методы реиндексации (SearchReindex
) и выдачи результатов поиска (SearchFillResult
). Для этого нам потребуется получить ссылку на интерфейс ISearchManager
.
При обновлении записи (метод CHelloWorldManager::Update
) будем вызывать процедуру ее индексации (метод SearchReindexRecord
), а при удалении записи (метод CHelloWorldManager::InfoDelete
) - удалим информацию о записи и комментариев к ней из поискового индекса (метод SearchRemoveRecord
).
1. В классе менеджера модуля объявите указатель на интерфейс менеджера поиска.
//+------------------------------------------------------------------+ //| Менеджер модуля | //+------------------------------------------------------------------+ class CHelloWorldManager : public IReportsProvider { private: static HelloWorldRecord m_info_records[]; // список общедоступной информации static HelloWorldRecordAdv m_advanced_records[]; // список информации с ограниченным доступом //--- УСТАНАВЛИВАЕМ ЗНАЧЕНИЕ ТОЛЬКО ПРИ ИНИЦИАЛИЗАЦИИ IServer *m_server; // ссылка на сервер IFilesManager *m_files_manager; // файловый менеджер ISearchManager *m_search_manager; // менеджер модуля 'Поиск' IComments *m_comments_manager; // менеджер комментариев
2. Поскольку Поиск - это отдельный НЕ СИСТЕМНЫЙ модуль, то его интерфейс (как и интерфейс любого другого НЕ СИСТЕМНОГО модуля) следует получать на втором этапе инициализации.
- Описание
//+------------------------------------------------------------------+ //| Менеджер модуля | //+------------------------------------------------------------------+ class CHelloWorldManager : public IReportsProvider { public: CHelloWorldManager(); ~CHelloWorldManager(); //--- TWRESULT Initialize(IServer *server, int prev_build); TWRESULT PostInitialize();
- Реализация
//+------------------------------------------------------------------+ //| Второй этап инициализации менеджера | //+------------------------------------------------------------------+ TWRESULT CHelloWorldManager::PostInitialize() { //--- проверки if(m_server==NULL) ReturnError(RES_E_INVALID_ARGS); //--- Получим ссылку на интерфейс поиска. Если не удалось, продолжим работать без него (сообщать об этом не надо) m_server->GetInterface(TWX_SEARCH,L"ISearchManager",(void**)&m_search_manager); //--- return(RES_S_OK); }
3. В менеджере модуля объявите следующие методы:
SearchReindexRecord
- индексирование записи при ее измененииSearchRemoveRecord
- удаление записи из поискового индексаSearchFillResult
- формирование страницы с результатами поискаSearchReindex
- полная реиндексация данных модуляReadDescription
- утилитарный метод чтения содержимого записи из файлового хранилищаSearchRecordAdd
- индексирование содержимого, прочитанного из файлового хранилища
//+------------------------------------------------------------------+ //| Менеджер модуля | //+------------------------------------------------------------------+ class CHelloWorldManager : public IReportsProvider { public: //--- поиск TWRESULT SearchReindex(ISearchManager *search_manager); TWRESULT SearchFillResult(const Context *context,ISearchResult *result); private: //--- TWRESULT ReadDescription(HelloWorldRecord* record, wchar_t** description, size_t* description_len, size_t* description_alocated); //--- TWRESULT SearchRecordAdd(ISearchManager* search_manager, ISearchIndexer* indexer, HelloWorldRecord* record, const wchar_t* description, size_t description_len); //--- TWRESULT SearchReindexRecord(const Context* context,HelloWorldRecord* record); TWRESULT SearchRemoveRecord(const Context* context,INT64 record_id);
4. Добавьте вызов процедуры реиндексации в метод CHelloWorldManager::Update
. Так при любом изменении записи информация будет гарантировано проиндексирована.
//+------------------------------------------------------------------+ //| Добавление, сохранение записи в таблицу HELLOWORLD | //+------------------------------------------------------------------+ TWRESULT CHelloWorldManager::Update(const Context *context,HelloWorldRecord *record,AttachmentsModify *attachments) { ............................. //--- переиндексируем сообщение SearchReindexRecord(context,record); //--- return(RES_S_OK); }
5. В метод CHelloWorldManager::InfoDelete
добавьте вызов процедуры удаления информации о записи и комментариев к ней из поискового индекса.
//+------------------------------------------------------------------+ //| Удаление записи из таблицы HELLOWORLD | //+------------------------------------------------------------------+ TWRESULT CHelloWorldManager::InfoDelete(const Context *context,INT64 id) { ............................. //--- удалим из поиска SearchRemoveRecord(context,id); //--- return(RES_S_OK); }
4. Индексирование
В системе TeamWox индексирование выполняется с определенной задержкой, которая напрямую зависит от частоты добавления содержимого и его объема. Такой подход оптимизирует нагрузку на сервер: данные сначала накапливаются, а затем по достижению определенного объема или по истечении определенного количества времени эти данные обрабатываются в пакетном режиме.
В файле HelloWorldManager.h
создадим перечисление EnHelloWorldSearchId
, содержащее два идентификатора. Эти идентификаторы будут определять категории индексируемых записей: первый - содержимое записи, второй - содержимое комментариев.
enum EnHelloWorldSearchId { HELLOWORLD_BODY_SEARCH_ID =0, HELLOWORLD_COMMENT_SEARCH_ID=1 };
4a. Индексирование отдельной записи
1. Сначала создается указатель на интерфейс индексатора ISearchIndexer
. Каждый раз при запуске процедуры реиндексации создается новый объект индексатора, в который добавляются все связанные с записью данные.
//--- получаем индексатор CSmartInterface<ISearchIndexer> indexer; if(RES_FAILED(res=m_search_manager->GetSearchIndexer(&indexer)
2. Считывается содержимое записи, а затем после обработки оно добавляется в индексатор.
//--- прочитаем описание ReadDescription(record,&description,&description_len,&description_allocated); //--- добавим/обновим запись в поиске SearchRecordAdd(m_search_manager,indexer,record,description,description_len);
3. Комментарии - это стандартный функционал TeamWox. Разработчикам нет необходимости писать процедуры индексирования их содержимого - все необходимое предоставляет интерфейс IComments
. В частности, для индексирования комментариев к записи используется метод IComments::SearchReindexRecord
.
//--- проиндексируем комментарии m_comments_manager->SearchReindexRecord(context->sql,m_search_manager,indexer,0,NULL,0, HELLOWORLD_COMMENT_TYPE_COMMENTS,0,record->id, HELLOWORLD_COMMENT_SEARCH_ID);
4. Ключевые этапы в реализации метода SearchRecordAdd
.
Сначала в объект индексатора мы поместим данные записи. В нашем примере это будет обычный текст без учета HTML тэгов форматирования.
В нашем примере тексту заголовков мы дадим больший вес (2.0), чем тексту описания (1.0). Так при поиске запись с искомыми словами в заголовке будет иметь большую релевантность, чем запись с теми же словами только в описании.
//--- indexer->AddText(SEARCH_PLAIN_TEXT,record->name,2.0f); //--- if(description!=NULL && description_len>0) indexer->AddText(SEARCH_PLAIN_TEXT,description,description_len,1.0f);
После этого нам нужно будет сопоставить эти данные с некоторым уникальным идентификатором, используя метод
ISearchManager::Insert
. В нашем примере он будет состоять из двух частей: константыHELLOWORLD_BODY_SEARCH_ID
(из созданного перечисленияEnHelloWorldSearchId
) и идентификатора записи в СУБД.
//--- добавляем проиндексированные данные
search_manager->Insert(HELLOWORLD_MODULE_ID,SEARCH_GENERIC,HELLOWORLD_BODY_SEARCH_ID,record->id,indexer);
В общем случае достаточно указать лишь один из аргументов record1
или record2
метода ISearchManager::Insert
. Возможность указать второй идентификатор придает большую гибкость при реализации поиска в модуле.
record1
и record2
для каждой конкретной реализации индексирования данных остается на усмотрение разработчика.5. При удалении содержимого записи из поискового индекса последовательно удаляются содержимое записи и содержимое комментариев к ней.
//+------------------------------------------------------------------+ //| Удаление записи из поискового индекса | //+------------------------------------------------------------------+ TWRESULT CHelloWorldManager::SearchRemoveRecord(const Context *context,INT64 record_id) { //--- проверки if(context==NULL || m_search_manager==NULL || m_comments_manager==NULL) ReturnError(RES_E_INVALID_ARGS); //--- m_search_manager->Remove(HELLOWORLD_MODULE_ID,SEARCH_GENERIC,HELLOWORLD_BODY_SEARCH_ID,record_id); //--- m_comments_manager->SearchRemove(context,m_search_manager,HELLOWORLD_COMMENT_TYPE_COMMENTS,record_id,HELLOWORLD_COMMENT_SEARCH_ID); //--- return(RES_S_OK); }
4b. Полное реиндексирование модуля
Эта процедура аналогична индексированию отдельной записи. Модуль поиска очищает поисковый индекс и последовательно индексирует все записи выбранных модулей. Отличие заключается в области применения - в некоторых случаях эффективнее выполнить полное реиндексирование, чем индексировать каждую запись по отдельности:
- Нештатное прекращение работы сервера. В этом случае полное реиндексирование запускается автоматически.
- Нештатное прекращение работы сервера во время полного реиндексирования. В этом случае необходимо завершить начатую процедуру.
- Недоработки в модуле.
- Массовое удаление записей.
Полное реиндексирование записей выполняется в служебное время по расписанию (раз в неделю). По этой причине поисковые индексы не затрагиваются при резервном копировании.
Рассмотрим реализацию метода полной реиндексации SearchReindex
.
1. В одном запросе делаем выборку всех записей.
//--- CSmartSql sql(m_server); if(sql==NULL) ReturnError(RES_E_FAIL); //--- текст SQL-запроса на получение строки из таблицы HELLOWORLD по указанному ID. char query_select_string[]="SELECT id,name,category,description_id,attachments_id FROM helloworld"; //--- "Привязываем" данные к параметрам запроса SqlParam params_query_select_string[] ={ SQL_INT64, &record.id, sizeof(record.id), SQL_WTEXT, record.name, sizeof(record.name), SQL_LONG, &record.category, sizeof(record.category), SQL_INT64, &record.description_id,sizeof(record.description_id), SQL_TEXT, record.attachments_id,sizeof(record.attachments_id) }; //--- шлем запрос if(!sql.Query(query_select_string, NULL, 0, params_query_select_string, _countof(params_query_select_string))) ReturnErrorExt(RES_E_SQL_ERROR,NULL,"helloworld record query failed");
2. Индексируем последовательно все записи. Аналогично индексированию отдельной записи, индексируется содержимое каждой записи и комментариев к ней.
//--- получаем ответ while(sql->QueryFetch()) { description_len=0; //--- прочитаем описание ReadDescription(&record,&description,&description_len,&description_allocated); //--- добавим/обновим запись в поиске SearchRecordAdd(m_search_manager,indexer,&record,description,description_len); //--- проиндексируем комментарии m_comments_manager->SearchReindexRecord(sql.Interface(), search_manager, indexer,0,NULL,0, HELLOWORLD_COMMENT_TYPE_COMMENTS, 0,record.id, HELLOWORLD_COMMENT_SEARCH_ID);
3. Для получения информации о текущем состоянии процесса реиндексирования рекомендуется выводить в журнал соответствующие отладочные сообщения (например, через каждые 500 успешно проиндексированных записей). По завершении процесса выводим в журнал также общее количество проиндексированных записей.
//--- if((++count)%500==0) ExtLogger(NULL, LOG_STATUS_INFO) << count << " records reindexed"; //--- зачистка ZeroMemory(&record,sizeof(record)); } //--- ExtLogger(NULL, LOG_STATUS_INFO) << count << " records reindexed"; //--- if(description!=NULL) { delete [] description; /*description=NULL;*/ } //--- освободим запрос sql.QueryFree(); //--- return(RES_S_OK);
5. Отображение результатов поискового запроса
Поисковый запрос возвращает лишь идентификаторы найденных записей. Разработчик модуля решает, какую часть содержимого записи затем отобразить по этим идентификаторам на странице с результатами поиска.
Рассмотрим реализацию метода ISearchClient::FillResult
(в методе CHelloWorldManager::SearchFillResult
).
1. Получим идентификаторы найденных записей.
//--- проверим параметры const SearchResult *data=result->GetResult(); if(data==NULL || data->module_id!=HELLOWORLD_MODULE_ID) ReturnErrorExt(RES_E_INVALID_ARGS,context,"invalid parameters for search result");
2. Необходимо определить, к какому типу записей относится найденный идентификатор - либо непосредственно к исходной записи, либо к ее комментариям.
//--- определим откуда брать информацию switch(data->record1) { case HELLOWORLD_COMMENT_SEARCH_ID: m_comments_manager->SearchFillResult(context,result,data->record2,comment_url,_countof(url),&record_id); break; case HELLOWORLD_BODY_SEARCH_ID: default: record_id=data->record2; }
В случае с комментариями опять же все необходимое предоставляется интерфейсом IComments
. В частности, для отображения результатов поиска в комментариях к записи используется метод IComments::SearchFillResult
.
3. По найденному идентификатору получим запись из СУБД.
//--- if(record_id==0) return(RES_E_NOT_FOUND); //--- if(RES_FAILED(res=Get(context,record_id,&record))) { ExtLogger(context,LOG_STATUS_ERROR) << "failed to get record info #" << record_id << " for search [" << res << "]"; return(res); }
4. Теперь необходимо заполнить страницу с результатами поиска. В общем случае такая страница состоит из заголовка записи (в виде ссылки), краткого описания (включает в себя часть содержимого найденной записи), информации об дате изменения, списка назначенных сотрудников и т.д. В нашем примере мы ограничимся лишь заголовком-ссылкой и кратким описанием. Дата изменения записи выводится в результате запроса по умолчанию.
- Заголовок
//--- выставим заголовок
result->SetTitle(record.name);
- Ссылка для заголовка
//--- сформируем и укажем URL if(comment_url[0]!=0) StringCchPrintf(url,_countof(url),L"/helloworld/number_two/view/%I64d?%s",record_id,comment_url); else StringCchPrintf(url,_countof(url),L"/helloworld/number_two/view/%I64d",record_id); //--- result->SetUrl(url);
- Описание
//--- сформируем описание if(data->record1==HELLOWORLD_BODY_SEARCH_ID && record.description_id>0) { if(RES_SUCCEEDED(m_files_manager->FileInfoGet(NULL,record.description_id,&file_info)) && file_info.size>0 && (file_info.size%2)==0) { wchar_t description_stat[512]={0}; //--- if(file_info.size>sizeof(description_stat)) { UINT64 len =file_info.size; wchar_t *description=new wchar_t[size_t(len/sizeof(wchar_t))]; //--- if(description==NULL) ReturnErrorExt(RES_E_OUT_OF_MEMORY,context,"failed not enough memory for description content"); //--- if(RES_SUCCEEDED(m_files_manager->FileRead(NULL,record.description_id,description,&len,0))) result->AddText(SEARCH_PLAIN_TEXT,description,int(len/sizeof(wchar_t))); //--- delete []description; } else { UINT64 len=file_info.size; if(RES_SUCCEEDED(m_files_manager->FileRead(NULL,record.description_id,description_stat,&len,0))) result->AddText(SEARCH_PLAIN_TEXT,description_stat,int(len/sizeof(wchar_t))); } } }
6. Демонстрация
Продемонстрируем реализованный функционал.
1. На странице Page 2 добавьте новую запись в визуальном редакторе.
2. Подождите немного, либо полностью реиндексируйте модуль Hello World.
3. Выполните поиск по добавленным ранее данным.
4. Результаты поиска представляются на отдельной странице.
Заключение
Как видите реализовать поиск в своем модуле не так уж сложно. Во второй части статьи мы поговорим о фильтрации результатов поиска.
- Как добавить готовый модуль в TeamWox
- Как добавить страницу в модуль TeamWox
- Построение пользовательского интерфейса
- Взаимодействие с СУБД
- Создание пользовательских отчетов
- Файловое хранилище - Часть 1
- Файловое хранилище - Часть 2
- Настройка окружения пользовательских модулей - Часть 1
- Настройка окружения пользовательских модулей - Часть 2
- Поиск и фильтрация - Часть 1
- Поиск и фильтрация - Часть 2
- Настройка "Онлайн-консультанта" на вашем сайте
- Как создать дополнительный языковой пакет для TeamWox
2011.08.16