aethertool/aether
2021-06-21 15:04:10 -05:00

570 lines
16 KiB
Python
Executable file

#!/usr/bin/env python
# This is a component of aethertool, a command-line interface to aether
# Copyright 2005-2021 Jeff Epler <jepler@unpythonic.net>
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3 as published by
# the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
import getpass, urllib.parse, urllib.request, urllib.error, tempfile, os, time, webbrowser, sys
import getopt, cgi, re, disorient
import shutil, atexit
import urllib, html
from sys import argv
from html.entities import name2codepoint
from PIL import Image
import mechanize as ClientForm
def execfile(fn):
with open(fn) as f:
content = f.read()
print("exec", content)
exec(content, globals(), globals())
EDITOR = os.environ.get("EDITOR", "vim")
thumb_geometry = (300, 300)
medium_geometry = (900, 900)
config = {}
default_config = "default"
browser_wait = 5
def add_config(name, root, password=None, thumb_geometry=None, alternates=[]):
config[name] = (root, password, thumb_geometry, alternates)
def set_default(name):
global default_config
default_config = name
def decode_geometry(s):
if isinstance(s, str):
s = tuple(map(int, s.split("x")))
return s
def load_config(name):
c = config[name]
global AETHER_TOP, PASS, thumb_geometry
AETHER_TOP = c[0]
PASS = c[1] or getpass.getpass()
thumb_geometry = c[2] or thumb_geometry
rcfile = os.path.join(os.environ.get("HOME", ""), ".aetherrc")
if os.path.exists(rcfile):
execfile(rcfile)
else:
print(
"The configuration file %r does not exist.\n"
"aethertool cannot run without it." % rcfile
)
raise SystemExit
config_name = os.path.splitext(os.path.split(sys.argv[0])[1])[0]
if len(config) == 1:
default_config = config.keys()[0]
if config_name not in config:
config_name = None
def quote_paranoid(text):
"""Convert utf-8 string to sequence of lower case English characters."""
text = text.encode("utf-8")
result = ""
for char in text:
result += chr(ord("a") + char // 16) + chr(ord("a") + char % 16)
return result
import mimetypes
def post_multipart(url, fields, files):
"""
Post fields and files to an http host as multipart/form-data.
fields is a sequence of (name, value) elements for regular form fields.
files is a sequence of (name, filename, value) elements for data to be uploaded as files
Return the server's response page.
"""
content_type, data = encode_multipart_formdata(fields, files)
headers = {"Content-Type": content_type}
request = urllib.request.Request(url, data=data, headers=headers)
f = urllib.request.urlopen(request)
return f.read()
def encode_multipart_formdata(fields, files):
"""
fields is a sequence of (name, value) elements for regular form fields.
files is a sequence of (name, filename, value) elements for data to be uploaded as files
Return (content_type, body) ready for httplib.HTTP instance
"""
BOUNDARY = b"----------ThIs_Is_tHe_bouNdaRY_$"
CRLF = b"\r\n"
L = []
for (key, value) in fields:
L.append(b"--" + BOUNDARY)
L.append(b'Content-Disposition: form-data; name="%s"' % key.encode('utf-8'))
L.append(b"")
L.append(value.encode('utf-8'))
for (key, filename, value) in files:
L.append(b"--" + BOUNDARY)
L.append(
b'Content-Disposition: form-data; name="%s"; filename="%s"' % (key.encode('utf-8'), filename.encode('utf-8'))
)
L.append(b"Content-Type: %s" % get_content_type(filename).encode('utf-8'))
L.append(b"")
L.append(value)
L.append(b"--" + BOUNDARY + b"--")
L.append(b"")
body = CRLF.join(L)
content_type = (b"multipart/form-data; boundary=%s" % BOUNDARY)
return content_type, body
def get_content_type(filename):
return mimetypes.guess_type(filename)[0] or "application/octet-stream"
def posturl(url, fields, files):
urlparts = urllib.parse.urlsplit(url)
return post_multipart(url, fields, files)
def webbrowser_open(u):
os.spawnvp(os.P_NOWAIT, "firefox", ["firefox", u])
time.sleep(browser_wait)
def get_edit_url(page):
pw = urllib.parse.quote(PASS)
return AETHER_TOP + "?action=edit&password=%s&name=%s" % (pw, page)
def get_edit_form(page):
url = get_edit_url(page)
forms = ClientForm.ParseResponse(urllib.request.urlopen(url))
for f in forms:
try:
action = f.get_value("action")
except ClientForm.ControlNotFoundError:
continue
if action == "edit":
return f
print("Required form not found. AETHER_TOP or password may be incorrect.")
entity_re = re.compile("&(#[0-9]+|[a-zA-Z0-9]+);?")
def unescape_replace(m):
g = m.group(1)
if g.startswith("#"):
return chr(int(g[1:]))
codepoint = name2codepoint.get(g)
if codepoint is None:
return g
return chr(codepoint)
def unescape(s):
return entity_re.sub(unescape_replace, s)
current_text_pat = re.compile(
"<textarea[^>]*>" "([^<]*)" "</textarea>", re.I | re.M | re.DOTALL
)
def get_current_text(page):
url = get_edit_url(page)
page = urllib.request.urlopen(url).read().decode("utf-8", errors="replace")
m = current_text_pat.search(page).group(1)
m = unescape(m)
return m
def save_text(page, newtext):
f = get_edit_form(page)
f["text"] = newtext
return f.click("save")
def preview_text(page, newtext):
return """<HTML>
<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
<BODY onload="document.forms[0].submit()">
<DIV style="display: none">
<FORM method=post accept-charset=\"UTF-8\" action=\"%s#buttonsandpreview\">
<input type=hidden name=action value=edit>
<input type=hidden name=password value=\"%s\">
<input name=newname value=\"%s\">
<input name=name value=\"%s\">
<input name=hasnewname value=yes>
<textarea name=text>%s</textarea>
<input type=submit name=preview>
</form>
</DIV>
Submitting updated text...
</body>""" % (
html.escape(AETHER_TOP, True),
html.escape(PASS, True),
html.escape(page, True),
html.escape(page, True),
html.escape(newtext, True),
)
def upload_file(page, local, remote, quiet=0):
if page.startswith("http"):
raise SystemExit("URL vs path confusion, page=%s" % page)
qPASS = quote_paranoid(PASS)
qpage = quote_paranoid(page)
url = AETHER_TOP
data = [
("action", "attach"),
("password", qPASS),
("name", qpage),
("nolisting", "1"),
]
fdata = [("file", remote, open(local, "rb").read())]
posturl(url, data, fdata)
if not quiet:
print("Uploaded file is:")
print(" [page %s] [file %s]" % (page, remote))
if AETHER_TOP.endswith("/"):
print(" " + AETHER_TOP + "files/%s/%s" % (page, remote))
else:
print(" " + AETHER_TOP + "-files/%s/%s" % (page, remote))
def put_page(page, filename):
if hasattr(filename, "read"):
contents = filename.read()
elif filename is None:
contents = ""
else:
contents = open(filename).read()
print("size of new contents", len(contents))
u = save_text(page, contents)
try:
urllib.request.urlopen(u)
except urllib.error.HTTPError as detail:
if detail.code != 404 or contents:
raise
def edit_page(page):
open(os.path.expanduser("~/.aetherlast"), "w").write(
"%s\n%s\n" % (config_name, page)
)
t = get_current_text(page)
name = os.path.join(tempdir, (page.replace("/", "_") or "index") + ".ae")
fd = open(name, "w")
fd.write(t)
fd.close()
hname = os.path.join(tempdir, "submit.html")
pid = os.spawnvp(os.P_NOWAIT, EDITOR, [EDITOR, name])
ost = os.stat(name)
while 1:
nst = os.stat(name)
if ost.st_mtime != nst.st_mtime:
ost = nst
new_text = open(name).read()
u = preview_text(page, new_text)
hfd = open(hname, "w")
hfd.write(u)
hfd.close()
webbrowser_open(hname)
sts = os.waitpid(0, os.P_NOWAIT)
if sts[0] == pid:
break
time.sleep(1)
os.unlink(name)
def isimage(f):
ext = os.path.splitext(f)[1].lower()
if ext in [".jpg", ".jpeg", ".png", ".gif"]:
return True
return False
def get_orient(f):
return disorient.exif_orientation(f) or 1
def optimize(f):
ext = os.path.splitext(f)[1].lower()
t = os.path.join(tempdir, "optimized" + ext)
if ext in [".jpg", ".jpeg"]:
command = [
"jpegtran",
"-optimize",
"-progressive",
"-copy",
"all",
"-outfile",
t,
]
o = get_orient(f)
if o == 8:
command.extend(["-rotate", "270"])
elif o == 6:
command.extend(["-rotate", "90"])
command.append(f)
os.spawnvp(os.P_WAIT, command[0], command)
if o in (6, 8):
disorient.clear_exif_orientation(t)
return t
if ext in [".png"]:
os.spawnvp(os.P_WAIT, "pngcrush", ["pngcrush", "-q", "-reduce", f, t])
return t
# XXX convert gif to png?
return f
def resize(geometry, tag, src):
print("resize", geometry, tag, src)
geometry = decode_geometry(geometry)
ext = os.path.splitext(src)[1]
local = os.path.join(tempdir, "thumb" + ext)
localjpg = os.path.join(tempdir, "thumb" + ".jpg")
i = Image.open(src)
if i.size[0] < geometry[0] and i.size[1] < geometry[1]:
print("size fail", i.size, geometry)
return None
if i.mode == "P":
i = i.convert("RGB")
i.thumbnail(geometry, Image.ANTIALIAS)
i.save(local)
local = optimize(local)
if localjpg != local and allow_jpg:
i.save(localjpg)
localjpg = optimize(localjpg)
print(os.stat(localjpg).st_size, os.stat(local).st_size)
if os.stat(localjpg).st_size < 0.9 * os.stat(local).st_size:
local = localjpg
return local
tempdir = tempfile.mkdtemp()
atexit.register(shutil.rmtree, tempdir)
def usage(exitcode=1):
print(
"""\
Usage:
%(name)s [-e] page
Edit 'page'. Use '/' (forward slash) to edit the front page. To
preview the changes in your browser, save the file. Use the browser's
save button in the browser to save the changes.
%(name)s -p page < contents
Put new contents to 'page' from standard input.
%(name)s -n [-k suffix] blog
Create a new blog entry on 'blog', optonally with 'suffix' added
to the name of the created page.
%(name)s -u [-t] [-g WxH] page file[=localfile] file...
%(name)s -u [-t] [-g WxH] -l file[=localfile] file...
Upload files to 'page' or the last page edited (-l). Create thumbnails
unless -t is specified. Use -g to specify the maximum thumbnail size,
currently %(thumbsize)s.
%(name)s -d page
Delete 'page'
%(name)s -c configname [other usage from above]
Use the configuration 'configname' instead of the default
For help on the syntax of configuration files, use "-c help".
"""
% {"name": os.path.basename(sys.argv[0]), "thumbsize": "%dx%d" % thumb_geometry}
)
raise SystemExit(exitcode)
def help_config():
print(
"""\
The configuration file ~/.aetherrc is a python script. A typical one might
look like this (without the indentation):
add_config('configname', 'http://www.example.com/index.cgi', 'password')
set_default('configname')
The configuration is guessed from the script's name, otherwise the value
given by set_default is used. If only one call to add_configuration is
present, then that configuration is the default.
If 'password' is not specified, then it is prompted when the script is run.
"""
)
print("The following configurations are defined:")
for k in config:
print("\t" + k)
print()
print(
"When invoked as '%s', the default configuration is '%s'"
% (os.path.basename(sys.argv[0]), default_config)
)
raise SystemExit(0)
def parse_url(u):
best = ""
for n, c in config.items():
root = c[0]
if u.startswith(root) and len(root) > len(best):
best = root
bestname = n
bestpage = u[len(root) :]
for root in c[3]:
if u.startswith(root) and len(root) > len(best):
best = root
bestname = n
bestpage = u[len(root) :]
if best:
return bestname, bestpage
try:
opts, args = getopt.getopt(argv[1:], "+c: denpul U tj m:k: g: c: h?")
except getopt.GetoptError as detail:
print(os.path.basename(sys.argv[0]), detail, file=sys.stderr)
usage()
MODE_EDIT, MODE_NEW_ENTRY, MODE_PUT, MODE_UPLOAD, MODE_DELETE, MODE_URL = range(6)
mode = MODE_EDIT
suffix = ""
do_thumbnail = True
allow_jpg = True
for k, v in opts:
if k == "-c":
config_name = v
if k == "-d":
mode = MODE_DELETE
if k == "-e":
mode = MODE_EDIT
if k == "-n":
mode = MODE_NEW_ENTRY
if k == "-p":
mode = MODE_PUT
if k == "-u":
mode = MODE_UPLOAD
if k == "-U":
mode = MODE_URL
if k == "-t":
do_thumbnail = not do_thumbnail
if k == "-j":
allow_jpg = not allow_jpg
if k == "-k":
suffix = "-" + v.replace(" ", "-")
if k == "-g":
thumb_geometry = v
if k == "-m":
medium_geometry = v
if k == "-?" or k == "-h":
usage(0)
if k == "-l":
config_name, page = open(os.path.expanduser("~/.aetherlast")).read().split()
args.insert(0, page)
if config_name is None and args:
parsed = parse_url(args[0])
if parsed is not None:
config_name, args[0] = parsed
elif args:
parsed = parse_url(args[0])
if parsed is not None:
parsed_config_name, args[0] = parsed
if parsed_config_name != config_name:
raise SystemExit(
"URL points to site %s, but you asked for %s"
% (parsed_config_name, config_name)
)
if config_name is None:
config_name = default_config
if config_name == "help":
help_config()
load_config(config_name)
if mode == MODE_URL:
print(get_edit_url(""))
elif mode == MODE_UPLOAD:
page = args[0]
for filename in args[1:]:
if "=" in filename:
remote, local = filename.split("=", 1)
else:
remote = os.path.basename(filename)
if not re.search("[a-z]", remote):
remote = remote.lower()
local = filename
if isimage(remote):
local = optimize(local)
upload_file(page, local, remote)
if do_thumbnail and isimage(remote):
base, ext = os.path.splitext(remote)
localmed = resize(medium_geometry, "medium", local)
if localmed:
med = base + "-medium" + os.path.splitext(localmed)[1]
upload_file(page, localmed, med)
localthumb = resize(thumb_geometry, "small", local)
if localthumb:
thumb = base + "-small" + os.path.splitext(localthumb)[1]
upload_file(page, localthumb, thumb)
elif mode == MODE_NEW_ENTRY:
if args:
page = args[0] + "/0" + str(int(time.time())) + suffix
else:
page = "0" + str(int(time.time())) + suffix
edit_page(page)
elif mode == MODE_EDIT:
if args:
page = args[0]
else:
page = "sandbox"
page = page.strip("/")
edit_page(page)
elif mode == MODE_PUT:
page = args[0].strip("/")
put_page(page, sys.stdin)
elif mode == MODE_DELETE:
page = args[0].strip("/")
put_page(page, None)
# vim:sw=4:sts=4:et