Skip to content

Latest commit

 

History

History
886 lines (716 loc) · 32.3 KB

750words-client.org

File metadata and controls

886 lines (716 loc) · 32.3 KB

750words client

A command-line client for 750words.com, and libraries to use it from within Emacs and Org mode.

Table of Contents

Installation

Docker image

[750words-client on Docker Hub][Dockerfile]

You can use 750words-client.py from its Docker image as follows (the image will be downloaded from Docker Hub the first time you use it):

docker run zzamboni/750words-client --help

Note that you have to define the USER_750WORDS and PASS_750WORDS environment variables in your environment, and pass them to the container. You also need to pass the -i option to docker run if you want to read the input from standard input, e.

cat file.txt | docker run -i -e USER_750WORDS -e PASS_750WORDS zzamboni/750words-client

If you want to build the image yourself, you can do it as follows from a checkout of its git repository:

docker build --tag 750words-client .

Installation from source

[GitHub repository]

Clone the git repository:

git clone https://github.com/zzamboni/750words.git

You need the following libraries and components installed:

  • Selenium Python bindings (run pip install -r requirements.txt)
  • Google Chrome is used to automate the connections.
  • ChromeDriver so that Selenium can connect to Chrome - make sure you install the version that corresponds to the Chrome version you have installed.

You can then copy 750words-client.py to somewhere in your $PATH to use it.

Usage

750words-client.py --help
usage: 750words-client.py [-h] [--min MIN] [--max MAX] [--only-if-needed]
                          [--replace] [--count] [--text] [--quiet]
                          [--no-headless] [--no-quit]
                          [FILE ...]

Interact with 750words.com from the command line.

positional arguments:
  FILE              Input files for text to add. Default is to read from
                    standard input.

optional arguments:
  -h, --help        show this help message and exit
  --min MIN         Minimum number of words needed. Default: 750.
  --max MAX         Maximum total number of words allowed. Default: 5000.
  --only-if-needed  Only add text if current word count is below MIN.
  --replace         Replace any current text with the new one, default is to
                    add at the end.
  --count           Don't upload text, only print the current word count.
  --text            Don't upload text, only print the current text.
  --quiet           Don't print progress messages.

debugging options:
  --no-headless     Disable headless mode (opens the Chrome app window).
  --no-quit         Don't quit the browser at the end.

Your 750words.com credentials must be stored in the USER_750WORDS and
PASS_750WORDS environment variables.

For example (in this case there were already some words entered previously in the day):

> echo "This is some text to enter" | 750words-client.py
Got text: This is some text to enter

 (6 words)
Connecting to 750words.com...
Authenticating...
Finding current text entry...
Current word count: 1324
Entering new text...
Saving...
New word count: 1330
You completed your 750 words for today!
Done!

Client Implementation

Dependencies and Dockerfile

Necessary libraries and software.

  • Selenium Python bindings (run pip install -r requirements.txt). This is the contents of requirements.txt:
    selenium
        
  • Google Chrome is used to automate the connections.
  • ChromeDriver so that Selenium can connect to Chrome - make sure you install the version that corresponds to the Chrome version you have installed.

The Docker image allows the program to be used directly from the container by passing the corresponding arguments, e.g.:

docker run zzamboni/750words-client --help

This is the Dockerfile to build it:

## -*- dockerfile-image-name: "zzamboni/750words-client" -*-

FROM python:3.9-alpine
MAINTAINER Diego Zamboni <diego@zzamboni.org>

WORKDIR /app

RUN apk --no-cache add chromium chromium-chromedriver gcc libc-dev libffi-dev

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY 750words-client.py .

ENTRYPOINT [ "python", "/app/750words-client.py" ]

Code

Libraries

We load the necessary standard libraries.

import argparse
import os
import sys
import time
import re

We also load the necessary Selenium libraries.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

Utility functions

Print a progress/status message to stderr, which can be muted with the --quiet option.

