Action

Readwise Review

Posted by FlohGro, Last update 8 months ago

UPDATES

8 months ago

slightly reduced size of note field to fit all buttons on macOS

show all updates...

8 months ago

slightly reduced size of note field to fit all buttons on macOS

8 months ago

fixed tags import issue

8 months ago

Added import highlight feature

9 months ago

added forum post link to description

9 months ago

changed height of notes field

9 months ago

  • Changed open links behavior to separate prompt
  • updated description

9 months ago

update description

9 months ago

update description

created by @FlohGro / more on my Blog

Readwise Review

This Action imitates the Review feature of Readwise. It displays a configurable amount of highlights (one after the other) and allows you to favorite, discard and also modify them (tags / notes).

note: the readwise review streak is not maintained with this action!

If you find andy issues or have further ideas and requests - please reach out in the Forum or on Mastodon

[Configuration]

When you run this Action the first time it will ask you for your readwise access token. Please go to https://readwise.io/access_token to retrieve your token and copy it to the clipboard. Insert it into the displayed dialogue which will securely store the token in Drafts.

You can configure the amount of highlights that are displayed in the review action by editing the “Define Template Tag” step. The default value is 5 but you can change it to any number.

[Usage]

Everytime you want to Review a few of your Readwise highlights, run this Action.
Depending on the number of highlights you configured for the review you will see a different amount of prompts, one for each highlight.
The prompt displays the title and author of the highlights source and then the text of the highlight.
It offers several options to interact with the highlight:

  • modify highlight: allows you to modify the noteand/or tagsof the highlight
    • > changing tags: make sure to comma-separate tags! you can add or delete tags
    • > changing the note: just edit the note text field
  • favorite highlight: favorites the highlight in Readwise
  • discard highlight: discards the highlgiht in Readwise
  • open: opens the url of the highlight; presents another prompt if the source url is available
    • open source in readwise: opens the book / article,… in Readwise (will abort the Review)
    • open source url (if available): opens the source URL (e.g. a web article) if it is available (will abort the Review)
  • import: import the highlight with different options
    • copy highlight text: copies the text of the highlight to the clipboard
    • copy highlight as quote: copies the highlight text with author in a markdown quote format
    • import single highlight: imports the highlight with some metadata (author, source,..) into a new draft (opens an existing draft if already imported)
    • import all highlights from source: imports all highlights of the source with some metadata into a new draft (opens an existing draft if already imported)
  • continue / finish review: skip to the next highlight (or finish if its the last one)
  • cancel: abort the Review immediately

If you find this useful and want to support me you can donate or buy me a coffe

Buy Me A Coffee

