5
\$\begingroup\$

The requirement: search a string in a text-string (from DB) and show additional info around when presenting the result.

Examples:

text string = "The Quick Brown Fox Jumps Over the Lazy Dog"

search ==> result:

  1. "The" ==> "The Quick ..."
  2. "Quick" ==> "The Quick Brown ..."
  3. "Brown" ==> "... Quick Brown Fox ..."
  4. "Lazy" ==> "... the Lazy Dog"
  5. "ump" ==> "... Fox Jumps Over ..."

Rules are:

  • aways complete word containing search string
  • the word before if exists
  • the word after if exists
  • if there is something before the "before" word, show "..." at beginning
  • if there is something after the "after" word, show "..." at end

How I solved it:

// initialize text
$text_found = '';
// first eliminate line feeds, tabs, double spaces etc
$tmp = str_replace(array( "\r\n", "\r", "\n", "\t", "  " ), ' ', $text );
// now get starting position
$posStart = strpos( mb_convert_case( $tmp, MB_CASE_LOWER, 'UTF-8' ), $search );
// search first space before position
$space = strrpos( substr( $tmp, 0, $posStart +1 ), ' ', -1 );
if ( $space === FALSE ):
    // no earlier space found, so start from beginning
    $posStart = 0;
else:
    $posStart = $space;
    // check if there is an additional word before
    $space = strrpos( substr( $tmp, 0, $space ), ' ', -1 );
    if ( $space === FALSE ):
        $text_found .= substr( $tmp, 0, $posStart );
    else:
        $text_found .= '... ' . substr( $tmp, $space, $posStart - $space );
    endif;
endif;
// now we search first space after position
$space = strpos( substr( $tmp, $posStart +1 ), ' ' ) + 1;
if ( $space === FALSE ):
    // no space after found, so add text till end
    $text_found .= substr( $tmp, $posStart );
else:
    // check if there is an additional word after
    $space2 = strpos( substr( $tmp, $posStart + $space + 1 ), ' ' );
    if ( $space2 === FALSE ):
        $text_found .= substr( $tmp, $posStart );
    else:
        $text_found .= substr( $tmp, $posStart, $space2 + $space + 1 ) . ' ...';
    endif;
endif;

Question is if there is a more elegance solution ? And if so how ?

Please see post below with my final outcome.

\$\endgroup\$
  • \$\begingroup\$ Solved: find finally something better at: stackoverflow.com/questions/22762797/… \$\endgroup\$ – user2037828 Oct 20 '16 at 10:58
  • \$\begingroup\$ "..." are called ellipses. You can make CSS do them for you, no programming required. \$\endgroup\$ – I wrestled a bear once. Jun 19 '17 at 17:33
  • \$\begingroup\$ OK, thanks. Didn't know about this. Will have a look into. \$\endgroup\$ – user2037828 Jul 17 '17 at 6:08
1
\$\begingroup\$

You say you want to search a text string from a database, but are not showing the database part. This is key to think about, as I am guessing you could cut out all this work if you made appropriate search requests against an appropriately designed data store. Most any modern relational database supports some level of natural language search, which would allow you to actually do this across more than a single string at a time.

This is not to mention the existence of data stores both open source and commercial geared specifically towards search cases, even allowing the ability to return search matches with context as you are trying to do.

Consider doing this sort of work with a tool that is optimized for this purpose.

Thoughts on code:

  • You are clearly working with multibyte strings yet are not using the multibyte versions of the the standards string manipulation functions. This could lead to unexpected behavior. If you need to have string functions overloaded with mb counterparts, you might consider a comment in the code noting that this PHP setting needs to be turned on for this code to operate properly.
  • you should add vertical white space to you code around logical sections to help ease of reading. Think of empty lines like you would paragraphs in writing. They help break up your thoughts.
  • you are casting to lowercase only on haystack and not on needle. Why is this? Typically if you are doing case insensitive search, you would cast both in same manner.
  • you may consider using regex with appropriate substring pattern capture and compare from a performance standpoint. I am not sure if it will perform as well, but it could certainly simplify your code, which can sometimes holds more value if performance is not too big of a trade off.
  • your code isn't designed to handle multiple matches. This may be OK with regards to the design, but figured I would point this out. You could take your example haystack and search for "the", and never get the second occurrence found.
  • what happens if space is passed as search string?
  • what happens if single character is passed or other similar trivial or ambiguous search term? Do you have something up the call stack which weeds out these potentially undesirable use cases?
