2 """hipwgi -- hiptop sync as WSGI app
6 1. mkdir /tmp/danger_me
7 2. use dangerSync --prod --user=xxx --pgp >/tmp/danger_me/session-id
8 3. start the server: python hipwsgi.py
9 4. visit http://127.0.0.1:8080/sync/me
10 5. use Pull button to sync danger data to localcopy
11 6. use event/task/note/contact links to browse records
12 7. edit raw JSON, Save back to cache
13 8. go back and use Push button to commit cached writes to danger/t-mobile
16 * migrate project from cvs on dev.w3.org to hg on homer
17 * add note editing support
18 * control access using openid
19 * add hcalendar view of events/tasks
20 ** with ETAG/last-modified support
21 * add .ics view of events/tasks
22 * add hcard view of contacts
24 parts cribbed from http://lukearno.com/projects/selector/
25 and from some bitworking clues.
30 from stat import ST_MTIME
32 from cgi import escape
34 from selector import Selector
35 from genshi.template import MarkupTemplate # http://genshi.edgewall.org/
38 from dangerSync import Anchored as DangerServer
40 from hipstore import FilePerRecord
42 HTML8 = 'text/html; charset=utf-8'
44 Kinds = ("event", "contact", "task", "note")
50 s.add('/sync/{account}', GET=sync_options)
51 s.add('/pull/{account}', POST=pull)
52 s.add('/push/{account}', POST=push)
53 s.add('/info/{kind}/{account}', GET=list_records)
54 s.add('/info/{kind}/{account}/{key}', GET=show_record, POST=save_record)
55 #@@s.add('/info/event/{account}/hcalendar', GET=show_record)
58 def clues(environ, start_response):
59 start_response("200 ok", [('Content-type', HTML8)])
62 def sync_options(environ, start_response):
63 acctname = environ['selector.vars']['account']
64 acct = AccountData(acctname)
66 start_response('200 OK', [('Content-type', HTML8)])
68 stream = MarkupTemplate(SyncHomePage).generate(acct=acct, Kinds=Kinds)
69 for chunk in stream.serialize():
72 def login(environ, start_response):
73 start_response('500 @@not implemented', [('Content-type','text/plain')])
74 yield "@@not implemented yet"
77 def pull(environ, start_response):
78 acct = AccountData(environ['selector.vars']['account'])
80 s = acct.session_creds()
81 except EnvironmentError, e:
82 start_response('403 no session found', [("content-type","text/plain")])
83 yield "no session. @@perhaps give clues on how to start one"
92 testing = ds.get(Kinds[0], [])
93 except xmlrpclib.Fault, e:
94 start_response('401 session expired', [("content-type","text/plain")])
95 yield "session expired?\n"
99 start_response('200 OK', [("content-type", HTML8)])
101 stream = MarkupTemplate(PullPage).generate(acct=acct,
105 for chunk in stream.serialize():
110 """summary of record; e.g. for link label
112 return record.get('title', '') or \
113 record.get('last_name', '') or \
114 record.get('first_name', '') or \
115 record.get('nick_name', '') or \
116 record.get('company', '') or \
117 record.get('body', '').split("\n")[0]
120 def list_records(environ, start_response):
121 acct = AccountData(environ['selector.vars']['account'])
122 os.chdir(acct.path()) #@@ catch errors
124 kind = environ['selector.vars']['kind']
125 rep = FilePerRecord(kind)
126 start_response('200 OK', [('Content-type', HTML8)])
128 stream = MarkupTemplate(RecordListPage).generate(acct=acct,
132 for chunk in stream.serialize():
133 yield chunk.encode('utf-8')
136 def show_record(environ, start_response):
137 acct = AccountData(environ['selector.vars']['account'])
139 os.chdir(acct.path()) #@@ catch errors
141 kind = environ['selector.vars']['kind']
142 key = int(environ['selector.vars']['key'])
144 rep = FilePerRecord(kind)
145 start_response('200 OK', [('Content-type', HTML8)])
148 tpl = MarkupTemplate(RecordDetailPage)
149 stream = tpl.generate(acct = acct,
152 edit = json_lines(record))
153 for chunk in stream.serialize():
154 yield chunk.encode('utf-8')
157 def json_lines(record):
158 """pretty print json object a bit for editing
160 lines = [ simplejson.dumps(k) + ": " + simplejson.dumps(v) + ","
161 for k, v in record.iteritems()]
163 lines[-1] = lines[-1][:-1] # chop last ,
164 return ["{"] + lines + ["}"]
168 def save_record(environ, start_response):
169 acct = AccountData(environ['selector.vars']['account'])
170 os.chdir(acct.path()) #@@ catch errors
172 kind = environ['selector.vars']['kind']
173 key = int(environ['selector.vars']['key'])
175 # http://pythonpaste.org/do-it-yourself-framework.html
176 # http://trac.pythonpaste.org/pythonpaste/browser/Paste/trunk/paste/request.py
177 fields = cgi.FieldStorage(environ['wsgi.input'],
179 keep_blank_values=True)
180 record = simplejson.loads(fields['record'].value.decode('utf-8'))
181 record['last_modified'] = timestrg(time.time())
182 rep = FilePerRecord(kind)
184 rep.cache(key, record, dirty=True)
185 start_response('200 OK', [('Content-type', 'text/plain')])
187 yield "saved %s %s from %s; don't forget to push.\n" % \
188 (kind, key, acct.name)
193 >>> timestr(1183583183)
194 '2007-07-04T16:06:23'
197 return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(secs))
200 """convert seconds-since-the-epoch to danger time format
202 >>> timestrg(1183583183)
206 return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(secs))
208 def form_field(environ, n):
209 fields = cgi.FieldStorage(environ['wsgi.input'],
211 keep_blank_values=True)
212 return fields[n].value.decode('utf-8')
214 def push(environ, start_response):
215 acct = AccountData(environ['selector.vars']['account'])
216 client = form_field(environ, 'client')
219 s = acct.session_creds()
220 except EnvironmentError, e:
221 start_response('403 no session found', [("content-type","text/plain")])
222 yield "no session. @@perhaps give clues on how to start one"
231 testing = ds.get(Kinds[0], [])
232 except xmlrpclib.Fault, e:
233 start_response('401 session expired', [("content-type","text/plain")])
234 yield "session expired?\n"
238 start_response('200 OK', [("content-type", 'text/plain')])
243 yield "%s to push: %s\n" % (kind, ids)
244 yield ("%s committed:" % (kind, )) + str(acct.commit(ds))
249 class AccountData(object):
250 session_fn = 'session-id'
251 def __init__(self, name):
256 def path(self, part=None):
257 d = '/tmp/danger_%s' % self.name
259 return os.path.join(d, part)
263 def session_creds(self):
264 return file(self.path(self.session_fn)).read().strip()
266 def session_time(self, minutes=0):
267 """raises EnvironmentError
269 return timestr(os.stat(self.path(self.session_fn))[ST_MTIME] +
272 def focus(self, kind):
273 self._rep = FilePerRecord(kind)
275 os.chdir(self.path())
277 def anchor(self, ds):
278 a = self._rep.getAnchor()
282 def deleteAll(self, keys):
290 except EnvironmentError:
291 # perhaps a record got deleted before we pulled it?
292 # seems to be harmless
293 # OSError: [Errno 2] No such file or directory: 'event/6250'
296 def get(self, keys, ds):
297 """fetch current kind of records corresponding to keys from ds;
298 store; synch anchor if needed.
301 records = ds.get(self._kind, keys)
302 for record in records:
303 self._rep.cache(record['id'],
309 self._rep.setAnchor(ds.resetAnchor())
313 return self._rep.pending()
315 def commit(self, ds):
316 ids = self._rep.pending()
318 #@@ split into groups of 30 as in dangerSync.py
319 ids_new = ds.put(self._kind, [self._rep.dirtyitem(i) for i in ids])
321 self._rep.commit(ids)
323 return zip(ids, ids_new)
330 <html xmlns="http://www.w3.org/1999/xhtml">
331 <head><title>Hiptop Sync</title></head>
335 <p>Try: /sync/<var>account_name</var>.</p>
337 <address>Dan Connolly</address>
345 xmlns:py="http://genshi.edgewall.org/"
346 xmlns="http://www.w3.org/1999/xhtml">
347 <head><title>%(account)s Hiptop Sync</title></head>
348 <style type="text/css">
349 dl dt { font-weight: bold }
352 <h1>Hiptop Sync for ${acct.name}</h1>
356 <dd>${acct.name}</dd>
357 <dt>dbdir</dt><dd>${acct.path()}</dd>
358 <dt>session</dt><dd>${acct.session_time()} - ${acct.session_time(30)}</dd>
362 <li py:for="k in Kinds"
363 py:with="dummy = acct.focus(k); ids = acct.pending()">
364 <a href="/info/$k/${acct.name}">$k</a>
365 <b py:if="ids">${len(ids)} edited records to push</b>
369 <form method="POST" action="/pull/${acct.name}">
370 <input type="submit" value="Pull" />
373 <form method="POST" action="/push/${acct.name}">
374 <input type="submit" value="Push" />
375 <input name="client" value="Missing Sync" />
379 <address>hipwsgi by Dan Connolly</address>
387 xmlns:py="http://genshi.edgewall.org/"
388 xmlns="http://www.w3.org/1999/xhtml">
389 <head><title>Pull %(account)s</title></head>
390 <style type="text/css">
391 dl dt { font-weight: bold }
394 <h1>Pull ${acct.name} from danger hiptop service</h1>
396 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
398 <div py:for="kind in Kinds"
399 py:with="dummy = acct.focus(kind); a = acct.anchor(ds)">
401 <div py:choose="" py:strip="1">
403 py:with="dkeys = ds.deletedItemIds(kind)">
404 <p><small>anchor: $a</small></p>
405 <p py:if="dkeys">Deleting: ${",".join([str(k) for k in dkeys])}
406 <span py:strip="1" py:with="dummy=acct.deleteAll(dkeys)"></span>
408 <ul py:with="ckeys = ds.changedItemIds(kind)">
409 <li py:for="record in acct.get(ckeys, ds)"
410 >${record.id}: ${summary(record)}</li>
413 <div py:otherwise="">
414 <p>no anchor; getting all...</p>
415 <ul py:with="keys = ds.allItemIds(kind)">
416 <li py:for="record in acct.get(keys, ds)"
417 >${record.id}: ${summary(record)}</li>
424 <address>hipwsgi by Dan Connolly</address>
432 xmlns:py="http://genshi.edgewall.org/"
433 xmlns="http://www.w3.org/1999/xhtml">
434 <head><title>$kind from ${acct.name}</title></head>
435 <style type="text/css">
436 dl dt { font-weight: bold }
440 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
443 <li py:for="k in rep.keys()">
444 <a href='/info/$kind/${acct.name}/$k'>${summary(rep[k])}</a></li>
447 <address>hipwsgi by Dan Connolly</address>
452 RecordDetailPage = """
455 xmlns:py="http://genshi.edgewall.org/"
456 xmlns="http://www.w3.org/1999/xhtml">
457 <head><title>$kind $key from ${acct.name}</title></head>
458 <style type="text/css">
459 dl dt { font-weight: bold }
464 <p>back to <a href='/sync/${acct.name}'>sync options</a></p>
466 <py:def function="xoxo(item)">
467 <div py:strip="1" py:choose="">
468 <ul py:when="type(item) is type([])">
469 <li py:for="i in item">${xoxo(i)}</li>
471 <dl py:when="type(item) is type({})">
472 <div py:strip="1" py:for="k, v in item.iteritems()">
477 <span py:when="type(item) is type(1)">$item</span>
478 <span py:when="type(item) is type(True)">$item</span>
479 <pre py:when="'\\n' in item">$item</pre>
480 <span py:otherwise="">$item</span>
487 <textarea rows='${len(edit)}' cols='70' name='record'>
491 <input type="submit" value="Save" />
495 <address>hipwsgi by Dan Connolly</address>
504 from wsgiref.simple_server import WSGIServer, WSGIRequestHandler
505 httpd = WSGIServer(("", 8080), WSGIRequestHandler)
506 httpd.set_app(hiptop_app())
508 print "Serving HTTP on %s port %s ..." % httpd.socket.getsockname()
509 httpd.serve_forever()
515 if __name__ == '__main__':
517 if '--test' in sys.argv:
519 elif os.environ.has_key('SCRIPT_NAME'):
520 import cgitb; cgitb.enable()
521 from wsgiref.handlers import CGIHandler
523 # thttpd seems to not supply PATH_INFO.
524 # The selector module seems to assume it's given.
525 # I'm not sure which is in error, but this is a work-around...
526 if not os.environ.has_key('PATH_INFO'):
527 os.environ['PATH_INFO'] = '/'
528 CGIHandler().run(hiptop_app())