FetLife Text Search

Searches through FetLife group discussions for a specific keyword or phrase.

/**
 *
 * This is a Greasemonkey script and must be run using a Greasemonkey-compatible browser.
 *
 * @author maymay <bitetheappleback@gmail.com>
 */
// ==UserScript==
// @name           FetLife Text Search
// @version        0.1.1
// @namespace      com.maybemaimed.fetlife.textsearch
// @description    Searches through FetLife group discussions for a specific keyword or phrase.
// @include        https://fetlife.com/groups*
// @exclude        https://fetlife.com/adgear/*
// @exclude        https://fetlife.com/chat/*
// @exclude        https://fetlife.com/im_sessions*
// @exclude        https://fetlife.com/polling/*
// @grant          GM_xmlhttpRequest
// @grant          GM_addStyle
// @grant          GM_log
// ==/UserScript==

FL_TXT = {};
FL_TXT.CONFIG = {
    'debug': false, // switch to true to debug.
};

// Utility debugging function.
FL_TXT.log = function (msg) {
    if (!FL_TXT.CONFIG.debug) { return; }
    GM_log('FETLIFE TEXT SEARCH: ' + msg);
};

// Initializations.
var uw = (unsafeWindow) ? unsafeWindow : window ; // Help with Chrome compatibility?
GM_addStyle('\
#tabnav li:last-child { position: relative; }\
#tabnav li:last-child div {\
    opacity: .95;\
    display: none;\
    position: absolute;\
    background: gray;\
    width: 930px;\
    min-height: 125px;\
    padding: 3px 10px;\
}\
#tabnav li:last-child input[type="text"] { width: 100%; }\
#tabnav #fetlife_text_search_results li {\
    opacity: 1;\
    display: list-item;\
    margin: 1em 0;\
    clear: left;\
}\
#tabnav #fetlife_text_search_results .avatar { float: left; }\
#tabnav #fetlife_text_search_results .permalink {\
    font-weight: normal;\
    background-color: transparent;\
    padding: 0;\
    border: none;\
    color: black;\
    line-height: 1.3em;\
}\
.comment::target { border: 1px solid red; }\
');
FL_TXT.init = function () {
    FL_TXT.currentSearch = {};
    FL_TXT.CONFIG.search_form = document.getElementById('tabnav');
    FL_TXT.main();
};
window.addEventListener('DOMContentLoaded', FL_TXT.init);

FL_TXT.getPageFromURL = function (url) {
    FL_TXT.log('Getting page from URL: ' + url);
    GM_xmlhttpRequest({
        'method': 'GET',
        'url': url,
        'onload': function (response) {

            // Parse page now to find pagination, in a moment.
            var parser = new DOMParser();
            var doc = parser.parseFromString(response.responseText, 'text/html');

            // Set up next request.
            my_page = (url.match(/\?page=\d+$/)) ? parseInt(url.match(/\d+$/)[0]) : 1 ;
            next_page = my_page + 1;
            if (next_page > 2) {
                next_url = url.replace(/\d+$/, next_page.toString());
            } else {
                next_url = url + '?page=' + next_page.toString();
            }

            // If this was a discussion listing page
            if (!url.match(/group_posts/)) {
                // Make a list of links to each thread, and recurse on those.
                var as = doc.querySelectorAll('#discussions h4 a');
                for (var i = 0; i < as.length; i++) {
                    FL_TXT.getPageFromURL(as[i].href);
                }

                // Check for pagination on discussion lists, follow if necessary.
                if (FL_TXT.isPaginated(doc)) {
                    FL_TXT.getPageFromURL(next_url);
                }
                return;
            }

            // Search group post content, too, if on first page.
            if (my_page === 1 && (-1 !== doc.querySelector('.group_post').textContent.toLowerCase().search(FL_TXT.currentSearch.search_string.toLowerCase()))) {
                FL_TXT.displayResult(doc.querySelector('.group_post'), doc.querySelector('.group_post').textContent.toLowerCase().search(FL_TXT.currentSearch.search_string.toLowerCase()), url);
            }

            var els = doc.querySelectorAll('.comment');

            result_count = 0;
            for (var i = 0; i < els.length; i++) {
                // Parse results for this page and make note of any search string hits.
                var x = els[i].textContent.toLowerCase().search(FL_TXT.currentSearch.search_string.toLowerCase());
                if (-1 !== x) {
                    FL_TXT.displayResult(els[i], x, url);
                }
                result_count++;
            }

            // Check for pagination of group thread itself, follow.
            if (FL_TXT.isPaginated(doc)) {
                FL_TXT.getPageFromURL(next_url);
            }
        }
    });
};

FL_TXT.isPaginated = function (doc) {
    if (!doc.querySelector('.pagination')) {
        return false;
    } else if (!doc.querySelector('.pagination .next_page.disabled')) {
        return true;
    } else {
        return false;
    }
};

FL_TXT.toggleSearchInterface = function (e) {
    var lis = e.target.parentNode.parentNode.children;
    for (var i = 0; i < lis.length; i++) {
        lis[i].className = lis[i].className.replace('in_section', '');
    }
    e.target.parentNode.className = "in_section";
    e.target.nextSibling.style.display = 'block';
};

