Greasy Fork is available in English.

OSRS Wiki - Leagues Task Filters

Additional filtering for leagues tasks on the osrs wiki

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         OSRS Wiki - Leagues Task Filters
// @namespace    http://tampermonkey.net/
// @version      2024-11-27.3
// @description  Additional filtering for leagues tasks on the osrs wiki
// @author       https://oldschool.runescape.wiki/w/User:Loaf
// @icon         https://www.google.com/s2/favicons?sz=64&domain=runescape.wiki
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @grant        none
// @license      MIT
// @run-at       document-idle
// @include      https://oldschool.runescape.wiki/w/Raging_Echoes_League/Tasks
// @include      https://oldschool.runescape.wiki/w/Trailblazer_Reloaded_League/Tasks
// ==/UserScript==
const $ = window.jQuery;

// //////////////////////////////////////////
// CONSTANTS
const DATA_TYPE = { CHECKBOX: '0', CHECK_ALL_CHECKBOX: '1' };
const DATA_TYPE_PROP = 'data-lf-datatype';
const FILTER_TARGET_TYPE = { DIFFICULTY: 'difficulty', SKILL: 'skill', REGION: 'region', MISC: 'misc' };
const FILTER_MISC_TYPE = { NA: 'na', COMPLETE: 'complete', TODO: 'tod0' };
const FILTER_TARGET_PROP = 'data-lf-target';
const FILTER_TARGET_TYPE_PROP = 'data-lf-target-type';
const REGION_TARGET_PROP = 'data-tbz-area-for-filtering';

const WIKI_TASK_ROW_PROP = 'data-taskid';
const LEAGUES_TABLE = 'leagues-table';
const FILTERED_ROW_PROP = 'leagues-row-filter';
const FILTERED_ROW_PROP_VALUE = 'filtered';
const FILTERED_SELECTOR = '[leagues-row-filter=filtered]';
const ALL_FILTERS_DIV_SELECTOR = '#leagues-table-all-filters';
const ROW_STATUS_LABEL_SELECTOR = '#row-status-label';

// //////////////////////////////////////////
// HELPERS
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const getTargetType = (target) => $(target).attr(FILTER_TARGET_TYPE_PROP);
const getTarget = (target) => $(target).attr(FILTER_TARGET_PROP);
const getDataType = (target) => $(target).attr(DATA_TYPE_PROP);
const getTaskId = (target) => $(target).attr('data-taskid');

// //////////////////////////////////////////
// FILTERING
let allTaskIds = [];
let todoTasks = new Set();
let activeFilters = [];

const updateRowCountLabel = () => {
    const rowCount = $('#leagues-table tr').length - 2; // Minus header and clear rows
    const hiddenRowCount = $('#leagues-table [leagues-row-filter=filtered]').length;
	const visibleRows = rowCount - hiddenRowCount;
	const visiblePct = Math.trunc(visibleRows / rowCount * 10000) / 100;
	$('#row-status-label')
		.text(`Displaying: ${visibleRows} / ${rowCount} tasks (${visiblePct}%)`);
}

const unfilter = () => {
	$(FILTERED_SELECTOR)
		.removeAttr('style')
		.removeAttr(FILTERED_ROW_PROP);
};

const filter = (target) => {
	const selection = $(target);
	selection
		.attr(FILTERED_ROW_PROP, FILTERED_ROW_PROP_VALUE)
		.css("display", "none");
};

const filterCompleted = () => {
	const row = $(`#${LEAGUES_TABLE} .wikisync-completed`);
	filter(row);
};

const filterNA = () => {
	const row = $(`#${LEAGUES_TABLE} small`).parent().parent();
	filter(row);
};

const filterTodo = () => {
    const row = $('[todo] > input:not(:checked)').parent().parent();
    filter(row);
};

const filterRegion = (target) => {
	const row = $(`[${REGION_TARGET_PROP}=${target}]`);
	filter(row);
};

const filterSkill = (target) => {
	const row = $(`#${LEAGUES_TABLE} span[data-skill=${target}]`).parent().parent();
	filter(row);
};

const filterDifficulty = (target) => {
    const row = $(`#leagues-table img[src*="${target}.png"]`).parent().parent().parent().parent();
	filter(row);
};

const filterMisc = (target) => {
	if (target === FILTER_MISC_TYPE.NA) {
		filterNA();
	} else if (target === FILTER_MISC_TYPE.COMPLETE) {
		filterCompleted();
	} else if (target === FILTER_MISC_TYPE.TODO) {
		filterTodo();
    }
};