|improve this answer|||||
\$\endgroup\$
1
\$\begingroup\$

I can appreciate wanting to avoid regex because you need to write readable / maintainable code. Unfortunately, there is unavoidable complexity in your task -- regex or otherwise. That said, a regex function like preg_match() (or possibly preg_replace_callback()) is perfectly suited for your use case for several reasons:

  • simple case-insensitive substring matching
  • simple multibyte character matching
  • simple white-space character matching
  • explode() (a somewhat blunt tool for this surgery) avoidance

Once the pattern extracts the desired set of words, the handling is quite simple.

Code: (Demo with a battery of $needles)

$needle=preg_quote(trim($needle),'/');  // prepare needle for regex
if(preg_match('/(.*?\S+)??\s*(\S+)?\s*(\S*'.$needle.'\S*)\s*(\S+)?\s*(\S+)?/i',$haystack,$m)){
    unset($m[0]);  // omit unwanted fullstring match
    $m=array_filter($m,'strlen');  // remove empty elements to avoid back-to-back glue in return value
    if(isset($m[1])) $m[1]='...';  // apply ellipsis to 1st leading word
    if(isset($m[5])) $m[5]='...';  // apply ellipsis to 2nd trailing word
    echo implode(' ',$m);  // use single space as glue
}

I understand that you are not a big fan of regex, so I'll do my best to explain what I've done and why it isn't too scary:

  1. Now, I don't know what kind of validation you are running on the $needle ahead of time, so I'm using a couple of sensible precautions. If your validation makes one or both of these functions redundant, just remove them.

    • trim() is used to remove white-space characters from both sides of the needle string.
    • preg_quote() is used to escape characters with special meaning within a regex pattern.
  2. preg_match() is going to search the haystack from start to the end of the second word that trails the needle.

    • / start pattern
    • (.*?\S+)?? means capture everything from start to one or more non-white characters. The first ? means the capture group is optional (it may occur 0 or 1 time); the second ? means quantifier is lazy -- it will prefer not to match when it can. This is vital to getting the ellipsis replacement right.
    • \s* matches zero or more white-space characters
    • (\S+)? captures the second word that precedes the needle, it is optional but not lazy -- it will be matched when it can be.
    • \s* matchs zero or more white-space characters
    • (\S*'.$needle.'\S*) captures the needle substring and any leading/trailing non-white-space characters.
    • \s* matchs zero or more white-space characters
    • (\S+)? captures the first word that follows the needle, it is optional but not lazy -- it will be matched when it can be.
    • \s* matchs zero or more white-space characters
    • (\S+)? captures the first word that follows the needle, it is optional but not lazy -- it will be matched when it can be.
    • /i end pattern and make the pattern case insensitive.
  3. The remaining techniques are sufficiently self-descriptive or explained via inline comments.


Upon reading your posted solution, it seems you want to have the flexibility to modify the number of leading and trailing words around the needle. This ramps up pattern complexity, but I think I managed to pull it off.

Code: (Demo)

function ellipse($needle,$haystack,$words=2){
    $show=$words-1;  // number of non-ellipsis words on either side of needle
    $needle=preg_quote(trim($needle),'/');  // prepare needle for regex
    if(preg_match('/(.*?\S+)??\s*((?:\S+\s*?){0,'.$show.'})?\s*(\S*'.$needle.'\S*)\s*((?:\s*\S+){0,'.$show.'})\s*(\S+)?/i',$haystack,$m)){
        unset($m[0]);  // omit unwanted fullstring match
        $m=array_filter($m,'strlen');  // remove empty elements to avoid back-to-back glue in return value
        if(isset($m[1])) $m[1]='...';  // apply ellipsis to 1st leading word
        if(isset($m[5])) $m[5]='...';  // apply ellipsis to last trailing word
        return implode(' ',$m);  // use single space as glue
    }

}

