570 lines
16 KiB
Python
Executable file
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
|