function filterTasks() {
	unfilter();
	activeFilters.forEach((filter) => {
		if (filter.targetType === FILTER_TARGET_TYPE.DIFFICULTY) {
			filterDifficulty(filter.target);
		} else if (filter.targetType === FILTER_TARGET_TYPE.SKILL) {
			filterSkill(filter.target);
		} else if (filter.targetType === FILTER_TARGET_TYPE.REGION) {
			filterRegion(filter.target);
		} else if (filter.targetType === FILTER_TARGET_TYPE.MISC) {
			filterMisc(filter.target);
		} else {
			console.error('filter type is not implemented');
		}
	});

    updateRowCountLabel();
}

// //////////////////////////////////////////
// UI ELEMENTS / DOM
const updateParentCheckboxState = (targetType) => {
    const parentCheckbox = $(`input[data-lf-target=${targetType}]`);
    const childCount = $(`input[data-lf-target-type=${targetType}]`).length
    const checkedChildCount = $(`input[data-lf-target-type=${targetType}]:checked`).length;
    const uncheckedChildCount = childCount - checkedChildCount;

    if (checkedChildCount === childCount) return parentCheckbox.prop({ indeterminate: false, checked: true });
    if (uncheckedChildCount < childCount && checkedChildCount > 0) return parentCheckbox.prop({ indeterminate: true, checked: false });
    parentCheckbox.prop({ indeterminate: false, checked: false });
};

const fetchSavedTodo = () => {
    // Fetch saved todo list
    const savedTodo = localStorage.getItem('todoTasks') || '[]';
    const todoArray = JSON.parse(savedTodo);
    todoTasks = new Set(todoArray);

    // Update checkboxes
    const checkboxes = $('#leagues-table [todo] > input');
    checkboxes.each(function() {
        const taskId = getTaskId($(this).parent().parent());
        if (todoTasks.has(taskId)) $(this).prop({checked: true});
    });

    // Update the parent checkbox state
    updateParentCheckboxState('todo');
}

let suppressAutoFilter = false;
function handleCheckboxChange() {
	const filterEnabled = !($(this).is(':checked'));
	const targetType = getTargetType(this);
	const target = getTarget(this);

	if (filterEnabled) {
		// GUARD: Don't add a filter multiple times
		if (activeFilters.filter((x) => x.target === target).length) return;
		activeFilters.push({ target, targetType });
	} else {
		activeFilters = activeFilters.filter((x) => x.target !== target);
	}

    updateParentCheckboxState(targetType);
	if (!suppressAutoFilter) filterTasks();
}

function handleParentCheckboxChange() {
    // Don't run filters until all updates are complete to improve performance
    suppressAutoFilter = true;
	const filterEnabled = ($(this).is(':checked'));
	const targetType = getTargetType(this);
	const target = getTarget(this);
    $(`input[data-lf-target-type=${target}]`)
        .prop('checked', filterEnabled)
        .trigger('change');

    // Re-enable auto filtering
    suppressAutoFilter = false;
	filterTasks();
}

const saveTodo = () => {
    const taskArr = [...todoTasks];
    const taskArrJson = JSON.stringify(taskArr);
    localStorage.setItem('todoTasks', taskArrJson);
};

let suppressSave = false;
function handleTodoCheckboxChange() {
    const taskId = getTaskId($(this).parent().parent());
    const checked = ($(this).is(':checked'));

    // Add or remove the task from the todo list
    if (checked) todoTasks.add(taskId);
    else todoTasks.delete(taskId);

    // Save to localstorage
    if (!suppressSave) saveTodo();
    updateParentCheckboxState('todo');
}

async function handleTodoHeaderCheckboxChange() {
    suppressSave = true;
    const checked = ($(this).is(':checked'));
    $('input[data-lf-target-type=todo]').prop('checked', checked);
    suppressSave = false;

    if (!checked) todoTasks.clear();
    else todoTasks = new Set([...allTaskIds]);
    saveTodo();
}

const createCheckbox = (targetCell, callback, checked = true) => {
    const targetType = getTargetType(targetCell);
	const target = getTarget(targetCell);
    const checkbox = $(`
		<input
			type="checkbox"
			${checked ? 'checked' : ''}
			${FILTER_TARGET_TYPE_PROP}=${targetType}
			${FILTER_TARGET_PROP}=${target}
		/>
	`);
    $(targetCell).append(checkbox);
    checkbox.change(callback);

    return checkbox;
}