FL_TXT.getGroupName = function () {
    return document.querySelector('.group_name a').innerHTML;
};

FL_TXT.doTextSearch = function (e) {
    e.preventDefault();
    var group_id = document.getElementById('fetlife_text_search_group_id').value;
    var search_string = document.getElementById('fetlife_text_search').value;
    FL_TXT.currentSearch.search_string = search_string;
    switch (e.explicitOriginalTarget.value) {
        case 'Search all discussions.':
            FL_TXT.searchGroup(search_string, group_id);
            break;
        case 'Search this thread.':
        default:
            var group_post_id = document.getElementById('fetlife_text_search_group_post_id').value;
            FL_TXT.searchGroup(search_string, group_id, group_post_id);
            break;
    };
};

FL_TXT.searchGroup = function (str, group_id, group_post_id) {
    var url = 'https://fetlife.com/groups/' + group_id;
    if (group_post_id) {
        url += '/group_posts/' + group_post_id;
    }
    FL_TXT.getPageFromURL(url);
};

FL_TXT.displayResult = function (el, pos, url) {
    var ul = document.getElementById('fetlife_text_search_results');
    var li = document.createElement('li');

    var icon = el.querySelector('.avatar');
    var nick = el.querySelector('.nickname');
    var permalink = url;
    if (el.getAttribute('id')) {
        url += '#' + el.getAttribute('id');
    }

    var start = (100 > pos) ? 0 : pos - 100;
    var end = pos + 100;
    var html_string = '<a href="' + permalink + '" class="permalink">' + icon.innerHTML + ': ' + el.textContent.substring(start, end) + '</a>';

    li.innerHTML = html_string;
    ul.appendChild(li);
};

// This is the main() function, executed on page load.
FL_TXT.main = function () {
    // Get relevant object IDs.
    var group_id, group_post_id;
    var m = window.location.href.match(/https:\/\/fetlife\.com\/groups\/(\d+)(\/group_posts\/(\d+))?/);
    if (!m) {
        FL_TXT.log('Failed to find FetLife Group ID or group post ID in ' + window.location.href);
        return false;
    } else {
        group_id = m[1];
        group_post_id = m[3];
    }

    // Insert FetLife Text Search button interface.
    var li = document.createElement('li');
    li.setAttribute('id', 'tab-text-search');
    var a  = document.createElement('a');
    a.setAttribute('href', '#');
    a.addEventListener('click', FL_TXT.toggleSearchInterface);
    a.innerHTML = 'Search ' + this.getGroupName();
    li.appendChild(a);

    var div = document.createElement('div');
    var form = document.createElement('form');
    form.setAttribute('action', window.location.href)
    form.setAttribute('method', 'GET');
    form.addEventListener('submit', FL_TXT.doTextSearch);
    var html_string = '<input type="hidden" id="fetlife_text_search_group_id" name="fetlife_text_search_group_id" value="' + group_id + '" />';
    html_string += '<label><input type="text" id="fetlife_text_search" name="fetlife_text_search" value="" placeholder="Search for&hellip;"/></label>';
    html_string += '<input type="submit" name="fetlife_text_search_discussions" value="Search all discussions." />';
    // If this is a specific discussion, offer to search only within that discussion thread.
    if (group_post_id) {
        html_string += '<input type="submit" name="fetlife_text_search_this_thread" value="Search this thread." />';
        html_string += '<input type="hidden" id="fetlife_text_search_group_post_id" name="fetlife_text_search_group_post_id" value="' + group_post_id + '" />';
    }
    form.innerHTML = html_string;

    div.appendChild(form);
    li.appendChild(div);
    document.getElementById('tabnav').appendChild(li);

    var ul = document.createElement('ul');
    ul.setAttribute('id', 'fetlife_text_search_results');
    div.appendChild(ul);
};

// The following is required for Chrome compatibility, as we need "text/html" parsing.
/*
 * DOMParser HTML extension
 * 2012-09-04
 *
 * By Eli Grey, http://eligrey.com
 * Public domain.
 * NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
 */

/*! @source https://gist.github.com/1129031 */
/*global document, DOMParser*/

(function(DOMParser) {
	"use strict";

	var
	  DOMParser_proto = DOMParser.prototype
	, real_parseFromString = DOMParser_proto.parseFromString
	;

	// Firefox/Opera/IE throw errors on unsupported types
	try {
		// WebKit returns null on unsupported types
		if ((new DOMParser).parseFromString("", "text/html")) {
			// text/html parsing is natively supported
			return;
		}
	} catch (ex) {}

	DOMParser_proto.parseFromString = function(markup, type) {
		if (/^\s*text\/html\s*(?:;|$)/i.test(type)) {
			var
			  doc = document.implementation.createHTMLDocument("")
			;

			doc.body.innerHTML = markup;
			return doc;
		} else {
			return real_parseFromString.apply(this, arguments);
		}
	};
}(DOMParser));