Readwise Reader Bulk Tagger

This script will apply tags to items in bulk (e.g. from a search result)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Readwise Reader Bulk Tagger
// @namespace    https://axley.net/
// @version      1.1
// @description  This script will apply tags to items in bulk (e.g. from a search result)
// @author       Jason Axley
// @license      MIT
// @match        https://read.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: 'readwiseBulkTagger',
    title: 'readwise Bulk Tagger 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 base_headers = {
    "accept": "*/*",
    "accept-language": "en-US,en;q=0.9"
};

function buildRequestHeaders() {
    const theHeaders = base_headers;

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

let tagstring = "";

function bulkApplyTags() {
    tagstring = window.prompt("Comma-separated tag list", tagstring);
    let tags = tagstring.replace(" ", "").split(",");
    if (tags && tags.length) {
        const articles = document.querySelectorAll('[id^=document-row-]');
        for (var i=0; i<articles.length; i++) {
            // for every document ID, add the tag to the document
            let article_id = articles[i].getAttribute("id").replace("document-row-", "")
            updateDocumentTags(article_id, tags);
        }
    }
    return;
}

async function listAllTags() {
    const headers = buildRequestHeaders();
    headers["Content-Type"] = "application/json";
    // GET https://readwise.io/api/v3/tags
    let tags = await fetchWithPagination("https://readwise.io/api/v3/tags/?page_size=1000", {
        "headers": headers,
        "referrer": "https://readwise.io/articles",
        "body": null,
        "method": "GET"
    });

    if (tags) {
        return tags.map((t) => t.key);
    }
    console.error("Did not list tags successfully!");
    return [];
}

async function listBooksHavingTag(tag) {
   const headers = buildRequestHeaders();
   headers["Content-Type"] = "application/json";

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

async function listBookDetails(book_id) {
   const headers = buildRequestHeaders();
   headers["Content-Type"] = "application/json";

   let books = await fetchWithPagination(`https://readwise.io/api/v3/list/?id=${book_id}&page_size=1000`, {
        "headers": headers,
        "referrer": "https://readwise.io/articles",
        "body": null,
        "method": "GET",
        "mode": "cors",
        "credentials": "include"
    });
    return books;
}

async function updateDocumentTags(book_id, tag_list) {
    console.debug(`updating tags for ${book_id}: ${tag_list}`);
    const headers = buildRequestHeaders();
    headers["Content-Type"] = "application/json";

    // PATCH allows to update only the tags without having to echo back existing values for other fields. However, to preserve existing tags,
    // you need to query the existing tags and merge them together.
    let book_info = await listBookDetails(book_id);

    let current_tags = book_info && book_info.length === 1 ? (book_info[0].tags ? Object.entries(book_info[0].tags).map(t => t[0]) : []) : [];
    let union_tag_list = [...new Set([...current_tags, ...tag_list])];

    // New v3 UPDATE API supposed to work
    // Request: PATCH to https://readwise.io/api/v3/update/<document_id>/
    const update_result = fetch(`https://readwise.io/api/v3/update/${book_id}`, {
            "headers": headers,
            "referrer": "https://readwise.io/articles",
            "body": JSON.stringify({
                tags: union_tag_list
            }),
            "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.get("Retry-After");
            console.warn(`Throttled: Waiting for ${secs} seconds before retry`);
            await new Promise(r => setTimeout(r, secs));
        }
    }

    return aggregate_results;
}


(async function() {
    'use strict';

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

    GM_registerMenuCommand('Bulk apply tags', () => {
        bulkApplyTags();
    }, {
        autoClose: true
    });

    window.addEventListener("load", (event) => {

        let observer = new MutationObserver(mutationRecords => {
            console.log(mutationRecords);
        });

        // watch for new nodes added to DOM
        const listRootElement = document.querySelector('.listRoot > ol');
        observer.observe(listRootElement, {
            childList: true // observe direct children
        });
    });

})();