Readwise tag clean-up

This script will fix all readwise tags that mistakenly have trailing whitespaces by removing the trailing whitespace

// ==UserScript==
// @name         Readwise tag clean-up
// @namespace    https://axley.net/
// @version      1.0.0
// @description  This script will fix all readwise tags that mistakenly have trailing whitespaces by removing the trailing whitespace
// @author       Jason Axley
// @license      MIT
// @match        https://readwise.io/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=readwise.io
// @grant        GM_registerMenuCommand
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// ==/UserScript==

const cfg = new GM_config({
    id: 'readwiseTagCleanupConfig',
    title: 'readwise Tag Cleanup Settings', // Panel Title
    fields: {
        "apiKey": {
            'label': 'Readwise API key (https://readwise.io/access_token)', // Appears next to field
            'type': 'string', // Makes this setting a text field
            'default': null
        }
    }
});

const headers_orig = {
    "accept": "*/*",
    "accept-language": "en-US,en;q=0.9",
    "sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": "\"macOS\"",
    "sec-fetch-dest": "empty",
    "sec-fetch-mode": "cors",
    "sec-fetch-site": "same-origin",
    "sec-gpc": "1",
    "x-requested-with": "XMLHttpRequest"
};

const headers = {
    "accept": "*/*",
    "accept-language": "en-US,en;q=0.9"
};

function buildRequestHeaders() {
    const theHeaders = headers;

    const apiKey = cfg.get('apiKey');
    if (apiKey) {
        theHeaders["Authorization"] = `Token ${apiKey}`;
    }
    return theHeaders;
}

async function renameTag(book_id, tag_id, new_name) {
    // because renameTag_api_broken() doesn't work, I'm DELETING and RECREATING a tag to rename it
    // Request: DELETE to https://readwise.io/api/v2/books/<book id>/tags/<tag id>
    const renameHeaders = buildRequestHeaders();
    renameHeaders["Content-Type"] = "application/json";

    // Request: POST to https://readwise.io/api/v2/books/<book id>/tags/
    const create_result = await fetch(`https://readwise.io/api/v2/books/${book_id}/tags/`, {
        "headers": renameHeaders,
        "referrer": "https://readwise.io/articles",
        "body": JSON.stringify({"name": new_name}),
        "method": "POST",
        "mode": "same-origin" /*,
        "mode": "cors",
        "credentials": "include" */
    });

    if (create_result.ok) {
        // only delete if the create succeeded to avoid losing information
        const delete_result = fetch(`https://readwise.io/api/v2/books/${book_id}/tags/${tag_id}`, {
            "headers": renameHeaders,
            "referrer": "https://readwise.io/articles",
            "body": null,
            "method": "DELETE",
            "mode": "same-origin"/*,
        "mode": "cors",
        "credentials": "include" */
        });
    } else {
        debugger;
    }
}

async function renameTag_api_broken(book_id, tag_id, new_name) {
    // I'm trusting the IDs from readwise
    return fetch(`https://readwise.io/api/v2/books/${book_id}/tags/${tag_id}`, {
        "headers": headers,
        "referrer": "https://readwise.io/articles",
        "headers": {
            "Content-Type": "application/json",
        },
        "body": JSON.stringify({"name": new_name}),
        "method": "PATCH",
        "mode": "cors",
        "credentials": "include"
    });
}

async function fetchWithPagination(url, params) {
    let aggregate_results = [];

    let next_url = url;
    while (next_url) {
        let results = await fetch(next_url, params);
        if (results.ok) {
            let results_json = await results.json();

            aggregate_results = aggregate_results.concat(results_json.results);
            next_url = results_json.next;
        } else if (results.status == 429) {
            // too many requests
            // book list endpoints are restricted to 20 per minute (per access token).
            // Retry-After to sleep before retry
            let secs = results.headers["Retry-After"];
            console.warn(`Throttled: Waiting for ${secs} seconds before retry`);
            await new Promise(r => setTimeout(r, secs));
        }
    }

    return aggregate_results;
}

async function cleanTags(){

    let books = await fetchWithPagination("https://readwise.io/api/v2/books/?page_size=1000", {
        "headers": headers,
        "referrer": "https://readwise.io/articles",
        "body": null,
        "method": "GET",
        "mode": "cors",
        "credentials": "include"
    });

    books.forEach((b) => {
        if (b.tags) {
            b.tags.forEach((t) => {
                if (t.name.endsWith(" ")) {
                    console.log(`${JSON.stringify(t)} needs cleanup`);
                    renameTag(t.user_book, t.id, t.name.trim());
                }
            }
            );
        }
    }
    );
}

(async function() {
    'use strict';

    GM_registerMenuCommand("Change settings", function(event) {
        cfg.open();
    }, {
        autoClose: true
    });

    GM_registerMenuCommand('Clean Up Readwise Tags', () => {
        cleanTags();
    }, {
        autoClose: true
    });

})();