Pythonista: Zitatesammlung

Als ich morgens in der Ringbahn durch den App Store klickte, fielen mir einige Apps auf, die nichts anderes machten, als ein paar Zitate anzuzeigen. Da dachte ich mir, so etwas doch auch mal in Python klöppeln zu können. Gesagt, getan. Zumindest der Anfang ist gemacht.

# -*- coding: utf-8 -*-
from scene import *
from random import randrange, choice
 
class MyScene (Scene):
	def setup(self):
		self._changebackground()
		self._shownewquote()
		
	def _changebackground(self):
		background(randrange(50, 90)/100.0, 
		           randrange(50, 90)/100.0,
		           randrange(50, 90)/100.0)
	
	def _truncatestring(self, s, length=25):
		# Split into quote and author
		s, author = s
		# Truncate quote to fixed length, but
		# keeping word boundaries.
		output = []
		while len(s) > 0:
			if len(s) < length:
				output.append(s)
				break
			elif s[length - 1] == ' 'and len(s) > length:
				output.append(s[:length])
				s = s[length:]
			else:
				adjust = s[length:].find(' ')
				if adjust == -1:
					output.append(s)
					s = ''
				else:
					output.append(s[:length + adjust])
					s = s[length + adjust + 1:]
		# Strip all unnecessary blank spaces
		# from resulting strings.
		output = [line.strip() for line in output]
		# Righta-djust author and append to output
		w = max([len(data) for data in output])
		output.append(author.rjust(w))
		return '\n'.join(output)
		
	def _shownewquote(self):
		text(self._truncatestring(rq.next(), 20), x=self.size.w/2, y=self.size.h/2, font_name='DejaVuSansMono', font_size=12)
	
	def touch_began(self, touch):
		self._changebackground()
		self._shownewquote()
 
class RandomQuotes():
	def __init__(self):
		self.quotes = list(open('quotes.txt'))
		self.currentquote = self._newquote()
 
	def next(self):
		while True:
			next = self._newquote()
			if next != self.currentquote:
				self.currentquote = next
				break
		return self.currentquote
		
	def _newquote(self):
		 return choice(self.quotes).split('#-#')
		
if __name__ == '__main__':
	rq = RandomQuotes()
	run(MyScene())

Das Script nimmt sich aus einer weiteren Textdatei (quotes.txt), die auch auf Gist liegt, einige Zitate* und gibt sie dann aus. Bei jedem Touch auf das Display wird ein neues Zitat angezeigt und die Hintergrundfarbe geändert. Feinschliff ist noch überhaupt nicht angesetzt, dafür musste ich zu früh aus der Bahn aussteigen. Aber es tut immerhin ganz ordentlich.

* Die Zitate selbst stammen von Wikiquote.

Drafts-Rezepte XI: Sprachausgabe

Endlich wurde das Update von Pythonista durch Apples Schleusen gespült. Und wie gewohnt, kommt mit einem Update noch einmal eine Menge Qualität zu Pythonista hinzu. Unter den vielen Änderungen sind wieder so viele dabei, ich will das mal am Beispiel eines der neuen Module feiern. Die Version 1.4 von Pythonista bringt das speech-Modul, mit dem eine einfache Text-to-speech-Ausgabe unter iOS genutzt wird. Viel ist da gar nicht zu machen.

import speech
import sys

speech.say(sys.argv[1])

Die Action für Drafts ist auch nicht viel länger.

pythonista://texttospeech?action=run&argv=[[draft]]

Import-Link

Dieses kleine Skript macht natürlich nicht mehr als die Sprachausgabe – und das auch nur in der Systemsprache. Ich war zu faul, auf die Schnelle noch eine Option für die Sprache zu basteln. Ich muss ja auch noch den Rest des Updates kennenlernen.

Drafts-Rezepte IX: Serverstatus bei Github

Da auch den guten Leuten von github mal die Server wegbrechen, gibt es dort eine kleine API. Es ist nur eine klitzekleine Schnittstelle, aber sie wirft raus, wie es gerade und in letzter Zeit um die Erreichbarkeit von github steht. Also basteln wir uns doch mal ein kleines Script, mit dem wir die Daten abrufen können.

from console import alert
from json import loads
from sys import exit
from urllib import urlopen, quote
import webbrowser

