A command-line client for 750words.com, and libraries to use it from within Emacs and Org mode.
[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 .
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.
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!
Necessary libraries and software.
- Selenium Python bindings (run
pip install -r requirements.txt
). This is the contents ofrequirements.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" ]
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
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'))
)
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)
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 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')
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")
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.")
We close the driver, which also quits the Chrome instance.
eprint("Done!")
if not args.no_quit:
driver.quit()
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
.
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.
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)
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 ofFILENAME
.
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.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:
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\"\)")
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))))))
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
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.
;;; 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:
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))))))
(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