def eprint(*eargs, **ekwargs):
    if not args.quiet:
        print(*eargs, file=sys.stderr, **ekwargs)

Count words in a string. We use simple space-separated word count, which is what 750words.com uses as well.

def word_count(text):
    return len(text.split())

Enter text into a field. We use a Javascript snippet to set the value instead of using the Selenium send_keys() function, since it is much faster, particularly for longer texts.

def enter_text(driver, field, value):
    driver.execute_script('arguments[0].value=arguments[1];', field, value)

Find the main text entry field in the page.

def find_text_field(driver):
    return WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.ID, 'entry_body'))
    )

Configuration and command line arguments

We configure the minimum and maximum word thresholds. The maximum may change if you have a paid 750words.com account, which allows you to write more than 5000 words.

min_words = 750
max_words = 5000

Process the command line options. All the values end up stored in args.

parser = argparse.ArgumentParser(description="Interact with 750words.com from the command line.",
                                 epilog=("Your 750words.com credentials must be stored in the "
                                         "USER_750WORDS and PASS_750WORDS environment variables."))
parser.add_argument('FILE',
                    help='Input files for text to add. Default is to read from standard input.',
                    type=argparse.FileType('r'),
                    nargs='*',
                    default=[sys.stdin],)
parser.add_argument("--min",
                    help=("Minimum number of words needed. Default: %d." % min_words),
                    default=min_words,
                    type=int)
parser.add_argument("--max",
                    help=("Maximum total number of words allowed. Default: %d." % max_words),
                    default=max_words)
parser.add_argument("--only-if-needed",
                    help="Only add text if current word count is below MIN.",
                    action="store_true")
parser.add_argument("--replace",
                    help="Replace any current text with the new one, default is to add at the end.",
                    action="store_true")
parser.add_argument("--count",
                    help="Don't upload text, only print the current word count.",
                    action="store_true")
parser.add_argument("--text",
                    help="Don't upload text, only print the current text.",
                    action="store_true")
parser.add_argument("--quiet",
                    help="Don't print progress messages.",
                    action="store_true")
debug_options = parser.add_argument_group('debugging options')
debug_options.add_argument("--no-headless",
                           help="Disable headless mode (opens the Chrome app window).",
                           action="store_true")
debug_options.add_argument("--no-quit",
                           help="Don't quit the browser at the end.",
                           action="store_true")
args = parser.parse_args()

Verify that the username and password have been provided through the corresponding environment variables, otherwise fail.

username = os.getenv('USER_750WORDS') or None
password = os.getenv('PASS_750WORDS') or None

if not(username and password):
    eprint("Please set the USER_750WORDS/PASS_750WORDS environment variables")
    sys.exit(1)

Read new text

Text is read from the provided files (default STDIN) only if --count and --text are not given. We also count how many words it contains.

text = ""
text_count = 0
if not (args.count or args.text):
    for infile in args.FILE:
        text = text + infile.read() + "\n"
    text_count = word_count(text)
    eprint("Got text: " + text + (" (%d words)" % text_count))

Start up Chrome using Selenium and connect to 750words.com

Start Chrome using the necessary options. These options ensure that Chrome runs well inside a Docker container.

opts = Options()
opts.add_argument("--window-size=1200,800")
if not args.no_headless:
    opts.add_argument("--headless")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-gpu")
opts.add_argument("--verbose")
opts.add_argument("--disable-setuid-sandbox")
opts.add_argument("--disable-dev-shm-usage")
opts.add_argument("--disable-infobars")
opts.add_argument("--disable-popup-blocking")

driver = webdriver.Chrome(options=opts)

Now load the website’s authentication screen.

eprint("Connecting to 750words.com...")
driver.get('https://750words.com/auth')

Authenticate

Find the authentication form inside the page.

eprint("Authenticating...")
login_form = WebDriverWait(driver, 10).until(
    EC.presence_of_element_located((By.ID, 'signin_form'))
)

If found, find the username/password fields and send the correct information, else signal an error.