def error_dialog(title, message):
    '''A diaolog box for error messages.'''
    try:
        alert(title, message)
    except KeyboardInterrupt:
        pass
    webbrowser.open('drafts://')
    exit(message)

def formatdate(date):
    '''bring the date into shape'''
    return date[:-1].replace('T', ' ')

def request(method):
    '''a request to github's status API.'''
    api_url_base = 'https://status.github.com/api/{method}'
    try:
        response = urlopen(api_url_base.format(method=method))
    except IOError:
        error_dialog('Connection Error', 'Unable to perform request.')
    if response.getcode() != 200:
        error_dialog('Error', 'Status code: {0} - Message: {1}'.format(response.getcode(), response.read()))
    return loads(response.read())

def github_status():
    '''get information and admin messages for github servers.'''
    status = request('status.json')
    messages = request('messages.json')
    output = '''
GITHUB STATUS
================
{date}: {status}

Last messages:
    '''.format(date=formatdate(status['last_updated']),
               status=status['status'])
    for item in messages:
        output += '\n{date}: {status}\n{comment}\n'.format(date=formatdate(item['created_on']),
                                                             status=item['status'],
                                                             comment=item['body'])
    output += '\nFor further information: https://status.github.com/'
    webbrowser.open('drafts://x-callback-url/create?text={0}'.format(quote(output)))

if __name__ == '__main__':
    github_status()

Gist

Die Action in Drafts ist auch keine Neuerung mehr.

pythonista://githubstatus?action=run

Import-Link

Seitdem Drafts auch Actions ausführt, wenn ein Draft keine Zeichen enthält, muss auch nur die Action gefeuert werden. Daraufhin rödelt Pythonista etwas rum, wirft dann aber das Ergebnis als Text in einen neuen Draft aus. Darin stehen dann in knapper Form die Informationen.

Drafts-Rezepte VII: Postleitzahlen mit Zippopotamus II

Der zweite Teil zu Postleitzahlen mit Zippopotamus geht in die entgegengesetzte Richtung. Statt nun also eine Postleitzahl zu ermitteln, geht es nun um die Städte oder Stadtteile hinter eine Postleitzahl.

Das Script ist wieder genauso einfach wie das erste. In Drafts muss eine neue Notiz nur die folgende Syntax einhalten:

[PLZ],[LÄNDERKÜRZEL]

Beispielsweise würde die folgende Eingabe der berühmten Postleitzahl eine Markdown-Liste in Drafts öffnen.

90210 , us

Auch hier kommt ein kleiner Haken ins Spiel, auch wenn er nicht ganz so lästig ist. Denn die API von Zippopotamus erlaubt es derzeit nicht, das Land nicht anzugeben. Unter Umständen müssen wir also mehr Infos angeben, als wir haben. Das Python-Script sieht derzeit so aus:

# -*- coding: utf-8 -*-
from console import alert
from json import loads
from sys import argv, exit
from urllib import urlopen, quote
import webbrowser

def error_dialog(title, message):
    '''
    a diaolog box for error messages.
    '''
    try:
        alert(title, message)
    except KeyboardInterrupt:
        pass
    webbrowser.open('drafts://')
    exit(message)

def handle_data(data):
    '''
    process json response from zippopotamus.
    will return a markdown list of items.
    '''
    city_json = loads(data)
    output = ''
    for item in city_json['places']:
        output += '- Ort: {place}, Region: {state}\n'.format(place=item['place name'], state=item['state'])
    return output

def get_by_postalcode(data):
    '''
    get all possible cities for a postal code in
    the given country.
    '''
    api_url_base = 'http://api.zippopotam.us/{country}/{postcode}'
    try:
        postcode_, country_= [item.strip() for item in data.split(',')]
    except Exception as err:
        error_dialog(str(err.__class__), err.message)

    try:
        response = urlopen(api_url_base.format(country=country_, postcode=postcode_))
    except IOError:
        error_dialog('Connection Error', 'Unable to perform request.')
    if response.getcode() == 200:
        postcode_data = handle_data(response.read())
        webbrowser.open('drafts://x-callback-url/create?text={0}'.format(quote(postcode_data)))
    else:
        error_dialog('Error', 'Status code: {0} - Message: {1}'.format(response.getcode(), response.read()))

if __name__ == '__main__':
    get_by_postalcode(argv[1])

Gist

Die dazugehörige Action für Drafts ist:

pythonista://city-by-postalcode?action=run&argv=[[draft]]