Effectively, I've changed the pattern to "pile up" the "non-ellipsis" words into a single capture group. Beyond that, the method remains mostly unchanged.

*note in this second, more dynamic method, you may need to replace white-space characters that were not replaced by my gluing action (the piled up substrings).

|improve this answer|||||
\$\endgroup\$
0
\$\begingroup\$

Finally I come to following solution:

Call via:

$result = search( $search, $text, 2 );

Search function:

function search( $search, $text, $words )
{
    // first eliminate line feeds, tabs, double spaces etc
    $tmp = str_replace( array( "\r\n", "\r", "\n", "\t", "  " ), ' ', $text );

    // save text for output
    $outarray = explode( ' ', $tmp );

    // convert search to lowercase
    $search = mb_convert_case( $search, MB_CASE_LOWER, 'UTF-8' );

    // convert into array, inarray to lowercase for search
    $tmp = mb_convert_case( $tmp, MB_CASE_LOWER, 'UTF-8' );
    $inarray = explode( ' ', $tmp );

    // search in array
    $index = array_search( $search, $inarray );
    if ( $index === false ):
        // word not found, maybe just part of a word, needs to evaluate the whole word first ...
        $posStart = strpos( mb_convert_case( $tmp, MB_CASE_LOWER, 'UTF-8' ), $search );
        // search first space before position
        $prePos = strrpos( substr( $tmp, 0, $posStart + 1 ), ' ', -1 );
        if ( $prePos === FALSE ):
            $prePos = 0;    // means no space found
        endif;
        // search first space after position
        $postPos = strpos( substr( $tmp, $prePos+1 ), ' ' );
        if ( $postPos === FALSE ):
            $postPos = strlen( substr( $tmp, $prePos+1 ) ); // means no space found
        endif;
        $word = substr( $tmp, $prePos+1, $postPos );
        // new search
        $index = array_search( $word, $inarray );
        if ( $index === false ):
            // nothing found
            return 'not found: >' . $word . '<';
        endif;
    endif;

    // beginning of text
    if ( $index == 0 ):
        $pos = 0;
        $pre = '';
        $len = 1;   // current word only
    elseif ( $index - $words <= 0 ):
        $pos = 0;
        $pre = '';
        $len = $index + 1;  // from test start incl current word
    else:
        $pos = $index - $words;
        $pre = '... ';
        $len = $words + 1;  // number of words before + current word
    endif;

    // end of text
    if ( count( $outarray ) - 1 - $index - $words <= 0 ):
        $post = '';
        $len += count( $outarray ) - 1 - $index;    // till the end
    else:
        $post = ' ...';
        $len += $words;     // add words after
    endif;

    // build string
    return $pre . implode( ' ', array_slice( $outarray, $pos, $len )) . $post; 
}

Might be helpfull for others too.

In regards to the points mentioned by Mike below:

  • I have not posted database part, correct. I use MySQL 5.5. There are in total 6 fields in which I search, but only 1 is a "text" field (description) where I need to use this logic. The others are CHAR/VARCHAR fields These others are shown with complete content when text is found there in.
  • The note on multibyte is good point. Thanks.
  • White space lines I added into the function of this post. Or do I missunderstand ?
  • casting lowercase of needle is done in this function above.
  • regex, well yes .. To be honest, I dislike. Hard to read, harder to maintain.
  • not covering multiple matches is fine in my case at the moment.
  • the search is triggered only if at least 2 characters have been entered in search field (jquery UI autosearch ajax call), trimmed. Due to, at least 2 non-space chars are needed.
  • the SQL I use to find rows with matching fields is limited to 10 rows max. Therefore there are stupid search needles covered somehow. Note that input validation into ajax script is done too.

Thanks for review.

|improve this answer|||||
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.