if login_form:
    user_field = driver.find_element(By.ID, 'person_email_address')
    password_field = driver.find_element(By.ID,'person_password')
    enter_text(driver, user_field, username)
    enter_text(driver, password_field, password)
    login_form.submit()
else:
    raise BaseException("Could not find login form in https://750words.com/auth")

Do the work

By now we should be in the 750words.com main “Today” page, which contains a big text field for entering today’s words. So the first thing we do is find that field.

eprint("Finding current text entry...")
# We use WebDriverWait to wait (with a limit) until the page is loaded and the
# necessary element appears.
# text_field = driver.find_element_by_id('entry_body')
text_field = find_text_field(driver)

Finally, we can perform the requested actions with the text according to the options.

if text_field:
    # Get current text and word count
    current_text = text_field.get_attribute("value")
    current_word_count = word_count(current_text)

    # If --count is given, print the word count
    if args.count:
        print("Current word count: "+str(current_word_count))

    # If --text is given, print the text
    if args.text:
        print(current_text)

    # Otherwise, prepare to enter text
    if not (args.count or args.text):
        add_text = True
        # Print current word count also when adding text, but this can be
        # controlled with --quiet
        eprint("Current word count: "+str(current_word_count))
        # If --only-if-needed is used without --replace, we need to check if we
        # already have enough words
        if (not args.replace) and args.only_if_needed and (current_word_count >= args.min):
            eprint("Word count is already enough, not entering text.")
            add_text = False

        # Finally we get to entering new text
        if add_text:
            # First clear the field if --replace was used
            if args.replace:
                eprint("Clearing existing text...")
                current_text = ""
                current_word_count = 0

            # Check if the end text would have more words than the maximum
            # allowed, and in that case trim it down.
            if (current_word_count + text_count) > args.max:
                new_word_count = args.max - current_word_count
                eprint("Trimming new text to %d words to keep total below %d" % (new_word_count, args.max))
                text = ''.join(re.findall(r'\S+\s*', text)[:new_word_count])

            # Enter the new text in the text field
            eprint("Entering new text...")
            enter_text(driver, text_field, current_text + text)
            text_field.send_keys("\n")

            # Send Ctrl-s to force save
            eprint("Saving...")
            text_field.send_keys(Keys.CONTROL, "s")
            time.sleep(1)

            # 750words issues a warning dialog if the word count gets reduced by
            # a lot when saving the text. This might happen with --replace, so
            # we catch it. If the dialog appears, we click "Save anyway". Note
            # that the <div id="losing_words"> element is always there, but
            # normally empty, so we need to check if it contains any text
            # instead of its existence.
            warning_dialog_text = driver.find_element(By.XPATH, '//div[@id="losing_words"]').text
            if warning_dialog_text:
                eprint("Got the reduced-word-count warning dialog, clicking 'Save anyway'")
                # Press Enter to select the default button, which is "Save anyway"
                driver.switch_to.active_element.send_keys(Keys.ENTER)

            eprint("Reloading page to ensure save succeeded")
            # Disable "Are you sure?" alert on reload
            driver.execute_script("window.onbeforeunload = function() {};")
            driver.refresh()
            time.sleep(1)

            # Get new text and word count
            text_field = find_text_field(driver)
            new_text = text_field.get_attribute("value")
            new_word_count = word_count(new_text)
            eprint("New word count: %d" % new_word_count)
            if new_word_count >= args.min:
                eprint("You completed your %d words for today!" % args.min)
else:
    raise BaseException("Could not find text entry form in page.")

Finish

We close the driver, which also quits the Chrome instance.

eprint("Done!")
if not args.no_quit:
    driver.quit()

Emacs support

The code below integrates 750words-client into Emacs, so I can post text directly from the current buffer. The library is called 750words, and tangled to 750words.el.

Emacs integration