Import-Link

Drafts-Rezepte VI: Postleitzahlen mit Zippopotamus I

Den kleinen Gag kann ich mir nicht verkneifen. Ich mache zu Zippopotamus, einem guten, kleinen Dienst für Postleitzahlen in einer Vielzahl von Ländern, zwei Teile. Damit artet die Kleinteiligkeit aus wie der überbordende Song-Kosmos von Coheed & Cambria.

Das Script ist wieder sehr einfach. In Drafts muss eine neue Notiz nur die folgende Syntax einhalten:

[STADT], [REGIONSKÜRZEL], [LÄNDERKÜRZEL]

Beispielsweise würde die folgende Eingabe die berühmte Postleitzahlen für die Stadt als Markdown-Liste in Drafts öffenen.

beverly hills, ca, us

Leider kommt diese Sache nicht ohne einen kleinen Haken aus. Denn die API von Zippopotamus erlaubt es derzeit nicht, die Region und das Land nicht anzugeben. Unter Umständen müssen wir also mehr Infos angeben, als wir haben. Ich bin dabei, mir dafür eine Lösung zu überlegen. Das Python-Script sieht derzeit so aus:

# -*- coding: utf-8 -*-
from console import alert
from json import loads
from sys import argv, exit
from urllib import urlopen, quote
import webbrowser

def error_dialog(title, message):
    '''
    a diaolog box for error messages.
    '''
    try:
        alert(title, message)
    except KeyboardInterrupt:
        pass
    webbrowser.open('drafts://')
    exit(message)

def handle_data(data):
    '''
    process json response from zippopotamus.
    will return a markdown list of items.
    '''
    city_json = loads(data)
    output = ''
    for item in city_json['places']:
        output += '- City: {place}, Post code: {postcode}\n'.format(place=item['place name'], postcode=item['post code'])
    return output

def get_by_city(data):
    '''
    get all possible post codes for a city in
    the given country.
    '''
    api_url_base = 'http://api.zippopotam.us/{country}/{state}/{city}'
    try:
        city_, state_, country_= [item.strip() for item in data.split(',')]
    except Exception as err:
        error_dialog(str(err.__class__), err.message)

    try:
        response = urlopen(api_url_base.format(country=country_, state=state_, city=city_))
    except IOError:
        error_dialog('Connection Error', 'Unable to perform request.')
    if response.getcode() == 200:
        city_data = handle_data(response.read())
        webbrowser.open('drafts://x-callback-url/create?text={0}'.format(quote(city_data)))
    else:
        error_dialog('Error', 'Status code: {0} - Message: {1}'.format(response.getcode(), response.read()))

if __name__ == '__main__':
    get_by_city(argv[1])

Gist

Die dazugehörige Action für Drafts ist hier:

pythonista://postalcode-by-city?action=run&argv=[[draft]]

Import-Link

Drafts-Rezepte V: Wetterdaten mit OpenWeatherMap

Ich habe mir wieder ein kleines Skript (current_weather.py) gebastelt, mit dem ich für einen beliebigen Ort die aktuelle Temperatur und Luftfeuchtigkeit erhalte. Als Datenquelle dient die API von OpenWeatherMap, abgerufen wird es wieder über Pythonista. Das Skript sieht folgendermaßen aus:

# -*- coding: utf-8 -*-
import clipboard
from console import alert
from json import loads
from sys import argv, exit
from urllib import urlopen, quote
import webbrowser

def error_dialog(title, message):
  '''A diaolog box for error messages.'''
	try:
		alert(title, message)
	except KeyboardInterrupt:
		pass
	webbrowser.open('drafts://')
	exit(message)

def filter_data(data):
	'''create output string from response'''
	weather = loads(data)
	temperature = weather['main']['temp']
	humidity = weather['main']['humidity']
	output = 'Temperatur: {0} Celsius\nLuftfeuchtigkeit: {1}%'.format(temperature, humidity)
	return output

def get_current_weather_in(data):
	'''get current weather data'''
	api_url_base = 'http://api.openweathermap.org/data/2.5/weather?q={0}&units=metric&lang=de'
	try:
		response = urlopen(api_url_base.format(data))
	except IOError:
		error_dialog('Connection Error', 'Unable to perform request.')
	if response.getcode() == 200:
		weather_data = filter_data(response.read())
		webbrowser.open('drafts://x-callback-url/create?text={0}'.format(quote(weather_data)))
	else:
		error_dialog('Error', 'Status code: {0} - Message: {1}'.format(response.getcode(), response.read()))

