Readwise Reader Bulk Tagger

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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
        });
    });

})();