The 750words Emacs library allows using the 750words-client command line program to post text from within Emacs. With it, you can post an entire buffer, or a selected region. Support for auth-sources is provided so you don’t have to store your credentials in your Emacs config. Additionally, the ox-750words library enables an Org exporter which posts the contents of your Org buffer, region or subtree to 750words.com, converting it first to Markdown, which is understood by 750words.com.

Installation

First, you need to have the 750words-client.py command line installed, or its Docker image.

For now the library is not yet in MELPA, so you need to install it from this repository. If you use Doom Emacs, you can add the following line to your package.el file:

(package! 750words
  :recipe (:host github
           :repo "zzamboni/750words-client"
           :files ("*.el")))

And then load it from your config.el as follows. You only need to load ox-750words if you want to use the exporter from within Org mode.

(use-package! 750words)
(use-package! ox-750words)

If you prefer to install by hand, you can clone this repository, store the 750words.el and ox-750words.el files somewhere in your load-path, and load them as follows:

(require '750words)
(require 'ox-750words)

Usage

If you use auth-sources, you can store your 750words.com credentials by storing them in the appropriate store associated with the host “750words.com”. For example, if variable auth-sources contains ~/.authinfo.gpg, you can add a line in the following format:

machine 750words.com login <email address> password <password>

You can then run 750words-credentials-setenv to read the credentials and store them in the correct environment variables.

Note: If the auth-source you use supports entry creation (for example, ~/.authinfo.gpg does) you can run C-u M-x 750words-credentials-setenv - you will be prompted for your credentials and they will be automatically stored.

After you have loaded your credentials, you can use the following commands to post text:

  • M-x 750words-region-or-buffer: if you have a region selected, it will be posted. Otherwise, the whole buffer will be posted.
  • M-x 750words-region: post the currently selected region (issues an error if no region is selected).
  • M-x 750words-buffer: post the entire current buffer.
  • If you are in an Org buffer and loaded ox-750words, you can open the export screen (C-c C-e) and find the item [7] Post to 750words.com inside the [m] Export to Markdown section to post your current Org file in Markdown format.
  • From LISP, you can also use (750words-file FILENAME) to post the contents of FILENAME.

By default, the 750words-client.py is executed, assuming you have it installed. If you want to use its Docker image, you can configure it as follows:

(setq 750words-client-command "cat %s | docker run -i -e USER_750WORDS -e PASS_750WORDS zzamboni/750words-client")

750words library implementation

File header (required by MELPA)

;;; 750words.el --- Emacs integration and Org exporter for 750words.com -*- lexical-binding: t; -*-
;;
;; Copyright (C) 2021 Diego Zamboni
;;
;; Author: Diego Zamboni <https://github.com/zzamboni>
;; Maintainer: Diego Zamboni <diego@zzamboni.org>
;; Created: June 10, 2021
;; Modified: June 10, 2021
;; Version: 0.0.1
;; Keywords: files, org, writing
;; Homepage: https://github.com/zzamboni/750words-client
;; Package-Requires: ((emacs "24.4"))
;;
;; This file is not part of GNU Emacs.
;;
;; Licensed under the Apache License, Version 2.0 (the "License");
;; you may not use this file except in compliance with the License.
;; You may obtain a copy of the License at
;;
;;     https://www.apache.org/licenses/LICENSE-2.0
;;
;; Unless required by applicable law or agreed to in writing, software
;; distributed under the License is distributed on an "AS IS" BASIS,
;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
;; See the License for the specific language governing permissions and
;; limitations under the License.
;; 
;;; Commentary:
;;
;; This package provides functions for posting text from Emacs to the
;; 750words.com website. It includes two libraries:
;; 
;; - `750words' contains functions to handle authentication and to post a file,
;;   the current buffer or current selected region to 750words.com.
;; - `ox-750words'' defines an Org exporter to convert Org text to Markdown and
;;   then post it to 750words.com
;; 
;; See https://github.com/zzamboni/750words-client for full usage instructions.
;;
;;; Code:

Libraries and configuration

We use the auth-source library for the authentication functions.

(require 'auth-source)

The only configurable variable is the one that contains the command to run to post text to 750words.com.

(defvar 750words-client-command "750words-client.py %s"

  "Program to call to post text to 750words.com.

It must contain one '%s' representing the file in which the text
will be stored before calling it. If you want to use the
750words-client Docker container, you can set it as follows:

\(setq 750words-client-command \"cat %s | docker run -i -e USER_750WORDS -e PASS_750WORDS zzamboni/750words-client\"\)")

Authentication

Next we define functions to fetch/store the credentials, and also to store them in the necessary environment variables. The function you would normally use is 750words-credentials-setenv before calling one of the functions that post text.

(defun 750words-credentials (&optional create)
  "Fetch/create 750words.com credentials.

Search credentials from 750words.com in the configured
`auth-sources'. For example, if `auth-sources' contains
`~/.authinfo.gpg', you can add a line like this to it:

machine 750words.com login <your@email> password <your-password>

If the CREATE argument is t, the credentials are prompted for and
a function returned to save them.

Returns a list containing the following elements: the
750words.com username, the password, and a function which must be
called to save them. For an example of how to use it, see
`750words-credentials-setenv'."
  (let* ((auth-source-creation-prompts
          '((user  . "750words.com username: ")
            (secret . "750words.com password for %u: ")))
         (found (nth 0 (auth-source-search :max 1
                                           :host "750words.com"
                                           :require '(:user :secret)
                                           :create create))))
    (if found
        (list (plist-get found :user)
              (let ((secret (plist-get found :secret)))
                (if (functionp secret)
                    (funcall secret)
                  secret))
              (plist-get found :save-function))
      nil)))
(defun 750words-credentials-setenv (&optional save)
  "Fetch 750words.com credentials and store them in environment variables.

Call `750words-credentials' to fetch the credentials, and stores
the username and password in the USER_750WORDS and PASS_750WORDS
environment variables, respectively, so that they can be used by
750words-client.

If SAVE is t or if called interactively with a prefix argument,
prompt for the credentials if they are not found, and save them
to the configured auth source."
  (interactive "P")
  (let ((creds (750words-credentials save)))
    (when creds
      (setenv "USER_750WORDS" (nth 0 creds))
      (setenv "PASS_750WORDS" (nth 1 creds))
      (when (functionp (nth 2 creds))
        (funcall (nth 2 creds))))))

Posting text to 750words.com

Finally we get to the functions that do the actual work!

750word-file is the main backbone - it receives a filename, and posts it to 750words.com using 750words-client-command, running it asynchronously and displaying the progress in a separate buffer, which is converted to special-mode at the end.

(defun 750words-file (fname)
  "Post a file to 750words.com.

Post the contents of FNAME to 750words.com."
  ;; From https://emacs.stackexchange.com/a/42174/11843: Execute the command
  ;; asynchronously, and set up a sentinel to detect when the process ends and
  ;; set up its buffer to special-mode, so that it can be easily dismissed by
  ;; the user by pressing `q'.
  (let* ((output-buffer-name "*750words-client-command*")
         (output-buffer (generate-new-buffer output-buffer-name))
         (cmd (format 750words-client-command fname))
         (proc (progn
                 (async-shell-command cmd output-buffer)
                 (get-buffer-process output-buffer))))
    (if (process-live-p proc)
        (set-process-sentinel
         proc
         (apply-partially #'750words--post-process-fn output-buffer))
      (message "Running '%s' failed." cmd))))

The previous function uses 750words--post-process-fn to make it easier to see the results and clean up when the command is finished.

(defun 750words--post-process-fn (output-buffer-name process signal)
  "Switch to output buffer and set to `special-mode' when process exits.

This function gets called when the 750words-client PROCESS
finishes with an exit SIGNAL. Switch to its output buffer as
indicated by OUTPUT-BUFFER-NAME and set it to `special-mode',
which makes it read-only and the user can dismiss it by pressing
`q'."
  (when (memq (process-status process) '(exit signal))
    (switch-to-buffer-other-window output-buffer-name)
    (special-mode)
    (shell-command-sentinel process signal)))

750words-region posts an arbitrary region of the current buffer to 750words.com. When called interactively, it fetches the currently selected region, and produces an error if no region is selected.

(defun 750words-region (start end)
  "Post the current region to 750words.com.

If run interactively with a region selected, it will post the
content of the region.

When called from LISP, pass START and END arguments to indicate
the part of the buffer to post."
  (interactive "r")
  (let* ((fname (make-temp-file "750words")))
    ;; Write the region to a temporary file
    (write-region start end fname)
    ;; Post the temporary file
    (750words-file fname)))

750words-buffer is simply a wrapper around 750words-region which passes the whole buffer as the region to post.

(defun 750words-buffer ()
  "Post the current buffer to 750words.com.

Posts the entire contents of the current buffer. If you want to
post only a part of it, see `750words-region' or
`750words-region-or-buffer'."
  (interactive)
  (750words-region (point-min) (point-max)))

Finally, 750words-region-or-buffer calls one of the above functions depending on whether a region is currently selected.

(defun 750words-region-or-buffer ()
  "Post the current region or the whole buffer to 750words.com.

If a region is selected, post it, otherwise post the whole
buffer."
  (interactive)
  (if (region-active-p)
      (750words-region (point) (mark))
    (750words-buffer)))

We signal the package provided by this file.

(provide '750words)
;;; 750words.el ends here

ox-750words Org exporter

The ox-750words library is an Org mode exporter which converts Org text to Markdown (since 750words.com understands Markdown) and posts it. It uses the 750words library in the backend.

File header (required by MELPA)

;;; ox-750words.el --- Org mode exporter for 750words.com -*- lexical-binding: t; -*-
;;
;; Copyright (C) 2021 Diego Zamboni
;;
;; Author: Diego Zamboni <https://github.com/zzamboni>
;; Maintainer: Diego Zamboni <diego@zzamboni.org>
;; Created: June 10, 2021
;; Modified: June 10, 2021
;; Version: 0.0.1
;; Keywords: files, org, writing
;; Homepage: https://github.com/zzamboni/750words-client
;; Package-Requires: ((emacs "24.4") (750words "0.0.1"))
;;
;; This file is not part of GNU Emacs.
;;
;; Licensed under the Apache License, Version 2.0 (the "License");
;; you may not use this file except in compliance with the License.
;; You may obtain a copy of the License at
;;
;;     https://www.apache.org/licenses/LICENSE-2.0
;;
;; Unless required by applicable law or agreed to in writing, software
;; distributed under the License is distributed on an "AS IS" BASIS,
;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
;; See the License for the specific language governing permissions and
;; limitations under the License.
;;
;;; Commentary:
;;
;; An Org exporter which converts Org to Markdown and posts it to 750words.com.
;;
;; See https://github.com/zzamboni/750words-client for full usage instructions.
;;
;;; Code:

Define the new exporter

We define the ‘750words export backend as derived from the Markdown exporter, and add its menu item under the Markdown menu.

(require '750words)
(require 'ox-md)

(org-export-define-derived-backend '750words 'md
  :menu-entry
  '(?m 1
       ((?7 "Post to 750words.com"
            (lambda (_a s v _b) (org-750words-export-to-750words s v))))))

Export text to 750words.com

(defun org-750words-export-to-750words (subtreep visible-only)
  "Post Org text to 750words.com.

The Org buffer is first converted to Markdown using ox-md, and
the result posted to 750words.com.

When optional argument SUBTREEP is non-nil, export the sub-tree
at point, extracting information from the headline properties
first.

When optional argument VISIBLE-ONLY is non-nil, don't export
contents of hidden elements."
  (let* ((outfile (make-temp-file "ox-750words"))
         (org-export-with-smart-quotes nil))
    (org-export-to-file 'md outfile nil subtreep visible-only)
    (750words-file outfile)))
(provide 'ox-750words)
;;; ox-750words.el ends here