if __name__ == '__main__':
	get_current_weather_in(argv[1])

Gist

Das Skript kann dann über die Drafts-Action ausgeführt werden:

pythonista://current_weather?action=run&argv=[[draft]]

Import-Link

Im Grunde war das auch schon alles. In Drafts muss dann in einer neuen Notiz nur der Name der Stadt eingegeben werden. Gegebenenfalls sollte sicherheitshalber noch das Land hinzugefügt werden. So würde Hamburg, de mir die aktuelle Temperatur und Luftfeuchtigkeit für Hamburg geben.

Drafts-Rezepte IIII: Hashes berechnen

Dann geht es fröhlich in der Reihe kleiner Skripte für Drafts und Pythonista weiter. Da ich hin und wieder mal Hashes als Prüfsummen benötige, hatte ich immer eigene Apps dafür auf dem iPhone. Da diese allerdings für ihren eigentlichen Zweck zu groß waren – sie waren allesamt auch noch hässlich -, habe ich sie nun durch folgendes Skript ersetzt. Das läuft bei mir ganz ordentlich.

Hierzu brauche ich wieder einen Action in Drafts, die in diesem Fall auf das Skript hasher in Pythonista zugreifen soll. Das sieht so aus:

pythonista://hasher?action=run&argv=[[draft]]

Import-Link

Etwas umfangreicher, daher auch erklärungsbedürftiger ist das Skript in Python. Dieses habe ich mal im Grunde angelegt, dass es wie ein Programm für die Shell genutzt werden kann.

# -*- coding: utf-8 -*-
import argparse
import clipboard
import hashlib
from sys import argv
import webbrowser

def hash_data(hashstring, hashfunction, url):
    '''Get the hash of given string.'''
    # Determine if a method for hashfunction
    # exists in hashlib. If no attribute/method is
    # found, default to sha1.   
    if hasattr(hashlib, hashfunction):
      hash_method = getattr(hashlib, hashfunction)
    else:
    	hash_method = hashlib.sha1

    # Put hash to clipboard, if hashstring exists.
    if hashstring:
    	clipboard.set(hash_method(hashstring).hexdigest())
    else:
        raise ValueError

    # Pythonista doesn't support x-callback.
    # So this is a pragmatic approach to calling
    # another app after hashing the string.
    webbrowser.open(url)

def parse_input(data):
    '''Parse input from Drafts command-line-like.'''
    parser = argparse.ArgumentParser(description='input a string to hash.')

    # Expects strings to hash.
    parser.add_argument('inputstring',
                        metavar='STRING',
                        nargs='*',
                        help='the string to hash')

    # Set the hash function.
    parser.add_argument('-hs', '--hs', '-hash', '--hash',
                        metavar='HASH-NAME',
                        default='sha1',
                        dest='hash',
                        help='the hash function of hashlib to use. defaults to sha1')

    # Intended to set a callback-like action.
    # Use to open a specific app via url scheme, if necessary. Otherwise will open Drafts.
    parser.add_argument('-u', '--u', '-url', '--url',
                        metavar='URL',
                        default='drafts://',
                        dest='url',
                        help='url scheme to call after hashing. use to call an app.')

    args = parser.parse_args(data)
    hash_data(' '.join(args.inputstring), args.hash, args.url)

if __name__ == '__main__':
    parse_input(argv[1].split(' '))

Gist

Ich muss also etwas erklären, was das Skript macht. Es kann drei Sorten von Input verarbeiten:

-hs (–hs, -hash, –hash) HASH_BEZEICHNUNG: Dieser Befehl ist optional und gibt die zu verwendenden Hashfunktion* an. Ist die Bezeichnung unbekannt oder falsch, wird der Standardwert ’sha1′ angenommen.

-u (–u, -url, –url) URL_SCHEMA: Dieser Befehl ist optional und gibt das URL-Schema einer aufzurufenden Anwendung an. Standardmäßig ist ‚drafts://‘ vorgegeben.

inputstring *STRING: Alle übrigen Übergabewerte werden zum Inputstring hinzugefügt, der gehasht wird.

Es lässt sich doch besser mit einigen Beispielen erklären. Nehmen wir mal folgenden Eingaben in Drafts an, wobei jede Zeile einer Eingabe entspricht:

