1: <?php
2:
3: /**
4: * This file contains the class for content search.
5: *
6: * @package Core
7: * @subpackage Frontend_Search
8: * @author Willi Man
9: * @copyright four for business AG <www.4fb.de>
10: * @license http://www.contenido.org/license/LIZENZ.txt
11: * @link http://www.4fb.de
12: * @link http://www.contenido.org
13: */
14:
15: defined('CON_FRAMEWORK') || die('Illegal call: Missing framework initialization - request aborted.');
16:
17: cInclude('includes', 'functions.encoding.php');
18:
19: /**
20: * CONTENIDO API - Search Object
21: *
22: * This object starts a indexed fulltext search.
23: *
24: * TODO:
25: * - The way to set the search options could be done much more better!
26: * - The computation of the set of searchable articles should not be
27: * treated in this class.
28: * - It is better to compute the array of searchable articles from the
29: * outside and to pass the array of searchable articles as parameter.
30: * - Avoid foreach loops.
31: *
32: * Use object with
33: *
34: * $options = array(
35: * // use db function regexp
36: * 'db' => 'regexp',
37: * // combine searchwords with or
38: * 'combine' => 'or'
39: * );
40: *
41: * The range of searchable articles is by default the complete content
42: * which is online and not protected.
43: *
44: * With option 'searchable_articles' you can define your own set of
45: * searchable articles.
46: *
47: * If parameter 'searchable_articles' is set, the options 'cat_tree',
48: * 'categories', 'articles', 'exclude', 'artspecs', 'protected' and
49: * 'dontshowofflinearticles' won't have any effect.
50: *
51: * $options = array(
52: * // use db function regexp
53: * 'db' => 'regexp',
54: * // combine searchwords with or
55: * 'combine' => 'or',
56: * 'searchable_articles' => array(5, 6, 9, 13)
57: * );
58: *
59: * One can define the range of searchable articles by setting the
60: * parameter 'exclude' to false which means the range of categories
61: * defined by parameter 'cat_tree' or 'categories' and the range of
62: * articles defined by parameter 'articles' is included.
63: *
64: * $options = array(
65: * // use db function regexp
66: * 'db' => 'regexp',
67: * // combine searchwords with or
68: * 'combine' => 'or',
69: * // searchrange specified in 'cat_tree', 'categories' and
70: * // 'articles' is included
71: * 'exclude' => false,
72: * // tree with root 12 included
73: * 'cat_tree' => array(12),
74: * // categories 100, 111 included
75: * 'categories' => array(100,111),
76: * // article 33 included
77: * 'articles' => array(33),
78: * // array of article specifications => search only articles with
79: * // these artspecs
80: * 'artspecs' => array(2, 3),
81: * // results per page
82: * 'res_per_page' => 2,
83: * // do not search articles or articles in categories which are
84: * // offline or protected
85: * 'protected' => true,
86: * // search offline articles or articles in categories which are
87: * // offline
88: * 'dontshowofflinearticles' => false
89: * );
90: *
91: * You can build the complement of the range of searchable articles by
92: * setting the parameter 'exclude' to true which means the range of
93: * categories defined by parameter 'cat_tree' or 'categories' and the
94: * range of articles defined by parameter 'articles' is excluded from
95: * search.
96: *
97: * $options = array(
98: * // use db function regexp
99: * 'db' => 'regexp',
100: * // combine searchwords with or
101: * 'combine' => 'or',
102: * // searchrange specified in 'cat_tree', 'categories' and
103: * // 'articles' is excluded
104: * 'exclude' => true,
105: * // tree with root 12 excluded
106: * 'cat_tree' => array(12),
107: * // categories 100, 111 excluded
108: * 'categories' => array(100,111),
109: * // article 33 excluded
110: * 'articles' => array(33),
111: * // array of article specifications => search only articles with
112: * // these artspecs
113: * 'artspecs' => array(2, 3),
114: * // results per page
115: * 'res_per_page' => 2,
116: * // do not search articles or articles in categories which are
117: * // offline or protected
118: * 'protected' => true,
119: * // search offline articles or articles in categories which are
120: * // offline
121: * 'dontshowofflinearticles' => false
122: * );
123: *
124: * $search = new Search($options);
125: *
126: * // search only in these cms-types
127: * $search->setCmsOptions(array(
128: * "htmlhead",
129: * "html",
130: * "head",
131: * "text",
132: * "imgdescr",
133: * "link",
134: * "linkdescr"
135: * ));
136: *
137: * // start search
138: * $search_result = $search->searchIndex($searchword, $searchwordex);
139: *
140: * The search result structure has following form
141: * Array (
142: * [20] => Array (
143: * [CMS_HTML] => Array (
144: * [0] => 1
145: * [1] => 1
146: * [2] => 1
147: * )
148: * [keyword] => Array (
149: * [0] => content
150: * [1] => contenido
151: * [2] => wwwcontenidoorg
152: * )
153: * [search] => Array (
154: * [0] => con
155: * [1] => con
156: * [2] => con
157: * )
158: * [occurence] => Array (
159: * [0] => 1
160: * [1] => 5
161: * [2] => 1
162: * )
163: * [similarity] => 60
164: * )
165: * )
166: *
167: * The keys of the array are the article ID's found by search.
168: *
169: * Searching 'con' matches keywords 'content', 'contenido' and
170: * 'wwwcontenidoorg' in article with ID 20 in content type CMS_HTML[1].
171: * The search term occurs 7 times.
172: * The maximum similarity between searchterm and matching keyword is 60%.
173: *
174: * // rank and display the results
175: * $oSearchResults = new cSearchResult($search_result, 10);
176: *
177: * @package Core
178: * @subpackage Frontend_Search
179: */
180: class cSearch extends cSearchBaseAbstract {
181:
182: /**
183: * Instance of class Index
184: *
185: * @var object
186: */
187: protected $_index;
188:
189: /**
190: * search words
191: *
192: * @var array
193: */
194: protected $_searchWords = array();
195:
196: /**
197: * words which should be excluded from search
198: *
199: * @var array
200: */
201: protected $_searchWordsExclude = array();
202:
203: /**
204: * type of db search
205: *
206: * like => 'sql like'
207: * regexp => 'sql regexp'
208: *
209: * @var string
210: */
211: protected $_searchOption;
212:
213: /**
214: * logical combination of searchwords (and, or)
215: *
216: * @var string
217: */
218: protected $_searchCombination;
219:
220: /**
221: * array of searchable articles
222: *
223: * @var array
224: */
225: protected $_searchableArts = array();
226:
227: /**
228: * article specifications
229: *
230: * @var array
231: */
232: protected $_articleSpecs = array();
233:
234: /**
235: * If $protected = true => do not search articles which are offline
236: * or articles in catgeories which are offline (protected) unless
237: * the user has access to them.
238: *
239: * @var bool
240: */
241: protected $_protected;
242:
243: /**
244: * If $dontshowofflinearticles = false => search offline articles or
245: * articles in categories which are offline.
246: *
247: * @var bool
248: */
249: protected $_dontshowofflinearticles;
250:
251: /**
252: * If $exclude = true => the specified search range is excluded from
253: * search, otherwise included.
254: *
255: * @var bool
256: */
257: protected $_exclude;
258:
259: /**
260: * Array of article id's with information about cms-types, occurence
261: * of keyword/searchword, similarity.
262: *
263: * @var array
264: */
265: protected $_searchResult = array();
266:
267: /**
268: * Constructor to create an instance of this class.
269: *
270: * @param array $options
271: * $options['db']
272: * 'regexp' => DB search with REGEXP
273: * 'like' => DB search with LIKE
274: * 'exact' => exact match;
275: * $options['combine']
276: * 'and', 'or' Combination of search words with AND, OR
277: * $options['exclude']
278: * 'true' => searchrange specified in 'cat_tree', 'categories'
279: * and 'articles' is excluded;
280: * 'false' => searchrange specified in 'cat_tree', 'categories'
281: * and 'articles' is included
282: * $options['cat_tree']
283: * e.g. array(8) => The complete tree with root 8 is in/excluded
284: * from search
285: * $options['categories']
286: * e.g. array(10, 12) => Categories 10, 12 in/excluded
287: * $options['articles']
288: * e.g. array(23) => Article 33 in/excluded
289: * $options['artspecs']
290: * e.g. array(2, 3) => search only articles with certain article
291: * specifications
292: * $options['protected']
293: * 'true' => do not search articles which are offline (locked)
294: * or articles in catgeories which are offline (protected)
295: * $options['dontshowofflinearticles']
296: * 'false' => search offline articles or articles in categories
297: * which are offline
298: * $options['searchable_articles']
299: * array of article ID's which should be searchable
300: * @param cDb $db [optional]
301: * CONTENIDO database object
302: *
303: * @throws cDbException
304: * @throws cInvalidArgumentException
305: */
306: public function __construct($options, $db = NULL) {
307: parent::__construct($db);
308:
309: $this->_index = new cSearchIndex($db);
310:
311: $this->_searchOption = (array_key_exists('db', $options)) ? cString::toLowerCase($options['db']) : 'regexp';
312: $this->_searchCombination = (array_key_exists('combine', $options)) ? cString::toLowerCase($options['combine']) : 'or';
313: $this->_protected = (array_key_exists('protected', $options)) ? $options['protected'] : true;
314: $this->_dontshowofflinearticles = (array_key_exists('dontshowofflinearticles', $options)) ? $options['dontshowofflinearticles'] : true;
315: $this->_exclude = (array_key_exists('exclude', $options)) ? $options['exclude'] : true;
316: $this->_articleSpecs = (array_key_exists('artspecs', $options) && is_array($options['artspecs'])) ? $options['artspecs'] : array();
317:
318: if (array_key_exists('searchable_articles', $options) && is_array($options['searchable_articles'])) {
319: $this->_searchableArts = $options['searchable_articles'];
320: } else {
321: $this->_searchableArts = $this->getSearchableArticles($options);
322: }
323:
324: // minimum similarity between searchword and keyword in percent
325: $this->intMinimumSimilarity = 50;
326: }
327:
328: /**
329: * indexed fulltext search
330: *
331: * @param string $searchwords
332: * The search words
333: * @param string $searchwords_exclude [optional]
334: * The words, which should be excluded from search
335: *
336: * @return bool|array
337: * @throws cDbException
338: * @throws cException
339: * @throws cInvalidArgumentException
340: */
341: public function searchIndex($searchwords, $searchwords_exclude = '') {
342: if (cString::getStringLength(trim($searchwords)) > 0) {
343: $this->_searchWords = $this->stripWords($searchwords);
344: } else {
345: return false;
346: }
347:
348: if (cString::getStringLength(trim($searchwords_exclude)) > 0) {
349: $this->_searchWordsExclude = $this->stripWords($searchwords_exclude);
350: }
351:
352: $tmp_searchwords = array();
353: foreach ($this->_searchWords as $word) {
354: $wordEscaped = cSecurity::escapeDB($word, $this->db);
355: if ($this->_searchOption == 'like') {
356: $wordEscaped = "'%" . $wordEscaped . "%'";
357: } elseif ($this->_searchOption == 'exact') {
358: $wordEscaped = "'" . $wordEscaped . "'";
359: }
360: $tmp_searchwords[] = $wordEscaped;
361: }
362:
363: if (count($this->_searchWordsExclude) > 0) {
364: foreach ($this->_searchWordsExclude as $word) {
365: $wordEscaped = cSecurity::escapeDB($word, $this->db);
366: if ($this->_searchOption == 'like') {
367: $wordEscaped = "'%" . $wordEscaped . "%'";
368: } elseif ($this->_searchOption == 'exact') {
369: $wordEscaped = "'" . $wordEscaped . "'";
370: }
371: $tmp_searchwords[] = $wordEscaped;
372: $this->_searchWords[] = $word;
373: }
374: }
375:
376: if ($this->_searchOption == 'regexp') {
377: // regexp search
378: $kwSql = "keyword REGEXP '" . implode('|', $tmp_searchwords) . "'";
379: } elseif ($this->_searchOption == 'like') {
380: // like search
381: $search_like = implode(" OR keyword LIKE ", $tmp_searchwords);
382: $kwSql = "keyword LIKE " . $search_like;
383: } elseif ($this->_searchOption == 'exact') {
384: // exact match
385: $search_exact = implode(" OR keyword = ", $tmp_searchwords);
386: $kwSql = "keyword LIKE " . $search_exact;
387: }
388:
389: $sql = "SELECT keyword, auto FROM " . $this->cfg['tab']['keywords'] . " WHERE idlang=" . cSecurity::toInteger($this->lang) . " AND " . $kwSql . " ";
390: $this->_debug('sql', $sql);
391: $this->db->query($sql);
392:
393: while ($this->db->nextRecord()) {
394:
395: $tmp_index_string = preg_split('/&/', $this->db->f('auto'), -1, PREG_SPLIT_NO_EMPTY);
396:
397: $this->_debug('index', $this->db->f('auto'));
398:
399: $tmp_index = array();
400: foreach ($tmp_index_string as $string) {
401: $tmp_string = preg_replace('/[=\(\)]/', ' ', $string);
402: $tmp_index[] = preg_split('/\s/', $tmp_string, -1, PREG_SPLIT_NO_EMPTY);
403: }
404: $this->_debug('tmp_index', $tmp_index);
405:
406: foreach ($tmp_index as $string) {
407: $artid = $string[0];
408:
409: // filter nonsearchable articles
410: if (in_array($artid, $this->_searchableArts)) {
411:
412: $cms_place = $string[2];
413: $keyword = $this->db->f('keyword');
414: $percent = 0;
415: $similarity = 0;
416: foreach ($this->_searchWords as $word) {
417: // computes similarity between searchword and keyword in
418: // percent
419: similar_text($word, $keyword, $percent);
420: if ($percent > $similarity) {
421: $similarity = $percent;
422: $searchword = $word;
423: }
424: }
425:
426: $tmp_cmstype = preg_split('/[,]/', $cms_place, -1, PREG_SPLIT_NO_EMPTY);
427: $this->_debug('tmp_cmstype', $tmp_cmstype);
428:
429: $tmp_cmstype2 = array();
430: foreach ($tmp_cmstype as $type) {
431: $tmp_cmstype2[] = preg_split('/-/', $type, -1, PREG_SPLIT_NO_EMPTY);
432: }
433: $this->_debug('tmp_cmstype2', $tmp_cmstype2);
434:
435: foreach ($tmp_cmstype2 as $type) {
436: if (!$this->_index->checkCmsType($type[0])) {
437: // search for specified cms-types
438: if ($similarity >= $this->intMinimumSimilarity) {
439: // include article into searchresult set only if
440: // similarity between searchword and keyword is
441: // big enough
442: $this->_searchResult[$artid][$type[0]][] = $type[1];
443: $this->_searchResult[$artid]['keyword'][] = $this->db->f('keyword');
444: $this->_searchResult[$artid]['search'][] = $searchword;
445: $this->_searchResult[$artid]['occurence'][] = $string[1];
446: $this->_searchResult[$artid]['debug_similarity'][] = $percent;
447: if (isset($this->_searchResult[$artid]['similarity']) && $similarity > $this->_searchResult[$artid]['similarity']) {
448: $this->_searchResult[$artid]['similarity'] = $similarity;
449: }
450: }
451: }
452: }
453: }
454: }
455: }
456:
457: if ($this->_searchCombination == 'and') {
458: // all search words must appear in the article
459: foreach ($this->_searchResult as $article => $val) {
460: if (!count(array_diff($this->_searchWords, $val['search'])) == 0) {
461: // $this->rank_structure[$article] = $rank[$article];
462: unset($this->_searchResult[$article]);
463: }
464: }
465: }
466:
467: if (count($this->_searchWordsExclude) > 0) {
468: // search words to be excluded must not appear in article
469: foreach ($this->_searchResult as $article => $val) {
470: if (!count(array_intersect($this->_searchWordsExclude, $val['search'])) == 0) {
471: // $this->rank_structure[$article] = $rank[$article];
472: unset($this->_searchResult[$article]);
473: }
474: }
475: }
476:
477: $this->_debug('$this->search_result', $this->_searchResult);
478: $this->_debug('$this->searchable_arts', $this->_searchableArts);
479:
480: $searchTracking = new cApiSearchTrackingCollection();
481: $searchTracking->trackSearch($searchwords, count($this->_searchResult));
482:
483: return $this->_searchResult;
484: }
485:
486: /**
487: *
488: * @param mixed $cms_options
489: * The cms-types (htmlhead, html, ...) which should explicitly be
490: * searched.
491: */
492: public function setCmsOptions($cms_options) {
493: if (is_array($cms_options) && count($cms_options) > 0) {
494: $this->_index->setCmsOptions($cms_options);
495: }
496: }
497:
498: /**
499: *
500: * @param string $searchwords
501: * The search-words
502: * @return array
503: * of stripped search-words
504: */
505: public function stripWords($searchwords) {
506: // remove backslash and html tags
507: $searchwords = trim(strip_tags(stripslashes($searchwords)));
508:
509: // split the phrase by any number of commas or space characters
510: $tmp_words = mb_split('[\s,]+', $searchwords);
511:
512: $tmp_searchwords = array();
513:
514: foreach ($tmp_words as $word) {
515:
516: $word = htmlentities($word, ENT_COMPAT, 'UTF-8');
517: $word = (trim(cString::toLowerCase($word)));
518: $word = html_entity_decode($word, ENT_COMPAT, 'UTF-8');
519:
520: // $word =(trim(cString::toLowerCase($word)));
521: if (cString::getStringLength($word) > 1) {
522: $tmp_searchwords[] = $word;
523: }
524: }
525:
526: return array_unique($tmp_searchwords);
527: }
528:
529: /**
530: * Returns the category tree array.
531: *
532: * @todo This is not the job for search, should be outsourced ...
533: * @param int $cat_start
534: * Root of a category tree
535: * @return array
536: * Category Tree
537: * @throws cDbException
538: * @throws cInvalidArgumentException
539: */
540: public function getSubTree($cat_start) {
541: $sql = "SELECT
542: B.idcat, B.parentid
543: FROM
544: " . $this->cfg['tab']['cat_tree'] . " AS A,
545: " . $this->cfg['tab']['cat'] . " AS B,
546: " . $this->cfg['tab']['cat_lang'] . " AS C
547: WHERE
548: A.idcat = B.idcat AND
549: B.idcat = C.idcat AND
550: C.idlang = '" . cSecurity::toInteger($this->lang) . "' AND
551: B.idclient = '" . cSecurity::toInteger($this->client) . "'
552: ORDER BY
553: idtree";
554: $this->_debug('sql', $sql);
555: $this->db->query($sql);
556:
557: // $aSubCats = array();
558: // $i = false;
559: // while ($this->db->nextRecord()) {
560: // if ($this->db->f('parentid') < $cat_start) {
561: // // ending part of tree
562: // $i = false;
563: // }
564: // if ($this->db->f('idcat') == $cat_start) {
565: // // starting part of tree
566: // $i = true;
567: // }
568: // if ($i == true) {
569: // $aSubCats[] = $this->db->f('idcat');
570: // }
571: // }
572:
573: $aSubCats = array(
574: $cat_start
575: );
576: while ($this->db->nextRecord()) {
577: // ommit if cat is no child of any recognized descendant
578: if (!in_array($this->db->f('parentid'), $aSubCats)) {
579: continue;
580: }
581: // ommit if cat is already recognized (happens with $cat_start)
582: if (in_array($this->db->f('idcat'), $aSubCats)) {
583: continue;
584: }
585: // add cat as recognized descendant
586: $aSubCats[] = $this->db->f('idcat');
587: }
588:
589: return $aSubCats;
590: }
591:
592: /**
593: * Returns list of searchable article ids in given search range.
594: *
595: * @param array $search_range
596: * @return array
597: * @throws cDbException
598: * @throws cInvalidArgumentException
599: */
600: public function getSearchableArticles($search_range) {
601: global $auth;
602:
603: $aCatRange = array();
604: if (array_key_exists('cat_tree', $search_range) && is_array($search_range['cat_tree'])) {
605: if (count($search_range['cat_tree']) > 0) {
606: foreach ($search_range['cat_tree'] as $cat) {
607: $aCatRange = array_merge($aCatRange, $this->getSubTree($cat));
608: }
609: }
610: }
611:
612: if (array_key_exists('categories', $search_range) && is_array($search_range['categories'])) {
613: if (count($search_range['categories']) > 0) {
614: $aCatRange = array_merge($aCatRange, $search_range['categories']);
615: }
616: }
617:
618: $aCatRange = array_unique($aCatRange);
619: $sCatRange = implode("','", $aCatRange);
620: $sArtRange = '';
621:
622: if (array_key_exists('articles', $search_range) && is_array($search_range['articles'])) {
623: if (count($search_range['articles']) > 0) {
624: $sArtRange = implode("','", $search_range['articles']);
625: }
626: }
627:
628: if ($this->_protected == true) {
629: // access will be checked later
630: $sProtected = " C.visible = 1 AND B.online = 1 ";
631: } else {
632: if ($this->_dontshowofflinearticles == true) {
633: $sProtected = " C.visible = 1 AND B.online = 1 ";
634: } else {
635: $sProtected = " 1 ";
636: }
637: }
638:
639: if ($this->_exclude == true) {
640: // exclude searchrange
641: $sSearchRange = " A.idcat NOT IN ('" . $sCatRange . "') AND B.idart NOT IN ('" . $sArtRange . "') AND ";
642: } else {
643: // include searchrange
644: if (cString::getStringLength($sArtRange) > 0) {
645: $sSearchRange = " A.idcat IN ('" . $sCatRange . "') AND B.idart IN ('" . $sArtRange . "') AND ";
646: } else {
647: $sSearchRange = " A.idcat IN ('" . $sCatRange . "') AND ";
648: }
649: }
650:
651: if (count($this->_articleSpecs) > 0) {
652: $sArtSpecs = " B.artspec IN ('" . implode("','", $this->_articleSpecs) . "') AND ";
653: } else {
654: $sArtSpecs = '';
655: }
656:
657: $sql = "SELECT
658: A.idart,
659: A.idcat,
660: C.public
661: FROM
662: " . $this->cfg["tab"]["cat_art"] . " as A,
663: " . $this->cfg["tab"]["art_lang"] . " as B,
664: " . $this->cfg["tab"]["cat_lang"] . " as C
665: WHERE
666: " . $sSearchRange . "
667: B.idlang = '" . cSecurity::toInteger($this->lang) . "' AND
668: C.idlang = '" . cSecurity::toInteger($this->lang) . "' AND
669: A.idart = B.idart AND
670: B.searchable = 1 AND
671: A.idcat = C.idcat AND
672: " . $sArtSpecs . "
673: " . $sProtected . " ";
674: $this->_debug('sql', $sql);
675: $this->db->query($sql);
676:
677: $aIdArts = array();
678: while ($this->db->nextRecord()) {
679: if($this->db->f("idcat") != "" && $this->_protected) {
680: if($this->db->f("public") == "0") {
681: // CEC to check category access
682: // break at 'true', default value 'false'
683: cApiCecHook::setBreakCondition(true, false);
684: $allow = cApiCecHook::executeWhileBreakCondition('Contenido.Frontend.CategoryAccess', $this->lang, $this->db->f("idcat"), $auth->auth['uid']);
685: if (!$allow) {
686: continue;
687: }
688: }
689: }
690:
691: $aIdArts[] = $this->db->f('idart');
692: }
693: return $aIdArts;
694: }
695:
696: /**
697: * Fetch all article specifications which are online.
698: *
699: * @return array
700: * Array of article specification Ids
701: * @throws cDbException
702: * @throws cInvalidArgumentException
703: */
704: public function getArticleSpecifications() {
705: $sql = "SELECT
706: idartspec
707: FROM
708: " . $this->cfg['tab']['art_spec'] . "
709: WHERE
710: client = " . cSecurity::toInteger($this->client) . " AND
711: lang = " . cSecurity::toInteger($this->lang) . " AND
712: online = 1 ";
713: $this->_debug('sql', $sql);
714: $this->db->query($sql);
715: $aArtspec = array();
716: while ($this->db->nextRecord()) {
717: $aArtspec[] = $this->db->f('idartspec');
718: }
719: return $aArtspec;
720: }
721:
722: /**
723: * Set article specification.
724: *
725: * @param int $iArtspecID
726: */
727: public function setArticleSpecification($iArtspecID) {
728: $this->_articleSpecs[] = $iArtspecID;
729: }
730:
731: /**
732: * Add all article specifications matching name of article
733: * specification (client dependent but language independent).
734: *
735: * @param string $sArtSpecName
736: * @return bool
737: * @throws cDbException
738: * @throws cInvalidArgumentException
739: */
740: public function addArticleSpecificationsByName($sArtSpecName) {
741: if (!isset($sArtSpecName) || cString::getStringLength($sArtSpecName) == 0) {
742: return false;
743: }
744:
745: $sql = "SELECT
746: idartspec
747: FROM
748: " . $this->cfg['tab']['art_spec'] . "
749: WHERE
750: client = " . cSecurity::toInteger($this->client) . " AND
751: artspec = '" . $this->db->escape($sArtSpecName) . "' ";
752: $this->_debug('sql', $sql);
753: $this->db->query($sql);
754: while ($this->db->nextRecord()) {
755: $this->_articleSpecs[] = $this->db->f('idartspec');
756: }
757: }
758: }
759: