Distributed Proofreaders - Re-format Project Table

Re-writes the Project Table for readability and accessibility of its commonly used parts

// ==UserScript==
// @name         Distributed Proofreaders - Re-format Project Table
// @namespace    dp-scripts
// @version      1.03
// @description  Re-writes the Project Table for readability and accessibility of its commonly used parts
// @author       IsolationBooth @ Distributed Proofreaders
// @match        https://www.pgdp.net/c/project.php?*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @require      https://code.jquery.com/jquery-3.7.1.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/cdn.min.js
// @grant        none
// ==/UserScript==

// This script was written by someone who knows how to program, but hasn't used JQuery, or even much Javascript & CSS,
// functional programming, etc. Laugh all you want! It's still better than the default.

(function() {

    'use strict';
    var $ = window.jQuery.noConflict(true); // this became necessary after adding date-fns
    var doc = document.documentElement.outerHTML
    var url = window.location.href
    var projectStateFull, projectState, projectStateDesc, title, author, scanSource, authorFirstLast, language, genre, difficulty,
        projectID, clearanceLine, source, pm, pp, ppv, credits, lastUpdate, lastProofreadSpan, wordLists, characterSuites, lastEditSpan, lastStateChangeSpan,
        lastPostSpan, forum, pageDetail, pageBrowser, unsavedPages, savedPages, projectCommentsHeader, projectComments, startProofing,
        pagesText, pgNumber, visibilityClass

    // default values for variables that may be referenced later but not assigned text
    pp = "<i>none yet</i>"
    ppv = ""
    pageBrowser = ""
    pagesText = ""
    clearanceLine = ""
    lastProofreadSpan = ""
    visibilityClass = ""

    function parseDate(dateString) {
        let myDate = dateFns.parse(dateString, "EEEE, MMMM d, yyyy 'at' HH:mm", new Date()); //couldn't get .parse "XX"= e.g. "-400" (offset/tz) to work here https://date-fns.org/docs/parse
        let tempDate = myDate
        tempDate.setHours(tempDate.getHours() + 4);
        myDate = new Date(dateFns.format(tempDate, "yyyy-MM-dd'T'HH:mm:00'Z'"))
        // as a newbie, I spent untold hours trying to add 4 hours to "server time" (EST/EDT), have it understood as UTC and compare it to "now UTC"
        // apparently all I can do is use date-fns to add 4, spit out the date format [that says "UTC" ("Z") explicitly] that JS understands as a string, and make a date out of it again
        //I could not import date-fns-tz here, to start. Of course four hours is a hack; I am unaware of other options.
        return myDate
    }

    function getDateSpan(myDate, myLiteralDate) { // re second argument--help--with the time zone issue "fixed" above, lastPostFormatted is not what the string originally said
        // let lastPostFormatted = dateFns.format(myDate, "MMM d/yy, h:mm a");
        let now = new Date()
        let lastPostRelative = dateFns.formatDistance(myDate, now, { addSuffix: true });
        let mySpan = `<span class='tooltip' title='${myLiteralDate} (server time)'>${lastPostRelative}</span>`
        return mySpan
    }

    function getScanSource(projectComments) {
        let start = `<br/><a id="scanSource" title="Copied from a Project Comments link" href="`
        let end = `">[view scan source]</a>`
        let capture = /<a href=["'](https?:\/\/archive\.org\/details\/[^"']+|https?:\/\/.*\.?hathitrust\.org\/[^"']+|https?:\/\/hdl.handle.net\/[^"']+|https?:\/\/books\.google\.com\/books\?[^"']+)/
        let matches = projectComments.match(capture) // the above regex has four "or"s, and finds when the link stops via "not a quotation mark ending the link"
        if ( matches !== null ) {
            return start + matches[1] + end }
        return ""
    }

    function processProjectTable(tableId) {
        const table = document.getElementById(tableId);
        if (!table) {
            console.error('Table not found');
            return 0;
        }
        // Get all rows from the table
        const rows = table.getElementsByTagName('tr');
        for (let row of rows) {
            // Get all cells in the current row
            const cells = row.querySelectorAll('td,th');
            // Extract the text content from each cell
            let value, field
            if ( row.childElementCount == 2 ) { // this is a "Field Name" - "Field Contents" duple in the original table
                field = row.childNodes[0].innerHTML
                value = row.childNodes[1].innerHTML
            }
            switch (field) {
                case "PG etext number":
                    var matches = value.match(/^\d+/)
                    pgNumber = matches[0]
                    console.log(pgNumber)
                    break
                case "Project State":
                    projectStateFull = value;
                    if ( projectStateFull.includes(":") ) {
                        projectState = projectStateFull.slice(0, projectStateFull.indexOf(":"))
                        projectStateDesc = projectStateFull.slice(projectStateFull.indexOf(":")+2, )
                    } else {
                        projectState = projectStateFull
                        projectStateDesc = projectStateFull
                        }
                    break
                case "Title": title = value; break
                case "Author": author = value; break
                case "Language": language = value; break
                case "Genre": genre = value; break
                case "Difficulty": difficulty = value; break
                case "Project ID": projectID = value; break
                case "Clearance Line": clearanceLine = "Clearance:" + value; break
                case "Image Source": source = value; break
                case "Project Manager": pm = value; break
                case "Post Processor":
                    pp = `<a href="https://www.pgdp.net/c/tools/search.php?show=search&checkedoutby=${value}&state[]=proj_post_first_checked_out">` + value + `</a>`
                    break
                case "PP Verifier": ppv = "PP verifier: " + value; break
                case "Credits line so far": credits = value; break
                case "Last Edit of Project Info":
                    value = value.slice(0, value.indexOf("&")) // remove "&nbsp;...Current Time...")
                    lastEditSpan = "Last project edit: " + getDateSpan(parseDate(value), value)
                    break
                case "Last State Change":
                    lastStateChangeSpan = "Last state change: " + getDateSpan(parseDate(value), value)
                    break
                case "Last Proofread":
                    if ( value !== "Project has not been proofread in this round." ) {
                    lastProofreadSpan = "Last proofread: " + getDateSpan(parseDate(value), value) }
                    else { lastProofreadSpan = "Not proofread in this round." }
                    break
                case "Word Lists": wordLists = value; break
                case "Character Suites": characterSuites = value; break
                case "Last Forum Post": lastPostSpan = getDateSpan(parseDate(value), value); break
                case "Forum": forum = value; break // TODO: add a "last page" link based on "\d+ replies" and the URL https://www.pgdp.net/phpBB3/viewtopic.php?t=\d+&start=\d\d if \d+ greater than # of comments per page
                case "Page Detail": pageDetail = value; break
                case "Page Browser": pageBrowser = value; break
            }
       }
        // get the often-large Project Comments, which spans columns in the original table
        projectComments = $('tr').has('td.project-comments').next('tr').find('td')[0].innerHTML //2nd try here accommodates tables in proj comments, which prev method didn't
        projectCommentsHeader = $('td.project-comments')[0].innerHTML

        if ( projectState.includes("Completed and Posted") ) { // add ebook # if complete. Haven't had time to organize project states and descriptions somewhere
               projectState = `<a title="PG book #${pgNumber}" href="https://www.gutenberg.org/ebooks/${pgNumber}">` + projectState + `</a>`
        }
        if ( /(Processing|Unavailable|Bad |Waiting|Complete|Delete)/.test(projectState) ) {
               visibilityClass = "hidden" //for things we don't want to show in the non-"active" states
        }
        if (( doc.includes("<b>Advanced</b>") || doc.includes("<b>Everything</b>")) && /Round \d: Available/.test(doc) ) {
            //  this number of pages information is only available in 2 of the 4 views
            let pagesLeft = ''
            let totalPages = ''
            let matches = doc.match(/(\d+)<\/td><td>Pages Total/);
            if ( matches !== null ) { totalPages = matches[1] }
            matches = doc.match(/(\d+)<\/td><td>in ..\.page_avail<\/td><\/tr>/);
            if ( matches !== null ) { pagesLeft = matches[1] }

            if ( pagesLeft !== '' ) { // a special case when the HTML does have Total pages but the Available/left is not in HTML because it's zero (basically just-finished rounds still IN the round)
            pagesText = `ℹ️ ${pagesLeft} left of ${totalPages} pages`
            }
            else {
            pagesText = `ℹ️ ${totalPages} pages`
            }

            // see if we can provide the "next page" nnn.png, if the file-naming system is a simple, zero-padded increment - and make a link for it
            let firstPage = ''
            let pagePrefixLength = ''
            matches = doc.match(/imagefile=(\d+)\.png(?:&amp;|&)mode=image/)
            if ( matches !== null ) {
                firstPage = +matches[1];
                pagePrefixLength = matches[1].length;
                let nextPage = +totalPages - +pagesLeft + firstPage; //+variable casts to integer; if firstPage is not 1, adjust accordingly
                pagesText = pagesText + ` &middot; <a href='https://www.pgdp.net/c/tools/page_browser.php?project=` + projectID + `&imagefile=`
                    + nextPage.toString().padStart(pagePrefixLength,'0') + `.png&mode=image'><span title="approximate!">view next page in round</span></a>`
            }
        }

        // convert author to first-last for some author searches
        if ( author.includes(",") ) {
            authorFirstLast = author.slice(author.indexOf(",")+2, author.length) + " " + author.slice(0,author.indexOf(","))
        }
        else { authorFirstLast = author }

        // get the scan source in project comments, if any, to give it a predictable location in the table
        scanSource = getScanSource(projectComments)

        // GET IN-PROGRESS AND "DONE" PAGES - the current table shows up to five of each
        // TODO: make a new interface element with a VIEW image+text link to the LAST DONE page - I would use this - clicking on the green ones opens the proofing i-face, not wanted
        let capture = /(<tr><td class=["']center-align["']><a href="https:\/\/www.pgdp.net\/c\/tools\/proofers\/proof\.php\?projectid=projectID.{13}&amp;imagefile=.{1,6}\.png&amp;proj_state=..\.proj_avail&amp;page_state=..\.page_(out|temp)">\w\w\w \d\d: .{1,6}\.png<\/a>(?:.*?)?<\/td><\/tr>)/
        matches = doc.match(capture);
        if ( matches ) {
            unsavedPages = matches[0].replace(/td/g, "span").replace(/<\/?tr>/g, "").replace(/"center-align"/g, '"unsavedPage"') // take the td-based HTML and replace them with spans and give a new class
        } else { unsavedPages = "none" }

        // now repeat the same logic without one function for both, for "saved" pages ("out" in url string becomes "saved"; different class assigned)
        // the optional part toward the end is to catch WordCheck stuff that isn't present in formatting rounds, conveniently carrying the HTML through as is
        capture = /(<tr><td class=["']center-align["']><a href="https:\/\/www.pgdp.net\/c\/tools\/proofers\/proof\.php\?projectid=projectID.{13}&amp;imagefile=.{1,6}\.png&amp;proj_state=..\.proj_avail&amp;page_state=..\.page_saved">\w\w\w \d\d: .{1,6}\.png<\/a>(?:.*?)?<\/td><\/tr>)/
        matches = doc.match(capture);
        if ( matches ) {
            savedPages = matches[0].replace(/td/g, "span").replace(/<\/?tr>/g, "").replace(/"center-align"/g, '"savedPage"') // capturing tr was necessary to avoid getting the next unrelated row, and non-greediness on the outside didn't work for that
        } else { savedPages = "none" }

        return 1;
    }

    // START THE SHOW
    $('#project_info_table').hide();
    if ( processProjectTable('project_info_table') ) {

    // now remember that yours truly doesn't know anything about div-based CSS layout at the page level, other than "it's the modern way!"
    // and we are after all replacing a c. 2000 _field-value table_
    var newTable = $(`
    <table id="pi">
    <colgroup>
      <col style="width: 70%;" />
      <col style="width: 30%;" />
    </colgroup>

    <tbody>
      <tr>
        <td id="left">
            <div id="title">
              ${title}
            </div>
            <div id="author">by ${author}</div>
              <div id="authorSearch">
              Author search:
              <a href="https://www.pgdp.net/c/tools/search.php?show=search&author=${author}">DP</a>
              &middot <a href="https://archive.org/search?query=creator%3A%22${author}%22">Internet Archive</a>
              &middot <a href="https://www.gutenberg.org/ebooks/authors/search/?query=${author}">Project Gutenberg</a>
              &middot <a href="https://en.wikipedia.org/w/index.php?search=${authorFirstLast}&title=Special%3ASearch&ns0=1">Wikipedia</a>
              &middot <a href="https://catalog.hathitrust.org/Search/Home?lookfor=${authorFirstLast}+&searchtype=author&ft=ft&setft=true">HathiTrust</a>
              &middot <a href="https://www.google.com/search?q=${authorFirstLast}">Google</a>
              </div>

        <div class="subhead" id="myPagesHeader">My pages</div>
        <div id="pageDetail">📋 ${pageDetail}</div>
        <div id="unsavedPages" class="${visibilityClass}">⏳ Pages in progress: <span id="unsavedPage">${unsavedPages}</span></div>
        <div id="savedPages" class="${visibilityClass}">✔️ Pages saved recently: <span id="savedPage">${savedPages}</span></div>

        <div class="subhead" id="pageInfoHeader">Page information</div>
        <div  id="pageBrowser">📄 ${pageBrowser}</div>
        <div id="pages">${pagesText}</div>

        <div class="subhead" id="forumHeader">Forum</div>
        <div id="forum">💬 ${forum}</span> &middot; Last post: <span id="lastPost">${lastPostSpan}</div>

        </td>

        <td id="right">
            <span id="projectState" title="${projectStateDesc}">${projectState}</span>
            <div id="bookMeta"><span id="language" title="language">${language}</span> &middot;
            <span id="genre" title="genre"><a href=
            "https://www.pgdp.net/c/tools/search.php?show=search&amp;genre%5B%5D=${genre}&amp;state%5B%5D=P1.proj_avail&amp;state%5B%5D=P2.proj_avail&amp;state%5B%5D=P3.proj_avail&amp;state%5B%5D=F1.proj_avail&amp;state%5B%5D=F2.proj_avail&amp;state%5B%5D=proj_post_first_available">
            ${genre}</a></span> &middot; <span id="difficulty" title="difficulty">${difficulty}</span>
            </div>
            <hr>
            <div id="source" title="Book source"><i>via</i> ${source} ${scanSource}</div>
            <hr>
        <div id="pm">Project Manager: <a href="https://www.pgdp.net/c/tools/search.php?show=search&project_manager=${pm}&state%5B0%5D=P1.proj_avail&state%5B1%5D=P2.proj_avail&state%5B2%5D=P3.proj_avail&state%5B3%5D=F1.proj_avail&state%5B4%5D=F2.proj_avail&state%5B5%5D=proj_post_first_available&sec_sort=genre&results_offset=0&sort=stateA#head">${pm}</a></div>
        <div id="pp">Post processor: ${pp}</div>
        <div id="ppv">${ppv}</div>
        <div id="projectID" title="Unique identifier">${projectID}</div>
        <div id="clearance" title="PG clearance">${clearanceLine}</div>
        <hr>
        <div id="divCharacterSuites">Character suites: <span id="characterSuites">${characterSuites}</span></div>
        <hr>
        <div id="wordLists">${wordLists}</div>
        <hr>
        <div id="dates">${lastProofreadSpan}<br/>
        ${lastStateChangeSpan}<br/>
        ${lastEditSpan}
        </div>

        <hr>
        <div id="credits">Credit line so far: <span id="credits">"${credits}"</div>
        </td>
      </tr>

      <tr>
        <td colspan="2"><div id="projectCommentsHeader">${projectCommentsHeader}</div></td>
      </tr>

      <tr id="trProjectComments">
        <td colspan="2"><span id="projectComments">${projectComments}</span></td>
      </tr>
    </tbody>
  </table>`);

    var table_css = `
#content-container h1 {
	display: none;
	/* redundant "Project page for..."*/
}

#left hr {
	color: #d8d8d8;
	margin: 1em;
}

#right hr {
	color: #d6d6d6;
	margin: 0.4em auto;
	width: 80%;
}

.hidden {
	display: none;
}

table#pi {
	border: 4px solid #336633;
	border-radius: 15px;
	border-collapse: separate;
	max-width: 1000px;
	margin-bottom: 1em;
	border-spacing: 0;
}

#pi td {
	padding: 0.2em;
}

table#pi a:link,
table#pi a:hover {
	text-decoration: none;
}

.tooltip {
	border-bottom: 0.1px dashed #6f6e6e;
}

table.blurb-box {
	max-width: 1000px;
	border: 2px solid #336633;
	border-collapse: separate;
	border-radius: 15px;
	background-color: #e0e8dd;
}

html[data-darkreader-scheme = "dark"] .blurb-box {
	background-color: #335566b0;
}

.blurb-box a {
	display: inline-block;
	margin-top: 0.5em;
	font-size: large;
	border: 3px solid;
	border-radius: 15px;
	border-spacing: 0px;
	padding: 5px;
	color: #3192e3;
	text-decoration: none;
}

html[data-darkreader-scheme = "dark"] .blurb-box a {
	color: #76b0e0;
}

#projectCommentsHeader {
	background-color: #e0e8dd;
	text-align: center;
	border: 2px solid #336633;
	border-collapse: separate;
	border-radius: 15px;
}

html[data-darkreader-scheme = "dark"] #projectCommentsHeader {
	background-color: #335566b0;
	text-align: center;
}

#left {
	vertical-align: text-top;
}

#author {
	font-size: 1.1em;
	font-weight: bold;
	color: #002680;
	text-align: center;
	margin: 0;
}

html[data-darkreader-scheme = "dark"] #author {
	color: #a8e2fb;
}

#authorSearch {
	font-size: small;
	font-weight: bold;
	margin-top: 0.5em;
	text-align: center;
}

#title,
#projectState {
	display: block;
	color: black;
	text-align: center;
	font-size: x-large;
	border: 2px black solid;
	border-radius: 8px;
	background-color: #c3e2fd;
	padding: 3px;
	margin-bottom: 0.3em;
	font-weight: bold;
}


html[data-darkreader-scheme = "dark"] :is(#projectState, #title) {
	background-color: #a8e2fb;
}

#title {
	font-style: italic;
}

#right {
	vertical-align: top;
}

#bookMeta {
	text-align: center;
}

#scanSource {
	font-weight: bold;
}

#source {
	text-align: center;
	font-size: small;
}

.subhead {
	text-align: center;
	font-size: large;
	font-weight: bold;
	border-radius: 8px;
	background-color: rgb(233, 233, 233);
	padding: 4px;
	margin: 0.8em 0 0.9em 0;
	font-variant: small-caps;
}

html[data-darkreader-scheme = "dark"] .subhead {
	background-color: rgb(182, 181, 181);
	color: black;
}

#pages {
	font-size: medium;
	padding-top: 4px;
	font-weight: bold;
	margin: 0.5em;
}

#source, #dates {
	font-size: small;
}

#pageBrowser {
	font-weight: bold;
	margin: 0.5em;
}

#pageDetail {
	font-weight: bold;
	margin: 0.5em;
}

#unsavedPages {
	font-weight: bold;
	margin: 0.5em;
}

.unsavedPage {
	margin-top: 0.5em;
	display: inline-block;
	padding: 3px;
	font-size: x-small;
	background-color: #f8f878;
	border: 1px dotted;
}

#savedPages {
	font-weight: bold;
	margin: 0.5em;
}

.savedPage {
	margin-top: 0.5em;
	display: inline-block;
	padding: 3px;
	font-size: x-small;
	background-color: #8df4a2;
	border: 1px dotted;
}

html[data-darkreader-scheme = "dark"] .savedPage {
	background-color: #194319;
}

#forum {
	font-weight: bold;
	margin-bottom: 0.8em;
}

#credits {
	font-size: small;
}
`;

    $('#project_info_table').before(newTable);
    // Re-add the 'bookmark' icon implemented by DP on 2024-09-11, inside the original 'h1' heading, which this script hides. It is wrapped in a FORM element.
    $('#title').prepend($('h1 form'))
    var style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = table_css
    document.head.appendChild(style)
    }
    else {
        $('#project_info_table').show(); // function returned 0, it didn't work, and hiding the old table was the first thing we did
        console.log("TamperMonkey script for DP Project Table failed. Table not found by its ID.")
    }
})();