Test

Test -s md5

Test -u tweetbot://

Test und noch mehr Test -s sha512

Die erste Eingabe würde den SHA1-Hash von ‚Test‘ berechnen, die zweite würde denselben String als MD5-Hash berechnen. Beide Eingaben führen dazu, dass nach der Berechnung in Pythonista wieder Drafts aufgerufen wird. Die dritte Eingabe berechnet den Standard, also SHA1, von ‚Test, kehrt aber nicht nach Drafts zurück, sondern öffnet Tweetbot (sofern es installiert wurde). Die letzte Eingabe nimmt ‚Test und noch mehr Test‘ und berechnet den SHA512-Hashwert für die Zeichenfolge.

* Laut der Dokumentation für Python 2.7, das von Pythonista genutzt wird, werden folgende Hashfunktionen unterstützt:

This module implements a common interface to many different secure hash and message digest algorithms. Included are the FIPS secure hash algorithms SHA1, SHA224, SHA256, SHA384, and SHA512 (defined in FIPS 180-2) as well as RSA’s MD5 algorithm (defined in Internet RFC 1321).

Drafts-Rezepte II: Kurzlinks mit is.gd

Es ist ja durchaus sinnvoll, Links ab und an mal zu verkürzen, jedoch dafür extra eine App zu installieren erscheint mir etwas übertrieben. Das lässt sich zwar auch im Browser erledigen, artet aber in eine Klickorgie aus, auf die ich auch keine Lust habe. Mit Pythonista und Drafts ist das im Handumdrehen erledigt.

Dieses kleine Script habe ich als isgd.py im Dokumentenverzeichnis von Pythonista angelegt:

import clipboard
from console import alert
from sys import argv, exit
from urllib import urlopen
import webbrowser

def error_dialog(title, message):
	'''A diaolog box for error messages.'''
	try:
		alert(title, message)
	except KeyboardInterrupt:
		pass
	webbrowser.open('drafts://')
	exit(message)

def shorten_with_isgd(long_url):
	'''basic link-shortening via is.gd.'''
	api_url_base = 'http://is.gd/create.php?format=simple&url='
	try:
		response = urlopen(api_url_base + long_url)
	except IOError:
		error_dialog('Connection Error', 'Unable to perform request.')
	if response.getcode() == 200:
		short_link = response.read()
		clipboard.set(short_link)
	else:
		error_dialog('Error', 'Status code: {0} - Message: {1}'.format(response.getcode(), response.read()))
	webbrowser.open('drafts://')

if __name__ == '__main__':
	shorten_with_isgd(argv[1])

Gist

Es ist alles recht simpel gehalten, die Fehler werden nur rudimentär behandelt, aber es sollte ganz gut aufgehen. is.gd habe ich für den Anfang als Dienst gewählt, weil dort nicht großartig Registrierungen notwendig sind, um mit der API Links zu kürzen.

In Drafts muss dann nur die folgende Action angelegt werden, um das Script auszuführen.

pythonista://isgd?action=run&argv=[[draft]]

Import

Die Handhabung ist nun denkbar einfach: In Drafts in einer neuen Notiz die lange URL eingeben, danach die Action auslösen. Es wird sich Pythonista öffnen. Sofern keine Fehlermeldung aufpoppt, ist der Kurzlink in die Zwischenablage kopiert worden. Auf jeden Fall wird aber wieder Drafts geöffnet.

Drafts-Rezepte I: Yubnub

Mit Drafts ist die Automatisierung auf iOS sehr viel einfacher geworden. Ich habe mir vorgenommen, in der nächsten Zeit mal ein paar nützliche Rezepte zu posten, die den Alltag erleichtern können. Spätestens wenn Pythonista ins Spiel kommt, wird es etwas komplexer. Den Anfang will ich aber mit einem vergleichsweise einfachen Fall machen, der mir das Leben aber ungemein erleichtert.

Ich habe noch nie einen Hehl aus meiner Begeisterung für Yubnub* gemacht. Auf dem Desktop wandele ich alle Browser-Adresszeilen in eine Art Kommandozeile für’s Web ab. Da das Web zu durchsuchen unter iOS eine Qual ist, hätte ich gerne Yubnub auch dort. Mit Drafts ist das sehr einfach. Ich lege ein neues Draft an, beispielsweise mit einem Yubnub-typischen Suchkürzel und den Suchbegriffen wie etwa g iPhone Bumper. In diesem Fall würde Yubnub die Anfrage nach iPhone Bumper an Google weiterleiten. Dann löse ich zum Beispiel folgende URL-Action auf den Draft aus:

Safari

http://yubnub.org/parser/parse?command=[[draft]]

Import-Link

Aber nicht alle von uns wollen im schnöden Safari durchs Web. Auch da lässt sich was machen, solange ein installierter Browser sich mit einem URL-Schema bei iOS angemeldet hat.

Chrome

googlechrome://yubnub.org/parser/parse?command=[[draft]]

Import-Link

Dolphin

dolphin://http://yubnub.org/parser/parse?command=[[draft]]

Import-Link

Das sind nur Beispiele für eine Vielzahl von iOS-Browsern, die das Schema unterstützen und damit auch nun mit Yubnub mehr Komfort ins mobile Surfen bringen. Ein Liste mit weiteren Browsern findet sich bei HandleOpenURL. Das Schema muss in der Regel nur um die Browserbezeichnung ergänzt werden.

In den kommenden Tagen werden hier noch mehr Rezepte für URL-Actions aufschlagen. Das war bislang nur der Anfang. Es geht noch sehr viel mehr.

* Yubnub als zwischengeschalteter Dienst kann natürlich alle Suchanfragen protokollieren – zusätzlich zu den eigentlichen Zielseiten. Die Abwägung von Nutzen und Datenschutzaspekten kann ich niemandem abnehmen.

Drafts: Ich automatisiere mich in Grund und Boden

Seit gestern bastele ich mit Drafts für iOS herum, das nicht mit Draft für Android zu verwechseln ist. Drafts ist weit mehr als ein Texteditor, was mir bisher aber nicht aufgegangen ist. Ich hatte übersehen, dass Drafts zu einer Schaltzentrale für Texte und die automatisierte Verarbeitung wird. Drafts unterstützt nicht nur URI Schemes, eigentlich basiert die ganze App darauf, sich über das Schema einen Workflow zu erstellen, bei dem verschiedene Apps, sofern sie das Schema unterstützen, miteinander verzahnt werden.

Eine simple Anwendung von Drafts‘ Fähigkeiten ist es beispielsweise, einen Text zu erstellen, der per selbstgeschriebener URL-Schema-Aktion bei mehreren Microblogging-Diensten gleichzeitig gepostet wird. Wir bauen uns also einen einen Kettenblitz für Twitter und app.net, wobei wir im URL-Schema einen Callback in einen Callback bauen:

drafts://x-callback-url/create?text=[[draft]]&action={{Tweet: DoctorProk}}&afterSuccess=Delete&x-success={{drafts://x-callback-url/create?text=[[draft]]&afterSuccess=Delete&action=Post%20to%20App.net}}

Damit schlage ich zwei Fliegen mit einer Drafts-Klappe. Es ist alles noch einwenig holprig in der Kette, tut seinen Dienst aber schon mehr als ordentlich.

Je umfangreicher die Callback-Kette wird, desto unübersichtlicher wird es natürlich. Mit der Zeit entstehen so URL-Schlangen, die an die abstrakte Kunst erinnern, die von BWLern in Excel fabriziert werden, weil sie nicht auf VBA umschwenken wollen. Diese Ungetüme kann kein Mensch mehr warten. Damit hat Drafts also doch eine natürliche Grenze bei den Aktionen, sobald es sich nicht mehr realistisch überblicken und warten lässt. Nicht ganz.

Es gibt noch ein weiteres Workflow-Genie auf iOS. Pythonista ist eine Perle. Schöner, praktischer und schneller geht Python auf Mobilgeräten nicht. Und manche übersehen schnell Pythonista ausgefeilte URL-Schema-Schnittstelle. Jedes Skript in Pythonista ist von externen Apps ausgührbar, sogar mit Argumenten. So können komplexere Aufgaben leicht und locker an Pythonista ausgelagert werden. Ist das Skript fertig, ruft es einfach Drafts auf, übergibt die Bearbeitung und es kann wieder weitergehen.

Mit Drafts wir schon vieles einfacher, zusammen mit Pythonista wird aus Textverarbeitung auf iOS ganz schnell elaboriertes Anrühren von Zaubertränke mit komplizierten Ingredenzien.