const createTargetElements = (targetCells) => {
	targetCells.each(function() {
		const dataType = $(this).attr(DATA_TYPE_PROP);
		if (dataType === DATA_TYPE.CHECKBOX) {
			return createCheckbox(this, handleCheckboxChange);
        } else if (dataType === DATA_TYPE.CHECK_ALL_CHECKBOX) {
            return createCheckbox(this, handleParentCheckboxChange);
		} else {
			console.error('data type is not implemented');
		}
	});
};

const createHeaderTodoCheckbox = () => {
    const headerElement = $(`
        <th id="todo-header">
            <div>To Do</div>
        </th>
    `).css({
        position: 'sticky',
        top: '-1px',
    }).attr({
        [FILTER_TARGET_TYPE_PROP]: 'checkbox',
        [FILTER_TARGET_PROP]: 'todo'
    });

    const headerCheckbox = createCheckbox(headerElement, undefined, false);
    $('#leagues-table thead > tr').append(headerElement);
    $('#todo-header').append(headerCheckbox);
    headerCheckbox.change(handleTodoHeaderCheckboxChange);
}

const createTodoRowCheckboxes = () => {
    $('#leagues-table tfoot > tr').append($('<td />'));
    const cellTargets = $('#leagues-table tbody > tr');
    const checkboxCell = $(`<td todo style="width: 5%;" ${FILTER_TARGET_TYPE_PROP}="todo" ${FILTER_TARGET_PROP}="checkbox" />`);
    checkboxCell.click(function(e) {
        e.stopPropagation();
    });

    cellTargets.append(checkboxCell);
    createCheckbox($('#leagues-table [todo]'), undefined, false);
    $('#leagues-table [todo] > input').change(handleTodoCheckboxChange);
}

const createTodoSelect = () => {
    createHeaderTodoCheckbox();
    createTodoRowCheckboxes();
}

const loadFilterTables = () => {
	$.get(window.mw.util.wikiScript('api'), {
		action: 'parse',
		page: 'Template:Sandbox/User:Loaf/AllCustomFilters',
		format: 'json',
	}, (data) => {
		$(`#${LEAGUES_TABLE}`).before(data.parse.text['*']);
	});
};

const layoutUpdates = async () => {
	$('#tbz-wikisync-number-of-shown-tasks').hide();
	$('.rs-wikisync-hide-completed').hide();
	$('#rs-qc-form').insertAfter(ALL_FILTERS_DIV_SELECTOR);
	$(ALL_FILTERS_DIV_SELECTOR).before(`
		<div
			style="
				border: 1px solid var(--wikitable-border);
				font-weight: bold;
				padding: 8px;
				background-color: var(--wikitable-header-bg);
				width: fit-content;"
		>
			<big id="row-status-label" />
		</div>
	`);

    createTodoSelect()
	updateRowCountLabel();
}

// //////////////////////////////////////////
// MAIN
// If anyone reading this is a wiki contributor, pls add the
// leagues-table id to the task table so this doesn't break <3
const getAllTaskIds = () => {
    $('tr[data-taskid]').each(function() {
        allTaskIds.push($(this).attr('data-taskid'));
    });
};

async function triggerLookupFilter () {
	await sleep(1000);
	filterTasks();
}

const markLeaguesTable = () => {
	const selection = $('[data-taskid]');
	selection
		.parent()
		.parent()
		.attr('id', LEAGUES_TABLE)
        .addClass('align-center-7');
}

const wikiScriptLoaded = async () => {
	let tries = 0;
	while ((!window.mw || !(window.mw?.util?.wikiScript)) && tries < 10) {
		await sleep(500);
		tries++;
	}
};

const filterTablesLoaded = async () => {
	let tries = 0;
	while(!$(`td[${DATA_TYPE_PROP}]`).length) {
		await sleep(500);
		tries++;
	}
};

const main = async () => {
	markLeaguesTable();
    getAllTaskIds();

	// Account for wiki JS delay
	await wikiScriptLoaded();
	loadFilterTables();

	// Account for JQuery injection delay
	await filterTablesLoaded();

	layoutUpdates();
	createTargetElements($(`[${DATA_TYPE_PROP}]`));

    // Update todo task checkboxes
    fetchSavedTodo();
}

$(document).ready(function() {
	main();
});

//	Listen for wiki task sync and re-filter
(function(open) {
    XMLHttpRequest.prototype.open = function() {
        this.addEventListener("readystatechange", async function(e) {
            if (this.readyState == 4 && e.currentTarget.responseURL.includes('sync.runescape.wiki/runelite/player')) {
				await sleep(500);
				filterTasks();
			}
        }, false);
        open.apply(this, arguments);
    };
})(XMLHttpRequest.prototype.open);