Steps

  • defineTemplateTag

    name
    highlights-review-count
    template
    5
  • script

    // readwise review
    // created by @FlohGro@social.lol
    
    let credential = Credential.create("Readwise", "insert your Readwise API token to allow Drafts to get data from Readwise.\nYou can retrieve the token by opening \"readwise.io/access_token\" and copy it from there");
    
    credential.addPasswordField("authtoken", "authentication token");
    
    credential.authorize();
    
    const highlightsListEndpoint = "https://readwise.io/api/v2/highlights/"
    const booksListEndpoint = "https://readwise.io/api/v2/books/"
    const bookHighlightsEndpoint = "https://readwise.io/api/v2/highlights/?book_id="
    const omitDiscardedHighlights = true;
    const reviewHighlightsAmount = parseInt(draft.processTemplate("[[highlights-review-count]]").trim())
    
    
    function run() {
        if (isNaN(reviewHighlightsAmount)) {
            // configured review amount is not a number
            alert("the configured value for thte template tag \"highlights-review-count\" contains \"" + draft.processTemplate("[[highlights-review-count]]") + "\" which is not a number.\nPlease fix this and try again")
            return false;
        }
        let allHighlights = getAllReadwiseHighlights();
        let allBooks = getAllReadwiseBooks();
        let randomHighlights = getRandomElementsOfSpecifiedAmount(allHighlights, reviewHighlightsAmount)
        let highlightBookMap = mapHighlightsToBooks(randomHighlights, allBooks)
        presentOptionsForHighlights(highlightBookMap)
    
    
    
    }
    
    run()
    
    function presentOptionsForHighlights(highlightBookMap) {
        let currentCount = 1
        let proceed = true
        highlightBookMap.forEach((book, highlight) => {
            if (proceed) {
                proceed = presentOptionsforHighlight(highlight, book, currentCount, highlightBookMap.size)
                currentCount++
            }
        })
    }
    
    function presentOptionsforHighlight(highlight, book, currentCount, overallCount) {
        let p = new Prompt();
        p.title = "Readwise Review (" + currentCount + "/" + overallCount + ")"
        // check if the highlight is favorited
        let isFavoriteText = ""
        if (highlightHasTag(highlight, "favorite")) {
            isFavoriteText = "\n♥️"
        }
        let separator = ""
        switch (device.model) {
            case "iPhone":
                separator = "--------------------------------------------------------";
                break;
            case "iPad":
                separator = "-----------------------------------------------------------";
                break;
            case "Mac":
                separator = "-----------------------------------------------------------";
                break;
            default:
                separator = "-----------------------------------------------------------";
                break;
        }
        p.message = book.title + " (" + book.category + ")\n-" + book.author + "\n" + separator + "\n" + highlight.text + isFavoriteText
        //        p.isCancellable = false
        p.addTextView("hNote", "note", highlight.note,{"height":60})
        let tags = []
        for (tag of highlight.tags) {
            tags.push(tag.name)
        }
        p.addTextField("hTags", "tags", tags.join(", "))
        p.addButton("modify highlight")
        p.addButton("favorite highlight")
        p.addButton("discard highlight", "discard highlight", false, true)
        p.addButton("open")
        p.addButton("import")
    
        if (currentCount < overallCount) {
            p.addButton("continue review")
        } else {
            p.addButton("finish review")
        }
        if (p.show()) {
            if (p.buttonPressed == "modify highlight") {
                // add note if changed
                let pNote = p.fieldValues["hNote"]
                if (pNote != highlight.note) {
                    // need to change that in readwise
                    updateHighlightNote(highlight, pNote)
                }
                // add tags if changed
                let pTags = p.fieldValues["hTags"].split(", ")
                // omit empty length of array
                if (p.fieldValues["hTags"].length == 0) {
                    pTags = []
                }
                comparisonResult = compareArrays(tags, pTags);
                let addedTags = comparisonResult.addedItems
                let removedTags = comparisonResult.removedItems
                if (addedTags.length > 0 || removedTags.length > 0) {
                    updateHighlightTags(highlight, addedTags, removedTags)
                }
            } else if (p.buttonPressed == "favorite highlight") {
                favoriteHighlight(highlight)
            } else if (p.buttonPressed == "discard highlight") {
                discardHighlight(highlight)
            } else if (p.buttonPressed == "open") {
                if (book.source_url != null) {
                    let pOpen = new Prompt()
                    pOpen.title = "open highlight"
                    pOpen.addButton("open source in readwise", book.highlights_url)
                    pOpen.addButton("open source url", book.source_url)
                    if (pOpen.show()) {
                        app.openURL(pOpen.buttonPressed, false)
                        return false
                    } else {
                        return true
                    }
                } else {
                    app.openURL(book.highlights_url, false)
                    return false
                }
            } else if (p.buttonPressed == "import") {
                let pI = new Prompt()
                pI.title = "select import"
                pI.addButton("copy highlight text")
                pI.addButton("copy as quote")
                pI.addButton("import single highlight")
                pI.addButton("import all highlights from source")
                if (pI.show()) {
                    switch (pI.buttonPressed) {
                        case "copy highlight text":
                            copyHighlightText(highlight);
                            return false
                        case "copy as quote":
                            copyHighlightAsQuote(highlight, book);
                            return false;
                        case "import single highlight":
                            importSingleHighlight(highlight, book);
                            return false;
                        case "import all highlights from source":
                            importAllHighlightsFromSource(book);
                            return false;
                    }
                }
            }
            return true;
        } else {
            return false;
        }
    
    
    }
    
    function mapHighlightsToBooks(highlights, books) {
        let highlightBook = new Map()
        for (highlight of highlights) {
            highlightBook.set(highlight, getMatchingBook(highlight.book_id, books))
        }
        return highlightBook
    }
    
    function getRandomElementsOfSpecifiedAmount(data, number) {
        if (number >= data.length) {
            // return copy of array
            return data.slice()
        }
        let randomElements = []
        let availableIndices = data.length
        while (randomElements.length < number) {
            let index = Math.floor(Math.random() * availableIndices)
            if (omitDiscardedHighlights) {
                if (highlightHasTag(data[index], "discard")) {
                    continue;
                }
            }
            randomElements.push(data[index])
            data[index] = data[availableIndices - 1]
            availableIndices--;
    
        }
        return randomElements
    }
    
    function highlightHasTag(highlight, tagName) {
        let hTags = []
        for (tag of highlight.tags) {
            hTags.push(tag.name)
        }
        if (hTags.includes(tagName)) {
            return true
        } else {
            return false
        }
    }
    
    function getMatchingBook(bookId, books) {
        let matchingBook = books.filter((book) => {
            return book.id == bookId
        })
        if (matchingBook.length > 1) {
            console.log("more than 1 matching book!?")
            return undefined
        }
        return matchingBook[0]
    }
    
    function getAllReadwiseBooks() {
        let firstEndpoint = booksListEndpoint + "?page_size=1000";
        let responseData = performPaginatedRequestToGivenReadwiseApiEndpoint(firstEndpoint);
        return responseData
    }
    
    function getAllReadwiseHighlights() {
    
        let firstEndpoint = highlightsListEndpoint + "?page_size=1000";
        let responseData = performPaginatedRequestToGivenReadwiseApiEndpoint(firstEndpoint);
    
        // alert(responseData.length + "\n" + JSON.stringify(responseData[0]))
        return responseData
    }
    
    function performPaginatedRequestToGivenReadwiseApiEndpoint(firstEndpoint) {
        let http = HTTP.create(); // create HTTP object
        let continueRequest = true
        let responseData = [];
        let currentPageRequest = firstEndpoint
        while (continueRequest) {
            let response = http.request({
                "url": currentPageRequest,
                "method": "GET",
                "headers": {
                    "Authorization": "Token " + credential.getValue("authtoken"),
                }
            });
            if (response.success) {
                let data = response.responseData
                responseData = responseData.concat(data.results);
                let nextPage = data.next
                if (nextPage) {
                    currentPageRequest = nextPage
                } else {
                    continueRequest = false
                }
                //            alert(nextPage + "\n" + data.count + "\n" + responseData.length)
            } else {
                alert("error:\n" + response.statusCode + "\n" + response.error)
            }
    
        }
        return responseData
    }
    
    function updateHighlightNote(originalHighlight, updatedNote) {
        const highlightUpdateEndpoint = "https://readwise.io/api/v2/highlights/" + originalHighlight.id + "/"
        let http = HTTP.create();
        let response = http.request({
            "url": highlightUpdateEndpoint,
            "method": "PATCH",
            "headers": {
                "Authorization": "Token " + credential.getValue("authtoken"),
            },
            "data": {
                "note": updatedNote
            }
        });
        if (response.success) {
            app.displaySuccessMessage("highlight updated")
        } else {
            alert("updating note failed:\n" + response.statusCode + "\n" + response.error)
        }
    }
    
    function discardHighlight(highlight) {
        // add a discard tag to the highlight
        const tagModifyEndpoint = "https://readwise.io/api/v2/highlights/" + highlight.id + "/tags"
        let http = HTTP.create();
        let response = http.request({
            "url": tagModifyEndpoint,
            "method": "POST",
            "headers": {
                "Authorization": "Token " + credential.getValue("authtoken"),
            },
            "data": {
                "name": "discard",
            }
        });
        if (response.success || response.statusCode == 201) {
            app.displaySuccessMessage("highlight discarded")
        } else {
            alert("discarding highlight failed:\n" + response.statusCode + "\n" + response.error)
        }
    }
    
    function favoriteHighlight(highlight) {
        // add a discard tag to the highlight
        const tagModifyEndpoint = "https://readwise.io/api/v2/highlights/" + highlight.id + "/tags"
        let http = HTTP.create();
        let response = http.request({
            "url": tagModifyEndpoint,
            "method": "POST",
            "headers": {
                "Authorization": "Token " + credential.getValue("authtoken"),
            },
            "data": {
                "name": "favorite",
            }
        });
        if (response.success || response.statusCode == 201) {
            app.displaySuccessMessage("highlight favorited")
        } else {
            alert("favoriting highlight failed:\n" + response.statusCode + "\n" + response.error)
        }
    }
    
    
    
    function updateHighlightTags(originalHighlight, addedTags, removedTags) {
        const tagModifyEndpoint = "https://readwise.io/api/v2/highlights/" + originalHighlight.id + "/tags"
        let http = HTTP.create();
        // delete tags that should be removed
        for (rTag of removedTags) {
            // first get the id of the tag:
            let tags = originalHighlight.tags
            tagObj = tags.filter((tag) => {
                return tag.name == rTag
            })
    
            let response = http.request({
                "url": tagModifyEndpoint + "/" + tagObj[0].id,
                "method": "DELETE",
                "headers": {
                    "Authorization": "Token " + credential.getValue("authtoken"),
                }
            });
            if (response.success || response.statusCode == 204) {
                app.displaySuccessMessage("tag removed")
            } else {
                alert("removing tag failed:\n" + response.statusCode + "\n" + response.error)
            }
        }
        // add the tags that should be added
        for (aTag of addedTags) {
            let response = http.request({
                "url": tagModifyEndpoint,
                "method": "POST",
                "headers": {
                    "Authorization": "Token " + credential.getValue("authtoken"),
                },
                "data": {
                    "name": aTag,
                }
            });
            if (response.success || response.statusCode == 201) {
                app.displaySuccessMessage("tag added")
            } else {
                alert("adding tag failed:\n" + response.statusCode + "\n" + response.error)
            }
        }
    }
    
    function copyHighlightText(highlight) {
        app.setClipboard(highlight.text)
        app.displaySuccessMessage("copied highlight text")
    }
    
    function copyHighlightAsQuote(highlight, book) {
        let text = "> " + highlight.text;
        if (book.author) {
            text = text + "\n> --" + book.author
        }
        app.setClipboard(text)
        app.displaySuccessMessage("copied highlight as quote")
    
    }
    
    function importSingleHighlight(highlight, book) {
        let sourceText = ""
        if (book.source_url) {
            sourceText = "[" + book.title + "]" + "(" + book.source_url + ")"
        } else {
            sourceText = book.title
        }
        let idStr = `${highlight.text}
    
    -- ${book.author} in ${sourceText}
    
    [Readwise URL](${book.highlights_url})
       `
        let existingDraft = getDraftContainingString(idStr);
        if (existingDraft) {
            // open the draft
            editor.load(existingDraft)
            return
        }
        let tags = []
        for (tag of highlight.tags) {
            tags.push(tag.name);
        }
        let content = `${idStr}
    
    ## Notes
    
    ${highlight.note}
    
    	`
        let d = new Draft()
        d.content = content
        d.update()
        tags.map((tag) => d.addTag(tag))
        editor.load(d)
        app.displaySuccessMessage("highlight imported")
    }
    
    function getDraftContainingString(str) {
        let foundDrafts = Draft.query(str, "all", [], [], "modified", false, false)
        if (foundDrafts.length == 0) {
            return undefined
        } else if (foundDrafts.length == 1) {
            return foundDrafts[0]
        } else {
            alert("found several drafts containing:\n\"" + str + "\"\nAction will open the first one in the list.")
            return foundDrafts[0]
        }
    }
    
    function importAllHighlightsFromSource(book) {
        let idStr = `> [Readwise Book Highlights](${book.highlights_url})`
        let existingDraft = getDraftContainingString(idStr);
        if (existingDraft) {
            // open the draft
            editor.load(existingDraft)
            app.displayInfoMessage("highlights already imported - opening draft")
            return
        }
        let highlights = getHighlightsOfBook(book)
        if (!highlights) {
            return false;
        }
    
        let highlightsTexts = [];
        for (let highlight of highlights) {
            let tags = highlight.tags
            tags = tags.map((tag) => {
                return "*#" + tag.name + "*"
            })
    
    
            const emojiRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
            let foundEmojis = ""
            for (tag of tags) {
                let match = tag.match(emojiRegex);
    
                if (match) {
                    foundEmojis = foundEmojis + match[0] + " "
                }
            }
    
            let text = highlight.text
            let highlightNote = highlight.note != "" ? " (" + highlight.note + ")" : ""
    
            highlightsTexts.push("- " + foundEmojis + text + " " + tags.join(", ") + highlightNote)
    
        }
        highlightsTexts = highlightsTexts.reverse()
    
        let sourceText = book.source_url ? "\n- source: [" + book.title + "](" + book.source_url + ")" : ""
        let documentNoteText = book.document_note ? "\n\n## Note\n\n" + book.document_note : ""
        // create the text
    
        if (book.highlights_url) {
    
            let content = `# ${book.title}
    
    ![](${book.cover_image_url})
    
    - author: [[${book.author}]]${sourceText}
    
    ## Highlights (${highlights.length})
    
    ${highlightsTexts.join("\n")}
    
    ---
    
    ${idStr}
    	`
            let d = new Draft()
            d.content = content
            // also add the document tags from readwise
            for (tag of book.tags) {
                d.addTag(tag.name)
            }
    
    
    
    
    
    
    
            d.update()
            editor.load(d)
            app.displaySuccessMessage("highlights imported")
        }
    }
    
    function getHighlightsOfBook(book) {
        let http = HTTP.create();
        let response = http.request({
            "url": bookHighlightsEndpoint + book.id,
            "method": "GET",
            "headers": {
                "Authorization": "Token " + credential.getValue("authtoken"),
            }
        });
        responseData = undefined;
        if (response.success) {
            responseData = response.responseData;
        } else {
            console.log(response.statusCode);
            console.log(response.error);
        }
        return responseData.results
    }
    
    function compareArrays(arr1, arr2) {
        // Sort both arrays
        const sortedArr1 = arr1.slice().sort();
        const sortedArr2 = arr2.slice().sort();
    
        const addedItems = [];
        const removedItems = [];
    
        let i = 0;
        let j = 0;
    
        // Compare and find added and removed items
        while (i < sortedArr1.length && j < sortedArr2.length) {
            if (sortedArr1[i] === sortedArr2[j]) {
                i++;
                j++;
            } else if (sortedArr1[i] < sortedArr2[j]) {
                removedItems.push(sortedArr1[i]);
                i++;
            } else {
                addedItems.push(sortedArr2[j]);
                j++;
            }
        }
    
        // Collect remaining elements
        while (i < sortedArr1.length) {
            removedItems.push(sortedArr1[i]);
            i++;
        }
        while (j < sortedArr2.length) {
            addedItems.push(sortedArr2[j]);
            j++;
        }
        //alert("arr1: " + arr1.length + " arr2: " + arr2.length + "\n" + addedItems.length + " " + "\"" + addedItems.join(", ") + "\"\n" + removedItems.length + " " + "\"" + removedItems.join(", ") + "\"")
        return {
            addedItems,
            removedItems,
        };
    }

Options

  • After Success Nothing
    Notification Error
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.