From 0f8dd9d965abc692b4624da75d5c65b3fe6feca4 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Aug 2011 19:10:18 -0500 Subject: [PATCH] BROKEN: Moved everything --- DialCentral | 15 + README | 37 - data/LICENSE | 11 - data/app/LICENSE | 11 + data/app/bell.flac | Bin 0 -> 20152 bytes data/app/bell.wav | Bin 0 -> 52268 bytes data/app/contacts.png | Bin 0 -> 6105 bytes data/app/dialpad.png | Bin 0 -> 6139 bytes data/app/history.png | Bin 0 -> 6031 bytes data/app/messages.png | Bin 0 -> 5888 bytes data/app/missed.png | Bin 0 -> 5286 bytes data/app/placed.png | Bin 0 -> 5538 bytes data/app/received.png | Bin 0 -> 2992 bytes data/bell.flac | Bin 20152 -> 0 bytes data/bell.wav | Bin 52268 -> 0 bytes data/contacts.png | Bin 6105 -> 0 bytes data/dialcentral-base.svg | 148 +++ data/dialcentral.colors | 3 + data/dialcentral.png | Bin 0 -> 5004 bytes data/dialcentral.svg | 56 + data/dialpad.png | Bin 6139 -> 0 bytes data/history.png | Bin 6031 -> 0 bytes data/messages.png | Bin 5888 -> 0 bytes data/missed.png | Bin 5286 -> 0 bytes data/placed.png | Bin 5538 -> 0 bytes data/received.png | Bin 2992 -> 0 bytes data/template.desktop | 8 + dialcentral/__init__.py | 1 + dialcentral/alarm_handler.py | 460 ++++++++ dialcentral/alarm_notify.py | 182 +++ dialcentral/backends/file_backend.py | 176 +++ dialcentral/backends/gv_backend.py | 321 ++++++ dialcentral/backends/gvoice/browser_emu.py | 210 ++++ dialcentral/backends/gvoice/gvoice.py | 1050 +++++++++++++++++ dialcentral/backends/null_backend.py | 39 + dialcentral/backends/qt_backend.py | 106 ++ dialcentral/call_handler.py | 144 +++ dialcentral/constants.py | 13 + dialcentral/dialcentral_qt.py | 812 +++++++++++++ dialcentral/dialogs.py | 1192 ++++++++++++++++++++ dialcentral/examples/log_notifier.py | 31 + dialcentral/examples/sound_notifier.py | 48 + dialcentral/gv_views.py | 977 ++++++++++++++++ dialcentral/led_handler.py | 66 ++ dialcentral/session.py | 830 ++++++++++++++ dialcentral/stream_gst.py | 145 +++ dialcentral/stream_handler.py | 113 ++ dialcentral/stream_null.py | 62 + dialcentral/stream_osso.py | 181 +++ dialcentral/util/__init__.py | 1 + dialcentral/util/algorithms.py | 664 +++++++++++ dialcentral/util/concurrent.py | 168 +++ dialcentral/util/coroutines.py | 623 ++++++++++ dialcentral/util/go_utils.py | 274 +++++ dialcentral/util/io.py | 231 ++++ dialcentral/util/linux.py | 79 ++ dialcentral/util/misc.py | 900 +++++++++++++++ dialcentral/util/overloading.py | 256 +++++ dialcentral/util/qore_utils.py | 99 ++ dialcentral/util/qt_compat.py | 46 + dialcentral/util/qtpie.py | 1094 ++++++++++++++++++ dialcentral/util/qtpieboard.py | 207 ++++ dialcentral/util/qui_utils.py | 419 +++++++ dialcentral/util/qwrappers.py | 328 ++++++ dialcentral/util/time_utils.py | 94 ++ dialcentral/util/tp_utils.py | 220 ++++ src | 1 + src/__init__.py | 1 - src/alarm_handler.py | 460 -------- src/alarm_notify.py | 182 --- src/backends/file_backend.py | 176 --- src/backends/gv_backend.py | 321 ------ src/backends/gvoice/browser_emu.py | 210 ---- src/backends/gvoice/gvoice.py | 1050 ----------------- src/backends/null_backend.py | 39 - src/backends/qt_backend.py | 106 -- src/call_handler.py | 144 --- src/constants.py | 13 - src/dialcentral.py | 15 - src/dialcentral_qt.py | 812 ------------- src/dialogs.py | 1192 -------------------- src/examples/log_notifier.py | 31 - src/examples/sound_notifier.py | 48 - src/gv_views.py | 977 ---------------- src/led_handler.py | 66 -- src/session.py | 830 -------------- src/stream_gst.py | 145 --- src/stream_handler.py | 113 -- src/stream_null.py | 62 - src/stream_osso.py | 181 --- src/util/__init__.py | 1 - src/util/algorithms.py | 664 ----------- src/util/concurrent.py | 168 --- src/util/coroutines.py | 623 ---------- src/util/go_utils.py | 274 ----- src/util/io.py | 231 ---- src/util/linux.py | 79 -- src/util/misc.py | 900 --------------- src/util/overloading.py | 256 ----- src/util/qore_utils.py | 99 -- src/util/qt_compat.py | 46 - src/util/qtpie.py | 1094 ------------------ src/util/qtpieboard.py | 207 ---- src/util/qui_utils.py | 419 ------- src/util/qwrappers.py | 328 ------ src/util/time_utils.py | 94 -- src/util/tp_utils.py | 220 ---- support/dialcentral.desktop | 8 - support/icons/hicolor/26x26/hildon/dialcentral.png | Bin 1671 -> 0 bytes support/icons/hicolor/64x64/hildon/dialcentral.png | Bin 6411 -> 0 bytes .../icons/hicolor/scalable/hildon/dialcentral.png | Bin 32182 -> 0 bytes 111 files changed, 13104 insertions(+), 12933 deletions(-) create mode 100755 DialCentral delete mode 100644 README delete mode 100644 data/LICENSE create mode 100644 data/app/LICENSE create mode 100644 data/app/bell.flac create mode 100644 data/app/bell.wav create mode 100644 data/app/contacts.png create mode 100644 data/app/dialpad.png create mode 100644 data/app/history.png create mode 100644 data/app/messages.png create mode 100644 data/app/missed.png create mode 100644 data/app/placed.png create mode 100644 data/app/received.png delete mode 100644 data/bell.flac delete mode 100644 data/bell.wav delete mode 100644 data/contacts.png create mode 100644 data/dialcentral-base.svg create mode 100644 data/dialcentral.colors create mode 100644 data/dialcentral.png create mode 100644 data/dialcentral.svg delete mode 100644 data/dialpad.png delete mode 100644 data/history.png delete mode 100644 data/messages.png delete mode 100644 data/missed.png delete mode 100644 data/placed.png delete mode 100644 data/received.png create mode 100644 data/template.desktop create mode 100644 dialcentral/__init__.py create mode 100644 dialcentral/alarm_handler.py create mode 100755 dialcentral/alarm_notify.py create mode 100644 dialcentral/backends/__init__.py create mode 100644 dialcentral/backends/file_backend.py create mode 100644 dialcentral/backends/gv_backend.py create mode 100644 dialcentral/backends/gvoice/__init__.py create mode 100644 dialcentral/backends/gvoice/browser_emu.py create mode 100755 dialcentral/backends/gvoice/gvoice.py create mode 100644 dialcentral/backends/null_backend.py create mode 100644 dialcentral/backends/qt_backend.py create mode 100644 dialcentral/call_handler.py create mode 100644 dialcentral/constants.py create mode 100755 dialcentral/dialcentral_qt.py create mode 100644 dialcentral/dialogs.py create mode 100644 dialcentral/examples/log_notifier.py create mode 100644 dialcentral/examples/sound_notifier.py create mode 100644 dialcentral/gv_views.py create mode 100755 dialcentral/led_handler.py create mode 100644 dialcentral/session.py create mode 100644 dialcentral/stream_gst.py create mode 100644 dialcentral/stream_handler.py create mode 100644 dialcentral/stream_null.py create mode 100644 dialcentral/stream_osso.py create mode 100644 dialcentral/util/__init__.py create mode 100644 dialcentral/util/algorithms.py create mode 100644 dialcentral/util/concurrent.py create mode 100755 dialcentral/util/coroutines.py create mode 100644 dialcentral/util/go_utils.py create mode 100644 dialcentral/util/io.py create mode 100644 dialcentral/util/linux.py create mode 100644 dialcentral/util/misc.py create mode 100644 dialcentral/util/overloading.py create mode 100644 dialcentral/util/qore_utils.py create mode 100644 dialcentral/util/qt_compat.py create mode 100755 dialcentral/util/qtpie.py create mode 100755 dialcentral/util/qtpieboard.py create mode 100644 dialcentral/util/qui_utils.py create mode 100644 dialcentral/util/qwrappers.py create mode 100644 dialcentral/util/time_utils.py create mode 100644 dialcentral/util/tp_utils.py create mode 120000 src delete mode 100644 src/__init__.py delete mode 100644 src/alarm_handler.py delete mode 100755 src/alarm_notify.py delete mode 100644 src/backends/__init__.py delete mode 100644 src/backends/file_backend.py delete mode 100644 src/backends/gv_backend.py delete mode 100644 src/backends/gvoice/__init__.py delete mode 100644 src/backends/gvoice/browser_emu.py delete mode 100755 src/backends/gvoice/gvoice.py delete mode 100644 src/backends/null_backend.py delete mode 100644 src/backends/qt_backend.py delete mode 100644 src/call_handler.py delete mode 100644 src/constants.py delete mode 100755 src/dialcentral.py delete mode 100755 src/dialcentral_qt.py delete mode 100644 src/dialogs.py delete mode 100644 src/examples/log_notifier.py delete mode 100644 src/examples/sound_notifier.py delete mode 100644 src/gv_views.py delete mode 100755 src/led_handler.py delete mode 100644 src/session.py delete mode 100644 src/stream_gst.py delete mode 100644 src/stream_handler.py delete mode 100644 src/stream_null.py delete mode 100644 src/stream_osso.py delete mode 100644 src/util/__init__.py delete mode 100644 src/util/algorithms.py delete mode 100644 src/util/concurrent.py delete mode 100755 src/util/coroutines.py delete mode 100644 src/util/go_utils.py delete mode 100644 src/util/io.py delete mode 100644 src/util/linux.py delete mode 100644 src/util/misc.py delete mode 100644 src/util/overloading.py delete mode 100644 src/util/qore_utils.py delete mode 100644 src/util/qt_compat.py delete mode 100755 src/util/qtpie.py delete mode 100755 src/util/qtpieboard.py delete mode 100644 src/util/qui_utils.py delete mode 100644 src/util/qwrappers.py delete mode 100644 src/util/time_utils.py delete mode 100644 src/util/tp_utils.py delete mode 100644 support/dialcentral.desktop delete mode 100644 support/icons/hicolor/26x26/hildon/dialcentral.png delete mode 100644 support/icons/hicolor/64x64/hildon/dialcentral.png delete mode 100644 support/icons/hicolor/scalable/hildon/dialcentral.png diff --git a/DialCentral b/DialCentral new file mode 100755 index 0000000..a20d4fe --- /dev/null +++ b/DialCentral @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +import sys + + +sys.path.append("/opt/dialcentral/lib") + + +import dialcentral_qt + + +if __name__ == "__main__": + dialcentral_qt.run() diff --git a/README b/README deleted file mode 100644 index b87ce9c..0000000 --- a/README +++ /dev/null @@ -1,37 +0,0 @@ -Building a package -=================== -Run - make PLATFORM=... package -which will create a "./pkg-.../..." heirarchy. Move this structure to somewhere on the tablet, then run pypackager. - -Supported PLATFORMs include - desktop - os2007 - os2008 - -SDK Enviroment -=================== - -Native - -Follow install instructions - Ubuntu: http://www.linuxuk.org/node/38 -Install Nokia stuff (for each target) - fakeroot apt-get install maemo-explicit - -Userful commands -Login - /scratchbox/login -Change targets - sb-conf select DIABLO_ARMEL - sb-conf select DIABLO_X86 -Fixing it - fakeroot apt-get -f install - -Starting scratchbox - Xephyr :2 -host-cursor -screen 800x480x16 -dpi 96 -ac -extension Composite - scratchbox - export DISPLAY=:2 - af-sb-init.sh start -Then running a command in the "Maemo" terminal will launch it in the Xephyr session - Tip: run with "run-standalone.sh" for niceness? diff --git a/data/LICENSE b/data/LICENSE deleted file mode 100644 index fb44a62..0000000 --- a/data/LICENSE +++ /dev/null @@ -1,11 +0,0 @@ -http://www.gentleface.com/free_icon_set.html -The Creative Commons Attribution-NonCommercial -- FREE -http://creativecommons.org/licenses/by-nc-nd/3.0/ - -Sound: -http://www.freesound.org/samplesViewSingle.php?id=2166 -http://creativecommons.org/licenses/sampling+/1.0/ - -placed.png, received.png, placed.png -Free for commercial use -http://www.iconeden.com/icon/free/get/bright-free-stock-iconset diff --git a/data/app/LICENSE b/data/app/LICENSE new file mode 100644 index 0000000..fb44a62 --- /dev/null +++ b/data/app/LICENSE @@ -0,0 +1,11 @@ +http://www.gentleface.com/free_icon_set.html +The Creative Commons Attribution-NonCommercial -- FREE +http://creativecommons.org/licenses/by-nc-nd/3.0/ + +Sound: +http://www.freesound.org/samplesViewSingle.php?id=2166 +http://creativecommons.org/licenses/sampling+/1.0/ + +placed.png, received.png, placed.png +Free for commercial use +http://www.iconeden.com/icon/free/get/bright-free-stock-iconset diff --git a/data/app/bell.flac b/data/app/bell.flac new file mode 100644 index 0000000000000000000000000000000000000000..419420ed3829e74e8b847c2cd0da55b3f7e6ed64 GIT binary patch literal 20152 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 z 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 literal 0 HcmV?d00001 diff --git a/data/app/dialpad.png b/data/app/dialpad.png new file mode 100644 index 0000000000000000000000000000000000000000..b54013bf57c92609bc46a086b4194e44bcfe8631 GIT binary patch literal 6139 zcmcIo2{@GP`hTVDBvB+9ODZvo-DI7y@5{)R7&Bu`m>Dx8OC_?m5RxrYeIi6rN+?N$ z5Iz-SDQooM`$F1q-WloWJDu~t&bh9?YZlM@-1qPId+z7H@8`KD!Oq51kZ%Vc004sK zW=0O+H=On6;Q~LMH++r*0NxQ2#)aWxZH2^90-#tt#hU;P37~@W0HC8ELdD|z2n>if zfk-0j%Fb3ll7*1)y0Xsd)^KYon&3+^3!@Pn!)!3PFh86&URGa^PbUNk0t65kSV%~K zKbejU(UoQ6BEkPzk72S9wh6;eSJse42ywBtgPZ(vxbrlU2 zH6@4&9Igd}Yr<6F$_OnaLIsJ?f_(pz)#HQc(C|J;2P2d3bl^x=)|bJcB4M!L;9zL5 zDwIMa!Vucp+Az2ZOhrW*v{0spk{Q?#Winlk1+j`@M4;nnBr1bMAwyW0SZ_)YLswQ7 zoQHe|r}|PD6uK{k`h$W@{~k3key|_YFa#71TkmL2W#H7nxUiP_!x!YjvBl9Dc-VRi zwmUm|KY`$JKU}FnG=H`$9tR`%69Nci1|9T5{A3UEIgp6H4EhhZpVWq6sca$|+ve|{ z|Ji5#+CUm>>;G&X5bzT`ond?wjO_Pa`lTp3CX`BmIS}ZSAR3Nfd=y-u9Gmn`2SRxS`h6n}yb7Ds@B+x0g?!ytw)g+{ld&`GQm z0F#r(fEbZz1RR4xW81FZBBVK)LGbqnCxfv5rUWu;Td5?_lt{y_B7Qk35T=M*=?p9xM=&?il?9(bNhCZHr42VxN2zHUYMN*n z86yx{DhRZ(8cGGNX{@SYq`BVRh=L1ZCBvFK{=aj#1#^K!#`^zHFswp?==>BU_V!R{ zXbMqa|7v50qu{|PA~fLNBlZg$G=m1> zpu-3_Ux)*N8szOy!u=A@H~>rXcc#$5Cgg`T_(fv;9o&ib-TW7c1Y2D?9*115CH8Q& zsTle*R!6Hte-f*cApNmqBDlZGcrY1*{28)(>_Cx@62Lwf5I`Z*A%+YFjpQA~Zbmi~ zvNG#AfrcYt{UJ6Kh!KHCI*Mh04s;zDt5^AxzH_oiYot1`pC##lLmaDOD71AF9SMOy zOVl&;Cxl?hcmfS#Nuq=8<>;?S*4)@6>rq)O_<#1&bxdozzBk-|7YvLqi4Fl@3||6- zgaf;=4~-H40gG&1@q*6?tFv?noxp%XekE~@k1g@FV~byQ6^1UwsDNeZXrM?8m(e9Q?Ss5y&7J4ZN2Pp6{oCcRF6GnJXOt__wm& z96;73aRA`6C85!Fc57D~h&dV!q0%TmB!2<`g!JV&5;2aG+jJKP=TVmN5f>~e4q`kI z2h^De(PRZh8D3$Fc$q7`V)l=>7#nYte3lo_nV1-HR?J>W;56TB?opYmiBZ?$Pq%!1 zd+eIu%bLaUw&lC8^d?H?awv#k%nyK2WM+n?NNpE=*)zr}Vb`)w5$tO<*gn26P zGV~=m4&YO`j?RABI_`%6M@Tn64^U^FT?gM8x3YN(m3fFGBATO)k*sCO6Cny1ge9V` z0|q7>5jhzOjzHc{cP=ZzDt1I1;ED)X zzPfv6qwit@KR&0!~oUiKb9UC2eZP9>o$F?}1SSHqc);TZv ze-79Awz%--;jnz9ibtgB#!qkRpU+$6@9aq7jrQ&zPcr>>kALNxRJUw{xp$R|qUead zNMNE-*4a6geAxt4%D$(&yH@bSLrPyc^@h}eTSxRbjwLG>df+%eTJ8~cVbl#T0>FoA z%H!Yn@^D7@M!oF_UYXTjGR@l!MEIDe1OmWeBY7yMr9!`n2LO!nA{B2LO3ptPSE}RM z`FKUeY}+jgF$4p4`WKYa)3!8b0IMik0b$=kPx5BA3XsJu~K$%ww~SB4^|w&GDl4vX;r}W(bGV z7>JXZ)CXPK2JN^*CJ&MiJOflteT%IzMO=t1x7Yn$;4ZqVP;*DL$yNRrcm^UXXx@R@6OuQ)e-Q1U?hi-z`y%XnFZSaRf8=s(VYbo)j;#zOZmIxF#?znZZgtIzf z0oQRmc%w+9aUHM5#$Xf#D{W}zV&!7<+N=<=Q(kRLAFn1aXS99&9@*4F%R34WwtQ|7 z_f|o~xtJ-*y%b5>3KKpSk8aSE7fC>??5R4x!Tk1bm@bhnlP3(C@Ni`r&xnSb+YTK5j$?Y@hovUiLJTL#jkZPH&j zcePHMzDs|{IUg(_7%LXl6i?hMkuAFy?2y! zB;Ghs7*|9rq+s-a3wK>g;BtLp^~4tan|8B0>tXLO5hT+v3WTBtf#ILuw}(dQn?2>f)3VZ%GIw;+8k{Pdvc?7rcBlGoL& zst|-jenl74FS-<)726k^zOU6Noi50^UTa+K`ZZF>u*qW2p*^O=r1niY6OO%(0|k5q z+pKkw3#HH^zkJCn)keXO$OSVQxepIV#CI5Rm6$ z3bc#sJXH^*=t#kun19)$J)Rh#^Ra2z^taK;Rh>H5dQ8rlY(7hBJTxLd63`gXcuj#z zK}NySddhkx%fY(p`o8N2tPWT;wLNdU+V(P2JzE#?N@XH@BKuYL&^<4=5I3dD!pi#u z^YT%*>#j4EHRKpKZP$DkCpV7@lbbm=cfYK@SzWNDpvorIra1Q{@yV^fDw`{B+>^L9 z_L_@XS{YDz0!#GXPQ3TD^vO(WZR&{!C(bSDatp@w3KdJaNrltz&_t03k%L+7muB=& zJe|Kd2_w362zR_z9jV;fy03wh{@a{>y1_zoC+FTeNK? z@$Q5i3HS6v@yVq*_gp%tSGlV`V zw}M{@Tj>M}B6f2SITIocBi%QA{SfypyO%GxvE$e?Pr?QE#Kgro$Lc3i<6U-iBGT1^JNwb}A)_xw z7mWt9i3nTmGGz;OMZF^E5uHBG2Wk)W8=*d~&wE^QT+IC~cWOfwOEt*;4~M=@&&3Vr z4D*j1+nxb?=0Od;(q2CwQ?Ts@R744R(ZDL+M(e_#hQf-4+s^L(ur+vhB422|R6S7^ z)~V4M{~CQrQQGE%nWA-A${)!!>Y?&aRr8g`s-pMYIA3EWXf=?H%-xoC6cV7Ki>yUN zr6ivZL>0+cr&bcDh~qv-TT2Kr<;zdWXYRqI)3}@cD$bW|a#iN5L|uP}aohV!k-MBo zaQ1oBme8Vou{d{s+w}8_7Up@)OS6|(rbX&S-lkOM$67ioOSz2KmK@P8*9i<|@{NdX z+8=w6t3>Di=}N_b9^2w+=k(I>T7@uczsSRnTTVT`%i;H)--&N5=EVE{SJNK0E-=@j zyVx(WIg)x}38za%ncLIFPQKpTA2KExM`?MK*?-~AjpBwXe1}h$&*b=^a<043#iI1_ zbRn`}Z{Hokp@Vm(7d{#@Vd-sqnblF1=&Eavbne#>3Yy#74(MZk!^~s8U^HgB2JT{C zeIj?xNFJ;CsP;z>)4gVKNw4`K(}}~$y2q!7@YVcl#iD*`Leb=>i(dmgZrTI{PB(FO zaV~Mo301zT`n^Z0OBdsi^m6}uQ=z6^50`UhgOvp}1f9-ZY8;lM8ikHdF7gg7N(&qC}Fwqw>;cW?Ixw$JrCUrr5%eKTe5+I&e($2HVq4%1BP zn>^K>O8n4qHYY9TL^$+wzZdgr<*^A|`_Pfil-UQujJI9JD`zGS)EsC(^x;0^LpgJ7 ztMh&5tTLo$z!YOjXF0XowyFA3byjKBA-}4bu-d!Fd=@7qb2Zxs=4(U$+P3ntCs$i{ zpmr{dviNjyVQAYxIBm~Tduw%X@Sd5QHMTXL^9Iv~z373s9)0)a{@1hpCy&$x=goEY zn=W$2M0G4DFC=Y7Z%WEcYK;|)eV?nPr>#A#Pg|P&R)FrncPQj;U%tBBTDpB3Ec{c? z`&aFq8J$G*$OrqKw&C-zfdCIYbq4c>dtm>BqtS!BVr=hBn)d9*3OV zyW;!#_JZS!g0HC(si`X)IWy+I;Z&8G;C&N(o@TsGgmJAz$8ztQ=eWxsAtg|; z;Xza&cXuyGsewnNQl#tS2tDy@fogvlA`~0Bx5_PzW8EADPm(#_raWK7pdyD}(QP`a z?(w8#_>eMa*0quN`558hf$YSQ40#U#+I$W^V0QStl9aTxhQcQ4a{20LG{H_^AC9 z^SWZ9m!IEz?g&ANPjBwusjT!V66Mh3su8h&q4!&o6t|uhvp1Hw71ix_0fV{+4ltb+HgsxbtR*9jIX6M!8}Z4sm}3bU2}6Y3^;Mki+R+w z*Mm2O{K#Fg*+L^*Jxz@o78b@^_r80x($HdYi}KX9mND9+YsGiS3)G;X1;+%v7lx$` z7p9bzmDAEJuGmdXObDhhMS}BdtrQ#&LSs;bie8V*dagHzJw0cW_Qwp1!%qyAna{X| zwE0v}tTV1>a@)zc?s4U|I~Er9;F~BXaI3uBn@iuv@~osfkk4c?o237|D-<Wcj>B^SUZJ7dyUy;o*uYn6+*Ms&%}kD-{U~)8g6c_6Pj9pX4FUuIYKtD_o7u3U z5mo$fmWgoXI=*8sLuRZa!E&u&i>218{M1d3Se{{yQ$i zp}D^4%J}YQ52nV)uW#v3n;JQ_hu6Bhyz5-a<*c@Gxd_>a+Q7Qh=-IF7w5np$#R zDG_)m_PZO-p9|I2-oEXWIwMf@PkYXsT%GVNA0HpSNk*7vj!idQVCsR5$E!?>{0u9F zOf&fmGxH4lyDsfP{G7S+}^=M5RWB^o4CGO7{Dkl6mWiv5*{|-v45w2F6H=T jRCnRZ$&VR#mW6=*1ICJF0YDpAZh*P5jZrDe>(oC0GLywL literal 0 HcmV?d00001 diff --git a/data/app/history.png b/data/app/history.png new file mode 100644 index 0000000000000000000000000000000000000000..887989a35007b998d24ad033128366b8e74f4726 GIT binary patch literal 6031 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 literal 0 HcmV?d00001 diff --git a/data/app/received.png b/data/app/received.png new file mode 100644 index 0000000000000000000000000000000000000000..2b45263cbedf6c5dcc9880d7c8ad4bd285ff6e55 GIT binary patch literal 2992 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 z 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 diff --git a/data/dialcentral-base.svg b/data/dialcentral-base.svg new file mode 100644 index 0000000..aa35390 --- /dev/null +++ b/data/dialcentral-base.svg @@ -0,0 +1,148 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/dialcentral.colors b/data/dialcentral.colors new file mode 100644 index 0000000..c6b9fa9 --- /dev/null +++ b/data/dialcentral.colors @@ -0,0 +1,3 @@ +Your icon's dominant color is #72ab40 +A suggested disabled color is #d1ffa9 +A suggested pressed color is #57743e diff --git a/data/dialcentral.png b/data/dialcentral.png new file mode 100644 index 0000000000000000000000000000000000000000..5f8b81160bba6a64deb28c77213f3864de51d9ca GIT binary patch literal 5004 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 W_`|Kz8_s$F0000 + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/dialpad.png b/data/dialpad.png deleted file mode 100644 index b54013bf57c92609bc46a086b4194e44bcfe8631..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6139 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 diff --git a/data/history.png b/data/history.png deleted file mode 100644 index 887989a35007b998d24ad033128366b8e74f4726..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6031 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 diff --git a/data/received.png b/data/received.png deleted file mode 100644 index 2b45263cbedf6c5dcc9880d7c8ad4bd285ff6e55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2992 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 mn&+q6?S(^k<#?lAS^IBJ1l>> bin(_create_recurrence_mask(60, 60)) + '0b1' + >>> bin(_create_recurrence_mask(30, 60)) + '0b1000000000000000000000000000001' + >>> bin(_create_recurrence_mask(2, 60)) + '0b10101010101010101010101010101010101010101010101010101010101' + >>> bin(_create_recurrence_mask(1, 60)) + '0b111111111111111111111111111111111111111111111111111111111111' + """ + mask = 0 + for i in xrange(base / recurrence): + mask |= 1 << (recurrence * i) + return mask + + +def _unpack_minutes(recurrence): + """ + >>> _unpack_minutes(0) + (0, 0, 0) + >>> _unpack_minutes(1) + (0, 0, 1) + >>> _unpack_minutes(59) + (0, 0, 59) + >>> _unpack_minutes(60) + (0, 1, 0) + >>> _unpack_minutes(129) + (0, 2, 9) + >>> _unpack_minutes(5 * 60 * 24 + 3 * 60 + 2) + (5, 3, 2) + >>> _unpack_minutes(12 * 60 * 24 + 3 * 60 + 2) + (5, 3, 2) + """ + minutesInAnHour = 60 + minutesInDay = 24 * minutesInAnHour + minutesInAWeek = minutesInDay * 7 + + days = recurrence / minutesInDay + daysOfWeek = days % 7 + recurrence -= days * minutesInDay + hours = recurrence / minutesInAnHour + recurrence -= hours * minutesInAnHour + mins = recurrence % minutesInAnHour + recurrence -= mins + assert recurrence == 0, "Recurrence %d" % recurrence + return daysOfWeek, hours, mins + + +class _FremantleAlarmHandler(object): + + _INVALID_COOKIE = -1 + _REPEAT_FOREVER = -1 + _TITLE = "Dialcentral Notifications" + _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py")) + + def __init__(self): + self._recurrence = 5 + + self._alarmCookie = self._INVALID_COOKIE + self._launcher = self._LAUNCHER + + def load_settings(self, config, sectionName): + try: + self._recurrence = config.getint(sectionName, "recurrence") + self._alarmCookie = config.getint(sectionName, "alarmCookie") + launcher = config.get(sectionName, "notifier") + if launcher: + self._launcher = launcher + except ConfigParser.NoOptionError: + pass + except ConfigParser.NoSectionError: + pass + + def save_settings(self, config, sectionName): + try: + config.set(sectionName, "recurrence", str(self._recurrence)) + config.set(sectionName, "alarmCookie", str(self._alarmCookie)) + launcher = self._launcher if self._launcher != self._LAUNCHER else "" + config.set(sectionName, "notifier", launcher) + except ConfigParser.NoOptionError: + pass + except ConfigParser.NoSectionError: + pass + + def apply_settings(self, enabled, recurrence): + if recurrence != self._recurrence or enabled != self.isEnabled: + if self.isEnabled: + self._clear_alarm() + if enabled: + self._set_alarm(recurrence) + self._recurrence = int(recurrence) + + @property + def recurrence(self): + return self._recurrence + + @property + def isEnabled(self): + return self._alarmCookie != self._INVALID_COOKIE + + def _set_alarm(self, recurrenceMins): + assert 1 <= recurrenceMins, "Notifications set to occur too frequently: %d" % recurrenceMins + alarmTime = _get_start_time(recurrenceMins) + + event = alarm.Event() + event.appid = self._TITLE + event.alarm_time = alarmTime + event.recurrences_left = self._REPEAT_FOREVER + + action = event.add_actions(1)[0] + action.flags |= alarm.ACTION_TYPE_EXEC | alarm.ACTION_WHEN_TRIGGERED + action.command = self._launcher + + recurrence = event.add_recurrences(1)[0] + recurrence.mask_min |= _create_recurrence_mask(recurrenceMins, 60) + recurrence.mask_hour |= alarm.RECUR_HOUR_DONTCARE + recurrence.mask_mday |= alarm.RECUR_MDAY_DONTCARE + recurrence.mask_wday |= alarm.RECUR_WDAY_DONTCARE + recurrence.mask_mon |= alarm.RECUR_MON_DONTCARE + recurrence.special |= alarm.RECUR_SPECIAL_NONE + + assert event.is_sane() + self._alarmCookie = alarm.add_event(event) + + def _clear_alarm(self): + if self._alarmCookie == self._INVALID_COOKIE: + return + alarm.delete_event(self._alarmCookie) + self._alarmCookie = self._INVALID_COOKIE + + +class _DiabloAlarmHandler(object): + + _INVALID_COOKIE = -1 + _TITLE = "Dialcentral Notifications" + _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py")) + _REPEAT_FOREVER = -1 + + def __init__(self): + self._recurrence = 5 + + bus = dbus.SystemBus() + self._alarmdDBus = bus.get_object("com.nokia.alarmd", "/com/nokia/alarmd"); + self._alarmCookie = self._INVALID_COOKIE + self._launcher = self._LAUNCHER + + def load_settings(self, config, sectionName): + try: + self._recurrence = config.getint(sectionName, "recurrence") + self._alarmCookie = config.getint(sectionName, "alarmCookie") + launcher = config.get(sectionName, "notifier") + if launcher: + self._launcher = launcher + except ConfigParser.NoOptionError: + pass + except ConfigParser.NoSectionError: + pass + + def save_settings(self, config, sectionName): + config.set(sectionName, "recurrence", str(self._recurrence)) + config.set(sectionName, "alarmCookie", str(self._alarmCookie)) + launcher = self._launcher if self._launcher != self._LAUNCHER else "" + config.set(sectionName, "notifier", launcher) + + def apply_settings(self, enabled, recurrence): + if recurrence != self._recurrence or enabled != self.isEnabled: + if self.isEnabled: + self._clear_alarm() + if enabled: + self._set_alarm(recurrence) + self._recurrence = int(recurrence) + + @property + def recurrence(self): + return self._recurrence + + @property + def isEnabled(self): + return self._alarmCookie != self._INVALID_COOKIE + + def _set_alarm(self, recurrence): + assert 1 <= recurrence, "Notifications set to occur too frequently: %d" % recurrence + alarmTime = _get_start_time(recurrence) + + #Setup the alarm arguments so that they can be passed to the D-Bus add_event method + _DEFAULT_FLAGS = ( + alarmd.ALARM_EVENT_NO_DIALOG | + alarmd.ALARM_EVENT_NO_SNOOZE | + alarmd.ALARM_EVENT_CONNECTED + ) + action = [] + action.extend(['flags', _DEFAULT_FLAGS]) + action.extend(['title', self._TITLE]) + action.extend(['path', self._launcher]) + action.extend([ + 'arguments', + dbus.Array( + [alarmTime, int(27)], + signature=dbus.Signature('v') + ) + ]) #int(27) used in place of alarm_index + + event = [] + event.extend([dbus.ObjectPath('/AlarmdEventRecurring'), dbus.UInt32(4)]) + event.extend(['action', dbus.ObjectPath('/AlarmdActionExec')]) #use AlarmdActionExec instead of AlarmdActionDbus + event.append(dbus.UInt32(len(action) / 2)) + event.extend(action) + event.extend(['time', dbus.Int64(alarmTime)]) + event.extend(['recurr_interval', dbus.UInt32(recurrence)]) + event.extend(['recurr_count', dbus.Int32(self._REPEAT_FOREVER)]) + + self._alarmCookie = self._alarmdDBus.add_event(*event); + + def _clear_alarm(self): + if self._alarmCookie == self._INVALID_COOKIE: + return + deleteResult = self._alarmdDBus.del_event(dbus.Int32(self._alarmCookie)) + self._alarmCookie = self._INVALID_COOKIE + assert deleteResult != -1, "Deleting of alarm event failed" + + +class _ApplicationAlarmHandler(object): + + _REPEAT_FOREVER = -1 + _MIN_TO_MS_FACTORY = 1000 * 60 + + def __init__(self): + self._timer = QtCore.QTimer() + self._timer.setSingleShot(False) + self._timer.setInterval(5 * self._MIN_TO_MS_FACTORY) + + def load_settings(self, config, sectionName): + try: + self._timer.setInterval(config.getint(sectionName, "recurrence") * self._MIN_TO_MS_FACTORY) + except ConfigParser.NoOptionError: + pass + except ConfigParser.NoSectionError: + pass + self._timer.start() + + def save_settings(self, config, sectionName): + config.set(sectionName, "recurrence", str(self.recurrence)) + + def apply_settings(self, enabled, recurrence): + self._timer.setInterval(recurrence * self._MIN_TO_MS_FACTORY) + if enabled: + self._timer.start() + else: + self._timer.stop() + + @property + def notifySignal(self): + return self._timer.timeout + + @property + def recurrence(self): + return int(self._timer.interval() / self._MIN_TO_MS_FACTORY) + + @property + def isEnabled(self): + return self._timer.isActive() + + +class _NoneAlarmHandler(object): + + def __init__(self): + self._enabled = False + self._recurrence = 5 + + def load_settings(self, config, sectionName): + try: + self._recurrence = config.getint(sectionName, "recurrence") + self._enabled = True + except ConfigParser.NoOptionError: + pass + except ConfigParser.NoSectionError: + pass + + def save_settings(self, config, sectionName): + config.set(sectionName, "recurrence", str(self.recurrence)) + + def apply_settings(self, enabled, recurrence): + self._enabled = enabled + + @property + def recurrence(self): + return self._recurrence + + @property + def isEnabled(self): + return self._enabled + + +_BACKGROUND_ALARM_FACTORY = { + _FREMANTLE_ALARM: _FremantleAlarmHandler, + _DIABLO_ALARM: _DiabloAlarmHandler, + _NO_ALARM: None, +}[ALARM_TYPE] + + +class AlarmHandler(object): + + ALARM_NONE = "No Alert" + ALARM_BACKGROUND = "Background Alert" + ALARM_APPLICATION = "Application Alert" + ALARM_TYPES = [ALARM_NONE, ALARM_BACKGROUND, ALARM_APPLICATION] + + ALARM_FACTORY = { + ALARM_NONE: _NoneAlarmHandler, + ALARM_BACKGROUND: _BACKGROUND_ALARM_FACTORY, + ALARM_APPLICATION: _ApplicationAlarmHandler, + } + + def __init__(self): + self._alarms = {self.ALARM_NONE: _NoneAlarmHandler()} + self._currentAlarmType = self.ALARM_NONE + + def load_settings(self, config, sectionName): + try: + self._currentAlarmType = config.get(sectionName, "alarm") + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + _moduleLogger.exception("Falling back to old style") + self._currentAlarmType = self.ALARM_BACKGROUND + if self._currentAlarmType not in self.ALARM_TYPES: + self._currentAlarmType = self.ALARM_NONE + + self._init_alarm(self._currentAlarmType) + if self._currentAlarmType in self._alarms: + self._alarms[self._currentAlarmType].load_settings(config, sectionName) + if not self._alarms[self._currentAlarmType].isEnabled: + _moduleLogger.info("Config file lied, not actually enabled") + self._currentAlarmType = self.ALARM_NONE + else: + _moduleLogger.info("Background alerts not supported") + self._currentAlarmType = self.ALARM_NONE + + def save_settings(self, config, sectionName): + config.set(sectionName, "alarm", self._currentAlarmType) + self._alarms[self._currentAlarmType].save_settings(config, sectionName) + + def apply_settings(self, t, recurrence): + self._init_alarm(t) + newHandler = self._alarms[t] + oldHandler = self._alarms[self._currentAlarmType] + if newHandler != oldHandler: + oldHandler.apply_settings(False, 0) + newHandler.apply_settings(True, recurrence) + self._currentAlarmType = t + + @property + def alarmType(self): + return self._currentAlarmType + + @property + def backgroundNotificationsSupported(self): + return self.ALARM_FACTORY[self.ALARM_BACKGROUND] is not None + + @property + def applicationNotifySignal(self): + self._init_alarm(self.ALARM_APPLICATION) + return self._alarms[self.ALARM_APPLICATION].notifySignal + + @property + def recurrence(self): + return self._alarms[self._currentAlarmType].recurrence + + @property + def isEnabled(self): + return self._currentAlarmType != self.ALARM_NONE + + def _init_alarm(self, t): + if t not in self._alarms and self.ALARM_FACTORY[t] is not None: + self._alarms[t] = self.ALARM_FACTORY[t]() + + +def main(): + logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' + logging.basicConfig(level=logging.DEBUG, format=logFormat) + import constants + try: + import optparse + except ImportError: + return + + parser = optparse.OptionParser() + parser.add_option("-x", "--display", action="store_true", dest="display", help="Display data") + parser.add_option("-e", "--enable", action="store_true", dest="enabled", help="Whether the alarm should be enabled or not", default=False) + parser.add_option("-d", "--disable", action="store_false", dest="enabled", help="Whether the alarm should be enabled or not", default=False) + parser.add_option("-r", "--recurrence", action="store", type="int", dest="recurrence", help="How often the alarm occurs", default=5) + (commandOptions, commandArgs) = parser.parse_args() + + alarmHandler = AlarmHandler() + config = ConfigParser.SafeConfigParser() + config.read(constants._user_settings_) + alarmHandler.load_settings(config, "alarm") + + if commandOptions.display: + print "Alarm (%s) is %s for every %d minutes" % ( + alarmHandler._alarmCookie, + "enabled" if alarmHandler.isEnabled else "disabled", + alarmHandler.recurrence, + ) + else: + isEnabled = commandOptions.enabled + recurrence = commandOptions.recurrence + alarmHandler.apply_settings(isEnabled, recurrence) + + alarmHandler.save_settings(config, "alarm") + configFile = open(constants._user_settings_, "wb") + try: + config.write(configFile) + finally: + configFile.close() + + +if __name__ == "__main__": + main() diff --git a/dialcentral/alarm_notify.py b/dialcentral/alarm_notify.py new file mode 100755 index 0000000..bc6240e --- /dev/null +++ b/dialcentral/alarm_notify.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python + +import os +import filecmp +import ConfigParser +import pprint +import logging +import logging.handlers + +import constants +from backends.gvoice import gvoice + + +def get_missed(backend): + missedPage = backend._browser.download(backend._XML_MISSED_URL) + missedJson = backend._grab_json(missedPage) + return missedJson + + +def get_voicemail(backend): + voicemailPage = backend._browser.download(backend._XML_VOICEMAIL_URL) + voicemailJson = backend._grab_json(voicemailPage) + return voicemailJson + + +def get_sms(backend): + smsPage = backend._browser.download(backend._XML_SMS_URL) + smsJson = backend._grab_json(smsPage) + return smsJson + + +def remove_reltime(data): + for messageData in data["messages"].itervalues(): + for badPart in [ + "relTime", + "relativeStartTime", + "time", + "star", + "isArchived", + "isRead", + "isSpam", + "isTrash", + "labels", + ]: + if badPart in messageData: + del messageData[badPart] + for globalBad in ["unreadCounts", "totalSize", "resultsPerPage"]: + if globalBad in data: + del data[globalBad] + + +def is_type_changed(backend, type, get_material): + jsonMaterial = get_material(backend) + unreadCount = jsonMaterial["unreadCounts"][type] + + previousSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.old.json" % type) + currentSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.json" % type) + + try: + os.remove(previousSnapshotPath) + except OSError, e: + # check if failed purely because the old file didn't exist, which is fine + if e.errno != 2: + raise + try: + os.rename(currentSnapshotPath, previousSnapshotPath) + previousExists = True + except OSError, e: + # check if failed purely because the new old file didn't exist, which is fine + if e.errno != 2: + raise + previousExists = False + + remove_reltime(jsonMaterial) + textMaterial = pprint.pformat(jsonMaterial) + currentSnapshot = file(currentSnapshotPath, "w") + try: + currentSnapshot.write(textMaterial) + finally: + currentSnapshot.close() + + if unreadCount == 0 or not previousExists: + return False + + seemEqual = filecmp.cmp(previousSnapshotPath, currentSnapshotPath) + return not seemEqual + + +def create_backend(config): + gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt") + backend = gvoice.GVoiceBackend(gvCookiePath) + + loggedIn = False + + if not loggedIn: + loggedIn = backend.refresh_account_info() is not None + + if not loggedIn: + import base64 + try: + blobs = ( + config.get(constants.__pretty_app_name__, "bin_blob_%i" % i) + for i in xrange(2) + ) + creds = ( + base64.b64decode(blob) + for blob in blobs + ) + username, password = tuple(creds) + loggedIn = backend.login(username, password) is not None + except ConfigParser.NoOptionError, e: + pass + except ConfigParser.NoSectionError, e: + pass + + assert loggedIn + return backend + + +def is_changed(config, backend): + try: + notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed") + notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail") + notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms") + except ConfigParser.NoOptionError, e: + notifyOnMissed = False + notifyOnVoicemail = False + notifyOnSms = False + except ConfigParser.NoSectionError, e: + notifyOnMissed = False + notifyOnVoicemail = False + notifyOnSms = False + logging.debug( + "Missed: %s, Voicemail: %s, SMS: %s" % (notifyOnMissed, notifyOnVoicemail, notifyOnSms) + ) + + notifySources = [] + if notifyOnMissed: + notifySources.append(("missed", get_missed)) + if notifyOnVoicemail: + notifySources.append(("voicemail", get_voicemail)) + if notifyOnSms: + notifySources.append(("sms", get_sms)) + + notifyUser = False + for type, get_material in notifySources: + if is_type_changed(backend, type, get_material): + notifyUser = True + return notifyUser + + +def notify_on_change(): + config = ConfigParser.SafeConfigParser() + config.read(constants._user_settings_) + backend = create_backend(config) + notifyUser = is_changed(config, backend) + + if notifyUser: + logging.info("Changed") + import led_handler + led = led_handler.LedHandler() + led.on() + else: + logging.info("No Change") + + +if __name__ == "__main__": + logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' + logging.basicConfig(level=logging.DEBUG, format=logFormat) + rotating = logging.handlers.RotatingFileHandler(constants._notifier_logpath_, maxBytes=512*1024, backupCount=1) + rotating.setFormatter(logging.Formatter(logFormat)) + root = logging.getLogger() + root.addHandler(rotating) + logging.info("Notifier %s-%s" % (constants.__version__, constants.__build__)) + logging.info("OS: %s" % (os.uname()[0], )) + logging.info("Kernel: %s (%s) for %s" % os.uname()[2:]) + logging.info("Hostname: %s" % os.uname()[1]) + try: + notify_on_change() + except: + logging.exception("Error") + raise diff --git a/dialcentral/backends/__init__.py b/dialcentral/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dialcentral/backends/file_backend.py b/dialcentral/backends/file_backend.py new file mode 100644 index 0000000..9f8927a --- /dev/null +++ b/dialcentral/backends/file_backend.py @@ -0,0 +1,176 @@ +#!/usr/bin/python + +""" +DialCentral - Front end for Google's Grand Central service. +Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library 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 +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Filesystem backend for contact support +""" + +from __future__ import with_statement + +import os +import csv + + +def try_unicode(s): + try: + return s.decode("UTF-8") + except UnicodeDecodeError: + return s + + +class CsvAddressBook(object): + """ + Currently supported file format + @li Has the first line as a header + @li Escapes with quotes + @li Comma as delimiter + @li Column 0 is name, column 1 is number + """ + + def __init__(self, name, csvPath): + self._name = name + self._csvPath = csvPath + self._contacts = {} + + @property + def name(self): + return self._name + + def update_account(self, force = True): + if not force or not self._contacts: + return + self._contacts = dict( + self._read_csv(self._csvPath) + ) + + def get_contacts(self): + """ + @returns Iterable of (contact id, contact name) + """ + if not self._contacts: + self._contacts = dict( + self._read_csv(self._csvPath) + ) + return self._contacts + + def _read_csv(self, csvPath): + try: + f = open(csvPath, "rU") + csvReader = iter(csv.reader(f)) + except IOError, e: + if e.errno == 2: + return + raise + + header = csvReader.next() + nameColumns, nameFallbacks, phoneColumns = self._guess_columns(header) + + yieldCount = 0 + for row in csvReader: + contactDetails = [] + for (phoneType, phoneColumn) in phoneColumns: + try: + if len(row[phoneColumn]) == 0: + continue + contactDetails.append({ + "phoneType": try_unicode(phoneType), + "phoneNumber": row[phoneColumn], + }) + except IndexError: + pass + if 0 < len(contactDetails): + nameParts = (row[i].strip() for i in nameColumns) + nameParts = (part for part in nameParts if part) + fullName = " ".join(nameParts).strip() + if not fullName: + for fallbackColumn in nameFallbacks: + if row[fallbackColumn].strip(): + fullName = row[fallbackColumn].strip() + break + else: + fullName = "Unknown" + fullName = try_unicode(fullName) + yield str(yieldCount), { + "contactId": "%s-%d" % (self._name, yieldCount), + "name": fullName, + "numbers": contactDetails, + } + yieldCount += 1 + + @classmethod + def _guess_columns(cls, row): + firstMiddleLast = [-1, -1, -1] + names = [] + nameFallbacks = [] + phones = [] + for i, item in enumerate(row): + lowerItem = item.lower() + if 0 <= lowerItem.find("name"): + names.append((item, i)) + + if 0 <= lowerItem.find("couple"): + names.insert(0, (item, i)) + + if 0 <= lowerItem.find("first") or 0 <= lowerItem.find("given"): + firstMiddleLast[0] = i + elif 0 <= lowerItem.find("middle"): + firstMiddleLast[1] = i + elif 0 <= lowerItem.find("last") or 0 <= lowerItem.find("family"): + firstMiddleLast[2] = i + elif 0 <= lowerItem.find("phone"): + phones.append((item, i)) + elif 0 <= lowerItem.find("mobile"): + phones.append((item, i)) + elif 0 <= lowerItem.find("email") or 0 <= lowerItem.find("e-mail"): + nameFallbacks.append(i) + if len(names) == 0: + names.append(("Name", 0)) + if len(phones) == 0: + phones.append(("Phone", 1)) + + nameColumns = [i for i in firstMiddleLast if 0 <= i] + if len(nameColumns) < 2: + del nameColumns[:] + nameColumns.append(names[0][1]) + + return nameColumns, nameFallbacks, phones + + +class FilesystemAddressBookFactory(object): + + FILETYPE_SUPPORT = { + "csv": CsvAddressBook, + } + + def __init__(self, path): + self._path = path + + def get_addressbooks(self): + for root, dirs, filenames in os.walk(self._path): + for filename in filenames: + try: + name, ext = filename.rsplit(".", 1) + except ValueError: + continue + + try: + cls = self.FILETYPE_SUPPORT[ext] + except KeyError: + continue + yield cls(name, os.path.join(root, filename)) diff --git a/dialcentral/backends/gv_backend.py b/dialcentral/backends/gv_backend.py new file mode 100644 index 0000000..17bbc90 --- /dev/null +++ b/dialcentral/backends/gv_backend.py @@ -0,0 +1,321 @@ +#!/usr/bin/python + +""" +DialCentral - Front end for Google's GoogleVoice service. +Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library 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 +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Google Voice backend code + +Resources + http://thatsmith.com/2009/03/google-voice-addon-for-firefox/ + http://posttopic.com/topic/google-voice-add-on-development +""" + +from __future__ import with_statement + +import itertools +import logging + +from gvoice import gvoice + +from util import io as io_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class GVDialer(object): + + MESSAGE_TEXTS = "Text" + MESSAGE_VOICEMAILS = "Voicemail" + MESSAGE_ALL = "All" + + HISTORY_RECEIVED = "Received" + HISTORY_MISSED = "Missed" + HISTORY_PLACED = "Placed" + HISTORY_ALL = "All" + + def __init__(self, cookieFile = None): + self._gvoice = gvoice.GVoiceBackend(cookieFile) + self._texts = [] + self._voicemails = [] + self._received = [] + self._missed = [] + self._placed = [] + + def is_quick_login_possible(self): + """ + @returns True then refresh_account_info might be enough to login, else full login is required + """ + return self._gvoice.is_quick_login_possible() + + def refresh_account_info(self): + return self._gvoice.refresh_account_info() + + def login(self, username, password): + """ + Attempt to login to GoogleVoice + @returns Whether login was successful or not + """ + return self._gvoice.login(username, password) + + def logout(self): + self._texts = [] + self._voicemails = [] + self._received = [] + self._missed = [] + self._placed = [] + return self._gvoice.logout() + + def persist(self): + return self._gvoice.persist() + + def is_dnd(self): + return self._gvoice.is_dnd() + + def set_dnd(self, doNotDisturb): + return self._gvoice.set_dnd(doNotDisturb) + + def call(self, outgoingNumber): + """ + This is the main function responsible for initating the callback + """ + return self._gvoice.call(outgoingNumber) + + def cancel(self, outgoingNumber=None): + """ + Cancels a call matching outgoing and forwarding numbers (if given). + Will raise an error if no matching call is being placed + """ + return self._gvoice.cancel(outgoingNumber) + + def send_sms(self, phoneNumbers, message): + self._gvoice.send_sms(phoneNumbers, message) + + def search(self, query): + """ + Search your Google Voice Account history for calls, voicemails, and sms + Returns ``Folder`` instance containting matching messages + """ + return self._gvoice.search(query) + + def get_feed(self, feed): + return self._gvoice.get_feed(feed) + + def download(self, messageId, targetPath): + """ + Download a voicemail or recorded call MP3 matching the given ``msg`` + which can either be a ``Message`` instance, or a SHA1 identifier. + Message hashes can be found in ``self.voicemail().messages`` for example. + Returns location of saved file. + """ + self._gvoice.download(messageId, targetPath) + + def is_valid_syntax(self, number): + """ + @returns If This number be called ( syntax validation only ) + """ + return self._gvoice.is_valid_syntax(number) + + def get_account_number(self): + """ + @returns The GoogleVoice phone number + """ + return self._gvoice.get_account_number() + + def get_callback_numbers(self): + """ + @returns a dictionary mapping call back numbers to descriptions + @note These results are cached for 30 minutes. + """ + return self._gvoice.get_callback_numbers() + + def set_callback_number(self, callbacknumber): + """ + Set the number that GoogleVoice calls + @param callbacknumber should be a proper 10 digit number + """ + return self._gvoice.set_callback_number(callbacknumber) + + def get_callback_number(self): + """ + @returns Current callback number or None + """ + return self._gvoice.get_callback_number() + + def get_call_history(self, historyType): + """ + @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) + """ + history = list(self._get_call_history(historyType)) + history.sort(key=lambda item: item["time"]) + return history + + def _get_call_history(self, historyType): + """ + @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) + """ + if historyType in [self.HISTORY_RECEIVED, self.HISTORY_ALL] or not self._received: + self._received = list(self._gvoice.get_received_calls()) + for item in self._received: + item["action"] = self.HISTORY_RECEIVED + if historyType in [self.HISTORY_MISSED, self.HISTORY_ALL] or not self._missed: + self._missed = list(self._gvoice.get_missed_calls()) + for item in self._missed: + item["action"] = self.HISTORY_MISSED + if historyType in [self.HISTORY_PLACED, self.HISTORY_ALL] or not self._placed: + self._placed = list(self._gvoice.get_placed_calls()) + for item in self._placed: + item["action"] = self.HISTORY_PLACED + received = self._received + missed = self._missed + placed = self._placed + for item in received: + yield item + for item in missed: + yield item + for item in placed: + yield item + + def get_messages(self, messageType): + messages = list(self._get_messages(messageType)) + messages.sort(key=lambda message: message["time"]) + return messages + + def _get_messages(self, messageType): + if messageType in [self.MESSAGE_VOICEMAILS, self.MESSAGE_ALL] or not self._voicemails: + self._voicemails = list(self._gvoice.get_voicemails()) + if messageType in [self.MESSAGE_TEXTS, self.MESSAGE_ALL] or not self._texts: + self._texts = list(self._gvoice.get_texts()) + voicemails = self._voicemails + smss = self._texts + + conversations = itertools.chain(voicemails, smss) + for conversation in conversations: + messages = conversation.messages + messageParts = [ + (message.whoFrom, self._format_message(message), message.when) + for message in messages + ] + + messageDetails = { + "id": conversation.id, + "contactId": conversation.contactId, + "name": conversation.name, + "time": conversation.time, + "relTime": conversation.relTime, + "prettyNumber": conversation.prettyNumber, + "number": conversation.number, + "location": conversation.location, + "messageParts": messageParts, + "type": conversation.type, + "isRead": conversation.isRead, + "isTrash": conversation.isTrash, + "isSpam": conversation.isSpam, + "isArchived": conversation.isArchived, + } + yield messageDetails + + def clear_caches(self): + pass + + def get_addressbooks(self): + """ + @returns Iterable of (Address Book Factory, Book Id, Book Name) + """ + yield self, "", "" + + def open_addressbook(self, bookId): + return self + + @staticmethod + def contact_source_short_name(contactId): + return "GV" + + @staticmethod + def factory_name(): + return "Google Voice" + + def _format_message(self, message): + messagePartFormat = { + "med1": "%s", + "med2": "%s", + "high": "%s", + } + return " ".join( + messagePartFormat[text.accuracy] % io_utils.escape(text.text) + for text in message.body + ) + + +def sort_messages(allMessages): + sortableAllMessages = [ + (message["time"], message) + for message in allMessages + ] + sortableAllMessages.sort(reverse=True) + return ( + message + for (exactTime, message) in sortableAllMessages + ) + + +def decorate_recent(recentCallData): + """ + @returns (personsName, phoneNumber, date, action) + """ + contactId = recentCallData["contactId"] + if recentCallData["name"]: + header = recentCallData["name"] + elif recentCallData["prettyNumber"]: + header = recentCallData["prettyNumber"] + elif recentCallData["location"]: + header = recentCallData["location"] + else: + header = "Unknown" + + number = recentCallData["number"] + relTime = recentCallData["relTime"] + action = recentCallData["action"] + return contactId, header, number, relTime, action + + +def decorate_message(messageData): + contactId = messageData["contactId"] + exactTime = messageData["time"] + if messageData["name"]: + header = messageData["name"] + elif messageData["prettyNumber"]: + header = messageData["prettyNumber"] + else: + header = "Unknown" + number = messageData["number"] + relativeTime = messageData["relTime"] + + messageParts = list(messageData["messageParts"]) + if len(messageParts) == 0: + messages = ("No Transcription", ) + elif len(messageParts) == 1: + messages = (messageParts[0][1], ) + else: + messages = [ + "%s: %s" % (messagePart[0], messagePart[1]) + for messagePart in messageParts + ] + + decoratedResults = contactId, header, number, relativeTime, messages + return decoratedResults diff --git a/dialcentral/backends/gvoice/__init__.py b/dialcentral/backends/gvoice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dialcentral/backends/gvoice/browser_emu.py b/dialcentral/backends/gvoice/browser_emu.py new file mode 100644 index 0000000..4fef6e8 --- /dev/null +++ b/dialcentral/backends/gvoice/browser_emu.py @@ -0,0 +1,210 @@ +""" +@author: Laszlo Nagy +@copyright: (c) 2005 by Szoftver Messias Bt. +@licence: BSD style + +Objects of the MozillaEmulator class can emulate a browser that is capable of: + + - cookie management + - configurable user agent string + - GET and POST + - multipart POST (send files) + - receive content into file + +I have seen many requests on the python mailing list about how to emulate a browser. I'm using this class for years now, without any problems. This is how you can use it: + + 1. Use firefox + 2. Install and open the livehttpheaders plugin + 3. Use the website manually with firefox + 4. Check the GET and POST requests in the livehttpheaders capture window + 5. Create an instance of the above class and send the same GET and POST requests to the server. + +Optional steps: + + - You can change user agent string in the build_opened method + - The "encode_multipart_formdata" function can be used alone to create POST data from a list of field values and files +""" + +import urllib2 +import cookielib +import logging + +import socket + + +_moduleLogger = logging.getLogger(__name__) +socket.setdefaulttimeout(25) + + +def add_proxy(protocol, url, port): + proxyInfo = "%s:%s" % (url, port) + proxy = urllib2.ProxyHandler( + {protocol: proxyInfo} + ) + opener = urllib2.build_opener(proxy) + urllib2.install_opener(opener) + + +class MozillaEmulator(object): + + USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.4) Gecko/20091016 Firefox/3.5.4 (.NET CLR 3.5.30729)' + #USER_AGENT = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 3_0 like Mac OS X; en-us) AppleWebKit/528.18 (KHTML, like Gecko) Version/4.0 Mobile/7A341 Safari/528.16" + + def __init__(self, trycount = 1): + """Create a new MozillaEmulator object. + + @param trycount: The download() method will retry the operation if it + fails. You can specify -1 for infinite retrying. A value of 0 means no + retrying. A value of 1 means one retry. etc.""" + self.debug = False + self.trycount = trycount + self._cookies = cookielib.LWPCookieJar() + self._loadedFromCookies = False + self._storeCookies = False + + def load_cookies(self, path): + assert not self._loadedFromCookies, "Load cookies only once" + if path is None: + return + + self._cookies.filename = path + try: + self._cookies.load() + except cookielib.LoadError: + _moduleLogger.exception("Bad cookie file") + except IOError: + _moduleLogger.exception("No cookie file") + except Exception, e: + _moduleLogger.exception("Unknown error with cookies") + else: + self._loadedFromCookies = True + self._storeCookies = True + + return self._loadedFromCookies + + def save_cookies(self): + if self._storeCookies: + self._cookies.save() + + def clear_cookies(self): + if self._storeCookies: + self._cookies.clear() + + def download(self, url, + postdata = None, extraheaders = None, forbidRedirect = False, + trycount = None, only_head = False, + ): + """Download an URL with GET or POST methods. + + @param postdata: It can be a string that will be POST-ed to the URL. + When None is given, the method will be GET instead. + @param extraheaders: You can add/modify HTTP headers with a dict here. + @param forbidRedirect: Set this flag if you do not want to handle + HTTP 301 and 302 redirects. + @param trycount: Specify the maximum number of retries here. + 0 means no retry on error. Using -1 means infinite retring. + None means the default value (that is self.trycount). + @param only_head: Create the openerdirector and return it. In other + words, this will not retrieve any content except HTTP headers. + + @return: The raw HTML page data + """ + _moduleLogger.debug("Performing download of %s" % url) + + if extraheaders is None: + extraheaders = {} + if trycount is None: + trycount = self.trycount + cnt = 0 + + while True: + try: + req, u = self._build_opener(url, postdata, extraheaders, forbidRedirect) + openerdirector = u.open(req) + if self.debug: + _moduleLogger.info("%r - %r" % (req.get_method(), url)) + _moduleLogger.info("%r - %r" % (openerdirector.code, openerdirector.msg)) + _moduleLogger.info("%r" % (openerdirector.headers)) + self._cookies.extract_cookies(openerdirector, req) + if only_head: + return openerdirector + + return self._read(openerdirector, trycount) + except urllib2.URLError, e: + _moduleLogger.debug("%s: %s" % (e, url)) + cnt += 1 + if (-1 < trycount) and (trycount < cnt): + raise + + # Retry :-) + _moduleLogger.debug("MozillaEmulator: urllib2.URLError, retrying %d" % cnt) + + def _build_opener(self, url, postdata = None, extraheaders = None, forbidRedirect = False): + if extraheaders is None: + extraheaders = {} + + txheaders = { + 'Accept': 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png', + 'Accept-Language': 'en,en-us;q=0.5', + 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'User-Agent': self.USER_AGENT, + } + for key, value in extraheaders.iteritems(): + txheaders[key] = value + req = urllib2.Request(url, postdata, txheaders) + self._cookies.add_cookie_header(req) + if forbidRedirect: + redirector = HTTPNoRedirector() + #_moduleLogger.info("Redirection disabled") + else: + redirector = urllib2.HTTPRedirectHandler() + #_moduleLogger.info("Redirection enabled") + + http_handler = urllib2.HTTPHandler(debuglevel=self.debug) + https_handler = urllib2.HTTPSHandler(debuglevel=self.debug) + + u = urllib2.build_opener( + http_handler, + https_handler, + urllib2.HTTPCookieProcessor(self._cookies), + redirector + ) + if not postdata is None: + req.add_data(postdata) + return (req, u) + + def _read(self, openerdirector, trycount): + chunks = [] + + chunk = openerdirector.read() + chunks.append(chunk) + #while chunk and cnt < trycount: + # time.sleep(1) + # cnt += 1 + # chunk = openerdirector.read() + # chunks.append(chunk) + + data = "".join(chunks) + + if "Content-Length" in openerdirector.info(): + assert len(data) == int(openerdirector.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % ( + openerdirector.info()["Content-Length"], + len(data), + ) + + return data + + +class HTTPNoRedirector(urllib2.HTTPRedirectHandler): + """This is a custom http redirect handler that FORBIDS redirection.""" + + def http_error_302(self, req, fp, code, msg, headers): + e = urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) + if e.code in (301, 302): + if 'location' in headers: + newurl = headers.getheaders('location')[0] + elif 'uri' in headers: + newurl = headers.getheaders('uri')[0] + e.newurl = newurl + _moduleLogger.info("New url: %s" % e.newurl) + raise e diff --git a/dialcentral/backends/gvoice/gvoice.py b/dialcentral/backends/gvoice/gvoice.py new file mode 100755 index 0000000..b0825ef --- /dev/null +++ b/dialcentral/backends/gvoice/gvoice.py @@ -0,0 +1,1050 @@ +#!/usr/bin/python + +""" +DialCentral - Front end for Google's GoogleVoice service. +Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library 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 +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Google Voice backend code + +Resources + http://thatsmith.com/2009/03/google-voice-addon-for-firefox/ + http://posttopic.com/topic/google-voice-add-on-development +""" + +from __future__ import with_statement + +import os +import re +import urllib +import urllib2 +import time +import datetime +import itertools +import logging +import inspect + +from xml.sax import saxutils +from xml.etree import ElementTree + +try: + import simplejson as _simplejson + simplejson = _simplejson +except ImportError: + simplejson = None + +import browser_emu + + +_moduleLogger = logging.getLogger(__name__) + + +class NetworkError(RuntimeError): + pass + + +class MessageText(object): + + ACCURACY_LOW = "med1" + ACCURACY_MEDIUM = "med2" + ACCURACY_HIGH = "high" + + def __init__(self): + self.accuracy = None + self.text = None + + def __str__(self): + return self.text + + def to_dict(self): + return to_dict(self) + + def __eq__(self, other): + return self.accuracy == other.accuracy and self.text == other.text + + +class Message(object): + + def __init__(self): + self.whoFrom = None + self.body = None + self.when = None + + def __str__(self): + return "%s (%s): %s" % ( + self.whoFrom, + self.when, + "".join(unicode(part) for part in self.body) + ) + + def to_dict(self): + selfDict = to_dict(self) + selfDict["body"] = [text.to_dict() for text in self.body] if self.body is not None else None + return selfDict + + def __eq__(self, other): + return self.whoFrom == other.whoFrom and self.when == other.when and self.body == other.body + + +class Conversation(object): + + TYPE_VOICEMAIL = "Voicemail" + TYPE_SMS = "SMS" + + def __init__(self): + self.type = None + self.id = None + self.contactId = None + self.name = None + self.location = None + self.prettyNumber = None + self.number = None + + self.time = None + self.relTime = None + self.messages = None + self.isRead = None + self.isSpam = None + self.isTrash = None + self.isArchived = None + + def __cmp__(self, other): + cmpValue = cmp(self.contactId, other.contactId) + if cmpValue != 0: + return cmpValue + + cmpValue = cmp(self.time, other.time) + if cmpValue != 0: + return cmpValue + + cmpValue = cmp(self.id, other.id) + if cmpValue != 0: + return cmpValue + + def to_dict(self): + selfDict = to_dict(self) + selfDict["messages"] = [message.to_dict() for message in self.messages] if self.messages is not None else None + return selfDict + + +class GVoiceBackend(object): + """ + This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers + the functions include login, setting up a callback number, and initalting a callback + """ + + PHONE_TYPE_HOME = 1 + PHONE_TYPE_MOBILE = 2 + PHONE_TYPE_WORK = 3 + PHONE_TYPE_GIZMO = 7 + + def __init__(self, cookieFile = None): + # Important items in this function are the setup of the browser emulation and cookie file + self._browser = browser_emu.MozillaEmulator(1) + self._loadedFromCookies = self._browser.load_cookies(cookieFile) + + self._token = "" + self._accountNum = "" + self._lastAuthed = 0.0 + self._callbackNumber = "" + self._callbackNumbers = {} + + # Suprisingly, moving all of these from class to self sped up startup time + + self._validateRe = re.compile("^\+?[0-9]{10,}$") + + self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth" + + SECURE_URL_BASE = "https://www.google.com/voice/" + SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/" + self._tokenURL = SECURE_URL_BASE + "m" + self._callUrl = SECURE_URL_BASE + "call/connect" + self._callCancelURL = SECURE_URL_BASE + "call/cancel" + self._sendSmsURL = SECURE_URL_BASE + "sms/send" + + self._isDndURL = "https://www.google.com/voice/m/donotdisturb" + self._isDndRe = re.compile(r"""""") + self._setDndURL = "https://www.google.com/voice/m/savednd" + + self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/" + self._markAsReadURL = SECURE_URL_BASE + "m/mark" + self._archiveMessageURL = SECURE_URL_BASE + "m/archive" + + self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/" + self._XML_ACCOUNT_URL = SECURE_URL_BASE + "contacts/" + # HACK really this redirects to the main pge and we are grabbing some javascript + self._XML_CONTACTS_URL = "http://www.google.com/voice/inbox/search/contact" + self._CSV_CONTACTS_URL = "http://mail.google.com/mail/contacts/data/export" + self._JSON_CONTACTS_URL = SECURE_URL_BASE + "b/0/request/user" + self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/" + + self.XML_FEEDS = ( + 'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms', + 'recorded', 'placed', 'received', 'missed' + ) + self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox" + self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred" + self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all" + self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam" + self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash" + self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/" + self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/" + self._JSON_SMS_URL = SECURE_URL_BASE + "b/0/request/messages/" + self._JSON_SMS_COUNT_URL = SECURE_URL_BASE + "b/0/request/unread" + self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/" + self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/" + self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/" + self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/" + + self._galxRe = re.compile(r"""""", re.MULTILINE | re.DOTALL) + + self._seperateVoicemailsRegex = re.compile(r"""^\s*
""", re.MULTILINE | re.DOTALL) + self._exactVoicemailTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE) + self._relativeVoicemailTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE) + self._voicemailNameRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) + self._voicemailNumberRegex = re.compile(r"""""", re.MULTILINE) + self._prettyVoicemailNumberRegex = re.compile(r"""(.*?)""", re.MULTILINE) + self._voicemailLocationRegex = re.compile(r""".*?(.*?)""", re.MULTILINE) + self._messagesContactIDRegex = re.compile(r""".*?\s*?(.*?)""", re.MULTILINE) + self._voicemailMessageRegex = re.compile(r"""((.*?)|(.*?))""", re.MULTILINE) + self._smsFromRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) + self._smsTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) + self._smsTextRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) + + def is_quick_login_possible(self): + """ + @returns True then refresh_account_info might be enough to login, else full login is required + """ + return self._loadedFromCookies or 0.0 < self._lastAuthed + + def refresh_account_info(self): + try: + page = self._get_page(self._JSON_CONTACTS_URL) + accountData = self._grab_account_info(page) + except Exception, e: + _moduleLogger.exception(str(e)) + return None + + self._browser.save_cookies() + self._lastAuthed = time.time() + return accountData + + def _get_token(self): + tokenPage = self._get_page(self._tokenURL) + + galxTokens = self._galxRe.search(tokenPage) + if galxTokens is not None: + galxToken = galxTokens.group(1) + else: + galxToken = "" + _moduleLogger.debug("Could not grab GALX token") + return galxToken + + def _login(self, username, password, token): + loginData = { + 'Email' : username, + 'Passwd' : password, + 'service': "grandcentral", + "ltmpl": "mobile", + "btmpl": "mobile", + "PersistentCookie": "yes", + "GALX": token, + "continue": self._JSON_CONTACTS_URL, + } + + loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData) + return loginSuccessOrFailurePage + + def login(self, username, password): + """ + Attempt to login to GoogleVoice + @returns Whether login was successful or not + @blocks + """ + self.logout() + galxToken = self._get_token() + loginSuccessOrFailurePage = self._login(username, password, galxToken) + + try: + accountData = self._grab_account_info(loginSuccessOrFailurePage) + except Exception, e: + # Retry in case the redirect failed + # luckily refresh_account_info does everything we need for a retry + accountData = self.refresh_account_info() + if accountData is None: + _moduleLogger.exception(str(e)) + return None + _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this") + + self._browser.save_cookies() + self._lastAuthed = time.time() + return accountData + + def persist(self): + self._browser.save_cookies() + + def shutdown(self): + self._browser.save_cookies() + self._token = None + self._lastAuthed = 0.0 + + def logout(self): + self._browser.clear_cookies() + self._browser.save_cookies() + self._token = None + self._lastAuthed = 0.0 + self._callbackNumbers = {} + + def is_dnd(self): + """ + @blocks + """ + isDndPage = self._get_page(self._isDndURL) + + dndGroup = self._isDndRe.search(isDndPage) + if dndGroup is None: + return False + dndStatus = dndGroup.group(1) + isDnd = True if dndStatus.strip().lower() == "true" else False + return isDnd + + def set_dnd(self, doNotDisturb): + """ + @blocks + """ + dndPostData = { + "doNotDisturb": 1 if doNotDisturb else 0, + } + + dndPage = self._get_page_with_token(self._setDndURL, dndPostData) + + def call(self, outgoingNumber): + """ + This is the main function responsible for initating the callback + @blocks + """ + outgoingNumber = self._send_validation(outgoingNumber) + subscriberNumber = None + phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack + + callData = { + 'outgoingNumber': outgoingNumber, + 'forwardingNumber': self._callbackNumber, + 'subscriberNumber': subscriberNumber or 'undefined', + 'phoneType': str(phoneType), + 'remember': '1', + } + _moduleLogger.info("%r" % callData) + + page = self._get_page_with_token( + self._callUrl, + callData, + ) + self._parse_with_validation(page) + return True + + def cancel(self, outgoingNumber=None): + """ + Cancels a call matching outgoing and forwarding numbers (if given). + Will raise an error if no matching call is being placed + @blocks + """ + page = self._get_page_with_token( + self._callCancelURL, + { + 'outgoingNumber': outgoingNumber or 'undefined', + 'forwardingNumber': self._callbackNumber or 'undefined', + 'cancelType': 'C2C', + }, + ) + self._parse_with_validation(page) + + def send_sms(self, phoneNumbers, message): + """ + @blocks + """ + validatedPhoneNumbers = [ + self._send_validation(phoneNumber) + for phoneNumber in phoneNumbers + ] + flattenedPhoneNumbers = ",".join(validatedPhoneNumbers) + page = self._get_page_with_token( + self._sendSmsURL, + { + 'phoneNumber': flattenedPhoneNumbers, + 'text': unicode(message).encode("utf-8"), + }, + ) + self._parse_with_validation(page) + + def search(self, query): + """ + Search your Google Voice Account history for calls, voicemails, and sms + Returns ``Folder`` instance containting matching messages + @blocks + """ + page = self._get_page( + self._XML_SEARCH_URL, + {"q": query}, + ) + json, html = extract_payload(page) + return json + + def get_feed(self, feed): + """ + @blocks + """ + actualFeed = "_XML_%s_URL" % feed.upper() + feedUrl = getattr(self, actualFeed) + + page = self._get_page(feedUrl) + json, html = extract_payload(page) + + return json + + def recording_url(self, messageId): + url = self._downloadVoicemailURL+messageId + return url + + def download(self, messageId, targetPath): + """ + Download a voicemail or recorded call MP3 matching the given ``msg`` + which can either be a ``Message`` instance, or a SHA1 identifier. + Message hashes can be found in ``self.voicemail().messages`` for example. + @returns location of saved file. + @blocks + """ + page = self._get_page(self.recording_url(messageId)) + with open(targetPath, 'wb') as fo: + fo.write(page) + + def is_valid_syntax(self, number): + """ + @returns If This number be called ( syntax validation only ) + """ + return self._validateRe.match(number) is not None + + def get_account_number(self): + """ + @returns The GoogleVoice phone number + """ + return self._accountNum + + def get_callback_numbers(self): + """ + @returns a dictionary mapping call back numbers to descriptions + @note These results are cached for 30 minutes. + """ + return self._callbackNumbers + + def set_callback_number(self, callbacknumber): + """ + Set the number that GoogleVoice calls + @param callbacknumber should be a proper 10 digit number + """ + self._callbackNumber = callbacknumber + _moduleLogger.info("Callback number changed: %r" % self._callbackNumber) + return True + + def get_callback_number(self): + """ + @returns Current callback number or None + """ + return self._callbackNumber + + def get_received_calls(self): + """ + @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) + @blocks + """ + return self._parse_recent(self._get_page(self._XML_RECEIVED_URL)) + + def get_missed_calls(self): + """ + @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) + @blocks + """ + return self._parse_recent(self._get_page(self._XML_MISSED_URL)) + + def get_placed_calls(self): + """ + @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) + @blocks + """ + return self._parse_recent(self._get_page(self._XML_PLACED_URL)) + + def get_csv_contacts(self): + data = { + "groupToExport": "mine", + "exportType": "ALL", + "out": "OUTLOOK_CSV", + } + encodedData = urllib.urlencode(data) + contacts = self._get_page(self._CSV_CONTACTS_URL+"?"+encodedData) + return contacts + + def get_voicemails(self): + """ + @blocks + """ + voicemailPage = self._get_page(self._XML_VOICEMAIL_URL) + voicemailHtml = self._grab_html(voicemailPage) + voicemailJson = self._grab_json(voicemailPage) + if voicemailJson is None: + return () + parsedVoicemail = self._parse_voicemail(voicemailHtml) + voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson) + return voicemails + + def get_texts(self): + """ + @blocks + """ + smsPage = self._get_page(self._XML_SMS_URL) + smsHtml = self._grab_html(smsPage) + smsJson = self._grab_json(smsPage) + if smsJson is None: + return () + parsedSms = self._parse_sms(smsHtml) + smss = self._merge_conversation_sources(parsedSms, smsJson) + return smss + + def get_unread_counts(self): + countPage = self._get_page(self._JSON_SMS_COUNT_URL) + counts = parse_json(countPage) + counts = counts["unreadCounts"] + return counts + + def mark_message(self, messageId, asRead): + """ + @blocks + """ + postData = { + "read": 1 if asRead else 0, + "id": messageId, + } + + markPage = self._get_page(self._markAsReadURL, postData) + + def archive_message(self, messageId): + """ + @blocks + """ + postData = { + "id": messageId, + } + + markPage = self._get_page(self._archiveMessageURL, postData) + + def _grab_json(self, flatXml): + xmlTree = ElementTree.fromstring(flatXml) + jsonElement = xmlTree.getchildren()[0] + flatJson = jsonElement.text + jsonTree = parse_json(flatJson) + return jsonTree + + def _grab_html(self, flatXml): + xmlTree = ElementTree.fromstring(flatXml) + htmlElement = xmlTree.getchildren()[1] + flatHtml = htmlElement.text + return flatHtml + + def _grab_account_info(self, page): + accountData = parse_json(page) + self._token = accountData["r"] + self._accountNum = accountData["number"]["raw"] + for callback in accountData["phones"].itervalues(): + self._callbackNumbers[callback["phoneNumber"]] = callback["name"] + if len(self._callbackNumbers) == 0: + _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page) + return accountData + + def _send_validation(self, number): + if not self.is_valid_syntax(number): + raise ValueError('Number is not valid: "%s"' % number) + return number + + def _parse_recent(self, recentPage): + allRecentHtml = self._grab_html(recentPage) + allRecentData = self._parse_history(allRecentHtml) + for recentCallData in allRecentData: + yield recentCallData + + def _parse_history(self, historyHtml): + splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml) + for messageId, messageHtml in itergroup(splitVoicemail[1:], 2): + exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) + exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else "" + exactTime = google_strptime(exactTime) + relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) + relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" + locationGroup = self._voicemailLocationRegex.search(messageHtml) + location = locationGroup.group(1).strip() if locationGroup else "" + + nameGroup = self._voicemailNameRegex.search(messageHtml) + name = nameGroup.group(1).strip() if nameGroup else "" + numberGroup = self._voicemailNumberRegex.search(messageHtml) + number = numberGroup.group(1).strip() if numberGroup else "" + prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) + prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" + contactIdGroup = self._messagesContactIDRegex.search(messageHtml) + contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" + + yield { + "id": messageId.strip(), + "contactId": contactId, + "name": unescape(name), + "time": exactTime, + "relTime": relativeTime, + "prettyNumber": prettyNumber, + "number": number, + "location": unescape(location), + } + + @staticmethod + def _interpret_voicemail_regex(group): + quality, content, number = group.group(2), group.group(3), group.group(4) + text = MessageText() + if quality is not None and content is not None: + text.accuracy = quality + text.text = unescape(content) + return text + elif number is not None: + text.accuracy = MessageText.ACCURACY_HIGH + text.text = number + return text + + def _parse_voicemail(self, voicemailHtml): + splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml) + for messageId, messageHtml in itergroup(splitVoicemail[1:], 2): + conv = Conversation() + conv.type = Conversation.TYPE_VOICEMAIL + conv.id = messageId.strip() + + exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) + exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else "" + conv.time = google_strptime(exactTimeText) + relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) + conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" + locationGroup = self._voicemailLocationRegex.search(messageHtml) + conv.location = unescape(locationGroup.group(1).strip() if locationGroup else "") + + nameGroup = self._voicemailNameRegex.search(messageHtml) + conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "") + numberGroup = self._voicemailNumberRegex.search(messageHtml) + conv.number = numberGroup.group(1).strip() if numberGroup else "" + prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) + conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" + contactIdGroup = self._messagesContactIDRegex.search(messageHtml) + conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" + + messageGroups = self._voicemailMessageRegex.finditer(messageHtml) + messageParts = [ + self._interpret_voicemail_regex(group) + for group in messageGroups + ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), ) + message = Message() + message.body = messageParts + message.whoFrom = conv.name + try: + message.when = conv.time.strftime("%I:%M %p") + except ValueError: + _moduleLogger.exception("Confusing time provided: %r" % conv.time) + message.when = "Unknown" + conv.messages = (message, ) + + yield conv + + @staticmethod + def _interpret_sms_message_parts(fromPart, textPart, timePart): + text = MessageText() + text.accuracy = MessageText.ACCURACY_MEDIUM + text.text = unescape(textPart) + + message = Message() + message.body = (text, ) + message.whoFrom = fromPart + message.when = timePart + + return message + + def _parse_sms(self, smsHtml): + splitSms = self._seperateVoicemailsRegex.split(smsHtml) + for messageId, messageHtml in itergroup(splitSms[1:], 2): + conv = Conversation() + conv.type = Conversation.TYPE_SMS + conv.id = messageId.strip() + + exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) + exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else "" + conv.time = google_strptime(exactTimeText) + relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) + conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" + conv.location = "" + + nameGroup = self._voicemailNameRegex.search(messageHtml) + conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "") + numberGroup = self._voicemailNumberRegex.search(messageHtml) + conv.number = numberGroup.group(1).strip() if numberGroup else "" + prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) + conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" + contactIdGroup = self._messagesContactIDRegex.search(messageHtml) + conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" + + fromGroups = self._smsFromRegex.finditer(messageHtml) + fromParts = (group.group(1).strip() for group in fromGroups) + textGroups = self._smsTextRegex.finditer(messageHtml) + textParts = (group.group(1).strip() for group in textGroups) + timeGroups = self._smsTimeRegex.finditer(messageHtml) + timeParts = (group.group(1).strip() for group in timeGroups) + + messageParts = itertools.izip(fromParts, textParts, timeParts) + messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts] + conv.messages = messages + + yield conv + + @staticmethod + def _merge_conversation_sources(parsedMessages, json): + for message in parsedMessages: + jsonItem = json["messages"][message.id] + message.isRead = jsonItem["isRead"] + message.isSpam = jsonItem["isSpam"] + message.isTrash = jsonItem["isTrash"] + message.isArchived = "inbox" not in jsonItem["labels"] + yield message + + def _get_page(self, url, data = None, refererUrl = None): + headers = {} + if refererUrl is not None: + headers["Referer"] = refererUrl + + encodedData = urllib.urlencode(data) if data is not None else None + + try: + page = self._browser.download(url, encodedData, None, headers) + except urllib2.URLError, e: + _moduleLogger.error("Translating error: %s" % str(e)) + raise NetworkError("%s is not accesible" % url) + + return page + + def _get_page_with_token(self, url, data = None, refererUrl = None): + if data is None: + data = {} + data['_rnr_se'] = self._token + + page = self._get_page(url, data, refererUrl) + + return page + + def _parse_with_validation(self, page): + json = parse_json(page) + self._validate_response(json) + return json + + def _validate_response(self, response): + """ + Validates that the JSON response is A-OK + """ + try: + assert response is not None, "Response not provided" + assert 'ok' in response, "Response lacks status" + assert response['ok'], "Response not good" + except AssertionError: + try: + if response["data"]["code"] == 20: + raise RuntimeError( +"""Ambiguous error 20 returned by Google Voice. +Please verify you have configured your callback number (currently "%s"). If it is configured some other suspected causes are: non-verified callback numbers, and Gizmo5 callback numbers.""" % self._callbackNumber) + except KeyError: + pass + raise RuntimeError('There was a problem with GV: %s' % response) + + +_UNESCAPE_ENTITIES = { + """: '"', + " ": " ", + "'": "'", +} + + +def unescape(text): + plain = saxutils.unescape(text, _UNESCAPE_ENTITIES) + return plain + + +def google_strptime(time): + """ + Hack: Google always returns the time in the same locale. Sadly if the + local system's locale is different, there isn't a way to perfectly handle + the time. So instead we handle implement some time formatting + """ + abbrevTime = time[:-3] + parsedTime = datetime.datetime.strptime(abbrevTime, "%m/%d/%y %I:%M") + if time.endswith("PM"): + parsedTime += datetime.timedelta(hours=12) + return parsedTime + + +def itergroup(iterator, count, padValue = None): + """ + Iterate in groups of 'count' values. If there + aren't enough values, the last result is padded with + None. + + >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): + ... print tuple(val) + (1, 2, 3) + (4, 5, 6) + >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): + ... print list(val) + [1, 2, 3] + [4, 5, 6] + >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3): + ... print tuple(val) + (1, 2, 3) + (4, 5, 6) + (7, None, None) + >>> for val in itergroup("123456", 3): + ... print tuple(val) + ('1', '2', '3') + ('4', '5', '6') + >>> for val in itergroup("123456", 3): + ... print repr("".join(val)) + '123' + '456' + """ + paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1)) + nIterators = (paddedIterator, ) * count + return itertools.izip(*nIterators) + + +def safe_eval(s): + _TRUE_REGEX = re.compile("true") + _FALSE_REGEX = re.compile("false") + _COMMENT_REGEX = re.compile("^\s+//.*$", re.M) + s = _TRUE_REGEX.sub("True", s) + s = _FALSE_REGEX.sub("False", s) + s = _COMMENT_REGEX.sub("#", s) + try: + results = eval(s, {}, {}) + except SyntaxError: + _moduleLogger.exception("Oops") + results = None + return results + + +def _fake_parse_json(flattened): + return safe_eval(flattened) + + +def _actual_parse_json(flattened): + return simplejson.loads(flattened) + + +if simplejson is None: + parse_json = _fake_parse_json +else: + parse_json = _actual_parse_json + + +def extract_payload(flatXml): + xmlTree = ElementTree.fromstring(flatXml) + + jsonElement = xmlTree.getchildren()[0] + flatJson = jsonElement.text + jsonTree = parse_json(flatJson) + + htmlElement = xmlTree.getchildren()[1] + flatHtml = htmlElement.text + + return jsonTree, flatHtml + + +def guess_phone_type(number): + if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"): + return GVoiceBackend.PHONE_TYPE_GIZMO + else: + return GVoiceBackend.PHONE_TYPE_MOBILE + + +def get_sane_callback(backend): + """ + Try to set a sane default callback number on these preferences + 1) 1747 numbers ( Gizmo ) + 2) anything with gizmo in the name + 3) anything with computer in the name + 4) the first value + """ + numbers = backend.get_callback_numbers() + + priorityOrderedCriteria = [ + ("\+1747", None), + ("1747", None), + ("747", None), + (None, "gizmo"), + (None, "computer"), + (None, "sip"), + (None, None), + ] + + for numberCriteria, descriptionCriteria in priorityOrderedCriteria: + numberMatcher = None + descriptionMatcher = None + if numberCriteria is not None: + numberMatcher = re.compile(numberCriteria) + elif descriptionCriteria is not None: + descriptionMatcher = re.compile(descriptionCriteria, re.I) + + for number, description in numbers.iteritems(): + if numberMatcher is not None and numberMatcher.match(number) is None: + continue + if descriptionMatcher is not None and descriptionMatcher.match(description) is None: + continue + return number + + +def set_sane_callback(backend): + """ + Try to set a sane default callback number on these preferences + 1) 1747 numbers ( Gizmo ) + 2) anything with gizmo in the name + 3) anything with computer in the name + 4) the first value + """ + number = get_sane_callback(backend) + backend.set_callback_number(number) + + +def _is_not_special(name): + return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name + + +def to_dict(obj): + members = inspect.getmembers(obj) + return dict((name, value) for (name, value) in members if _is_not_special(name)) + + +def grab_debug_info(username, password): + cookieFile = os.path.join(".", "raw_cookies.txt") + try: + os.remove(cookieFile) + except OSError: + pass + + backend = GVoiceBackend(cookieFile) + browser = backend._browser + + _TEST_WEBPAGES = [ + ("token", backend._tokenURL), + ("login", backend._loginURL), + ("isdnd", backend._isDndURL), + ("account", backend._XML_ACCOUNT_URL), + ("html_contacts", backend._XML_CONTACTS_URL), + ("contacts", backend._JSON_CONTACTS_URL), + ("csv", backend._CSV_CONTACTS_URL), + + ("voicemail", backend._XML_VOICEMAIL_URL), + ("html_sms", backend._XML_SMS_URL), + ("sms", backend._JSON_SMS_URL), + ("count", backend._JSON_SMS_COUNT_URL), + + ("recent", backend._XML_RECENT_URL), + ("placed", backend._XML_PLACED_URL), + ("recieved", backend._XML_RECEIVED_URL), + ("missed", backend._XML_MISSED_URL), + ] + + # Get Pages + print "Grabbing pre-login pages" + for name, url in _TEST_WEBPAGES: + try: + page = browser.download(url) + except StandardError, e: + print e.message + continue + print "\tWriting to file" + with open("not_loggedin_%s.txt" % name, "w") as f: + f.write(page) + + # Login + print "Attempting login" + galxToken = backend._get_token() + loginSuccessOrFailurePage = backend._login(username, password, galxToken) + with open("loggingin.txt", "w") as f: + print "\tWriting to file" + f.write(loginSuccessOrFailurePage) + try: + backend._grab_account_info(loginSuccessOrFailurePage) + except Exception: + # Retry in case the redirect failed + # luckily refresh_account_info does everything we need for a retry + loggedIn = backend.refresh_account_info() is not None + if not loggedIn: + raise + + # Get Pages + print "Grabbing post-login pages" + for name, url in _TEST_WEBPAGES: + try: + page = browser.download(url) + except StandardError, e: + print str(e) + continue + print "\tWriting to file" + with open("loggedin_%s.txt" % name, "w") as f: + f.write(page) + + # Cookies + browser.save_cookies() + print "\tWriting cookies to file" + with open("cookies.txt", "w") as f: + f.writelines( + "%s: %s\n" % (c.name, c.value) + for c in browser._cookies + ) + + +def grab_voicemails(username, password): + cookieFile = os.path.join(".", "raw_cookies.txt") + try: + os.remove(cookieFile) + except OSError: + pass + + backend = GVoiceBackend(cookieFile) + backend.login(username, password) + voicemails = list(backend.get_voicemails()) + for voicemail in voicemails: + print voicemail.id + backend.download(voicemail.id, ".") + + +def main(): + import sys + logging.basicConfig(level=logging.DEBUG) + args = sys.argv + if 3 <= len(args): + username = args[1] + password = args[2] + + grab_debug_info(username, password) + grab_voicemails(username, password) + + +if __name__ == "__main__": + main() diff --git a/dialcentral/backends/null_backend.py b/dialcentral/backends/null_backend.py new file mode 100644 index 0000000..ebaa932 --- /dev/null +++ b/dialcentral/backends/null_backend.py @@ -0,0 +1,39 @@ +#!/usr/bin/python + +""" +DialCentral - Front end for Google's Grand Central service. +Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library 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 +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + + +class NullAddressBook(object): + + @property + def name(self): + return "None" + + def update_account(self, force = True): + pass + + def get_contacts(self): + return {} + + +class NullAddressBookFactory(object): + + def get_addressbooks(self): + yield NullAddressBook() diff --git a/dialcentral/backends/qt_backend.py b/dialcentral/backends/qt_backend.py new file mode 100644 index 0000000..88e52fa --- /dev/null +++ b/dialcentral/backends/qt_backend.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import logging + +import util.qt_compat as qt_compat +if qt_compat.USES_PYSIDE: + try: + import QtMobility.Contacts as _QtContacts + QtContacts = _QtContacts + except ImportError: + QtContacts = None +else: + QtContacts = None + +import null_backend + + +_moduleLogger = logging.getLogger(__name__) + + +class QtContactsAddressBook(object): + + def __init__(self, name, uri): + self._name = name + self._uri = uri + self._manager = QtContacts.QContactManager.fromUri(uri) + self._contacts = None + + @property + def name(self): + return self._name + + @property + def error(self): + return self._manager.error() + + def update_account(self, force = True): + if not force and self._contacts is not None: + return + self._contacts = dict(self._get_contacts()) + + def get_contacts(self): + if self._contacts is None: + self._contacts = dict(self._get_contacts()) + return self._contacts + + def _get_contacts(self): + contacts = self._manager.contacts() + for contact in contacts: + contactId = contact.localId() + contactName = contact.displayLabel() + phoneDetails = contact.details(QtContacts.QContactPhoneNumber().DefinitionName) + phones = [{"phoneType": "Phone", "phoneNumber": phone.value(QtContacts.QContactPhoneNumber().FieldNumber)} for phone in phoneDetails] + contactDetails = phones + if 0 < len(contactDetails): + yield str(contactId), { + "contactId": str(contactId), + "name": contactName, + "numbers": contactDetails, + } + + +class _QtContactsAddressBookFactory(object): + + def __init__(self): + self._availableManagers = {} + + availableMgrs = QtContacts.QContactManager.availableManagers() + availableMgrs.remove("invalid") + for managerName in availableMgrs: + params = {} + managerUri = QtContacts.QContactManager.buildUri(managerName, params) + self._availableManagers[managerName] = managerUri + + def get_addressbooks(self): + for name, uri in self._availableManagers.iteritems(): + book = QtContactsAddressBook(name, uri) + if book.error: + _moduleLogger.info("Could not load %r due to %r" % (name, book.error)) + else: + yield book + + +class _EmptyAddressBookFactory(object): + + def get_addressbooks(self): + if False: + yield None + + +if QtContacts is not None: + QtContactsAddressBookFactory = _QtContactsAddressBookFactory +else: + QtContactsAddressBookFactory = _EmptyAddressBookFactory + _moduleLogger.info("QtContacts support not available") + + +if __name__ == "__main__": + factory = QtContactsAddressBookFactory() + books = factory.get_addressbooks() + for book in books: + print book.name + print book.get_contacts() diff --git a/dialcentral/call_handler.py b/dialcentral/call_handler.py new file mode 100644 index 0000000..9b9c47d --- /dev/null +++ b/dialcentral/call_handler.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import logging + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore +import dbus +try: + import telepathy as _telepathy + import util.tp_utils as telepathy_utils + telepathy = _telepathy +except ImportError: + telepathy = None + +import util.misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class _FakeSignaller(object): + + def start(self): + pass + + def stop(self): + pass + + +class _MissedCallWatcher(QtCore.QObject): + + callMissed = qt_compat.Signal() + + def __init__(self): + QtCore.QObject.__init__(self) + self._isStarted = False + self._isSupported = True + + self._newChannelSignaller = telepathy_utils.NewChannelSignaller(self._on_new_channel) + self._outstandingRequests = [] + + @property + def isSupported(self): + return self._isSupported + + @property + def isStarted(self): + return self._isStarted + + def start(self): + if self._isStarted: + _moduleLogger.info("voicemail monitor already started") + return + try: + self._newChannelSignaller.start() + except RuntimeError: + _moduleLogger.exception("Missed call detection not supported") + self._newChannelSignaller = _FakeSignaller() + self._isSupported = False + self._isStarted = True + + def stop(self): + if not self._isStarted: + _moduleLogger.info("voicemail monitor stopped without starting") + return + _moduleLogger.info("Stopping voicemail refresh") + self._newChannelSignaller.stop() + + # I don't want to trust whether the cancel happens within the current + # callback or not which could be the deciding factor between invalid + # iterators or infinite loops + localRequests = [r for r in self._outstandingRequests] + for request in localRequests: + localRequests.cancel() + + self._isStarted = False + + @misc_utils.log_exception(_moduleLogger) + def _on_new_channel(self, bus, serviceName, connObjectPath, channelObjectPath, channelType): + if channelType != telepathy.interfaces.CHANNEL_TYPE_STREAMED_MEDIA: + return + + conn = telepathy.client.Connection(serviceName, connObjectPath) + try: + chan = telepathy.client.Channel(serviceName, channelObjectPath) + except dbus.exceptions.UnknownMethodException: + _moduleLogger.exception("Client might not have implemented a deprecated method") + return + missDetection = telepathy_utils.WasMissedCall( + bus, conn, chan, self._on_missed_call, self._on_error_for_missed + ) + self._outstandingRequests.append(missDetection) + + @misc_utils.log_exception(_moduleLogger) + def _on_missed_call(self, missDetection): + _moduleLogger.info("Missed a call") + self.callMissed.emit() + self._outstandingRequests.remove(missDetection) + + @misc_utils.log_exception(_moduleLogger) + def _on_error_for_missed(self, missDetection, reason): + _moduleLogger.debug("Error: %r claims %r" % (missDetection, reason)) + self._outstandingRequests.remove(missDetection) + + +class _DummyMissedCallWatcher(QtCore.QObject): + + callMissed = qt_compat.Signal() + + def __init__(self): + QtCore.QObject.__init__(self) + self._isStarted = False + + @property + def isSupported(self): + return False + + @property + def isStarted(self): + return self._isStarted + + def start(self): + self._isStarted = True + + def stop(self): + if not self._isStarted: + _moduleLogger.info("voicemail monitor stopped without starting") + return + _moduleLogger.info("Stopping voicemail refresh") + self._isStarted = False + + +if telepathy is not None: + MissedCallWatcher = _MissedCallWatcher +else: + MissedCallWatcher = _DummyMissedCallWatcher + + +if __name__ == "__main__": + pass + diff --git a/dialcentral/constants.py b/dialcentral/constants.py new file mode 100644 index 0000000..b9d3c79 --- /dev/null +++ b/dialcentral/constants.py @@ -0,0 +1,13 @@ +import os + +__pretty_app_name__ = "DialCentral" +__app_name__ = "dialcentral" +__version__ = "1.3.6" +__build__ = 0 +__app_magic__ = 0xdeadbeef +_data_path_ = os.path.join(os.path.expanduser("~"), ".%s" % __app_name__) +_user_settings_ = "%s/settings.ini" % _data_path_ +_custom_notifier_settings_ = "%s/notifier.ini" % _data_path_ +_user_logpath_ = "%s/%s.log" % (_data_path_, __app_name__) +_notifier_logpath_ = "%s/notifier.log" % _data_path_ +IS_MAEMO = True diff --git a/dialcentral/dialcentral_qt.py b/dialcentral/dialcentral_qt.py new file mode 100755 index 0000000..a464ad6 --- /dev/null +++ b/dialcentral/dialcentral_qt.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python +# -*- coding: UTF8 -*- + +from __future__ import with_statement + +import os +import base64 +import ConfigParser +import functools +import logging +import logging.handlers + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +import constants +import alarm_handler +from util import qtpie +from util import qwrappers +from util import qui_utils +from util import misc as misc_utils + +import session + + +_moduleLogger = logging.getLogger(__name__) + + +class Dialcentral(qwrappers.ApplicationWrapper): + + _DATA_PATHS = [ + os.path.join(os.path.dirname(__file__), "../share"), + os.path.join(os.path.dirname(__file__), "../data"), + ] + + def __init__(self, app): + self._dataPath = None + self._aboutDialog = None + self.notifyOnMissed = False + self.notifyOnVoicemail = False + self.notifyOnSms = False + + self._streamHandler = None + self._ledHandler = None + self._alarmHandler = alarm_handler.AlarmHandler() + + qwrappers.ApplicationWrapper.__init__(self, app, constants) + + def load_settings(self): + try: + config = ConfigParser.SafeConfigParser() + config.read(constants._user_settings_) + except IOError, e: + _moduleLogger.info("No settings") + return + except ValueError: + _moduleLogger.info("Settings were corrupt") + return + except ConfigParser.MissingSectionHeaderError: + _moduleLogger.info("Settings were corrupt") + return + except Exception: + _moduleLogger.exception("Unknown loading error") + + self._mainWindow.load_settings(config) + + def save_settings(self): + _moduleLogger.info("Saving settings") + config = ConfigParser.SafeConfigParser() + + self._mainWindow.save_settings(config) + + with open(constants._user_settings_, "wb") as configFile: + config.write(configFile) + + def get_icon(self, name): + if self._dataPath is None: + for path in self._DATA_PATHS: + if os.path.exists(os.path.join(path, name)): + self._dataPath = path + break + if self._dataPath is not None: + icon = QtGui.QIcon(os.path.join(self._dataPath, name)) + return icon + else: + return None + + def get_resource(self, name): + if self._dataPath is None: + for path in self._DATA_PATHS: + if os.path.exists(os.path.join(path, name)): + self._dataPath = path + break + if self._dataPath is not None: + return os.path.join(self._dataPath, name) + else: + return None + + def _close_windows(self): + qwrappers.ApplicationWrapper._close_windows(self) + if self._aboutDialog is not None: + self._aboutDialog.close() + + @property + def fsContactsPath(self): + return os.path.join(constants._data_path_, "contacts") + + @property + def streamHandler(self): + if self._streamHandler is None: + import stream_handler + self._streamHandler = stream_handler.StreamHandler() + return self._streamHandler + + @property + def alarmHandler(self): + return self._alarmHandler + + @property + def ledHandler(self): + if self._ledHandler is None: + import led_handler + self._ledHandler = led_handler.LedHandler() + return self._ledHandler + + def _new_main_window(self): + return MainWindow(None, self) + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_about(self, checked = True): + with qui_utils.notify_error(self._errorLog): + if self._aboutDialog is None: + import dialogs + self._aboutDialog = dialogs.AboutDialog(self) + response = self._aboutDialog.run(self._mainWindow.window) + + +class DelayedWidget(object): + + def __init__(self, app, settingsNames): + self._layout = QtGui.QVBoxLayout() + self._layout.setContentsMargins(0, 0, 0, 0) + self._widget = QtGui.QWidget() + self._widget.setContentsMargins(0, 0, 0, 0) + self._widget.setLayout(self._layout) + self._settings = dict((name, "") for name in settingsNames) + + self._child = None + self._isEnabled = True + + @property + def toplevel(self): + return self._widget + + def has_child(self): + return self._child is not None + + def set_child(self, child): + if self._child is not None: + self._layout.removeWidget(self._child.toplevel) + self._child = child + if self._child is not None: + self._layout.addWidget(self._child.toplevel) + + self._child.set_settings(self._settings) + + if self._isEnabled: + self._child.enable() + else: + self._child.disable() + + @property + def child(self): + return self._child + + def enable(self): + self._isEnabled = True + if self._child is not None: + self._child.enable() + + def disable(self): + self._isEnabled = False + if self._child is not None: + self._child.disable() + + def clear(self): + if self._child is not None: + self._child.clear() + + def refresh(self, force=True): + if self._child is not None: + self._child.refresh(force) + + def get_settings(self): + if self._child is not None: + return self._child.get_settings() + else: + return self._settings + + def set_settings(self, settings): + if self._child is not None: + self._child.set_settings(settings) + else: + self._settings = settings + + +def _tab_factory(tab, app, session, errorLog): + import gv_views + return gv_views.__dict__[tab](app, session, errorLog) + + +class MainWindow(qwrappers.WindowWrapper): + + KEYPAD_TAB = 0 + RECENT_TAB = 1 + MESSAGES_TAB = 2 + CONTACTS_TAB = 3 + MAX_TABS = 4 + + _TAB_TITLES = [ + "Dialpad", + "History", + "Messages", + "Contacts", + ] + assert len(_TAB_TITLES) == MAX_TABS + + _TAB_ICONS = [ + "dialpad.png", + "history.png", + "messages.png", + "contacts.png", + ] + assert len(_TAB_ICONS) == MAX_TABS + + _TAB_CLASS = [ + functools.partial(_tab_factory, "Dialpad"), + functools.partial(_tab_factory, "History"), + functools.partial(_tab_factory, "Messages"), + functools.partial(_tab_factory, "Contacts"), + ] + assert len(_TAB_CLASS) == MAX_TABS + + # Hack to allow delay importing/loading of tabs + _TAB_SETTINGS_NAMES = [ + (), + ("filter", ), + ("status", "type"), + ("selectedAddressbook", ), + ] + assert len(_TAB_SETTINGS_NAMES) == MAX_TABS + + def __init__(self, parent, app): + qwrappers.WindowWrapper.__init__(self, parent, app) + self._window.setWindowTitle("%s" % constants.__pretty_app_name__) + self._window.resized.connect(self._on_window_resized) + self._errorLog = self._app.errorLog + + self._session = session.Session(self._errorLog, constants._data_path_) + self._session.error.connect(self._on_session_error) + self._session.loggedIn.connect(self._on_login) + self._session.loggedOut.connect(self._on_logout) + self._session.draft.recipientsChanged.connect(self._on_recipients_changed) + self._session.newMessages.connect(self._on_new_message_alert) + self._app.alarmHandler.applicationNotifySignal.connect(self._on_app_alert) + self._voicemailRefreshDelay = QtCore.QTimer() + self._voicemailRefreshDelay.setInterval(30 * 1000) + self._voicemailRefreshDelay.timeout.connect(self._on_call_missed) + self._voicemailRefreshDelay.setSingleShot(True) + self._callHandler = None + self._updateVoicemailOnMissedCall = False + + self._defaultCredentials = "", "" + self._curentCredentials = "", "" + self._currentTab = 0 + + self._credentialsDialog = None + self._smsEntryDialog = None + self._accountDialog = None + + self._tabsContents = [ + DelayedWidget(self._app, self._TAB_SETTINGS_NAMES[i]) + for i in xrange(self.MAX_TABS) + ] + for tab in self._tabsContents: + tab.disable() + + self._tabWidget = QtGui.QTabWidget() + if qui_utils.screen_orientation() == QtCore.Qt.Vertical: + self._tabWidget.setTabPosition(QtGui.QTabWidget.South) + else: + self._tabWidget.setTabPosition(QtGui.QTabWidget.West) + defaultTabIconSize = self._tabWidget.iconSize() + defaultTabIconWidth, defaultTabIconHeight = defaultTabIconSize.width(), defaultTabIconSize.height() + for tabIndex, (tabTitle, tabIcon) in enumerate( + zip(self._TAB_TITLES, self._TAB_ICONS) + ): + icon = self._app.get_icon(tabIcon) + if constants.IS_MAEMO and icon is not None: + tabTitle = "" + + if icon is None: + self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, tabTitle) + else: + iconSize = icon.availableSizes()[0] + defaultTabIconWidth = max(defaultTabIconWidth, iconSize.width()) + defaultTabIconHeight = max(defaultTabIconHeight, iconSize.height()) + self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, icon, tabTitle) + defaultTabIconWidth = max(defaultTabIconWidth, 32) + defaultTabIconHeight = max(defaultTabIconHeight, 32) + self._tabWidget.setIconSize(QtCore.QSize(defaultTabIconWidth, defaultTabIconHeight)) + self._tabWidget.currentChanged.connect(self._on_tab_changed) + self._tabWidget.setContentsMargins(0, 0, 0, 0) + + self._layout.addWidget(self._tabWidget) + + self._loginAction = QtGui.QAction(None) + self._loginAction.setText("Login") + self._loginAction.triggered.connect(self._on_login_requested) + + self._importAction = QtGui.QAction(None) + self._importAction.setText("Import") + self._importAction.triggered.connect(self._on_import) + + self._accountAction = QtGui.QAction(None) + self._accountAction.setText("Account") + self._accountAction.triggered.connect(self._on_account) + + self._refreshConnectionAction = QtGui.QAction(None) + self._refreshConnectionAction.setText("Refresh Connection") + self._refreshConnectionAction.setShortcut(QtGui.QKeySequence("CTRL+a")) + self._refreshConnectionAction.triggered.connect(self._on_refresh_connection) + + self._refreshTabAction = QtGui.QAction(None) + self._refreshTabAction.setText("Refresh Tab") + self._refreshTabAction.setShortcut(QtGui.QKeySequence("CTRL+r")) + self._refreshTabAction.triggered.connect(self._on_refresh) + + fileMenu = self._window.menuBar().addMenu("&File") + fileMenu.addAction(self._loginAction) + fileMenu.addAction(self._refreshTabAction) + fileMenu.addAction(self._refreshConnectionAction) + + toolsMenu = self._window.menuBar().addMenu("&Tools") + toolsMenu.addAction(self._accountAction) + toolsMenu.addAction(self._importAction) + toolsMenu.addAction(self._app.aboutAction) + + self._initialize_tab(self._tabWidget.currentIndex()) + self.set_fullscreen(self._app.fullscreenAction.isChecked()) + self.update_orientation(self._app.orientation) + + def _init_call_handler(self): + if self._callHandler is not None: + return + import call_handler + self._callHandler = call_handler.MissedCallWatcher() + self._callHandler.callMissed.connect(self._voicemailRefreshDelay.start) + + def set_default_credentials(self, username, password): + self._defaultCredentials = username, password + + def get_default_credentials(self): + return self._defaultCredentials + + def walk_children(self): + if self._smsEntryDialog is not None: + return (self._smsEntryDialog, ) + else: + return () + + def start(self): + qwrappers.WindowWrapper.start(self) + assert self._session.state == self._session.LOGGEDOUT_STATE, "Initialization messed up" + if self._defaultCredentials != ("", ""): + username, password = self._defaultCredentials[0], self._defaultCredentials[1] + self._curentCredentials = username, password + self._session.login(username, password) + else: + self._prompt_for_login() + + def close(self): + for diag in ( + self._credentialsDialog, + self._accountDialog, + ): + if diag is not None: + diag.close() + for child in self.walk_children(): + child.window.destroyed.disconnect(self._on_child_close) + child.window.closed.disconnect(self._on_child_close) + child.close() + self._window.close() + + def destroy(self): + qwrappers.WindowWrapper.destroy(self) + if self._session.state != self._session.LOGGEDOUT_STATE: + self._session.logout() + + def get_current_tab(self): + return self._currentTab + + def set_current_tab(self, tabIndex): + self._tabWidget.setCurrentIndex(tabIndex) + + def load_settings(self, config): + blobs = "", "" + isFullscreen = False + orientation = self._app.orientation + tabIndex = 0 + try: + blobs = [ + config.get(constants.__pretty_app_name__, "bin_blob_%i" % i) + for i in xrange(len(self.get_default_credentials())) + ] + isFullscreen = config.getboolean(constants.__pretty_app_name__, "fullscreen") + tabIndex = config.getint(constants.__pretty_app_name__, "tab") + orientation = config.get(constants.__pretty_app_name__, "orientation") + except ConfigParser.NoOptionError, e: + _moduleLogger.info( + "Settings file %s is missing option %s" % ( + constants._user_settings_, + e.option, + ), + ) + except ConfigParser.NoSectionError, e: + _moduleLogger.info( + "Settings file %s is missing section %s" % ( + constants._user_settings_, + e.section, + ), + ) + except Exception: + _moduleLogger.exception("Unknown loading error") + + try: + self._app.alarmHandler.load_settings(config, "alarm") + self._app.notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed") + self._app.notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail") + self._app.notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms") + self._updateVoicemailOnMissedCall = config.getboolean("2 - Account Info", "updateVoicemailOnMissedCall") + except ConfigParser.NoOptionError, e: + _moduleLogger.info( + "Settings file %s is missing option %s" % ( + constants._user_settings_, + e.option, + ), + ) + except ConfigParser.NoSectionError, e: + _moduleLogger.info( + "Settings file %s is missing section %s" % ( + constants._user_settings_, + e.section, + ), + ) + except Exception: + _moduleLogger.exception("Unknown loading error") + + creds = ( + base64.b64decode(blob) + for blob in blobs + ) + self.set_default_credentials(*creds) + self._app.fullscreenAction.setChecked(isFullscreen) + self._app.set_orientation(orientation) + self.set_current_tab(tabIndex) + + backendId = 2 # For backwards compatibility + for tabIndex, tabTitle in enumerate(self._TAB_TITLES): + sectionName = "%s - %s" % (backendId, tabTitle) + settings = self._tabsContents[tabIndex].get_settings() + for settingName in settings.iterkeys(): + try: + settingValue = config.get(sectionName, settingName) + except ConfigParser.NoOptionError, e: + _moduleLogger.info( + "Settings file %s is missing section %s" % ( + constants._user_settings_, + e.section, + ), + ) + return + except ConfigParser.NoSectionError, e: + _moduleLogger.info( + "Settings file %s is missing section %s" % ( + constants._user_settings_, + e.section, + ), + ) + return + except Exception: + _moduleLogger.exception("Unknown loading error") + return + settings[settingName] = settingValue + self._tabsContents[tabIndex].set_settings(settings) + + def save_settings(self, config): + config.add_section(constants.__pretty_app_name__) + config.set(constants.__pretty_app_name__, "tab", str(self.get_current_tab())) + config.set(constants.__pretty_app_name__, "fullscreen", str(self._app.fullscreenAction.isChecked())) + config.set(constants.__pretty_app_name__, "orientation", str(self._app.orientation)) + for i, value in enumerate(self.get_default_credentials()): + blob = base64.b64encode(value) + config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob) + + config.add_section("alarm") + self._app.alarmHandler.save_settings(config, "alarm") + config.add_section("2 - Account Info") + config.set("2 - Account Info", "notifyOnMissed", repr(self._app.notifyOnMissed)) + config.set("2 - Account Info", "notifyOnVoicemail", repr(self._app.notifyOnVoicemail)) + config.set("2 - Account Info", "notifyOnSms", repr(self._app.notifyOnSms)) + config.set("2 - Account Info", "updateVoicemailOnMissedCall", repr(self._updateVoicemailOnMissedCall)) + + backendId = 2 # For backwards compatibility + for tabIndex, tabTitle in enumerate(self._TAB_TITLES): + sectionName = "%s - %s" % (backendId, tabTitle) + config.add_section(sectionName) + tabSettings = self._tabsContents[tabIndex].get_settings() + for settingName, settingValue in tabSettings.iteritems(): + config.set(sectionName, settingName, settingValue) + + def update_orientation(self, orientation): + qwrappers.WindowWrapper.update_orientation(self, orientation) + windowOrientation = self.idealWindowOrientation + if windowOrientation == QtCore.Qt.Horizontal: + self._tabWidget.setTabPosition(QtGui.QTabWidget.West) + else: + self._tabWidget.setTabPosition(QtGui.QTabWidget.South) + + def _initialize_tab(self, index): + assert index < self.MAX_TABS, "Invalid tab" + if not self._tabsContents[index].has_child(): + tab = self._TAB_CLASS[index](self._app, self._session, self._errorLog) + self._tabsContents[index].set_child(tab) + self._tabsContents[index].refresh(force=False) + + def _prompt_for_login(self): + if self._credentialsDialog is None: + import dialogs + self._credentialsDialog = dialogs.CredentialsDialog(self._app) + credentials = self._credentialsDialog.run( + self._defaultCredentials[0], self._defaultCredentials[1], self.window + ) + if credentials is None: + return + username, password = credentials + self._curentCredentials = username, password + self._session.login(username, password) + + def _show_account_dialog(self): + if self._accountDialog is None: + import dialogs + self._accountDialog = dialogs.AccountDialog(self._window, self._app, self._app.errorLog) + self._accountDialog.setIfNotificationsSupported(self._app.alarmHandler.backgroundNotificationsSupported) + self._accountDialog.settingsApproved.connect(self._on_settings_approved) + + if self._callHandler is not None and not self._callHandler.isSupported: + self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED + elif self._updateVoicemailOnMissedCall: + self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_ENABLED + else: + self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_DISABLED + self._accountDialog.notifications = self._app.alarmHandler.alarmType + self._accountDialog.notificationTime = self._app.alarmHandler.recurrence + self._accountDialog.notifyOnMissed = self._app.notifyOnMissed + self._accountDialog.notifyOnVoicemail = self._app.notifyOnVoicemail + self._accountDialog.notifyOnSms = self._app.notifyOnSms + self._accountDialog.set_callbacks( + self._session.get_callback_numbers(), self._session.get_callback_number() + ) + accountNumberToDisplay = self._session.get_account_number() + if not accountNumberToDisplay: + accountNumberToDisplay = "Not Available (%s)" % self._session.state + self._accountDialog.set_account_number(accountNumberToDisplay) + self._accountDialog.orientation = self._app.orientation + + self._accountDialog.run() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_settings_approved(self): + if self._accountDialog.doClear: + self._session.logout_and_clear() + self._defaultCredentials = "", "" + self._curentCredentials = "", "" + for tab in self._tabsContents: + tab.disable() + else: + callbackNumber = self._accountDialog.selectedCallback + self._session.set_callback_number(callbackNumber) + + if self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED: + pass + elif self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_ENABLED: + self._updateVoicemailOnMissedCall = True + self._init_call_handler() + self._callHandler.start() + else: + self._updateVoicemailOnMissedCall = False + if self._callHandler is not None: + self._callHandler.stop() + if ( + self._accountDialog.notifyOnMissed or + self._accountDialog.notifyOnVoicemail or + self._accountDialog.notifyOnSms + ): + notifications = self._accountDialog.notifications + else: + notifications = self._accountDialog.ALARM_NONE + self._app.alarmHandler.apply_settings(notifications, self._accountDialog.notificationTime) + + self._app.notifyOnMissed = self._accountDialog.notifyOnMissed + self._app.notifyOnVoicemail = self._accountDialog.notifyOnVoicemail + self._app.notifyOnSms = self._accountDialog.notifyOnSms + self._app.set_orientation(self._accountDialog.orientation) + self._app.save_settings() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_window_resized(self): + with qui_utils.notify_error(self._app.errorLog): + windowOrientation = self.idealWindowOrientation + if windowOrientation == QtCore.Qt.Horizontal: + self._tabWidget.setTabPosition(QtGui.QTabWidget.West) + else: + self._tabWidget.setTabPosition(QtGui.QTabWidget.South) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_new_message_alert(self): + with qui_utils.notify_error(self._errorLog): + if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION: + if self._currentTab == self.MESSAGES_TAB or not self._app.ledHandler.isReal: + self._errorLog.push_message("New messages") + else: + self._app.ledHandler.on() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_call_missed(self): + with qui_utils.notify_error(self._errorLog): + self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force=True) + + @qt_compat.Slot(str) + @misc_utils.log_exception(_moduleLogger) + def _on_session_error(self, message): + with qui_utils.notify_error(self._errorLog): + self._errorLog.push_error(message) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_login(self): + with qui_utils.notify_error(self._errorLog): + changedAccounts = self._defaultCredentials != self._curentCredentials + noCallback = not self._session.get_callback_number() + if changedAccounts or noCallback: + self._show_account_dialog() + + self._defaultCredentials = self._curentCredentials + + for tab in self._tabsContents: + tab.enable() + self._initialize_tab(self._currentTab) + if self._updateVoicemailOnMissedCall: + self._init_call_handler() + self._callHandler.start() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_logout(self): + with qui_utils.notify_error(self._errorLog): + for tab in self._tabsContents: + tab.disable() + if self._callHandler is not None: + self._callHandler.stop() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_app_alert(self): + with qui_utils.notify_error(self._errorLog): + if self._session.state == self._session.LOGGEDIN_STATE: + messageType = { + (True, True): self._session.MESSAGE_ALL, + (True, False): self._session.MESSAGE_TEXTS, + (False, True): self._session.MESSAGE_VOICEMAILS, + }[(self._app.notifyOnSms, self._app.notifyOnVoicemail)] + self._session.update_messages(messageType, force=True) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_recipients_changed(self): + with qui_utils.notify_error(self._errorLog): + if self._session.draft.get_num_contacts() == 0: + return + + if self._smsEntryDialog is None: + import dialogs + self._smsEntryDialog = dialogs.SMSEntryWindow(self.window, self._app, self._session, self._errorLog) + self._smsEntryDialog.window.destroyed.connect(self._on_child_close) + self._smsEntryDialog.window.closed.connect(self._on_child_close) + self._smsEntryDialog.window.show() + + @misc_utils.log_exception(_moduleLogger) + def _on_child_close(self, obj = None): + self._smsEntryDialog = None + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_login_requested(self, checked = True): + with qui_utils.notify_error(self._errorLog): + self._prompt_for_login() + + @qt_compat.Slot(int) + @misc_utils.log_exception(_moduleLogger) + def _on_tab_changed(self, index): + with qui_utils.notify_error(self._errorLog): + self._currentTab = index + self._initialize_tab(index) + if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION: + self._app.ledHandler.off() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_refresh(self, checked = True): + with qui_utils.notify_error(self._errorLog): + self._tabsContents[self._currentTab].refresh(force=True) + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_refresh_connection(self, checked = True): + with qui_utils.notify_error(self._errorLog): + self._session.refresh_connection() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_import(self, checked = True): + with qui_utils.notify_error(self._errorLog): + csvName = QtGui.QFileDialog.getOpenFileName(self._window, caption="Import", filter="CSV Files (*.csv)") + csvName = unicode(csvName) + if not csvName: + return + import shutil + shutil.copy2(csvName, self._app.fsContactsPath) + if self._tabsContents[self.CONTACTS_TAB].has_child: + self._tabsContents[self.CONTACTS_TAB].child.update_addressbooks() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_account(self, checked = True): + with qui_utils.notify_error(self._errorLog): + assert self._session.state == self._session.LOGGEDIN_STATE, "Must be logged in for settings" + self._show_account_dialog() + + +def run(): + try: + os.makedirs(constants._data_path_) + except OSError, e: + if e.errno != 17: + raise + + logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' + logging.basicConfig(level=logging.DEBUG, format=logFormat) + rotating = logging.handlers.RotatingFileHandler(constants._user_logpath_, maxBytes=512*1024, backupCount=1) + rotating.setFormatter(logging.Formatter(logFormat)) + root = logging.getLogger() + root.addHandler(rotating) + _moduleLogger.info("%s %s-%s" % (constants.__app_name__, constants.__version__, constants.__build__)) + _moduleLogger.info("OS: %s" % (os.uname()[0], )) + _moduleLogger.info("Kernel: %s (%s) for %s" % os.uname()[2:]) + _moduleLogger.info("Hostname: %s" % os.uname()[1]) + + try: + import gobject + gobject.threads_init() + except ImportError: + _moduleLogger.info("GObject support not available") + try: + import dbus + try: + from dbus.mainloop.qt import DBusQtMainLoop + DBusQtMainLoop(set_as_default=True) + _moduleLogger.info("Using Qt mainloop") + except ImportError: + try: + from dbus.mainloop.glib import DBusGMainLoop + DBusGMainLoop(set_as_default=True) + _moduleLogger.info("Using GObject mainloop") + except ImportError: + _moduleLogger.info("Mainloop not available") + except ImportError: + _moduleLogger.info("DBus support not available") + + app = QtGui.QApplication([]) + handle = Dialcentral(app) + qtpie.init_pies() + return app.exec_() + + +if __name__ == "__main__": + import sys + + val = run() + sys.exit(val) diff --git a/dialcentral/dialogs.py b/dialcentral/dialogs.py new file mode 100644 index 0000000..8fbf328 --- /dev/null +++ b/dialcentral/dialogs.py @@ -0,0 +1,1192 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import functools +import copy +import logging + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +import constants +from util import qwrappers +from util import qui_utils +from util import misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class CredentialsDialog(object): + + def __init__(self, app): + self._app = app + self._usernameField = QtGui.QLineEdit() + self._passwordField = QtGui.QLineEdit() + self._passwordField.setEchoMode(QtGui.QLineEdit.Password) + + self._credLayout = QtGui.QGridLayout() + self._credLayout.addWidget(QtGui.QLabel("Username"), 0, 0) + self._credLayout.addWidget(self._usernameField, 0, 1) + self._credLayout.addWidget(QtGui.QLabel("Password"), 1, 0) + self._credLayout.addWidget(self._passwordField, 1, 1) + + self._loginButton = QtGui.QPushButton("&Login") + self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel) + self._buttonLayout.addButton(self._loginButton, QtGui.QDialogButtonBox.AcceptRole) + + self._layout = QtGui.QVBoxLayout() + self._layout.addLayout(self._credLayout) + self._layout.addWidget(self._buttonLayout) + + self._dialog = QtGui.QDialog() + self._dialog.setWindowTitle("Login") + self._dialog.setLayout(self._layout) + self._dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + self._buttonLayout.accepted.connect(self._dialog.accept) + self._buttonLayout.rejected.connect(self._dialog.reject) + + self._closeWindowAction = QtGui.QAction(None) + self._closeWindowAction.setText("Close") + self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) + self._closeWindowAction.triggered.connect(self._on_close_window) + + self._dialog.addAction(self._closeWindowAction) + self._dialog.addAction(app.quitAction) + self._dialog.addAction(app.fullscreenAction) + + def run(self, defaultUsername, defaultPassword, parent=None): + self._dialog.setParent(parent, QtCore.Qt.Dialog) + try: + self._usernameField.setText(defaultUsername) + self._passwordField.setText(defaultPassword) + + response = self._dialog.exec_() + if response == QtGui.QDialog.Accepted: + return str(self._usernameField.text()), str(self._passwordField.text()) + elif response == QtGui.QDialog.Rejected: + return None + else: + _moduleLogger.error("Unknown response") + return None + finally: + self._dialog.setParent(None, QtCore.Qt.Dialog) + + def close(self): + try: + self._dialog.reject() + except RuntimeError: + _moduleLogger.exception("Oh well") + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_close_window(self, checked = True): + with qui_utils.notify_error(self._app.errorLog): + self._dialog.reject() + + +class AboutDialog(object): + + def __init__(self, app): + self._app = app + self._title = QtGui.QLabel( + "

%s

Version: %s

" % ( + constants.__pretty_app_name__, constants.__version__ + ) + ) + self._title.setTextFormat(QtCore.Qt.RichText) + self._title.setAlignment(QtCore.Qt.AlignCenter) + self._copyright = QtGui.QLabel("
Developed by Ed Page
Icons: See website
") + self._copyright.setTextFormat(QtCore.Qt.RichText) + self._copyright.setAlignment(QtCore.Qt.AlignCenter) + self._link = QtGui.QLabel('DialCentral Website') + self._link.setTextFormat(QtCore.Qt.RichText) + self._link.setAlignment(QtCore.Qt.AlignCenter) + self._link.setOpenExternalLinks(True) + + self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel) + + self._layout = QtGui.QVBoxLayout() + self._layout.addWidget(self._title) + self._layout.addWidget(self._copyright) + self._layout.addWidget(self._link) + self._layout.addWidget(self._buttonLayout) + + self._dialog = QtGui.QDialog() + self._dialog.setWindowTitle("About") + self._dialog.setLayout(self._layout) + self._buttonLayout.rejected.connect(self._dialog.reject) + + self._closeWindowAction = QtGui.QAction(None) + self._closeWindowAction.setText("Close") + self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) + self._closeWindowAction.triggered.connect(self._on_close_window) + + self._dialog.addAction(self._closeWindowAction) + self._dialog.addAction(app.quitAction) + self._dialog.addAction(app.fullscreenAction) + + def run(self, parent=None): + self._dialog.setParent(parent, QtCore.Qt.Dialog) + + response = self._dialog.exec_() + return response + + def close(self): + try: + self._dialog.reject() + except RuntimeError: + _moduleLogger.exception("Oh well") + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_close_window(self, checked = True): + with qui_utils.notify_error(self._app.errorLog): + self._dialog.reject() + + +class AccountDialog(QtCore.QObject, qwrappers.WindowWrapper): + + # @bug Can't enter custom callback numbers + + _RECURRENCE_CHOICES = [ + (1, "1 minute"), + (2, "2 minutes"), + (3, "3 minutes"), + (5, "5 minutes"), + (8, "8 minutes"), + (10, "10 minutes"), + (15, "15 minutes"), + (30, "30 minutes"), + (45, "45 minutes"), + (60, "1 hour"), + (3*60, "3 hours"), + (6*60, "6 hours"), + (12*60, "12 hours"), + ] + + ALARM_NONE = "No Alert" + ALARM_BACKGROUND = "Background Alert" + ALARM_APPLICATION = "Application Alert" + + VOICEMAIL_CHECK_NOT_SUPPORTED = "Not Supported" + VOICEMAIL_CHECK_DISABLED = "Disabled" + VOICEMAIL_CHECK_ENABLED = "Enabled" + + settingsApproved = qt_compat.Signal() + + def __init__(self, parent, app, errorLog): + QtCore.QObject.__init__(self) + qwrappers.WindowWrapper.__init__(self, parent, app) + self._app = app + self._doClear = False + + self._accountNumberLabel = QtGui.QLabel("NUMBER NOT SET") + self._notificationSelecter = QtGui.QComboBox() + self._notificationSelecter.currentIndexChanged.connect(self._on_notification_change) + self._notificationTimeSelector = QtGui.QComboBox() + #self._notificationTimeSelector.setEditable(True) + self._notificationTimeSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop) + for _, label in self._RECURRENCE_CHOICES: + self._notificationTimeSelector.addItem(label) + self._missedCallsNotificationButton = QtGui.QCheckBox("Missed Calls") + self._voicemailNotificationButton = QtGui.QCheckBox("Voicemail") + self._smsNotificationButton = QtGui.QCheckBox("SMS") + self._voicemailOnMissedButton = QtGui.QCheckBox("Voicemail Update on Missed Calls") + self._clearButton = QtGui.QPushButton("Clear Account") + self._clearButton.clicked.connect(self._on_clear) + self._callbackSelector = QtGui.QComboBox() + #self._callbackSelector.setEditable(True) + self._callbackSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop) + self._orientationSelector = QtGui.QComboBox() + for orientationMode in [ + self._app.DEFAULT_ORIENTATION, + self._app.AUTO_ORIENTATION, + self._app.LANDSCAPE_ORIENTATION, + self._app.PORTRAIT_ORIENTATION, + ]: + self._orientationSelector.addItem(orientationMode) + + self._update_notification_state() + + self._credLayout = QtGui.QGridLayout() + self._credLayout.addWidget(QtGui.QLabel("Account"), 0, 0) + self._credLayout.addWidget(self._accountNumberLabel, 0, 1) + self._credLayout.addWidget(QtGui.QLabel("Callback"), 1, 0) + self._credLayout.addWidget(self._callbackSelector, 1, 1) + self._credLayout.addWidget(self._notificationSelecter, 2, 0) + self._credLayout.addWidget(self._notificationTimeSelector, 2, 1) + self._credLayout.addWidget(QtGui.QLabel(""), 3, 0) + self._credLayout.addWidget(self._missedCallsNotificationButton, 3, 1) + self._credLayout.addWidget(QtGui.QLabel(""), 4, 0) + self._credLayout.addWidget(self._voicemailNotificationButton, 4, 1) + self._credLayout.addWidget(QtGui.QLabel(""), 5, 0) + self._credLayout.addWidget(self._smsNotificationButton, 5, 1) + self._credLayout.addWidget(QtGui.QLabel("Other"), 6, 0) + self._credLayout.addWidget(self._voicemailOnMissedButton, 6, 1) + self._credLayout.addWidget(QtGui.QLabel("Orientation"), 7, 0) + self._credLayout.addWidget(self._orientationSelector, 7, 1) + self._credLayout.addWidget(QtGui.QLabel(""), 8, 0) + self._credLayout.addWidget(QtGui.QLabel(""), 9, 0) + self._credLayout.addWidget(self._clearButton, 9, 1) + + self._credWidget = QtGui.QWidget() + self._credWidget.setLayout(self._credLayout) + self._credWidget.setContentsMargins(0, 0, 0, 0) + self._scrollSettings = QtGui.QScrollArea() + self._scrollSettings.setWidget(self._credWidget) + self._scrollSettings.setWidgetResizable(True) + self._scrollSettings.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self._scrollSettings.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + self._applyButton = QtGui.QPushButton("&Apply") + self._applyButton.clicked.connect(self._on_settings_apply) + self._cancelButton = QtGui.QPushButton("&Cancel") + self._cancelButton.clicked.connect(self._on_settings_cancel) + self._buttonLayout = QtGui.QHBoxLayout() + self._buttonLayout.addStretch() + self._buttonLayout.addWidget(self._cancelButton) + self._buttonLayout.addStretch() + self._buttonLayout.addWidget(self._applyButton) + self._buttonLayout.addStretch() + + self._layout.addWidget(self._scrollSettings) + self._layout.addLayout(self._buttonLayout) + self._layout.setDirection(QtGui.QBoxLayout.TopToBottom) + + self._window.setWindowTitle("Account") + self._window.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + + @property + def doClear(self): + return self._doClear + + def setIfNotificationsSupported(self, isSupported): + if isSupported: + self._notificationSelecter.clear() + self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION, self.ALARM_BACKGROUND]) + self._notificationTimeSelector.setEnabled(False) + self._missedCallsNotificationButton.setEnabled(False) + self._voicemailNotificationButton.setEnabled(False) + self._smsNotificationButton.setEnabled(False) + else: + self._notificationSelecter.clear() + self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION]) + self._notificationTimeSelector.setEnabled(False) + self._missedCallsNotificationButton.setEnabled(False) + self._voicemailNotificationButton.setEnabled(False) + self._smsNotificationButton.setEnabled(False) + + def set_account_number(self, num): + self._accountNumberLabel.setText(num) + + orientation = property( + lambda self: str(self._orientationSelector.currentText()), + lambda self, mode: qui_utils.set_current_index(self._orientationSelector, mode), + ) + + def _set_voicemail_on_missed(self, status): + if status == self.VOICEMAIL_CHECK_NOT_SUPPORTED: + self._voicemailOnMissedButton.setChecked(False) + self._voicemailOnMissedButton.hide() + elif status == self.VOICEMAIL_CHECK_DISABLED: + self._voicemailOnMissedButton.setChecked(False) + self._voicemailOnMissedButton.show() + elif status == self.VOICEMAIL_CHECK_ENABLED: + self._voicemailOnMissedButton.setChecked(True) + self._voicemailOnMissedButton.show() + else: + raise RuntimeError("Unsupported option for updating voicemail on missed calls %r" % status) + + def _get_voicemail_on_missed(self): + if not self._voicemailOnMissedButton.isVisible(): + return self.VOICEMAIL_CHECK_NOT_SUPPORTED + elif self._voicemailOnMissedButton.isChecked(): + return self.VOICEMAIL_CHECK_ENABLED + else: + return self.VOICEMAIL_CHECK_DISABLED + + updateVMOnMissedCall = property(_get_voicemail_on_missed, _set_voicemail_on_missed) + + notifications = property( + lambda self: str(self._notificationSelecter.currentText()), + lambda self, enabled: qui_utils.set_current_index(self._notificationSelecter, enabled), + ) + + notifyOnMissed = property( + lambda self: self._missedCallsNotificationButton.isChecked(), + lambda self, enabled: self._missedCallsNotificationButton.setChecked(enabled), + ) + + notifyOnVoicemail = property( + lambda self: self._voicemailNotificationButton.isChecked(), + lambda self, enabled: self._voicemailNotificationButton.setChecked(enabled), + ) + + notifyOnSms = property( + lambda self: self._smsNotificationButton.isChecked(), + lambda self, enabled: self._smsNotificationButton.setChecked(enabled), + ) + + def _get_notification_time(self): + index = self._notificationTimeSelector.currentIndex() + minutes = self._RECURRENCE_CHOICES[index][0] + return minutes + + def _set_notification_time(self, minutes): + for i, (time, _) in enumerate(self._RECURRENCE_CHOICES): + if time == minutes: + self._notificationTimeSelector.setCurrentIndex(i) + break + else: + self._notificationTimeSelector.setCurrentIndex(0) + + notificationTime = property(_get_notification_time, _set_notification_time) + + @property + def selectedCallback(self): + index = self._callbackSelector.currentIndex() + data = str(self._callbackSelector.itemData(index)) + return data + + def set_callbacks(self, choices, default): + self._callbackSelector.clear() + + self._callbackSelector.addItem("Not Set", "") + + uglyDefault = misc_utils.make_ugly(default) + if not uglyDefault: + uglyDefault = default + for number, description in choices.iteritems(): + prettyNumber = misc_utils.make_pretty(number) + uglyNumber = misc_utils.make_ugly(number) + if not uglyNumber: + prettyNumber = number + uglyNumber = number + + self._callbackSelector.addItem("%s - %s" % (prettyNumber, description), uglyNumber) + if uglyNumber == uglyDefault: + self._callbackSelector.setCurrentIndex(self._callbackSelector.count() - 1) + + def run(self): + self._doClear = False + self._window.show() + + def close(self): + try: + self._window.hide() + except RuntimeError: + _moduleLogger.exception("Oh well") + + def _update_notification_state(self): + currentText = str(self._notificationSelecter.currentText()) + if currentText == self.ALARM_BACKGROUND: + self._notificationTimeSelector.setEnabled(True) + + self._missedCallsNotificationButton.setEnabled(True) + self._voicemailNotificationButton.setEnabled(True) + self._smsNotificationButton.setEnabled(True) + elif currentText == self.ALARM_APPLICATION: + self._notificationTimeSelector.setEnabled(True) + + self._missedCallsNotificationButton.setEnabled(False) + self._voicemailNotificationButton.setEnabled(True) + self._smsNotificationButton.setEnabled(True) + + self._missedCallsNotificationButton.setChecked(False) + else: + self._notificationTimeSelector.setEnabled(False) + + self._missedCallsNotificationButton.setEnabled(False) + self._voicemailNotificationButton.setEnabled(False) + self._smsNotificationButton.setEnabled(False) + + self._missedCallsNotificationButton.setChecked(False) + self._voicemailNotificationButton.setChecked(False) + self._smsNotificationButton.setChecked(False) + + @qt_compat.Slot(int) + @misc_utils.log_exception(_moduleLogger) + def _on_notification_change(self, index): + with qui_utils.notify_error(self._app.errorLog): + self._update_notification_state() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_settings_cancel(self, checked = False): + with qui_utils.notify_error(self._app.errorLog): + self.hide() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + def _on_settings_apply(self, checked = False): + self.__on_settings_apply(checked) + + @misc_utils.log_exception(_moduleLogger) + def __on_settings_apply(self, checked = False): + with qui_utils.notify_error(self._app.errorLog): + self.settingsApproved.emit() + self.hide() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_clear(self, checked = False): + with qui_utils.notify_error(self._app.errorLog): + self._doClear = True + self.settingsApproved.emit() + self.hide() + + +class ContactList(object): + + _SENTINEL_ICON = QtGui.QIcon() + + def __init__(self, app, session): + self._app = app + self._session = session + self._targetLayout = QtGui.QVBoxLayout() + self._targetList = QtGui.QWidget() + self._targetList.setLayout(self._targetLayout) + self._uiItems = [] + self._closeIcon = qui_utils.get_theme_icon(("window-close", "general_close", "gtk-close"), self._SENTINEL_ICON) + + @property + def toplevel(self): + return self._targetList + + def setVisible(self, isVisible): + self._targetList.setVisible(isVisible) + + def update(self): + cids = list(self._session.draft.get_contacts()) + amountCommon = min(len(cids), len(self._uiItems)) + + # Run through everything in common + for i in xrange(0, amountCommon): + cid = cids[i] + uiItem = self._uiItems[i] + title = self._session.draft.get_title(cid) + description = self._session.draft.get_description(cid) + numbers = self._session.draft.get_numbers(cid) + uiItem["cid"] = cid + uiItem["title"] = title + uiItem["description"] = description + uiItem["numbers"] = numbers + uiItem["label"].setText(title) + self._populate_number_selector(uiItem["selector"], cid, i, numbers) + uiItem["rowWidget"].setVisible(True) + + # More contacts than ui items + for i in xrange(amountCommon, len(cids)): + cid = cids[i] + title = self._session.draft.get_title(cid) + description = self._session.draft.get_description(cid) + numbers = self._session.draft.get_numbers(cid) + + titleLabel = QtGui.QLabel(title) + titleLabel.setWordWrap(True) + numberSelector = QtGui.QComboBox() + self._populate_number_selector(numberSelector, cid, i, numbers) + + callback = functools.partial( + self._on_change_number, + i + ) + callback.__name__ = "thanks partials for not having names and pyqt for requiring them" + numberSelector.activated.connect( + qt_compat.Slot(int)(callback) + ) + + if self._closeIcon is self._SENTINEL_ICON: + deleteButton = QtGui.QPushButton("Delete") + else: + deleteButton = QtGui.QPushButton(self._closeIcon, "") + deleteButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.PushButton, + )) + callback = functools.partial( + self._on_remove_contact, + i + ) + callback.__name__ = "thanks partials for not having names and pyqt for requiring them" + deleteButton.clicked.connect(callback) + + rowLayout = QtGui.QHBoxLayout() + rowLayout.addWidget(titleLabel, 1000) + rowLayout.addWidget(numberSelector, 0) + rowLayout.addWidget(deleteButton, 0) + rowWidget = QtGui.QWidget() + rowWidget.setLayout(rowLayout) + self._targetLayout.addWidget(rowWidget) + + uiItem = {} + uiItem["cid"] = cid + uiItem["title"] = title + uiItem["description"] = description + uiItem["numbers"] = numbers + uiItem["label"] = titleLabel + uiItem["selector"] = numberSelector + uiItem["rowWidget"] = rowWidget + self._uiItems.append(uiItem) + amountCommon = i+1 + + # More UI items than contacts + for i in xrange(amountCommon, len(self._uiItems)): + uiItem = self._uiItems[i] + uiItem["rowWidget"].setVisible(False) + amountCommon = i+1 + + def _populate_number_selector(self, selector, cid, cidIndex, numbers): + selector.clear() + + selectedNumber = self._session.draft.get_selected_number(cid) + if len(numbers) == 1: + # If no alt numbers available, check the address book + numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1]) + else: + defaultIndex = _index_number(numbers, selectedNumber) + + for number, description in numbers: + if description: + label = "%s - %s" % (number, description) + else: + label = number + selector.addItem(label) + selector.setVisible(True) + if 1 < len(numbers): + selector.setEnabled(True) + selector.setCurrentIndex(defaultIndex) + else: + selector.setEnabled(False) + + @misc_utils.log_exception(_moduleLogger) + def _on_change_number(self, cidIndex, index): + with qui_utils.notify_error(self._app.errorLog): + # Exception thrown when the first item is removed + try: + cid = self._uiItems[cidIndex]["cid"] + numbers = self._session.draft.get_numbers(cid) + except IndexError: + _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) + return + except KeyError: + _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) + return + number = numbers[index][0] + self._session.draft.set_selected_number(cid, number) + + @misc_utils.log_exception(_moduleLogger) + def _on_remove_contact(self, index, toggled): + with qui_utils.notify_error(self._app.errorLog): + self._session.draft.remove_contact(self._uiItems[index]["cid"]) + + +class VoicemailPlayer(object): + + def __init__(self, app, session, errorLog): + self._app = app + self._session = session + self._errorLog = errorLog + self._token = None + self._session.voicemailAvailable.connect(self._on_voicemail_downloaded) + self._session.draft.recipientsChanged.connect(self._on_recipients_changed) + + self._playButton = QtGui.QPushButton("Play") + self._playButton.clicked.connect(self._on_voicemail_play) + self._pauseButton = QtGui.QPushButton("Pause") + self._pauseButton.clicked.connect(self._on_voicemail_pause) + self._pauseButton.hide() + self._resumeButton = QtGui.QPushButton("Resume") + self._resumeButton.clicked.connect(self._on_voicemail_resume) + self._resumeButton.hide() + self._stopButton = QtGui.QPushButton("Stop") + self._stopButton.clicked.connect(self._on_voicemail_stop) + self._stopButton.hide() + + self._downloadButton = QtGui.QPushButton("Download Voicemail") + self._downloadButton.clicked.connect(self._on_voicemail_download) + self._downloadLayout = QtGui.QHBoxLayout() + self._downloadLayout.addWidget(self._downloadButton) + self._downloadWidget = QtGui.QWidget() + self._downloadWidget.setLayout(self._downloadLayout) + + self._playLabel = QtGui.QLabel("Voicemail") + self._saveButton = QtGui.QPushButton("Save") + self._saveButton.clicked.connect(self._on_voicemail_save) + self._playerLayout = QtGui.QHBoxLayout() + self._playerLayout.addWidget(self._playLabel) + self._playerLayout.addWidget(self._playButton) + self._playerLayout.addWidget(self._pauseButton) + self._playerLayout.addWidget(self._resumeButton) + self._playerLayout.addWidget(self._stopButton) + self._playerLayout.addWidget(self._saveButton) + self._playerWidget = QtGui.QWidget() + self._playerWidget.setLayout(self._playerLayout) + + self._visibleWidget = None + self._layout = QtGui.QHBoxLayout() + self._layout.setContentsMargins(0, 0, 0, 0) + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + self._update_state() + + @property + def toplevel(self): + return self._widget + + def destroy(self): + self._session.voicemailAvailable.disconnect(self._on_voicemail_downloaded) + self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed) + self._invalidate_token() + + def _invalidate_token(self): + if self._token is not None: + self._token.invalidate() + self._token.error.disconnect(self._on_play_error) + self._token.stateChange.connect(self._on_play_state) + self._token.invalidated.connect(self._on_play_invalidated) + + def _show_download(self, messageId): + if self._visibleWidget is self._downloadWidget: + return + self._hide() + self._layout.addWidget(self._downloadWidget) + self._visibleWidget = self._downloadWidget + self._visibleWidget.show() + + def _show_player(self, messageId): + if self._visibleWidget is self._playerWidget: + return + self._hide() + self._layout.addWidget(self._playerWidget) + self._visibleWidget = self._playerWidget + self._visibleWidget.show() + + def _hide(self): + if self._visibleWidget is None: + return + self._visibleWidget.hide() + self._layout.removeWidget(self._visibleWidget) + self._visibleWidget = None + + def _update_play_state(self): + if self._token is not None and self._token.isValid: + self._playButton.setText("Stop") + else: + self._playButton.setText("Play") + + def _update_state(self): + if self._session.draft.get_num_contacts() != 1: + self._hide() + return + + (cid, ) = self._session.draft.get_contacts() + messageId = self._session.draft.get_message_id(cid) + if messageId is None: + self._hide() + return + + if self._session.is_available(messageId): + self._show_player(messageId) + else: + self._show_download(messageId) + if self._token is not None: + self._token.invalidate() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_save(self, arg): + with qui_utils.notify_error(self._app.errorLog): + targetPath = QtGui.QFileDialog.getSaveFileName(None, caption="Save Voicemail", filter="Audio File (*.mp3)") + targetPath = unicode(targetPath) + if not targetPath: + return + + (cid, ) = self._session.draft.get_contacts() + messageId = self._session.draft.get_message_id(cid) + sourcePath = self._session.voicemail_path(messageId) + import shutil + shutil.copy2(sourcePath, targetPath) + + @misc_utils.log_exception(_moduleLogger) + def _on_play_error(self, error): + with qui_utils.notify_error(self._app.errorLog): + self._app.errorLog.push_error(error) + + @misc_utils.log_exception(_moduleLogger) + def _on_play_invalidated(self): + with qui_utils.notify_error(self._app.errorLog): + self._playButton.show() + self._pauseButton.hide() + self._resumeButton.hide() + self._stopButton.hide() + self._invalidate_token() + + @misc_utils.log_exception(_moduleLogger) + def _on_play_state(self, state): + with qui_utils.notify_error(self._app.errorLog): + if state == self._token.STATE_PLAY: + self._playButton.hide() + self._pauseButton.show() + self._resumeButton.hide() + self._stopButton.show() + elif state == self._token.STATE_PAUSE: + self._playButton.hide() + self._pauseButton.hide() + self._resumeButton.show() + self._stopButton.show() + elif state == self._token.STATE_STOP: + self._playButton.show() + self._pauseButton.hide() + self._resumeButton.hide() + self._stopButton.hide() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_play(self, arg): + with qui_utils.notify_error(self._app.errorLog): + (cid, ) = self._session.draft.get_contacts() + messageId = self._session.draft.get_message_id(cid) + sourcePath = self._session.voicemail_path(messageId) + + self._invalidate_token() + uri = "file://%s" % sourcePath + self._token = self._app.streamHandler.set_file(uri) + self._token.stateChange.connect(self._on_play_state) + self._token.invalidated.connect(self._on_play_invalidated) + self._token.error.connect(self._on_play_error) + self._token.play() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_pause(self, arg): + with qui_utils.notify_error(self._app.errorLog): + self._token.pause() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_resume(self, arg): + with qui_utils.notify_error(self._app.errorLog): + self._token.play() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_stop(self, arg): + with qui_utils.notify_error(self._app.errorLog): + self._token.stop() + + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_download(self, arg): + with qui_utils.notify_error(self._app.errorLog): + (cid, ) = self._session.draft.get_contacts() + messageId = self._session.draft.get_message_id(cid) + self._session.download_voicemail(messageId) + self._hide() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_recipients_changed(self): + with qui_utils.notify_error(self._app.errorLog): + self._update_state() + + @qt_compat.Slot(str, str) + @misc_utils.log_exception(_moduleLogger) + def _on_voicemail_downloaded(self, messageId, filepath): + with qui_utils.notify_error(self._app.errorLog): + self._update_state() + + +class SMSEntryWindow(qwrappers.WindowWrapper): + + MAX_CHAR = 160 + # @bug Somehow a window is being destroyed on object creation which causes glitches on Maemo 5 + + def __init__(self, parent, app, session, errorLog): + qwrappers.WindowWrapper.__init__(self, parent, app) + self._session = session + self._session.messagesUpdated.connect(self._on_refresh_history) + self._session.historyUpdated.connect(self._on_refresh_history) + self._session.draft.recipientsChanged.connect(self._on_recipients_changed) + + self._session.draft.sendingMessage.connect(self._on_op_started) + self._session.draft.calling.connect(self._on_op_started) + self._session.draft.calling.connect(self._on_calling_started) + self._session.draft.cancelling.connect(self._on_op_started) + + self._session.draft.sentMessage.connect(self._on_op_finished) + self._session.draft.called.connect(self._on_op_finished) + self._session.draft.cancelled.connect(self._on_op_finished) + self._session.draft.error.connect(self._on_op_error) + + self._errorLog = errorLog + + self._targetList = ContactList(self._app, self._session) + self._history = QtGui.QLabel() + self._history.setTextFormat(QtCore.Qt.RichText) + self._history.setWordWrap(True) + self._voicemailPlayer = VoicemailPlayer(self._app, self._session, self._errorLog) + self._smsEntry = QtGui.QTextEdit() + self._smsEntry.textChanged.connect(self._on_letter_count_changed) + + self._entryLayout = QtGui.QVBoxLayout() + self._entryLayout.addWidget(self._targetList.toplevel) + self._entryLayout.addWidget(self._history) + self._entryLayout.addWidget(self._voicemailPlayer.toplevel, 0) + self._entryLayout.addWidget(self._smsEntry) + self._entryLayout.setContentsMargins(0, 0, 0, 0) + self._entryWidget = QtGui.QWidget() + self._entryWidget.setLayout(self._entryLayout) + self._entryWidget.setContentsMargins(0, 0, 0, 0) + self._scrollEntry = QtGui.QScrollArea() + self._scrollEntry.setWidget(self._entryWidget) + self._scrollEntry.setWidgetResizable(True) + self._scrollEntry.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom) + self._scrollEntry.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self._scrollEntry.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + self._characterCountLabel = QtGui.QLabel("") + self._singleNumberSelector = QtGui.QComboBox() + self._cids = [] + self._singleNumberSelector.activated.connect(self._on_single_change_number) + self._smsButton = QtGui.QPushButton("SMS") + self._smsButton.clicked.connect(self._on_sms_clicked) + self._smsButton.setEnabled(False) + self._dialButton = QtGui.QPushButton("Dial") + self._dialButton.clicked.connect(self._on_call_clicked) + self._cancelButton = QtGui.QPushButton("Cancel Call") + self._cancelButton.clicked.connect(self._on_cancel_clicked) + self._cancelButton.setVisible(False) + + self._buttonLayout = QtGui.QHBoxLayout() + self._buttonLayout.addWidget(self._characterCountLabel) + self._buttonLayout.addStretch() + self._buttonLayout.addWidget(self._singleNumberSelector) + self._buttonLayout.addStretch() + self._buttonLayout.addWidget(self._smsButton) + self._buttonLayout.addWidget(self._dialButton) + self._buttonLayout.addWidget(self._cancelButton) + + self._layout.addWidget(self._errorDisplay.toplevel) + self._layout.addWidget(self._scrollEntry) + self._layout.addLayout(self._buttonLayout) + self._layout.setDirection(QtGui.QBoxLayout.TopToBottom) + + self._window.setWindowTitle("Contact") + self._window.closed.connect(self._on_close_window) + self._window.hidden.connect(self._on_close_window) + self._window.resized.connect(self._on_window_resized) + + self._scrollTimer = QtCore.QTimer() + self._scrollTimer.setInterval(100) + self._scrollTimer.setSingleShot(True) + self._scrollTimer.timeout.connect(self._on_delayed_scroll_to_bottom) + + self._smsEntry.setPlainText(self._session.draft.message) + self._update_letter_count() + self._update_target_fields() + self.set_fullscreen(self._app.fullscreenAction.isChecked()) + self.update_orientation(self._app.orientation) + + def close(self): + if self._window is None: + # Already closed + return + window = self._window + try: + message = unicode(self._smsEntry.toPlainText()) + self._session.draft.message = message + self.hide() + except AttributeError: + _moduleLogger.exception("Oh well") + except RuntimeError: + _moduleLogger.exception("Oh well") + + def destroy(self): + self._session.messagesUpdated.disconnect(self._on_refresh_history) + self._session.historyUpdated.disconnect(self._on_refresh_history) + self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed) + self._session.draft.sendingMessage.disconnect(self._on_op_started) + self._session.draft.calling.disconnect(self._on_op_started) + self._session.draft.calling.disconnect(self._on_calling_started) + self._session.draft.cancelling.disconnect(self._on_op_started) + self._session.draft.sentMessage.disconnect(self._on_op_finished) + self._session.draft.called.disconnect(self._on_op_finished) + self._session.draft.cancelled.disconnect(self._on_op_finished) + self._session.draft.error.disconnect(self._on_op_error) + self._voicemailPlayer.destroy() + window = self._window + self._window = None + try: + window.close() + window.destroy() + except AttributeError: + _moduleLogger.exception("Oh well") + except RuntimeError: + _moduleLogger.exception("Oh well") + + def update_orientation(self, orientation): + qwrappers.WindowWrapper.update_orientation(self, orientation) + self._scroll_to_bottom() + + def _update_letter_count(self): + count = len(self._smsEntry.toPlainText()) + numTexts, numCharInText = divmod(count, self.MAX_CHAR) + numTexts += 1 + numCharsLeftInText = self.MAX_CHAR - numCharInText + self._characterCountLabel.setText("%d (%d)" % (numCharsLeftInText, numTexts)) + + def _update_button_state(self): + self._cancelButton.setEnabled(True) + if self._session.draft.get_num_contacts() == 0: + self._dialButton.setEnabled(False) + self._smsButton.setEnabled(False) + elif self._session.draft.get_num_contacts() == 1: + count = len(self._smsEntry.toPlainText()) + if count == 0: + self._dialButton.setEnabled(True) + self._smsButton.setEnabled(False) + else: + self._dialButton.setEnabled(False) + self._smsButton.setEnabled(True) + else: + self._dialButton.setEnabled(False) + count = len(self._smsEntry.toPlainText()) + if count == 0: + self._smsButton.setEnabled(False) + else: + self._smsButton.setEnabled(True) + + def _update_history(self, cid): + draftContactsCount = self._session.draft.get_num_contacts() + if draftContactsCount != 1: + self._history.setVisible(False) + else: + description = self._session.draft.get_description(cid) + + self._targetList.setVisible(False) + if description: + self._history.setText(description) + self._history.setVisible(True) + else: + self._history.setText("") + self._history.setVisible(False) + + def _update_target_fields(self): + draftContactsCount = self._session.draft.get_num_contacts() + if draftContactsCount == 0: + self.hide() + del self._cids[:] + elif draftContactsCount == 1: + (cid, ) = self._session.draft.get_contacts() + title = self._session.draft.get_title(cid) + numbers = self._session.draft.get_numbers(cid) + + self._targetList.setVisible(False) + self._update_history(cid) + self._populate_number_selector(self._singleNumberSelector, cid, 0, numbers) + self._cids = [cid] + + self._scroll_to_bottom() + self._window.setWindowTitle(title) + self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason) + self.show() + self._window.raise_() + else: + self._targetList.setVisible(True) + self._targetList.update() + self._history.setText("") + self._history.setVisible(False) + self._singleNumberSelector.setVisible(False) + + self._scroll_to_bottom() + self._window.setWindowTitle("Contacts") + self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason) + self.show() + self._window.raise_() + + def _populate_number_selector(self, selector, cid, cidIndex, numbers): + selector.clear() + + selectedNumber = self._session.draft.get_selected_number(cid) + if len(numbers) == 1: + # If no alt numbers available, check the address book + numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1]) + else: + defaultIndex = _index_number(numbers, selectedNumber) + + for number, description in numbers: + if description: + label = "%s - %s" % (number, description) + else: + label = number + selector.addItem(label) + selector.setVisible(True) + if 1 < len(numbers): + selector.setEnabled(True) + selector.setCurrentIndex(defaultIndex) + else: + selector.setEnabled(False) + + def _scroll_to_bottom(self): + self._scrollTimer.start() + + @misc_utils.log_exception(_moduleLogger) + def _on_delayed_scroll_to_bottom(self): + with qui_utils.notify_error(self._app.errorLog): + self._scrollEntry.ensureWidgetVisible(self._smsEntry) + + @misc_utils.log_exception(_moduleLogger) + def _on_sms_clicked(self, arg): + with qui_utils.notify_error(self._app.errorLog): + message = unicode(self._smsEntry.toPlainText()) + self._session.draft.message = message + self._session.draft.send() + + @misc_utils.log_exception(_moduleLogger) + def _on_call_clicked(self, arg): + with qui_utils.notify_error(self._app.errorLog): + message = unicode(self._smsEntry.toPlainText()) + self._session.draft.message = message + self._session.draft.call() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_cancel_clicked(self, message): + with qui_utils.notify_error(self._app.errorLog): + self._session.draft.cancel() + + @misc_utils.log_exception(_moduleLogger) + def _on_single_change_number(self, index): + with qui_utils.notify_error(self._app.errorLog): + # Exception thrown when the first item is removed + cid = self._cids[0] + try: + numbers = self._session.draft.get_numbers(cid) + except KeyError: + _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) + return + number = numbers[index][0] + self._session.draft.set_selected_number(cid, number) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_refresh_history(self): + with qui_utils.notify_error(self._app.errorLog): + draftContactsCount = self._session.draft.get_num_contacts() + if draftContactsCount != 1: + # Changing contact count will automatically refresh it + return + (cid, ) = self._session.draft.get_contacts() + self._update_history(cid) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_recipients_changed(self): + with qui_utils.notify_error(self._app.errorLog): + self._update_target_fields() + self._update_button_state() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_op_started(self): + with qui_utils.notify_error(self._app.errorLog): + self._smsEntry.setReadOnly(True) + self._smsButton.setVisible(False) + self._dialButton.setVisible(False) + self.show() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_calling_started(self): + with qui_utils.notify_error(self._app.errorLog): + self._cancelButton.setVisible(True) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_op_finished(self): + with qui_utils.notify_error(self._app.errorLog): + self._smsEntry.setPlainText("") + self._smsEntry.setReadOnly(False) + self._cancelButton.setVisible(False) + self._smsButton.setVisible(True) + self._dialButton.setVisible(True) + self.close() + self.destroy() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_op_error(self, message): + with qui_utils.notify_error(self._app.errorLog): + self._smsEntry.setReadOnly(False) + self._cancelButton.setVisible(False) + self._smsButton.setVisible(True) + self._dialButton.setVisible(True) + + self._errorLog.push_error(message) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_letter_count_changed(self): + with qui_utils.notify_error(self._app.errorLog): + self._update_letter_count() + self._update_button_state() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_window_resized(self): + with qui_utils.notify_error(self._app.errorLog): + self._scroll_to_bottom() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_close_window(self, checked = True): + with qui_utils.notify_error(self._app.errorLog): + self.close() + + +def _index_number(numbers, default): + uglyDefault = misc_utils.make_ugly(default) + uglyContactNumbers = list( + misc_utils.make_ugly(contactNumber) + for (contactNumber, _) in numbers + ) + defaultMatches = [ + misc_utils.similar_ugly_numbers(uglyDefault, contactNumber) + for contactNumber in uglyContactNumbers + ] + try: + defaultIndex = defaultMatches.index(True) + except ValueError: + defaultIndex = -1 + _moduleLogger.warn( + "Could not find contact number %s among %r" % ( + default, numbers + ) + ) + return defaultIndex + + +def _get_contact_numbers(session, contactId, number, description): + contactPhoneNumbers = [] + if contactId and contactId != "0": + try: + contactDetails = copy.deepcopy(session.get_contacts()[contactId]) + contactPhoneNumbers = contactDetails["numbers"] + except KeyError: + contactPhoneNumbers = [] + contactPhoneNumbers = [ + (contactPhoneNumber["phoneNumber"], contactPhoneNumber.get("phoneType", "Unknown")) + for contactPhoneNumber in contactPhoneNumbers + ] + defaultIndex = _index_number(contactPhoneNumbers, number) + + if not contactPhoneNumbers or defaultIndex == -1: + contactPhoneNumbers += [(number, description)] + defaultIndex = 0 + + return contactPhoneNumbers, defaultIndex diff --git a/dialcentral/examples/log_notifier.py b/dialcentral/examples/log_notifier.py new file mode 100644 index 0000000..541ac18 --- /dev/null +++ b/dialcentral/examples/log_notifier.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python + +from __future__ import with_statement + +import sys +import datetime +import ConfigParser + + +sys.path.insert(0,"/usr/lib/dialcentral/") + + +import constants +import alarm_notify + + +def notify_on_change(): + with open(constants._notifier_logpath_, "a") as file: + file.write("Notification: %r\n" % (datetime.datetime.now(), )) + + config = ConfigParser.SafeConfigParser() + config.read(constants._user_settings_) + backend = alarm_notify.create_backend(config) + notifyUser = alarm_notify.is_changed(config, backend) + + if notifyUser: + file.write("\tChange occurred\n") + + +if __name__ == "__main__": + notify_on_change() diff --git a/dialcentral/examples/sound_notifier.py b/dialcentral/examples/sound_notifier.py new file mode 100644 index 0000000..c31e413 --- /dev/null +++ b/dialcentral/examples/sound_notifier.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +import os +import sys +import ConfigParser +import logging + + +sys.path.insert(0,"/usr/lib/dialcentral/") + + +import constants +import alarm_notify + + +def notify_on_change(): + config = ConfigParser.SafeConfigParser() + config.read(constants._user_settings_) + backend = alarm_notify.create_backend(config) + notifyUser = alarm_notify.is_changed(config, backend) + + config = ConfigParser.SafeConfigParser() + config.read(constants._custom_notifier_settings_) + soundFile = config.get("Sound Notifier", "soundfile") + soundFile = "/usr/lib/gv-notifier/alert.mp3" + + if notifyUser: + import subprocess + import led_handler + logging.info("Changed, playing %s" % soundFile) + led = led_handler.LedHandler() + led.on() + soundOn = subprocess.call("/usr/bin/dbus-send --dest=com.nokia.osso_media_server --print-reply /com/nokia/osso_media_server com.nokia.osso_media_server.music.play_media string:file://%s",shell=True) + else: + logging.info("No Change") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING, filename=constants._notifier_logpath_) + logging.info("Sound Notifier %s-%s" % (constants.__version__, constants.__build__)) + logging.info("OS: %s" % (os.uname()[0], )) + logging.info("Kernel: %s (%s) for %s" % os.uname()[2:]) + logging.info("Hostname: %s" % os.uname()[1]) + try: + notify_on_change() + except: + logging.exception("Error") + raise diff --git a/dialcentral/gv_views.py b/dialcentral/gv_views.py new file mode 100644 index 0000000..2bd0663 --- /dev/null +++ b/dialcentral/gv_views.py @@ -0,0 +1,977 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import datetime +import string +import itertools +import logging + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +from util import qtpie +from util import qui_utils +from util import misc as misc_utils + +import backends.null_backend as null_backend +import backends.file_backend as file_backend +import backends.qt_backend as qt_backend + + +_moduleLogger = logging.getLogger(__name__) + + +_SENTINEL_ICON = QtGui.QIcon() + + +class Dialpad(object): + + def __init__(self, app, session, errorLog): + self._app = app + self._session = session + self._errorLog = errorLog + + self._plus = QtGui.QPushButton("+") + self._plus.clicked.connect(lambda: self._on_keypress("+")) + self._entry = QtGui.QLineEdit() + + backAction = QtGui.QAction(None) + backAction.setText("Back") + backAction.triggered.connect(self._on_backspace) + backPieItem = qtpie.QActionPieItem(backAction) + clearAction = QtGui.QAction(None) + clearAction.setText("Clear") + clearAction.triggered.connect(self._on_clear_text) + clearPieItem = qtpie.QActionPieItem(clearAction) + backSlices = [ + qtpie.PieFiling.NULL_CENTER, + clearPieItem, + qtpie.PieFiling.NULL_CENTER, + qtpie.PieFiling.NULL_CENTER, + ] + self._back = qtpie.QPieButton(backPieItem) + self._back.set_center(backPieItem) + for slice in backSlices: + self._back.insertItem(slice) + + self._entryLayout = QtGui.QHBoxLayout() + self._entryLayout.addWidget(self._plus, 1, QtCore.Qt.AlignCenter) + self._entryLayout.addWidget(self._entry, 1000) + self._entryLayout.addWidget(self._back, 1, QtCore.Qt.AlignCenter) + + smsIcon = self._app.get_icon("messages.png") + self._smsButton = QtGui.QPushButton(smsIcon, "SMS") + self._smsButton.clicked.connect(self._on_sms_clicked) + self._smsButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.PushButton, + )) + callIcon = self._app.get_icon("dialpad.png") + self._callButton = QtGui.QPushButton(callIcon, "Call") + self._callButton.clicked.connect(self._on_call_clicked) + self._callButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.PushButton, + )) + + self._padLayout = QtGui.QGridLayout() + rows = [0, 0, 0, 1, 1, 1, 2, 2, 2] + columns = [0, 1, 2] * 3 + keys = [ + ("1", ""), + ("2", "ABC"), + ("3", "DEF"), + ("4", "GHI"), + ("5", "JKL"), + ("6", "MNO"), + ("7", "PQRS"), + ("8", "TUV"), + ("9", "WXYZ"), + ] + for (num, letters), (row, column) in zip(keys, zip(rows, columns)): + self._padLayout.addWidget(self._generate_key_button(num, letters), row, column) + self._zerothButton = QtGui.QPushButton("0") + self._zerothButton.clicked.connect(lambda: self._on_keypress("0")) + self._zerothButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.PushButton, + )) + self._padLayout.addWidget(self._smsButton, 3, 0) + self._padLayout.addWidget(self._zerothButton) + self._padLayout.addWidget(self._callButton, 3, 2) + + self._layout = QtGui.QVBoxLayout() + self._layout.addLayout(self._entryLayout, 0) + self._layout.addLayout(self._padLayout, 1000000) + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + + @property + def toplevel(self): + return self._widget + + def enable(self): + self._smsButton.setEnabled(True) + self._callButton.setEnabled(True) + + def disable(self): + self._smsButton.setEnabled(False) + self._callButton.setEnabled(False) + + def get_settings(self): + return {} + + def set_settings(self, settings): + pass + + def clear(self): + pass + + def refresh(self, force = True): + pass + + def _generate_key_button(self, center, letters): + button = QtGui.QPushButton("%s\n%s" % (center, letters)) + button.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.PushButton, + )) + button.clicked.connect(lambda: self._on_keypress(center)) + return button + + @misc_utils.log_exception(_moduleLogger) + def _on_keypress(self, key): + with qui_utils.notify_error(self._errorLog): + self._entry.insert(key) + + @misc_utils.log_exception(_moduleLogger) + def _on_backspace(self, toggled = False): + with qui_utils.notify_error(self._errorLog): + self._entry.backspace() + + @misc_utils.log_exception(_moduleLogger) + def _on_clear_text(self, toggled = False): + with qui_utils.notify_error(self._errorLog): + self._entry.clear() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_sms_clicked(self, checked = False): + with qui_utils.notify_error(self._errorLog): + number = misc_utils.make_ugly(str(self._entry.text())) + self._entry.clear() + + contactId = number + title = misc_utils.make_pretty(number) + description = misc_utils.make_pretty(number) + numbersWithDescriptions = [(number, "")] + self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc_utils.log_exception(_moduleLogger) + def _on_call_clicked(self, checked = False): + with qui_utils.notify_error(self._errorLog): + number = misc_utils.make_ugly(str(self._entry.text())) + self._entry.clear() + + contactId = number + title = misc_utils.make_pretty(number) + description = misc_utils.make_pretty(number) + numbersWithDescriptions = [(number, "")] + self._session.draft.clear() + self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) + self._session.draft.call() + + +class TimeCategories(object): + + _NOW_SECTION = 0 + _TODAY_SECTION = 1 + _WEEK_SECTION = 2 + _MONTH_SECTION = 3 + _REST_SECTION = 4 + _MAX_SECTIONS = 5 + + _NO_ELAPSED = datetime.timedelta(hours=1) + _WEEK_ELAPSED = datetime.timedelta(weeks=1) + _MONTH_ELAPSED = datetime.timedelta(days=30) + + def __init__(self, parentItem): + self._timeItems = [ + QtGui.QStandardItem(description) + for (i, description) in zip( + xrange(self._MAX_SECTIONS), + ["Now", "Today", "Week", "Month", "Past"], + ) + ] + for item in self._timeItems: + item.setEditable(False) + item.setCheckable(False) + row = (item, ) + parentItem.appendRow(row) + + self._today = datetime.datetime(1900, 1, 1) + + self.prepare_for_update(self._today) + + def prepare_for_update(self, newToday): + self._today = newToday + for item in self._timeItems: + item.removeRows(0, item.rowCount()) + try: + hour = self._today.strftime("%X") + day = self._today.strftime("%x") + except ValueError: + _moduleLogger.exception("Can't format times") + hour = "Now" + day = "Today" + self._timeItems[self._NOW_SECTION].setText(hour) + self._timeItems[self._TODAY_SECTION].setText(day) + + def add_row(self, rowDate, row): + elapsedTime = self._today - rowDate + todayTuple = self._today.timetuple() + rowTuple = rowDate.timetuple() + if elapsedTime < self._NO_ELAPSED: + section = self._NOW_SECTION + elif todayTuple[0:3] == rowTuple[0:3]: + section = self._TODAY_SECTION + elif elapsedTime < self._WEEK_ELAPSED: + section = self._WEEK_SECTION + elif elapsedTime < self._MONTH_ELAPSED: + section = self._MONTH_SECTION + else: + section = self._REST_SECTION + self._timeItems[section].appendRow(row) + + def get_item(self, timeIndex, rowIndex, column): + timeItem = self._timeItems[timeIndex] + item = timeItem.child(rowIndex, column) + return item + + +class History(object): + + DETAILS_IDX = 0 + FROM_IDX = 1 + MAX_IDX = 2 + + HISTORY_RECEIVED = "Received" + HISTORY_MISSED = "Missed" + HISTORY_PLACED = "Placed" + HISTORY_ALL = "All" + + HISTORY_ITEM_TYPES = [HISTORY_RECEIVED, HISTORY_MISSED, HISTORY_PLACED, HISTORY_ALL] + HISTORY_COLUMNS = ["", "From"] + assert len(HISTORY_COLUMNS) == MAX_IDX + + def __init__(self, app, session, errorLog): + self._selectedFilter = self.HISTORY_ITEM_TYPES[-1] + self._app = app + self._session = session + self._session.historyUpdated.connect(self._on_history_updated) + self._errorLog = errorLog + + self._typeSelection = QtGui.QComboBox() + self._typeSelection.addItems(self.HISTORY_ITEM_TYPES) + self._typeSelection.setCurrentIndex( + self.HISTORY_ITEM_TYPES.index(self._selectedFilter) + ) + self._typeSelection.currentIndexChanged[str].connect(self._on_filter_changed) + refreshIcon = qui_utils.get_theme_icon( + ("view-refresh", "general_refresh", "gtk-refresh", ), + _SENTINEL_ICON + ) + if refreshIcon is not _SENTINEL_ICON: + self._refreshButton = QtGui.QPushButton(refreshIcon, "") + else: + self._refreshButton = QtGui.QPushButton("Refresh") + self._refreshButton.clicked.connect(self._on_refresh_clicked) + self._refreshButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.PushButton, + )) + self._managerLayout = QtGui.QHBoxLayout() + self._managerLayout.addWidget(self._typeSelection, 1000) + self._managerLayout.addWidget(self._refreshButton, 0) + + self._itemStore = QtGui.QStandardItemModel() + self._itemStore.setHorizontalHeaderLabels(self.HISTORY_COLUMNS) + self._categoryManager = TimeCategories(self._itemStore) + + self._itemView = QtGui.QTreeView() + self._itemView.setModel(self._itemStore) + self._itemView.setUniformRowHeights(True) + self._itemView.setRootIsDecorated(False) + self._itemView.setIndentation(0) + self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + self._itemView.setHeaderHidden(True) + self._itemView.setItemsExpandable(False) + self._itemView.header().setResizeMode(QtGui.QHeaderView.ResizeToContents) + self._itemView.activated.connect(self._on_row_activated) + + self._layout = QtGui.QVBoxLayout() + self._layout.addLayout(self._managerLayout) + self._layout.addWidget(self._itemView) + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + + self._actionIcon = { + "Placed": self._app.get_icon("placed.png"), + "Missed": self._app.get_icon("missed.png"), + "Received": self._app.get_icon("received.png"), + } + + self._populate_items() + + @property + def toplevel(self): + return self._widget + + def enable(self): + self._itemView.setEnabled(True) + + def disable(self): + self._itemView.setEnabled(False) + + def get_settings(self): + return { + "filter": self._selectedFilter, + } + + def set_settings(self, settings): + selectedFilter = settings.get("filter", self.HISTORY_ITEM_TYPES[-1]) + if selectedFilter in self.HISTORY_ITEM_TYPES: + self._selectedFilter = selectedFilter + self._typeSelection.setCurrentIndex( + self.HISTORY_ITEM_TYPES.index(selectedFilter) + ) + + def clear(self): + self._itemView.clear() + + def refresh(self, force=True): + self._itemView.setFocus(QtCore.Qt.OtherFocusReason) + + if self._selectedFilter == self.HISTORY_RECEIVED: + self._session.update_history(self._session.HISTORY_RECEIVED, force) + elif self._selectedFilter == self.HISTORY_MISSED: + self._session.update_history(self._session.HISTORY_MISSED, force) + elif self._selectedFilter == self.HISTORY_PLACED: + self._session.update_history(self._session.HISTORY_PLACED, force) + elif self._selectedFilter == self.HISTORY_ALL: + self._session.update_history(self._session.HISTORY_ALL, force) + else: + assert False, "How did we get here?" + + if self._app.notifyOnMissed and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE: + self._app.ledHandler.off() + + def _populate_items(self): + self._categoryManager.prepare_for_update(self._session.get_when_history_updated()) + + history = self._session.get_history() + history.sort(key=lambda item: item["time"], reverse=True) + for event in history: + if self._selectedFilter not in [self.HISTORY_ITEM_TYPES[-1], event["action"]]: + continue + + relTime = event["relTime"] + action = event["action"] + number = event["number"] + prettyNumber = misc_utils.make_pretty(number) + if prettyNumber.startswith("+1 "): + prettyNumber = prettyNumber[len("+1 "):] + name = event["name"] + if not name or name == number: + name = event["location"] + if not name: + name = "Unknown" + + detailsItem = QtGui.QStandardItem(self._actionIcon[action], "%s\n%s" % (prettyNumber, relTime)) + detailsFont = detailsItem.font() + detailsFont.setPointSize(max(detailsFont.pointSize() - 6, 5)) + detailsItem.setFont(detailsFont) + nameItem = QtGui.QStandardItem(name) + nameFont = nameItem.font() + nameFont.setPointSize(nameFont.pointSize() + 4) + nameItem.setFont(nameFont) + row = detailsItem, nameItem + for item in row: + item.setEditable(False) + item.setCheckable(False) + row[self.DETAILS_IDX].setData(event) + self._categoryManager.add_row(event["time"], row) + self._itemView.expandAll() + + @qt_compat.Slot(str) + @misc_utils.log_exception(_moduleLogger) + def _on_filter_changed(self, newItem): + with qui_utils.notify_error(self._errorLog): + self._selectedFilter = str(newItem) + self._populate_items() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_history_updated(self): + with qui_utils.notify_error(self._errorLog): + self._populate_items() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_refresh_clicked(self, arg = None): + with qui_utils.notify_error(self._errorLog): + self.refresh(force=True) + + @qt_compat.Slot(QtCore.QModelIndex) + @misc_utils.log_exception(_moduleLogger) + def _on_row_activated(self, index): + with qui_utils.notify_error(self._errorLog): + timeIndex = index.parent() + if not timeIndex.isValid(): + return + timeRow = timeIndex.row() + row = index.row() + detailsItem = self._categoryManager.get_item(timeRow, row, self.DETAILS_IDX) + fromItem = self._categoryManager.get_item(timeRow, row, self.FROM_IDX) + contactDetails = detailsItem.data() + + title = unicode(fromItem.text()) + number = str(contactDetails["number"]) + contactId = number # ids don't seem too unique so using numbers + + descriptionRows = [] + for t in xrange(self._itemStore.rowCount()): + randomTimeItem = self._itemStore.item(t, 0) + for i in xrange(randomTimeItem.rowCount()): + iItem = randomTimeItem.child(i, 0) + iContactDetails = iItem.data() + iNumber = str(iContactDetails["number"]) + if number != iNumber: + continue + relTime = misc_utils.abbrev_relative_date(iContactDetails["relTime"]) + action = str(iContactDetails["action"]) + number = str(iContactDetails["number"]) + prettyNumber = misc_utils.make_pretty(number) + rowItems = relTime, action, prettyNumber + descriptionRows.append("%s" % "".join(rowItems)) + description = "%s
" % "".join(descriptionRows) + numbersWithDescriptions = [(str(contactDetails["number"]), "")] + self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) + + +class Messages(object): + + NO_MESSAGES = "None" + VOICEMAIL_MESSAGES = "Voicemail" + TEXT_MESSAGES = "SMS" + ALL_TYPES = "All Messages" + MESSAGE_TYPES = [NO_MESSAGES, VOICEMAIL_MESSAGES, TEXT_MESSAGES, ALL_TYPES] + + UNREAD_STATUS = "Unread" + UNARCHIVED_STATUS = "Inbox" + ALL_STATUS = "Any" + MESSAGE_STATUSES = [UNREAD_STATUS, UNARCHIVED_STATUS, ALL_STATUS] + + _MIN_MESSAGES_SHOWN = 1 + + def __init__(self, app, session, errorLog): + self._selectedTypeFilter = self.ALL_TYPES + self._selectedStatusFilter = self.ALL_STATUS + self._app = app + self._session = session + self._session.messagesUpdated.connect(self._on_messages_updated) + self._errorLog = errorLog + + self._typeSelection = QtGui.QComboBox() + self._typeSelection.addItems(self.MESSAGE_TYPES) + self._typeSelection.setCurrentIndex( + self.MESSAGE_TYPES.index(self._selectedTypeFilter) + ) + self._typeSelection.currentIndexChanged[str].connect(self._on_type_filter_changed) + + self._statusSelection = QtGui.QComboBox() + self._statusSelection.addItems(self.MESSAGE_STATUSES) + self._statusSelection.setCurrentIndex( + self.MESSAGE_STATUSES.index(self._selectedStatusFilter) + ) + self._statusSelection.currentIndexChanged[str].connect(self._on_status_filter_changed) + + refreshIcon = qui_utils.get_theme_icon( + ("view-refresh", "general_refresh", "gtk-refresh", ), + _SENTINEL_ICON + ) + if refreshIcon is not _SENTINEL_ICON: + self._refreshButton = QtGui.QPushButton(refreshIcon, "") + else: + self._refreshButton = QtGui.QPushButton("Refresh") + self._refreshButton.clicked.connect(self._on_refresh_clicked) + self._refreshButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.PushButton, + )) + + self._selectionLayout = QtGui.QHBoxLayout() + self._selectionLayout.addWidget(self._typeSelection, 1000) + self._selectionLayout.addWidget(self._statusSelection, 1000) + self._selectionLayout.addWidget(self._refreshButton, 0) + + self._itemStore = QtGui.QStandardItemModel() + self._itemStore.setHorizontalHeaderLabels(["Messages"]) + self._categoryManager = TimeCategories(self._itemStore) + + self._htmlDelegate = qui_utils.QHtmlDelegate() + self._itemView = QtGui.QTreeView() + self._itemView.setModel(self._itemStore) + self._itemView.setUniformRowHeights(False) + self._itemView.setRootIsDecorated(False) + self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + self._itemView.setHeaderHidden(True) + self._itemView.setItemsExpandable(False) + self._itemView.setItemDelegate(self._htmlDelegate) + self._itemView.activated.connect(self._on_row_activated) + self._itemView.header().sectionResized.connect(self._on_column_resized) + + self._layout = QtGui.QVBoxLayout() + self._layout.addLayout(self._selectionLayout) + self._layout.addWidget(self._itemView) + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + + self._populate_items() + + @property + def toplevel(self): + return self._widget + + def enable(self): + self._itemView.setEnabled(True) + + def disable(self): + self._itemView.setEnabled(False) + + def get_settings(self): + return { + "type": self._selectedTypeFilter, + "status": self._selectedStatusFilter, + } + + def set_settings(self, settings): + selectedType = settings.get("type", self.ALL_TYPES) + if selectedType in self.MESSAGE_TYPES: + self._selectedTypeFilter = selectedType + self._typeSelection.setCurrentIndex( + self.MESSAGE_TYPES.index(self._selectedTypeFilter) + ) + + selectedStatus = settings.get("status", self.ALL_STATUS) + if selectedStatus in self.MESSAGE_STATUSES: + self._selectedStatusFilter = selectedStatus + self._statusSelection.setCurrentIndex( + self.MESSAGE_STATUSES.index(self._selectedStatusFilter) + ) + + def clear(self): + self._itemView.clear() + + def refresh(self, force=True): + self._itemView.setFocus(QtCore.Qt.OtherFocusReason) + + if self._selectedTypeFilter == self.NO_MESSAGES: + pass + elif self._selectedTypeFilter == self.TEXT_MESSAGES: + self._session.update_messages(self._session.MESSAGE_TEXTS, force) + elif self._selectedTypeFilter == self.VOICEMAIL_MESSAGES: + self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force) + elif self._selectedTypeFilter == self.ALL_TYPES: + self._session.update_messages(self._session.MESSAGE_ALL, force) + else: + assert False, "How did we get here?" + + if (self._app.notifyOnSms or self._app.notifyOnVoicemail) and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE: + self._app.ledHandler.off() + + def _populate_items(self): + self._categoryManager.prepare_for_update(self._session.get_when_messages_updated()) + + rawMessages = self._session.get_messages() + rawMessages.sort(key=lambda item: item["time"], reverse=True) + for item in rawMessages: + isUnarchived = not item["isArchived"] + isUnread = not item["isRead"] + visibleStatus = { + self.UNREAD_STATUS: isUnarchived and isUnread, + self.UNARCHIVED_STATUS: isUnarchived, + self.ALL_STATUS: True, + }[self._selectedStatusFilter] + visibleType = self._selectedTypeFilter in [item["type"], self.ALL_TYPES] + if not (visibleType and visibleStatus): + continue + + relTime = misc_utils.abbrev_relative_date(item["relTime"]) + number = item["number"] + prettyNumber = misc_utils.make_pretty(number) + name = item["name"] + if not name or name == number: + name = item["location"] + if not name: + name = "Unknown" + + messageParts = list(item["messageParts"]) + if len(messageParts) == 0: + messages = ("No Transcription", ) + elif len(messageParts) == 1: + if messageParts[0][1]: + messages = (messageParts[0][1], ) + else: + messages = ("No Transcription", ) + else: + messages = [ + "%s: %s" % (messagePart[0], messagePart[1]) + for messagePart in messageParts + ] + + firstMessage = "%s
%s
(%s)" % (name, prettyNumber, relTime) + + expandedMessages = [firstMessage] + expandedMessages.extend(messages) + if self._MIN_MESSAGES_SHOWN < len(messages): + secondMessage = "%d Messages Hidden..." % (len(messages) - self._MIN_MESSAGES_SHOWN, ) + collapsedMessages = [firstMessage, secondMessage] + collapsedMessages.extend(messages[-(self._MIN_MESSAGES_SHOWN+0):]) + else: + collapsedMessages = expandedMessages + + item = dict(item.iteritems()) + item["collapsedMessages"] = "
\n".join(collapsedMessages) + item["expandedMessages"] = "
\n".join(expandedMessages) + + messageItem = QtGui.QStandardItem(item["collapsedMessages"]) + messageItem.setData(item) + messageItem.setEditable(False) + messageItem.setCheckable(False) + row = (messageItem, ) + self._categoryManager.add_row(item["time"], row) + self._itemView.expandAll() + + @qt_compat.Slot(str) + @misc_utils.log_exception(_moduleLogger) + def _on_type_filter_changed(self, newItem): + with qui_utils.notify_error(self._errorLog): + self._selectedTypeFilter = str(newItem) + self._populate_items() + + @qt_compat.Slot(str) + @misc_utils.log_exception(_moduleLogger) + def _on_status_filter_changed(self, newItem): + with qui_utils.notify_error(self._errorLog): + self._selectedStatusFilter = str(newItem) + self._populate_items() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_refresh_clicked(self, arg = None): + with qui_utils.notify_error(self._errorLog): + self.refresh(force=True) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_messages_updated(self): + with qui_utils.notify_error(self._errorLog): + self._populate_items() + + @qt_compat.Slot(QtCore.QModelIndex) + @misc_utils.log_exception(_moduleLogger) + def _on_row_activated(self, index): + with qui_utils.notify_error(self._errorLog): + timeIndex = index.parent() + if not timeIndex.isValid(): + return + timeRow = timeIndex.row() + row = index.row() + item = self._categoryManager.get_item(timeRow, row, 0) + contactDetails = item.data() + + name = unicode(contactDetails["name"]) + number = str(contactDetails["number"]) + if not name or name == number: + name = unicode(contactDetails["location"]) + if not name: + name = "Unknown" + + if str(contactDetails["type"]) == "Voicemail": + messageId = str(contactDetails["id"]) + else: + messageId = None + contactId = str(contactDetails["contactId"]) + title = name + description = unicode(contactDetails["expandedMessages"]) + numbersWithDescriptions = [(number, "")] + self._session.draft.add_contact(contactId, messageId, title, description, numbersWithDescriptions) + + @qt_compat.Slot(QtCore.QModelIndex) + @misc_utils.log_exception(_moduleLogger) + def _on_column_resized(self, index, oldSize, newSize): + self._htmlDelegate.setWidth(newSize, self._itemStore) + + +class Contacts(object): + + # @todo Provide some sort of letter jump + + def __init__(self, app, session, errorLog): + self._app = app + self._session = session + self._session.accountUpdated.connect(self._on_contacts_updated) + self._errorLog = errorLog + self._addressBookFactories = [ + null_backend.NullAddressBookFactory(), + file_backend.FilesystemAddressBookFactory(app.fsContactsPath), + qt_backend.QtContactsAddressBookFactory(), + ] + self._addressBooks = [] + + self._listSelection = QtGui.QComboBox() + self._listSelection.addItems([]) + self._listSelection.currentIndexChanged[str].connect(self._on_filter_changed) + self._activeList = "None" + refreshIcon = qui_utils.get_theme_icon( + ("view-refresh", "general_refresh", "gtk-refresh", ), + _SENTINEL_ICON + ) + if refreshIcon is not _SENTINEL_ICON: + self._refreshButton = QtGui.QPushButton(refreshIcon, "") + else: + self._refreshButton = QtGui.QPushButton("Refresh") + self._refreshButton.clicked.connect(self._on_refresh_clicked) + self._refreshButton.setSizePolicy(QtGui.QSizePolicy( + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.Minimum, + QtGui.QSizePolicy.PushButton, + )) + self._managerLayout = QtGui.QHBoxLayout() + self._managerLayout.addWidget(self._listSelection, 1000) + self._managerLayout.addWidget(self._refreshButton, 0) + + self._itemStore = QtGui.QStandardItemModel() + self._itemStore.setHorizontalHeaderLabels(["Contacts"]) + self._alphaItem = {} + + self._itemView = QtGui.QTreeView() + self._itemView.setModel(self._itemStore) + self._itemView.setUniformRowHeights(True) + self._itemView.setRootIsDecorated(False) + self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + self._itemView.setHeaderHidden(True) + self._itemView.setItemsExpandable(False) + self._itemView.activated.connect(self._on_row_activated) + + self._layout = QtGui.QVBoxLayout() + self._layout.addLayout(self._managerLayout) + self._layout.addWidget(self._itemView) + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + + self.update_addressbooks() + self._populate_items() + + @property + def toplevel(self): + return self._widget + + def enable(self): + self._itemView.setEnabled(True) + + def disable(self): + self._itemView.setEnabled(False) + + def get_settings(self): + return { + "selectedAddressbook": self._activeList, + } + + def set_settings(self, settings): + currentItem = settings.get("selectedAddressbook", "None") + bookNames = [book["name"] for book in self._addressBooks] + try: + newIndex = bookNames.index(currentItem) + except ValueError: + # Switch over to None for the user + newIndex = 0 + self._listSelection.setCurrentIndex(newIndex) + self._activeList = currentItem + + def clear(self): + self._itemView.clear() + + def refresh(self, force=True): + self._itemView.setFocus(QtCore.Qt.OtherFocusReason) + self._backend.update_account(force) + + @property + def _backend(self): + return self._addressBooks[self._listSelection.currentIndex()]["book"] + + def update_addressbooks(self): + self._addressBooks = [ + {"book": book, "name": book.name} + for factory in self._addressBookFactories + for book in factory.get_addressbooks() + ] + self._addressBooks.append( + { + "book": self._session, + "name": "Google Voice", + } + ) + + currentItem = str(self._listSelection.currentText()) + self._activeList = currentItem + if currentItem == "": + # Not loaded yet + currentItem = "None" + self._listSelection.clear() + bookNames = [book["name"] for book in self._addressBooks] + try: + newIndex = bookNames.index(currentItem) + except ValueError: + # Switch over to None for the user + newIndex = 0 + self._itemStore.clear() + _moduleLogger.info("Addressbook %r doesn't exist anymore, switching to None" % currentItem) + self._listSelection.addItems(bookNames) + self._listSelection.setCurrentIndex(newIndex) + + def _populate_items(self): + self._itemStore.clear() + self._alphaItem = dict( + (letter, QtGui.QStandardItem(letter)) + for letter in self._prefixes() + ) + for letter in self._prefixes(): + item = self._alphaItem[letter] + item.setEditable(False) + item.setCheckable(False) + row = (item, ) + self._itemStore.appendRow(row) + + for item in self._get_contacts(): + name = item["name"] + if not name: + name = "Unknown" + numbers = item["numbers"] + + nameItem = QtGui.QStandardItem(name) + nameItem.setEditable(False) + nameItem.setCheckable(False) + nameItem.setData(item) + nameItemFont = nameItem.font() + nameItemFont.setPointSize(max(nameItemFont.pointSize() + 4, 5)) + nameItem.setFont(nameItemFont) + + row = (nameItem, ) + rowKey = name[0].upper() + rowKey = rowKey if rowKey in self._alphaItem else "#" + self._alphaItem[rowKey].appendRow(row) + self._itemView.expandAll() + + def _prefixes(self): + return itertools.chain(string.ascii_uppercase, ("#", )) + + def _jump_to_prefix(self, letter): + i = list(self._prefixes()).index(letter) + rootIndex = self._itemView.rootIndex() + currentIndex = self._itemView.model().index(i, 0, rootIndex) + self._itemView.scrollTo(currentIndex) + self._itemView.setItemSelected(self._itemView.topLevelItem(i), True) + + def _get_contacts(self): + contacts = list(self._backend.get_contacts().itervalues()) + contacts.sort(key=lambda contact: contact["name"].lower()) + return contacts + + @qt_compat.Slot(str) + @misc_utils.log_exception(_moduleLogger) + def _on_filter_changed(self, newItem): + with qui_utils.notify_error(self._errorLog): + self._activeList = str(newItem) + self.refresh(force=False) + self._populate_items() + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_refresh_clicked(self, arg = None): + with qui_utils.notify_error(self._errorLog): + self.refresh(force=True) + + @qt_compat.Slot() + @misc_utils.log_exception(_moduleLogger) + def _on_contacts_updated(self): + with qui_utils.notify_error(self._errorLog): + self._populate_items() + + @qt_compat.Slot(QtCore.QModelIndex) + @misc_utils.log_exception(_moduleLogger) + def _on_row_activated(self, index): + with qui_utils.notify_error(self._errorLog): + letterIndex = index.parent() + if not letterIndex.isValid(): + return + letterRow = letterIndex.row() + letter = list(self._prefixes())[letterRow] + letterItem = self._alphaItem[letter] + rowIndex = index.row() + item = letterItem.child(rowIndex, 0) + contactDetails = item.data() + + name = unicode(contactDetails["name"]) + if not name: + name = unicode(contactDetails["location"]) + if not name: + name = "Unknown" + + contactId = str(contactDetails["contactId"]) + numbers = contactDetails["numbers"] + numbers = [ + dict( + (str(k), str(v)) + for (k, v) in number.iteritems() + ) + for number in numbers + ] + numbersWithDescriptions = [ + ( + number["phoneNumber"], + self._choose_phonetype(number), + ) + for number in numbers + ] + title = name + description = name + self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) + + @staticmethod + def _choose_phonetype(numberDetails): + if "phoneTypeName" in numberDetails: + return numberDetails["phoneTypeName"] + elif "phoneType" in numberDetails: + return numberDetails["phoneType"] + else: + return "" diff --git a/dialcentral/led_handler.py b/dialcentral/led_handler.py new file mode 100755 index 0000000..0914105 --- /dev/null +++ b/dialcentral/led_handler.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +import dbus + + +class _NokiaLedHandler(object): + + def __init__(self): + self._bus = dbus.SystemBus() + self._rawMceRequest = self._bus.get_object("com.nokia.mce", "/com/nokia/mce/request") + self._mceRequest = dbus.Interface(self._rawMceRequest, dbus_interface="com.nokia.mce.request") + + self._ledPattern = "PatternCommunicationChat" + + def on(self): + self._mceRequest.req_led_pattern_activate(self._ledPattern) + + def off(self): + self._mceRequest.req_led_pattern_deactivate(self._ledPattern) + + +class _NoLedHandler(object): + + def __init__(self): + pass + + def on(self): + pass + + def off(self): + pass + + +class LedHandler(object): + + def __init__(self): + self._actual = None + self._isReal = False + + def on(self): + self._lazy_init() + self._actual.on() + + def off(self): + self._lazy_init() + self._actual.off() + + @property + def isReal(self): + self._lazy_init() + self._isReal + + def _lazy_init(self): + if self._actual is not None: + return + try: + self._actual = _NokiaLedHandler() + self._isReal = True + except dbus.DBusException: + self._actual = _NoLedHandler() + self._isReal = False + + +if __name__ == "__main__": + leds = _NokiaLedHandler() + leds.off() diff --git a/dialcentral/session.py b/dialcentral/session.py new file mode 100644 index 0000000..dbdc3e4 --- /dev/null +++ b/dialcentral/session.py @@ -0,0 +1,830 @@ +from __future__ import with_statement + +import os +import time +import datetime +import contextlib +import logging + +try: + import cPickle + pickle = cPickle +except ImportError: + import pickle + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore + +from util import qore_utils +from util import qui_utils +from util import concurrent +from util import misc as misc_utils + +import constants + + +_moduleLogger = logging.getLogger(__name__) + + +class _DraftContact(object): + + def __init__(self, messageId, title, description, numbersWithDescriptions): + self.messageId = messageId + self.title = title + self.description = description + self.numbers = numbersWithDescriptions + self.selectedNumber = numbersWithDescriptions[0][0] + + +class Draft(QtCore.QObject): + + sendingMessage = qt_compat.Signal() + sentMessage = qt_compat.Signal() + calling = qt_compat.Signal() + called = qt_compat.Signal() + cancelling = qt_compat.Signal() + cancelled = qt_compat.Signal() + error = qt_compat.Signal(str) + + recipientsChanged = qt_compat.Signal() + + def __init__(self, asyncQueue, backend, errorLog): + QtCore.QObject.__init__(self) + self._errorLog = errorLog + self._contacts = {} + self._asyncQueue = asyncQueue + self._backend = backend + self._busyReason = None + self._message = "" + + def send(self): + assert 0 < len(self._contacts), "No contacts selected" + assert 0 < len(self._message), "No message to send" + numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()] + le = self._asyncQueue.add_async(self._send) + le.start(numbers, self._message) + + def call(self): + assert len(self._contacts) == 1, "Must select 1 and only 1 contact" + assert len(self._message) == 0, "Cannot send message with call" + (contact, ) = self._contacts.itervalues() + number = misc_utils.make_ugly(contact.selectedNumber) + le = self._asyncQueue.add_async(self._call) + le.start(number) + + def cancel(self): + le = self._asyncQueue.add_async(self._cancel) + le.start() + + def _get_message(self): + return self._message + + def _set_message(self, message): + self._message = message + + message = property(_get_message, _set_message) + + def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions): + if self._busyReason is not None: + raise RuntimeError("Please wait for %r" % self._busyReason) + # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up + contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions) + self._contacts[contactId] = contactDetails + self.recipientsChanged.emit() + + def remove_contact(self, contactId): + if self._busyReason is not None: + raise RuntimeError("Please wait for %r" % self._busyReason) + assert contactId in self._contacts, "Contact missing" + del self._contacts[contactId] + self.recipientsChanged.emit() + + def get_contacts(self): + return self._contacts.iterkeys() + + def get_num_contacts(self): + return len(self._contacts) + + def get_message_id(self, cid): + return self._contacts[cid].messageId + + def get_title(self, cid): + return self._contacts[cid].title + + def get_description(self, cid): + return self._contacts[cid].description + + def get_numbers(self, cid): + return self._contacts[cid].numbers + + def get_selected_number(self, cid): + return self._contacts[cid].selectedNumber + + def set_selected_number(self, cid, number): + # @note I'm lazy, this isn't firing any kind of signal since only one + # controller right now and that is the viewer + assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable" + self._contacts[cid].selectedNumber = number + + def clear(self): + if self._busyReason is not None: + raise RuntimeError("Please wait for %r" % self._busyReason) + self._clear() + + def _clear(self): + oldContacts = self._contacts + self._contacts = {} + self._message = "" + if oldContacts: + self.recipientsChanged.emit() + + @contextlib.contextmanager + def _busy(self, message): + if self._busyReason is not None: + raise RuntimeError("Already busy doing %r" % self._busyReason) + try: + self._busyReason = message + yield + finally: + self._busyReason = None + + def _send(self, numbers, text): + self.sendingMessage.emit() + try: + with self._busy("Sending Text"): + with qui_utils.notify_busy(self._errorLog, "Sending Text"): + yield ( + self._backend[0].send_sms, + (numbers, text), + {}, + ) + self.sentMessage.emit() + self._clear() + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + + def _call(self, number): + self.calling.emit() + try: + with self._busy("Calling"): + with qui_utils.notify_busy(self._errorLog, "Calling"): + yield ( + self._backend[0].call, + (number, ), + {}, + ) + self.called.emit() + self._clear() + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + + def _cancel(self): + self.cancelling.emit() + try: + with qui_utils.notify_busy(self._errorLog, "Cancelling"): + yield ( + self._backend[0].cancel, + (), + {}, + ) + self.cancelled.emit() + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + + +class Session(QtCore.QObject): + + # @todo Somehow add support for csv contacts + # @BUG When loading without caches, downloads messages twice + + stateChange = qt_compat.Signal(str) + loggedOut = qt_compat.Signal() + loggedIn = qt_compat.Signal() + callbackNumberChanged = qt_compat.Signal(str) + + accountUpdated = qt_compat.Signal() + messagesUpdated = qt_compat.Signal() + newMessages = qt_compat.Signal() + historyUpdated = qt_compat.Signal() + dndStateChange = qt_compat.Signal(bool) + voicemailAvailable = qt_compat.Signal(str, str) + + error = qt_compat.Signal(str) + + LOGGEDOUT_STATE = "logged out" + LOGGINGIN_STATE = "logging in" + LOGGEDIN_STATE = "logged in" + + MESSAGE_TEXTS = "Text" + MESSAGE_VOICEMAILS = "Voicemail" + MESSAGE_ALL = "All" + + HISTORY_RECEIVED = "Received" + HISTORY_MISSED = "Missed" + HISTORY_PLACED = "Placed" + HISTORY_ALL = "All" + + _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0") + + _LOGGEDOUT_TIME = -1 + _LOGGINGIN_TIME = 0 + + def __init__(self, errorLog, cachePath): + QtCore.QObject.__init__(self) + self._errorLog = errorLog + self._pool = qore_utils.FutureThread() + self._asyncQueue = concurrent.AsyncTaskQueue(self._pool) + self._backend = [] + self._loggedInTime = self._LOGGEDOUT_TIME + self._loginOps = [] + self._cachePath = cachePath + self._voicemailCachePath = None + self._username = None + self._password = None + self._draft = Draft(self._asyncQueue, self._backend, self._errorLog) + self._delayedRelogin = QtCore.QTimer() + self._delayedRelogin.setInterval(0) + self._delayedRelogin.setSingleShot(True) + self._delayedRelogin.timeout.connect(self._on_delayed_relogin) + + self._contacts = {} + self._accountUpdateTime = datetime.datetime(1971, 1, 1) + self._messages = [] + self._cleanMessages = [] + self._messageUpdateTime = datetime.datetime(1971, 1, 1) + self._history = [] + self._historyUpdateTime = datetime.datetime(1971, 1, 1) + self._dnd = False + self._callback = "" + + @property + def state(self): + return { + self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE, + self._LOGGINGIN_TIME: self.LOGGINGIN_STATE, + }.get(self._loggedInTime, self.LOGGEDIN_STATE) + + @property + def draft(self): + return self._draft + + def login(self, username, password): + assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state + assert username != "", "No username specified" + if self._cachePath is not None: + cookiePath = os.path.join(self._cachePath, "%s.cookies" % username) + else: + cookiePath = None + + if self._username != username or not self._backend: + from backends import gv_backend + del self._backend[:] + self._backend[0:0] = [gv_backend.GVDialer(cookiePath)] + + self._pool.start() + le = self._asyncQueue.add_async(self._login) + le.start(username, password) + + def logout(self): + assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state + _moduleLogger.info("Logging out") + self._pool.stop() + self._loggedInTime = self._LOGGEDOUT_TIME + self._backend[0].persist() + self._save_to_cache() + self._clear_voicemail_cache() + self.stateChange.emit(self.LOGGEDOUT_STATE) + self.loggedOut.emit() + + def clear(self): + assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state + self._backend[0].logout() + del self._backend[0] + self._clear_cache() + self._draft.clear() + + def logout_and_clear(self): + assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state + _moduleLogger.info("Logging out and clearing the account") + self._pool.stop() + self._loggedInTime = self._LOGGEDOUT_TIME + self.clear() + self.stateChange.emit(self.LOGGEDOUT_STATE) + self.loggedOut.emit() + + def update_account(self, force = True): + if not force and self._contacts: + return + le = self._asyncQueue.add_async(self._update_account), (), {} + self._perform_op_while_loggedin(le) + + def refresh_connection(self): + le = self._asyncQueue.add_async(self._refresh_authentication) + le.start() + + def get_contacts(self): + return self._contacts + + def get_when_contacts_updated(self): + return self._accountUpdateTime + + def update_messages(self, messageType, force = True): + if not force and self._messages: + return + le = self._asyncQueue.add_async(self._update_messages), (messageType, ), {} + self._perform_op_while_loggedin(le) + + def get_messages(self): + return self._messages + + def get_when_messages_updated(self): + return self._messageUpdateTime + + def update_history(self, historyType, force = True): + if not force and self._history: + return + le = self._asyncQueue.add_async(self._update_history), (historyType, ), {} + self._perform_op_while_loggedin(le) + + def get_history(self): + return self._history + + def get_when_history_updated(self): + return self._historyUpdateTime + + def update_dnd(self): + le = self._asyncQueue.add_async(self._update_dnd), (), {} + self._perform_op_while_loggedin(le) + + def set_dnd(self, dnd): + le = self._asyncQueue.add_async(self._set_dnd) + le.start(dnd) + + def is_available(self, messageId): + actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) + return os.path.exists(actualPath) + + def voicemail_path(self, messageId): + actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) + if not os.path.exists(actualPath): + raise RuntimeError("Voicemail not available") + return actualPath + + def download_voicemail(self, messageId): + le = self._asyncQueue.add_async(self._download_voicemail) + le.start(messageId) + + def _set_dnd(self, dnd): + oldDnd = self._dnd + try: + assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state + with qui_utils.notify_busy(self._errorLog, "Setting DND Status"): + yield ( + self._backend[0].set_dnd, + (dnd, ), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + self._dnd = dnd + if oldDnd != self._dnd: + self.dndStateChange.emit(self._dnd) + + def get_dnd(self): + return self._dnd + + def get_account_number(self): + if self.state != self.LOGGEDIN_STATE: + return "" + return self._backend[0].get_account_number() + + def get_callback_numbers(self): + if self.state != self.LOGGEDIN_STATE: + return {} + return self._backend[0].get_callback_numbers() + + def get_callback_number(self): + return self._callback + + def set_callback_number(self, callback): + le = self._asyncQueue.add_async(self._set_callback_number) + le.start(callback) + + def _set_callback_number(self, callback): + oldCallback = self._callback + try: + assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state + yield ( + self._backend[0].set_callback_number, + (callback, ), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + self._callback = callback + if oldCallback != self._callback: + self.callbackNumberChanged.emit(self._callback) + + def _login(self, username, password): + with qui_utils.notify_busy(self._errorLog, "Logging In"): + self._loggedInTime = self._LOGGINGIN_TIME + self.stateChange.emit(self.LOGGINGIN_STATE) + finalState = self.LOGGEDOUT_STATE + accountData = None + try: + if accountData is None and self._backend[0].is_quick_login_possible(): + accountData = yield ( + self._backend[0].refresh_account_info, + (), + {}, + ) + if accountData is not None: + _moduleLogger.info("Logged in through cookies") + else: + # Force a clearing of the cookies + yield ( + self._backend[0].logout, + (), + {}, + ) + + if accountData is None: + accountData = yield ( + self._backend[0].login, + (username, password), + {}, + ) + if accountData is not None: + _moduleLogger.info("Logged in through credentials") + + if accountData is not None: + self._loggedInTime = int(time.time()) + oldUsername = self._username + self._username = username + self._password = password + finalState = self.LOGGEDIN_STATE + if oldUsername != self._username: + needOps = not self._load() + else: + needOps = True + + self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username) + try: + os.makedirs(self._voicemailCachePath) + except OSError, e: + if e.errno != 17: + raise + + self.loggedIn.emit() + self.stateChange.emit(finalState) + finalState = None # Mark it as already set + self._process_account_data(accountData) + + if needOps: + loginOps = self._loginOps[:] + else: + loginOps = [] + del self._loginOps[:] + for asyncOp, args, kwds in loginOps: + asyncOp.start(*args, **kwds) + else: + self._loggedInTime = self._LOGGEDOUT_TIME + self.error.emit("Error logging in") + except Exception, e: + _moduleLogger.exception("Booh") + self._loggedInTime = self._LOGGEDOUT_TIME + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + finally: + if finalState is not None: + self.stateChange.emit(finalState) + if accountData is not None and self._callback: + self.set_callback_number(self._callback) + + def _update_account(self): + try: + with qui_utils.notify_busy(self._errorLog, "Updating Account"): + accountData = yield ( + self._backend[0].refresh_account_info, + (), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + self._loggedInTime = int(time.time()) + self._process_account_data(accountData) + + def _refresh_authentication(self): + try: + with qui_utils.notify_busy(self._errorLog, "Updating Account"): + accountData = yield ( + self._backend[0].refresh_account_info, + (), + {}, + ) + accountData = None + except Exception, e: + _moduleLogger.exception("Passing to user") + self.error.emit(str(e)) + # refresh_account_info does not normally throw, so it is fine if we + # just quit early because something seriously wrong is going on + return + + if accountData is not None: + self._loggedInTime = int(time.time()) + self._process_account_data(accountData) + else: + self._delayedRelogin.start() + + def _load(self): + updateMessages = len(self._messages) != 0 + updateHistory = len(self._history) != 0 + oldDnd = self._dnd + oldCallback = self._callback + + self._messages = [] + self._cleanMessages = [] + self._history = [] + self._dnd = False + self._callback = "" + + loadedFromCache = self._load_from_cache() + if loadedFromCache: + updateMessages = True + updateHistory = True + + if updateMessages: + self.messagesUpdated.emit() + if updateHistory: + self.historyUpdated.emit() + if oldDnd != self._dnd: + self.dndStateChange.emit(self._dnd) + if oldCallback != self._callback: + self.callbackNumberChanged.emit(self._callback) + + return loadedFromCache + + def _load_from_cache(self): + if self._cachePath is None: + return False + cachePath = os.path.join(self._cachePath, "%s.cache" % self._username) + + try: + with open(cachePath, "rb") as f: + dumpedData = pickle.load(f) + except (pickle.PickleError, IOError, EOFError, ValueError, ImportError): + _moduleLogger.exception("Pickle fun loading") + return False + except: + _moduleLogger.exception("Weirdness loading") + return False + + try: + version, build = dumpedData[0:2] + except ValueError: + _moduleLogger.exception("Upgrade/downgrade fun") + return False + except: + _moduleLogger.exception("Weirdlings") + return False + + if misc_utils.compare_versions( + self._OLDEST_COMPATIBLE_FORMAT_VERSION, + misc_utils.parse_version(version), + ) <= 0: + try: + ( + version, build, + messages, messageUpdateTime, + history, historyUpdateTime, + dnd, callback + ) = dumpedData + except ValueError: + _moduleLogger.exception("Upgrade/downgrade fun") + return False + except: + _moduleLogger.exception("Weirdlings") + return False + + _moduleLogger.info("Loaded cache") + self._messages = messages + self._alert_on_messages(self._messages) + self._messageUpdateTime = messageUpdateTime + self._history = history + self._historyUpdateTime = historyUpdateTime + self._dnd = dnd + self._callback = callback + return True + else: + _moduleLogger.debug( + "Skipping cache due to version mismatch (%s-%s)" % ( + version, build + ) + ) + return False + + def _save_to_cache(self): + _moduleLogger.info("Saving cache") + if self._cachePath is None: + return + cachePath = os.path.join(self._cachePath, "%s.cache" % self._username) + + try: + dataToDump = ( + constants.__version__, constants.__build__, + self._messages, self._messageUpdateTime, + self._history, self._historyUpdateTime, + self._dnd, self._callback + ) + with open(cachePath, "wb") as f: + pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL) + _moduleLogger.info("Cache saved") + except (pickle.PickleError, IOError): + _moduleLogger.exception("While saving") + + def _clear_cache(self): + updateMessages = len(self._messages) != 0 + updateHistory = len(self._history) != 0 + oldDnd = self._dnd + oldCallback = self._callback + + self._messages = [] + self._messageUpdateTime = datetime.datetime(1971, 1, 1) + self._history = [] + self._historyUpdateTime = datetime.datetime(1971, 1, 1) + self._dnd = False + self._callback = "" + + if updateMessages: + self.messagesUpdated.emit() + if updateHistory: + self.historyUpdated.emit() + if oldDnd != self._dnd: + self.dndStateChange.emit(self._dnd) + if oldCallback != self._callback: + self.callbackNumberChanged.emit(self._callback) + + self._save_to_cache() + self._clear_voicemail_cache() + + def _clear_voicemail_cache(self): + import shutil + shutil.rmtree(self._voicemailCachePath, True) + + def _update_messages(self, messageType): + try: + assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state + with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType): + self._messages = yield ( + self._backend[0].get_messages, + (messageType, ), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + self._messageUpdateTime = datetime.datetime.now() + self.messagesUpdated.emit() + self._alert_on_messages(self._messages) + + def _update_history(self, historyType): + try: + assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state + with qui_utils.notify_busy(self._errorLog, "Updating '%s' History" % historyType): + self._history = yield ( + self._backend[0].get_call_history, + (historyType, ), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + self._historyUpdateTime = datetime.datetime.now() + self.historyUpdated.emit() + + def _update_dnd(self): + with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"): + oldDnd = self._dnd + try: + assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state + self._dnd = yield ( + self._backend[0].is_dnd, + (), + {}, + ) + except Exception, e: + _moduleLogger.exception("Reporting error to user") + self.error.emit(str(e)) + return + if oldDnd != self._dnd: + self.dndStateChange(self._dnd) + + def _download_voicemail(self, messageId): + actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) + targetPath = "%s.%s.part" % (actualPath, time.time()) + if os.path.exists(actualPath): + self.voicemailAvailable.emit(messageId, actualPath) + return + with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"): + try: + yield ( + self._backend[0].download, + (messageId, targetPath), + {}, + ) + except Exception, e: + _moduleLogger.exception("Passing to user") + self.error.emit(str(e)) + return + + if os.path.exists(actualPath): + try: + os.remove(targetPath) + except: + _moduleLogger.exception("Ignoring file problems with cache") + self.voicemailAvailable.emit(messageId, actualPath) + return + else: + os.rename(targetPath, actualPath) + self.voicemailAvailable.emit(messageId, actualPath) + + def _perform_op_while_loggedin(self, op): + if self.state == self.LOGGEDIN_STATE: + op, args, kwds = op + op.start(*args, **kwds) + else: + self._push_login_op(op) + + def _push_login_op(self, asyncOp): + assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out" + if asyncOp in self._loginOps: + _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp) + return + self._loginOps.append(asyncOp) + + def _process_account_data(self, accountData): + self._contacts = dict( + (contactId, contactDetails) + for contactId, contactDetails in accountData["contacts"].iteritems() + # A zero contact id is the catch all for unknown contacts + if contactId != "0" + ) + + self._accountUpdateTime = datetime.datetime.now() + self.accountUpdated.emit() + + def _alert_on_messages(self, messages): + cleanNewMessages = list(self._clean_messages(messages)) + cleanNewMessages.sort(key=lambda m: m["contactId"]) + if self._cleanMessages: + if self._cleanMessages != cleanNewMessages: + self.newMessages.emit() + self._cleanMessages = cleanNewMessages + + def _clean_messages(self, messages): + for message in messages: + cleaned = dict( + kv + for kv in message.iteritems() + if kv[0] not in + [ + "relTime", + "time", + "isArchived", + "isRead", + "isSpam", + "isTrash", + ] + ) + + # Don't let outbound messages cause alerts, especially if the package has only outbound + cleaned["messageParts"] = [ + tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:" + ] + if not cleaned["messageParts"]: + continue + + yield cleaned + + @misc_utils.log_exception(_moduleLogger) + def _on_delayed_relogin(self): + try: + username = self._username + password = self._password + self.logout() + self.login(username, password) + except Exception, e: + _moduleLogger.exception("Passing to user") + self.error.emit(str(e)) + return diff --git a/dialcentral/stream_gst.py b/dialcentral/stream_gst.py new file mode 100644 index 0000000..ce97fb6 --- /dev/null +++ b/dialcentral/stream_gst.py @@ -0,0 +1,145 @@ +import logging + +import gobject +import gst + +import util.misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class Stream(gobject.GObject): + + # @bug Advertising state changes a bit early, should watch for GStreamer state change + + STATE_PLAY = "play" + STATE_PAUSE = "pause" + STATE_STOP = "stop" + + __gsignals__ = { + 'state-change' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_STRING, ), + ), + 'eof' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_STRING, ), + ), + 'error' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT), + ), + } + + def __init__(self): + gobject.GObject.__init__(self) + #Fields + self._uri = "" + self._elapsed = 0 + self._duration = 0 + + #Set up GStreamer + self._player = gst.element_factory_make("playbin2", "player") + bus = self._player.get_bus() + bus.add_signal_watch() + bus.connect("message", self._on_message) + + #Constants + self._timeFormat = gst.Format(gst.FORMAT_TIME) + self._seekFlag = gst.SEEK_FLAG_FLUSH + + @property + def playing(self): + return self.state == self.STATE_PLAY + + @property + def has_file(self): + return 0 < len(self._uri) + + @property + def state(self): + state = self._player.get_state()[1] + return self._translate_state(state) + + def set_file(self, uri): + if self._uri != uri: + self._invalidate_cache() + if self.state != self.STATE_STOP: + self.stop() + + self._uri = uri + self._player.set_property("uri", uri) + + def play(self): + if self.state == self.STATE_PLAY: + _moduleLogger.info("Already play") + return + _moduleLogger.info("Play") + self._player.set_state(gst.STATE_PLAYING) + self.emit("state-change", self.STATE_PLAY) + + def pause(self): + if self.state == self.STATE_PAUSE: + _moduleLogger.info("Already pause") + return + _moduleLogger.info("Pause") + self._player.set_state(gst.STATE_PAUSED) + self.emit("state-change", self.STATE_PAUSE) + + def stop(self): + if self.state == self.STATE_STOP: + _moduleLogger.info("Already stop") + return + self._player.set_state(gst.STATE_NULL) + _moduleLogger.info("Stopped") + self.emit("state-change", self.STATE_STOP) + + @property + def elapsed(self): + try: + self._elapsed = self._player.query_position(self._timeFormat, None)[0] + except: + pass + return self._elapsed + + @property + def duration(self): + try: + self._duration = self._player.query_duration(self._timeFormat, None)[0] + except: + _moduleLogger.exception("Query failed") + return self._duration + + def seek_time(self, ns): + self._elapsed = ns + self._player.seek_simple(self._timeFormat, self._seekFlag, ns) + + def _invalidate_cache(self): + self._elapsed = 0 + self._duration = 0 + + def _translate_state(self, gstState): + return { + gst.STATE_NULL: self.STATE_STOP, + gst.STATE_PAUSED: self.STATE_PAUSE, + gst.STATE_PLAYING: self.STATE_PLAY, + }.get(gstState, self.STATE_STOP) + + @misc_utils.log_exception(_moduleLogger) + def _on_message(self, bus, message): + t = message.type + if t == gst.MESSAGE_EOS: + self._player.set_state(gst.STATE_NULL) + self.emit("eof", self._uri) + elif t == gst.MESSAGE_ERROR: + self._player.set_state(gst.STATE_NULL) + err, debug = message.parse_error() + _moduleLogger.error("Error: %s, (%s)" % (err, debug)) + self.emit("error", err, debug) + + +gobject.type_register(Stream) diff --git a/dialcentral/stream_handler.py b/dialcentral/stream_handler.py new file mode 100644 index 0000000..3c0c9e3 --- /dev/null +++ b/dialcentral/stream_handler.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import logging + +import util.qt_compat as qt_compat +QtCore = qt_compat.QtCore + +import util.misc as misc_utils +try: + import stream_gst + stream = stream_gst +except ImportError: + try: + import stream_osso + stream = stream_osso + except ImportError: + import stream_null + stream = stream_null + + +_moduleLogger = logging.getLogger(__name__) + + +class StreamToken(QtCore.QObject): + + stateChange = qt_compat.Signal(str) + invalidated = qt_compat.Signal() + error = qt_compat.Signal(str) + + STATE_PLAY = stream.Stream.STATE_PLAY + STATE_PAUSE = stream.Stream.STATE_PAUSE + STATE_STOP = stream.Stream.STATE_STOP + + def __init__(self, stream): + QtCore.QObject.__init__(self) + self._stream = stream + self._stream.connect("state-change", self._on_stream_state) + self._stream.connect("eof", self._on_stream_eof) + self._stream.connect("error", self._on_stream_error) + + @property + def state(self): + if self.isValid: + return self._stream.state + else: + return self.STATE_STOP + + @property + def isValid(self): + return self._stream is not None + + def play(self): + self._stream.play() + + def pause(self): + self._stream.pause() + + def stop(self): + self._stream.stop() + + def invalidate(self): + if self._stream is None: + return + _moduleLogger.info("Playback token invalidated") + self._stream = None + + @misc_utils.log_exception(_moduleLogger) + def _on_stream_state(self, s, state): + if not self.isValid: + return + if state == self.STATE_STOP: + self.invalidate() + self.stateChange.emit(state) + + @misc_utils.log_exception(_moduleLogger) + def _on_stream_eof(self, s, uri): + if not self.isValid: + return + self.invalidate() + self.stateChange.emit(self.STATE_STOP) + + @misc_utils.log_exception(_moduleLogger) + def _on_stream_error(self, s, error, debug): + if not self.isValid: + return + _moduleLogger.info("Error %s %s" % (error, debug)) + self.error.emit(str(error)) + + +class StreamHandler(QtCore.QObject): + + def __init__(self): + QtCore.QObject.__init__(self) + self._stream = stream.Stream() + self._token = StreamToken(self._stream) + + def set_file(self, path): + self._token.invalidate() + self._token = StreamToken(self._stream) + self._stream.set_file(path) + return self._token + + @misc_utils.log_exception(_moduleLogger) + def _on_stream_state(self, s, state): + _moduleLogger.info("State change %r" % state) + + +if __name__ == "__main__": + pass + diff --git a/dialcentral/stream_null.py b/dialcentral/stream_null.py new file mode 100644 index 0000000..44fbbed --- /dev/null +++ b/dialcentral/stream_null.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import logging + + +_moduleLogger = logging.getLogger(__name__) + + +class Stream(object): + + STATE_PLAY = "play" + STATE_PAUSE = "pause" + STATE_STOP = "stop" + + def __init__(self): + pass + + def connect(self, signalName, slot): + pass + + @property + def playing(self): + return False + + @property + def has_file(self): + return False + + @property + def state(self): + return self.STATE_STOP + + def set_file(self, uri): + pass + + def play(self): + pass + + def pause(self): + pass + + def stop(self): + pass + + @property + def elapsed(self): + return 0 + + @property + def duration(self): + return 0 + + def seek_time(self, ns): + pass + + +if __name__ == "__main__": + pass + diff --git a/dialcentral/stream_osso.py b/dialcentral/stream_osso.py new file mode 100644 index 0000000..abc453f --- /dev/null +++ b/dialcentral/stream_osso.py @@ -0,0 +1,181 @@ +import logging + +import gobject +import dbus + +import util.misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class Stream(gobject.GObject): + + STATE_PLAY = "play" + STATE_PAUSE = "pause" + STATE_STOP = "stop" + + __gsignals__ = { + 'state-change' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_STRING, ), + ), + 'eof' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_STRING, ), + ), + 'error' : ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT), + ), + } + + _SERVICE_NAME = "com.nokia.osso_media_server" + _OBJECT_PATH = "/com/nokia/osso_media_server" + _AUDIO_INTERFACE_NAME = "com.nokia.osso_media_server.music" + + def __init__(self): + gobject.GObject.__init__(self) + #Fields + self._state = self.STATE_STOP + self._nextState = self.STATE_STOP + self._uri = "" + self._elapsed = 0 + self._duration = 0 + + session_bus = dbus.SessionBus() + + # Get the osso-media-player proxy object + oms_object = session_bus.get_object( + self._SERVICE_NAME, + self._OBJECT_PATH, + introspect=False, + follow_name_owner_changes=True, + ) + # Use the audio interface + oms_audio_interface = dbus.Interface( + oms_object, + self._AUDIO_INTERFACE_NAME, + ) + self._audioProxy = oms_audio_interface + + self._audioProxy.connect_to_signal("state_changed", self._on_state_changed) + self._audioProxy.connect_to_signal("end_of_stream", self._on_end_of_stream) + + error_signals = [ + "no_media_selected", + "file_not_found", + "type_not_found", + "unsupported_type", + "gstreamer", + "dsp", + "device_unavailable", + "corrupted_file", + "out_of_memory", + "audio_codec_not_supported", + ] + for error in error_signals: + self._audioProxy.connect_to_signal(error, self._on_error) + + @property + def playing(self): + return self.state == self.STATE_PLAY + + @property + def has_file(self): + return 0 < len(self._uri) + + @property + def state(self): + return self._state + + def set_file(self, uri): + if self._uri != uri: + self._invalidate_cache() + if self.state != self.STATE_STOP: + self.stop() + + self._uri = uri + self._audioProxy.set_media_location(self._uri) + + def play(self): + if self._nextState == self.STATE_PLAY: + _moduleLogger.info("Already play") + return + _moduleLogger.info("Play") + self._audioProxy.play() + self._nextState = self.STATE_PLAY + #self.emit("state-change", self.STATE_PLAY) + + def pause(self): + if self._nextState == self.STATE_PAUSE: + _moduleLogger.info("Already pause") + return + _moduleLogger.info("Pause") + self._audioProxy.pause() + self._nextState = self.STATE_PAUSE + #self.emit("state-change", self.STATE_PLAY) + + def stop(self): + if self._nextState == self.STATE_STOP: + _moduleLogger.info("Already stop") + return + self._audioProxy.stop() + _moduleLogger.info("Stopped") + self._nextState = self.STATE_STOP + #self.emit("state-change", self.STATE_STOP) + + @property + def elapsed(self): + pos_info = self._audioProxy.get_position() + if isinstance(pos_info, tuple): + self._elapsed, self._duration = pos_info + return self._elapsed + + @property + def duration(self): + pos_info = self._audioProxy.get_position() + if isinstance(pos_info, tuple): + self._elapsed, self._duration = pos_info + return self._duration + + def seek_time(self, ns): + _moduleLogger.debug("Seeking to: %s", ns) + self._audioProxy.seek( dbus.Int32(1), dbus.Int32(ns) ) + + def _invalidate_cache(self): + self._elapsed = 0 + self._duration = 0 + + @misc_utils.log_exception(_moduleLogger) + def _on_error(self, *args): + err, debug = "", repr(args) + _moduleLogger.error("Error: %s, (%s)" % (err, debug)) + self.emit("error", err, debug) + + @misc_utils.log_exception(_moduleLogger) + def _on_end_of_stream(self, *args): + self._state = self.STATE_STOP + self._nextState = self.STATE_STOP + self.emit("eof", self._uri) + + @misc_utils.log_exception(_moduleLogger) + def _on_state_changed(self, state): + _moduleLogger.info("State: %s", state) + state = { + "playing": self.STATE_PLAY, + "paused": self.STATE_PAUSE, + "stopped": self.STATE_STOP, + }[state] + if self._state == self.STATE_STOP and self._nextState == self.STATE_PLAY and state == self.STATE_STOP: + # They seem to want to advertise stop right as the stream is starting, breaking the owner of this + return + self._state = state + self._nextState = state + self.emit("state-change", state) + + +gobject.type_register(Stream) diff --git a/dialcentral/util/__init__.py b/dialcentral/util/__init__.py new file mode 100644 index 0000000..4265cc3 --- /dev/null +++ b/dialcentral/util/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python diff --git a/dialcentral/util/algorithms.py b/dialcentral/util/algorithms.py new file mode 100644 index 0000000..e94fb61 --- /dev/null +++ b/dialcentral/util/algorithms.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python + +""" +@note Source http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66448 +""" + +import itertools +import functools +import datetime +import types +import array +import random + + +def ordered_itr(collection): + """ + >>> [v for v in ordered_itr({"a": 1, "b": 2})] + [('a', 1), ('b', 2)] + >>> [v for v in ordered_itr([3, 1, 10, -20])] + [-20, 1, 3, 10] + """ + if isinstance(collection, types.DictType): + keys = list(collection.iterkeys()) + keys.sort() + for key in keys: + yield key, collection[key] + else: + values = list(collection) + values.sort() + for value in values: + yield value + + +def itercat(*iterators): + """ + Concatenate several iterators into one. + + >>> [v for v in itercat([1, 2, 3], [4, 1, 3])] + [1, 2, 3, 4, 1, 3] + """ + for i in iterators: + for x in i: + yield x + + +def product(*args, **kwds): + # product('ABCD', 'xy') --> Ax Ay Bx By Cx Cy Dx Dy + # product(range(2), repeat=3) --> 000 001 010 011 100 101 110 111 + pools = map(tuple, args) * kwds.get('repeat', 1) + result = [[]] + for pool in pools: + result = [x+[y] for x in result for y in pool] + for prod in result: + yield tuple(prod) + + +def iterwhile(func, iterator): + """ + Iterate for as long as func(value) returns true. + >>> through = lambda b: b + >>> [v for v in iterwhile(through, [True, True, False])] + [True, True] + """ + iterator = iter(iterator) + while 1: + next = iterator.next() + if not func(next): + raise StopIteration + yield next + + +def iterfirst(iterator, count=1): + """ + Iterate through 'count' first values. + + >>> [v for v in iterfirst([1, 2, 3, 4, 5], 3)] + [1, 2, 3] + """ + iterator = iter(iterator) + for i in xrange(count): + yield iterator.next() + + +def iterstep(iterator, n): + """ + Iterate every nth value. + + >>> [v for v in iterstep([1, 2, 3, 4, 5], 1)] + [1, 2, 3, 4, 5] + >>> [v for v in iterstep([1, 2, 3, 4, 5], 2)] + [1, 3, 5] + >>> [v for v in iterstep([1, 2, 3, 4, 5], 3)] + [1, 4] + """ + iterator = iter(iterator) + while True: + yield iterator.next() + # skip n-1 values + for dummy in xrange(n-1): + iterator.next() + + +def itergroup(iterator, count, padValue = None): + """ + Iterate in groups of 'count' values. If there + aren't enough values, the last result is padded with + None. + + >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): + ... print tuple(val) + (1, 2, 3) + (4, 5, 6) + >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): + ... print list(val) + [1, 2, 3] + [4, 5, 6] + >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3): + ... print tuple(val) + (1, 2, 3) + (4, 5, 6) + (7, None, None) + >>> for val in itergroup("123456", 3): + ... print tuple(val) + ('1', '2', '3') + ('4', '5', '6') + >>> for val in itergroup("123456", 3): + ... print repr("".join(val)) + '123' + '456' + """ + paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1)) + nIterators = (paddedIterator, ) * count + return itertools.izip(*nIterators) + + +def xzip(*iterators): + """Iterative version of builtin 'zip'.""" + iterators = itertools.imap(iter, iterators) + while 1: + yield tuple([x.next() for x in iterators]) + + +def xmap(func, *iterators): + """Iterative version of builtin 'map'.""" + iterators = itertools.imap(iter, iterators) + values_left = [1] + + def values(): + # Emulate map behaviour, i.e. shorter + # sequences are padded with None when + # they run out of values. + values_left[0] = 0 + for i in range(len(iterators)): + iterator = iterators[i] + if iterator is None: + yield None + else: + try: + yield iterator.next() + values_left[0] = 1 + except StopIteration: + iterators[i] = None + yield None + while 1: + args = tuple(values()) + if not values_left[0]: + raise StopIteration + yield func(*args) + + +def xfilter(func, iterator): + """Iterative version of builtin 'filter'.""" + iterator = iter(iterator) + while 1: + next = iterator.next() + if func(next): + yield next + + +def xreduce(func, iterator, default=None): + """Iterative version of builtin 'reduce'.""" + iterator = iter(iterator) + try: + prev = iterator.next() + except StopIteration: + return default + single = 1 + for next in iterator: + single = 0 + prev = func(prev, next) + if single: + return func(prev, default) + return prev + + +def daterange(begin, end, delta = datetime.timedelta(1)): + """ + Form a range of dates and iterate over them. + + Arguments: + begin -- a date (or datetime) object; the beginning of the range. + end -- a date (or datetime) object; the end of the range. + delta -- (optional) a datetime.timedelta object; how much to step each iteration. + Default step is 1 day. + + Usage: + """ + if not isinstance(delta, datetime.timedelta): + delta = datetime.timedelta(delta) + + ZERO = datetime.timedelta(0) + + if begin < end: + if delta <= ZERO: + raise StopIteration + test = end.__gt__ + else: + if delta >= ZERO: + raise StopIteration + test = end.__lt__ + + while test(begin): + yield begin + begin += delta + + +class LazyList(object): + """ + A Sequence whose values are computed lazily by an iterator. + + Module for the creation and use of iterator-based lazy lists. + this module defines a class LazyList which can be used to represent sequences + of values generated lazily. One can also create recursively defined lazy lists + that generate their values based on ones previously generated. + + Backport to python 2.5 by Michael Pust + """ + + __author__ = 'Dan Spitz' + + def __init__(self, iterable): + self._exhausted = False + self._iterator = iter(iterable) + self._data = [] + + def __len__(self): + """Get the length of a LazyList's computed data.""" + return len(self._data) + + def __getitem__(self, i): + """Get an item from a LazyList. + i should be a positive integer or a slice object.""" + if isinstance(i, int): + #index has not yet been yielded by iterator (or iterator exhausted + #before reaching that index) + if i >= len(self): + self.exhaust(i) + elif i < 0: + raise ValueError('cannot index LazyList with negative number') + return self._data[i] + + #LazyList slices are iterators over a portion of the list. + elif isinstance(i, slice): + start, stop, step = i.start, i.stop, i.step + if any(x is not None and x < 0 for x in (start, stop, step)): + raise ValueError('cannot index or step through a LazyList with' + 'a negative number') + #set start and step to their integer defaults if they are None. + if start is None: + start = 0 + if step is None: + step = 1 + + def LazyListIterator(): + count = start + predicate = ( + (lambda: True) + if stop is None + else (lambda: count < stop) + ) + while predicate(): + try: + yield self[count] + #slices can go out of actual index range without raising an + #error + except IndexError: + break + count += step + return LazyListIterator() + + raise TypeError('i must be an integer or slice') + + def __iter__(self): + """return an iterator over each value in the sequence, + whether it has been computed yet or not.""" + return self[:] + + def computed(self): + """Return an iterator over the values in a LazyList that have + already been computed.""" + return self[:len(self)] + + def exhaust(self, index = None): + """Exhaust the iterator generating this LazyList's values. + if index is None, this will exhaust the iterator completely. + Otherwise, it will iterate over the iterator until either the list + has a value for index or the iterator is exhausted. + """ + if self._exhausted: + return + if index is None: + ind_range = itertools.count(len(self)) + else: + ind_range = range(len(self), index + 1) + + for ind in ind_range: + try: + self._data.append(self._iterator.next()) + except StopIteration: #iterator is fully exhausted + self._exhausted = True + break + + +class RecursiveLazyList(LazyList): + + def __init__(self, prod, *args, **kwds): + super(RecursiveLazyList, self).__init__(prod(self, *args, **kwds)) + + +class RecursiveLazyListFactory: + + def __init__(self, producer): + self._gen = producer + + def __call__(self, *a, **kw): + return RecursiveLazyList(self._gen, *a, **kw) + + +def lazylist(gen): + """ + Decorator for creating a RecursiveLazyList subclass. + This should decorate a generator function taking the LazyList object as its + first argument which yields the contents of the list in order. + + >>> #fibonnacci sequence in a lazy list. + >>> @lazylist + ... def fibgen(lst): + ... yield 0 + ... yield 1 + ... for a, b in itertools.izip(lst, lst[1:]): + ... yield a + b + ... + >>> #now fibs can be indexed or iterated over as if it were an infinitely long list containing the fibonnaci sequence + >>> fibs = fibgen() + >>> + >>> #prime numbers in a lazy list. + >>> @lazylist + ... def primegen(lst): + ... yield 2 + ... for candidate in itertools.count(3): #start at next number after 2 + ... #if candidate is not divisible by any smaller prime numbers, + ... #it is a prime. + ... if all(candidate % p for p in lst.computed()): + ... yield candidate + ... + >>> #same for primes- treat it like an infinitely long list containing all prime numbers. + >>> primes = primegen() + >>> print fibs[0], fibs[1], fibs[2], primes[0], primes[1], primes[2] + 0 1 1 2 3 5 + >>> print list(fibs[:10]), list(primes[:10]) + [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] + """ + return RecursiveLazyListFactory(gen) + + +def map_func(f): + """ + >>> import misc + >>> misc.validate_decorator(map_func) + """ + + @functools.wraps(f) + def wrapper(*args): + result = itertools.imap(f, args) + return result + return wrapper + + +def reduce_func(function): + """ + >>> import misc + >>> misc.validate_decorator(reduce_func(lambda x: x)) + """ + + def decorator(f): + + @functools.wraps(f) + def wrapper(*args): + result = reduce(function, f(args)) + return result + return wrapper + return decorator + + +def any_(iterable): + """ + @note Python Version <2.5 + + >>> any_([True, True]) + True + >>> any_([True, False]) + True + >>> any_([False, False]) + False + """ + + for element in iterable: + if element: + return True + return False + + +def all_(iterable): + """ + @note Python Version <2.5 + + >>> all_([True, True]) + True + >>> all_([True, False]) + False + >>> all_([False, False]) + False + """ + + for element in iterable: + if not element: + return False + return True + + +def for_every(pred, seq): + """ + for_every takes a one argument predicate function and a sequence. + @param pred The predicate function should return true or false. + @returns true if every element in seq returns true for predicate, else returns false. + + >>> for_every (lambda c: c > 5,(6,7,8,9)) + True + + @author Source:http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52907 + """ + + for i in seq: + if not pred(i): + return False + return True + + +def there_exists(pred, seq): + """ + there_exists takes a one argument predicate function and a sequence. + @param pred The predicate function should return true or false. + @returns true if any element in seq returns true for predicate, else returns false. + + >>> there_exists (lambda c: c > 5,(6,7,8,9)) + True + + @author Source:http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52907 + """ + + for i in seq: + if pred(i): + return True + return False + + +def func_repeat(quantity, func, *args, **kwd): + """ + Meant to be in connection with "reduce" + """ + for i in xrange(quantity): + yield func(*args, **kwd) + + +def function_map(preds, item): + """ + Meant to be in connection with "reduce" + """ + results = (pred(item) for pred in preds) + + return results + + +def functional_if(combiner, preds, item): + """ + Combines the result of a list of predicates applied to item according to combiner + + @see any, every for example combiners + """ + pass_bool = lambda b: b + + bool_results = function_map(preds, item) + return combiner(pass_bool, bool_results) + + +def pushback_itr(itr): + """ + >>> list(pushback_itr(xrange(5))) + [0, 1, 2, 3, 4] + >>> + >>> first = True + >>> itr = pushback_itr(xrange(5)) + >>> for i in itr: + ... print i + ... if first and i == 2: + ... first = False + ... print itr.send(i) + 0 + 1 + 2 + None + 2 + 3 + 4 + >>> + >>> first = True + >>> itr = pushback_itr(xrange(5)) + >>> for i in itr: + ... print i + ... if first and i == 2: + ... first = False + ... print itr.send(i) + ... print itr.send(i) + 0 + 1 + 2 + None + None + 2 + 2 + 3 + 4 + >>> + >>> itr = pushback_itr(xrange(5)) + >>> print itr.next() + 0 + >>> print itr.next() + 1 + >>> print itr.send(10) + None + >>> print itr.next() + 10 + >>> print itr.next() + 2 + >>> print itr.send(20) + None + >>> print itr.send(30) + None + >>> print itr.send(40) + None + >>> print itr.next() + 40 + >>> print itr.next() + 30 + >>> print itr.send(50) + None + >>> print itr.next() + 50 + >>> print itr.next() + 20 + >>> print itr.next() + 3 + >>> print itr.next() + 4 + """ + for item in itr: + maybePushedBack = yield item + queue = [] + while queue or maybePushedBack is not None: + if maybePushedBack is not None: + queue.append(maybePushedBack) + maybePushedBack = yield None + else: + item = queue.pop() + maybePushedBack = yield item + + +def itr_available(queue, initiallyBlock = False): + if initiallyBlock: + yield queue.get() + while not queue.empty(): + yield queue.get_nowait() + + +class BloomFilter(object): + """ + http://en.wikipedia.org/wiki/Bloom_filter + Sources: + http://code.activestate.com/recipes/577684-bloom-filter/ + http://code.activestate.com/recipes/577686-bloom-filter/ + + >>> from random import sample + >>> from string import ascii_letters + >>> states = '''Alabama Alaska Arizona Arkansas California Colorado Connecticut + ... Delaware Florida Georgia Hawaii Idaho Illinois Indiana Iowa Kansas + ... Kentucky Louisiana Maine Maryland Massachusetts Michigan Minnesota + ... Mississippi Missouri Montana Nebraska Nevada NewHampshire NewJersey + ... NewMexico NewYork NorthCarolina NorthDakota Ohio Oklahoma Oregon + ... Pennsylvania RhodeIsland SouthCarolina SouthDakota Tennessee Texas Utah + ... Vermont Virginia Washington WestVirginia Wisconsin Wyoming'''.split() + >>> bf = BloomFilter(num_bits=1000, num_probes=14) + >>> for state in states: + ... bf.add(state) + >>> numStatesFound = sum(state in bf for state in states) + >>> numStatesFound, len(states) + (50, 50) + >>> trials = 100 + >>> numGarbageFound = sum(''.join(sample(ascii_letters, 5)) in bf for i in range(trials)) + >>> numGarbageFound, trials + (0, 100) + """ + + def __init__(self, num_bits, num_probes): + num_words = (num_bits + 31) // 32 + self._arr = array.array('B', [0]) * num_words + self._num_probes = num_probes + + def add(self, key): + for i, mask in self._get_probes(key): + self._arr[i] |= mask + + def union(self, bfilter): + if self._match_template(bfilter): + for i, b in enumerate(bfilter._arr): + self._arr[i] |= b + else: + # Union b/w two unrelated bloom filter raises this + raise ValueError("Mismatched bloom filters") + + def intersection(self, bfilter): + if self._match_template(bfilter): + for i, b in enumerate(bfilter._arr): + self._arr[i] &= b + else: + # Intersection b/w two unrelated bloom filter raises this + raise ValueError("Mismatched bloom filters") + + def __contains__(self, key): + return all(self._arr[i] & mask for i, mask in self._get_probes(key)) + + def _match_template(self, bfilter): + return self.num_bits == bfilter.num_bits and self.num_probes == bfilter.num_probes + + def _get_probes(self, key): + hasher = random.Random(key).randrange + for _ in range(self._num_probes): + array_index = hasher(len(self._arr)) + bit_index = hasher(32) + yield array_index, 1 << bit_index + + +if __name__ == "__main__": + import doctest + print doctest.testmod() diff --git a/dialcentral/util/concurrent.py b/dialcentral/util/concurrent.py new file mode 100644 index 0000000..f5f6e1d --- /dev/null +++ b/dialcentral/util/concurrent.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python + +from __future__ import with_statement + +import os +import errno +import time +import functools +import contextlib +import logging + +import misc + + +_moduleLogger = logging.getLogger(__name__) + + +class AsyncTaskQueue(object): + + def __init__(self, taskPool): + self._asyncs = [] + self._taskPool = taskPool + + def add_async(self, func): + self.flush() + a = AsyncGeneratorTask(self._taskPool, func) + self._asyncs.append(a) + return a + + def flush(self): + self._asyncs = [a for a in self._asyncs if not a.isDone] + + +class AsyncGeneratorTask(object): + + def __init__(self, pool, func): + self._pool = pool + self._func = func + self._run = None + self._isDone = False + + @property + def isDone(self): + return self._isDone + + def start(self, *args, **kwds): + assert self._run is None, "Task already started" + self._run = self._func(*args, **kwds) + trampoline, args, kwds = self._run.send(None) # priming the function + self._pool.add_task( + trampoline, + args, + kwds, + self.on_success, + self.on_error, + ) + + @misc.log_exception(_moduleLogger) + def on_success(self, result): + _moduleLogger.debug("Processing success for: %r", self._func) + try: + trampoline, args, kwds = self._run.send(result) + except StopIteration, e: + self._isDone = True + else: + self._pool.add_task( + trampoline, + args, + kwds, + self.on_success, + self.on_error, + ) + + @misc.log_exception(_moduleLogger) + def on_error(self, error): + _moduleLogger.debug("Processing error for: %r", self._func) + try: + trampoline, args, kwds = self._run.throw(error) + except StopIteration, e: + self._isDone = True + else: + self._pool.add_task( + trampoline, + args, + kwds, + self.on_success, + self.on_error, + ) + + def __repr__(self): + return "" % (self._func.__name__, id(self)) + + def __hash__(self): + return hash(self._func) + + def __eq__(self, other): + return self._func == other._func + + def __ne__(self, other): + return self._func != other._func + + +def synchronized(lock): + """ + Synchronization decorator. + + >>> import misc + >>> misc.validate_decorator(synchronized(object())) + """ + + def wrap(f): + + @functools.wraps(f) + def newFunction(*args, **kw): + lock.acquire() + try: + return f(*args, **kw) + finally: + lock.release() + return newFunction + return wrap + + +@contextlib.contextmanager +def qlock(queue, gblock = True, gtimeout = None, pblock = True, ptimeout = None): + """ + Locking with a queue, good for when you want to lock an item passed around + + >>> import Queue + >>> item = 5 + >>> lock = Queue.Queue() + >>> lock.put(item) + >>> with qlock(lock) as i: + ... print i + 5 + """ + item = queue.get(gblock, gtimeout) + try: + yield item + finally: + queue.put(item, pblock, ptimeout) + + +@contextlib.contextmanager +def flock(path, timeout=-1): + WAIT_FOREVER = -1 + DELAY = 0.1 + timeSpent = 0 + + acquired = False + + while timeSpent <= timeout or timeout == WAIT_FOREVER: + try: + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR) + acquired = True + break + except OSError, e: + if e.errno != errno.EEXIST: + raise + time.sleep(DELAY) + timeSpent += DELAY + + assert acquired, "Failed to grab file-lock %s within timeout %d" % (path, timeout) + + try: + yield fd + finally: + os.unlink(path) diff --git a/dialcentral/util/coroutines.py b/dialcentral/util/coroutines.py new file mode 100755 index 0000000..b1e539e --- /dev/null +++ b/dialcentral/util/coroutines.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python + +""" +Uses for generators +* Pull pipelining (iterators) +* Push pipelining (coroutines) +* State machines (coroutines) +* "Cooperative multitasking" (coroutines) +* Algorithm -> Object transform for cohesiveness (for example context managers) (coroutines) + +Design considerations +* When should a stage pass on exceptions or have it thrown within it? +* When should a stage pass on GeneratorExits? +* Is there a way to either turn a push generator into a iterator or to use + comprehensions syntax for push generators (I doubt it) +* When should the stage try and send data in both directions +* Since pull generators (generators), push generators (coroutines), subroutines, and coroutines are all coroutines, maybe we should rename the push generators to not confuse them, like signals/slots? and then refer to two-way generators as coroutines +** If so, make s* and co* implementation of functions +""" + +import threading +import Queue +import pickle +import functools +import itertools +import xml.sax +import xml.parsers.expat + + +def autostart(func): + """ + >>> @autostart + ... def grep_sink(pattern): + ... print "Looking for %s" % pattern + ... while True: + ... line = yield + ... if pattern in line: + ... print line, + >>> g = grep_sink("python") + Looking for python + >>> g.send("Yeah but no but yeah but no") + >>> g.send("A series of tubes") + >>> g.send("python generators rock!") + python generators rock! + >>> g.close() + """ + + @functools.wraps(func) + def start(*args, **kwargs): + cr = func(*args, **kwargs) + cr.next() + return cr + + return start + + +@autostart +def printer_sink(format = "%s"): + """ + >>> pr = printer_sink("%r") + >>> pr.send("Hello") + 'Hello' + >>> pr.send("5") + '5' + >>> pr.send(5) + 5 + >>> p = printer_sink() + >>> p.send("Hello") + Hello + >>> p.send("World") + World + >>> # p.throw(RuntimeError, "Goodbye") + >>> # p.send("Meh") + >>> # p.close() + """ + while True: + item = yield + print format % (item, ) + + +@autostart +def null_sink(): + """ + Good for uses like with cochain to pick up any slack + """ + while True: + item = yield + + +def itr_source(itr, target): + """ + >>> itr_source(xrange(2), printer_sink()) + 0 + 1 + """ + for item in itr: + target.send(item) + + +@autostart +def cofilter(predicate, target): + """ + >>> p = printer_sink() + >>> cf = cofilter(None, p) + >>> cf.send("") + >>> cf.send("Hello") + Hello + >>> cf.send([]) + >>> cf.send([1, 2]) + [1, 2] + >>> cf.send(False) + >>> cf.send(True) + True + >>> cf.send(0) + >>> cf.send(1) + 1 + >>> # cf.throw(RuntimeError, "Goodbye") + >>> # cf.send(False) + >>> # cf.send(True) + >>> # cf.close() + """ + if predicate is None: + predicate = bool + + while True: + try: + item = yield + if predicate(item): + target.send(item) + except StandardError, e: + target.throw(e.__class__, e.message) + + +@autostart +def comap(function, target): + """ + >>> p = printer_sink() + >>> cm = comap(lambda x: x+1, p) + >>> cm.send(0) + 1 + >>> cm.send(1.0) + 2.0 + >>> cm.send(-2) + -1 + >>> # cm.throw(RuntimeError, "Goodbye") + >>> # cm.send(0) + >>> # cm.send(1.0) + >>> # cm.close() + """ + while True: + try: + item = yield + mappedItem = function(item) + target.send(mappedItem) + except StandardError, e: + target.throw(e.__class__, e.message) + + +def func_sink(function): + return comap(function, null_sink()) + + +def expand_positional(function): + + @functools.wraps(function) + def expander(item): + return function(*item) + + return expander + + +@autostart +def append_sink(l): + """ + >>> l = [] + >>> apps = append_sink(l) + >>> apps.send(1) + >>> apps.send(2) + >>> apps.send(3) + >>> print l + [1, 2, 3] + """ + while True: + item = yield + l.append(item) + + +@autostart +def last_n_sink(l, n = 1): + """ + >>> l = [] + >>> lns = last_n_sink(l) + >>> lns.send(1) + >>> lns.send(2) + >>> lns.send(3) + >>> print l + [3] + """ + del l[:] + while True: + item = yield + extraCount = len(l) - n + 1 + if 0 < extraCount: + del l[0:extraCount] + l.append(item) + + +@autostart +def coreduce(target, function, initializer = None): + """ + >>> reduceResult = [] + >>> lns = last_n_sink(reduceResult) + >>> cr = coreduce(lns, lambda x, y: x + y, 0) + >>> cr.send(1) + >>> cr.send(2) + >>> cr.send(3) + >>> print reduceResult + [6] + >>> cr = coreduce(lns, lambda x, y: x + y) + >>> cr.send(1) + >>> cr.send(2) + >>> cr.send(3) + >>> print reduceResult + [6] + """ + isFirst = True + cumulativeRef = initializer + while True: + item = yield + if isFirst and initializer is None: + cumulativeRef = item + else: + cumulativeRef = function(cumulativeRef, item) + target.send(cumulativeRef) + isFirst = False + + +@autostart +def cotee(targets): + """ + Takes a sequence of coroutines and sends the received items to all of them + + >>> ct = cotee((printer_sink("1 %s"), printer_sink("2 %s"))) + >>> ct.send("Hello") + 1 Hello + 2 Hello + >>> ct.send("World") + 1 World + 2 World + >>> # ct.throw(RuntimeError, "Goodbye") + >>> # ct.send("Meh") + >>> # ct.close() + """ + while True: + try: + item = yield + for target in targets: + target.send(item) + except StandardError, e: + for target in targets: + target.throw(e.__class__, e.message) + + +class CoTee(object): + """ + >>> ct = CoTee() + >>> ct.register_sink(printer_sink("1 %s")) + >>> ct.register_sink(printer_sink("2 %s")) + >>> ct.stage.send("Hello") + 1 Hello + 2 Hello + >>> ct.stage.send("World") + 1 World + 2 World + >>> ct.register_sink(printer_sink("3 %s")) + >>> ct.stage.send("Foo") + 1 Foo + 2 Foo + 3 Foo + >>> # ct.stage.throw(RuntimeError, "Goodbye") + >>> # ct.stage.send("Meh") + >>> # ct.stage.close() + """ + + def __init__(self): + self.stage = self._stage() + self._targets = [] + + def register_sink(self, sink): + self._targets.append(sink) + + def unregister_sink(self, sink): + self._targets.remove(sink) + + def restart(self): + self.stage = self._stage() + + @autostart + def _stage(self): + while True: + try: + item = yield + for target in self._targets: + target.send(item) + except StandardError, e: + for target in self._targets: + target.throw(e.__class__, e.message) + + +def _flush_queue(queue): + while not queue.empty(): + yield queue.get() + + +@autostart +def cocount(target, start = 0): + """ + >>> cc = cocount(printer_sink("%s")) + >>> cc.send("a") + 0 + >>> cc.send(None) + 1 + >>> cc.send([]) + 2 + >>> cc.send(0) + 3 + """ + for i in itertools.count(start): + item = yield + target.send(i) + + +@autostart +def coenumerate(target, start = 0): + """ + >>> ce = coenumerate(printer_sink("%r")) + >>> ce.send("a") + (0, 'a') + >>> ce.send(None) + (1, None) + >>> ce.send([]) + (2, []) + >>> ce.send(0) + (3, 0) + """ + for i in itertools.count(start): + item = yield + decoratedItem = i, item + target.send(decoratedItem) + + +@autostart +def corepeat(target, elem): + """ + >>> cr = corepeat(printer_sink("%s"), "Hello World") + >>> cr.send("a") + Hello World + >>> cr.send(None) + Hello World + >>> cr.send([]) + Hello World + >>> cr.send(0) + Hello World + """ + while True: + item = yield + target.send(elem) + + +@autostart +def cointercept(target, elems): + """ + >>> cr = cointercept(printer_sink("%s"), [1, 2, 3, 4]) + >>> cr.send("a") + 1 + >>> cr.send(None) + 2 + >>> cr.send([]) + 3 + >>> cr.send(0) + 4 + >>> cr.send("Bye") + Traceback (most recent call last): + File "/usr/lib/python2.5/doctest.py", line 1228, in __run + compileflags, 1) in test.globs + File "", line 1, in + cr.send("Bye") + StopIteration + """ + item = yield + for elem in elems: + target.send(elem) + item = yield + + +@autostart +def codropwhile(target, pred): + """ + >>> cdw = codropwhile(printer_sink("%s"), lambda x: x) + >>> cdw.send([0, 1, 2]) + >>> cdw.send(1) + >>> cdw.send(True) + >>> cdw.send(False) + >>> cdw.send([0, 1, 2]) + [0, 1, 2] + >>> cdw.send(1) + 1 + >>> cdw.send(True) + True + """ + while True: + item = yield + if not pred(item): + break + + while True: + item = yield + target.send(item) + + +@autostart +def cotakewhile(target, pred): + """ + >>> ctw = cotakewhile(printer_sink("%s"), lambda x: x) + >>> ctw.send([0, 1, 2]) + [0, 1, 2] + >>> ctw.send(1) + 1 + >>> ctw.send(True) + True + >>> ctw.send(False) + >>> ctw.send([0, 1, 2]) + >>> ctw.send(1) + >>> ctw.send(True) + """ + while True: + item = yield + if not pred(item): + break + target.send(item) + + while True: + item = yield + + +@autostart +def coslice(target, lower, upper): + """ + >>> cs = coslice(printer_sink("%r"), 3, 5) + >>> cs.send("0") + >>> cs.send("1") + >>> cs.send("2") + >>> cs.send("3") + '3' + >>> cs.send("4") + '4' + >>> cs.send("5") + >>> cs.send("6") + """ + for i in xrange(lower): + item = yield + for i in xrange(upper - lower): + item = yield + target.send(item) + while True: + item = yield + + +@autostart +def cochain(targets): + """ + >>> cr = cointercept(printer_sink("good %s"), [1, 2, 3, 4]) + >>> cc = cochain([cr, printer_sink("end %s")]) + >>> cc.send("a") + good 1 + >>> cc.send(None) + good 2 + >>> cc.send([]) + good 3 + >>> cc.send(0) + good 4 + >>> cc.send("Bye") + end Bye + """ + behind = [] + for target in targets: + try: + while behind: + item = behind.pop() + target.send(item) + while True: + item = yield + target.send(item) + except StopIteration: + behind.append(item) + + +@autostart +def queue_sink(queue): + """ + >>> q = Queue.Queue() + >>> qs = queue_sink(q) + >>> qs.send("Hello") + >>> qs.send("World") + >>> qs.throw(RuntimeError, "Goodbye") + >>> qs.send("Meh") + >>> qs.close() + >>> print [i for i in _flush_queue(q)] + [(None, 'Hello'), (None, 'World'), (, 'Goodbye'), (None, 'Meh'), (, None)] + """ + while True: + try: + item = yield + queue.put((None, item)) + except StandardError, e: + queue.put((e.__class__, e.message)) + except GeneratorExit: + queue.put((GeneratorExit, None)) + raise + + +def decode_item(item, target): + if item[0] is None: + target.send(item[1]) + return False + elif item[0] is GeneratorExit: + target.close() + return True + else: + target.throw(item[0], item[1]) + return False + + +def queue_source(queue, target): + """ + >>> q = Queue.Queue() + >>> for i in [ + ... (None, 'Hello'), + ... (None, 'World'), + ... (GeneratorExit, None), + ... ]: + ... q.put(i) + >>> qs = queue_source(q, printer_sink()) + Hello + World + """ + isDone = False + while not isDone: + item = queue.get() + isDone = decode_item(item, target) + + +def threaded_stage(target, thread_factory = threading.Thread): + messages = Queue.Queue() + + run_source = functools.partial(queue_source, messages, target) + thread_factory(target=run_source).start() + + # Sink running in current thread + return functools.partial(queue_sink, messages) + + +@autostart +def pickle_sink(f): + while True: + try: + item = yield + pickle.dump((None, item), f) + except StandardError, e: + pickle.dump((e.__class__, e.message), f) + except GeneratorExit: + pickle.dump((GeneratorExit, ), f) + raise + except StopIteration: + f.close() + return + + +def pickle_source(f, target): + try: + isDone = False + while not isDone: + item = pickle.load(f) + isDone = decode_item(item, target) + except EOFError: + target.close() + + +class EventHandler(object, xml.sax.ContentHandler): + + START = "start" + TEXT = "text" + END = "end" + + def __init__(self, target): + object.__init__(self) + xml.sax.ContentHandler.__init__(self) + self._target = target + + def startElement(self, name, attrs): + self._target.send((self.START, (name, attrs._attrs))) + + def characters(self, text): + self._target.send((self.TEXT, text)) + + def endElement(self, name): + self._target.send((self.END, name)) + + +def expat_parse(f, target): + parser = xml.parsers.expat.ParserCreate() + parser.buffer_size = 65536 + parser.buffer_text = True + parser.returns_unicode = False + parser.StartElementHandler = lambda name, attrs: target.send(('start', (name, attrs))) + parser.EndElementHandler = lambda name: target.send(('end', name)) + parser.CharacterDataHandler = lambda data: target.send(('text', data)) + parser.ParseFile(f) + + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/dialcentral/util/go_utils.py b/dialcentral/util/go_utils.py new file mode 100644 index 0000000..61e731d --- /dev/null +++ b/dialcentral/util/go_utils.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python + +from __future__ import with_statement + +import time +import functools +import threading +import Queue +import logging + +import gobject + +import algorithms +import misc + + +_moduleLogger = logging.getLogger(__name__) + + +def make_idler(func): + """ + Decorator that makes a generator-function into a function that will continue execution on next call + """ + a = [] + + @functools.wraps(func) + def decorated_func(*args, **kwds): + if not a: + a.append(func(*args, **kwds)) + try: + a[0].next() + return True + except StopIteration: + del a[:] + return False + + return decorated_func + + +def async(func): + """ + Make a function mainloop friendly. the function will be called at the + next mainloop idle state. + + >>> import misc + >>> misc.validate_decorator(async) + """ + + @functools.wraps(func) + def new_function(*args, **kwargs): + + def async_function(): + func(*args, **kwargs) + return False + + gobject.idle_add(async_function) + + return new_function + + +class Async(object): + + def __init__(self, func, once = True): + self.__func = func + self.__idleId = None + self.__once = once + + def start(self): + assert self.__idleId is None + if self.__once: + self.__idleId = gobject.idle_add(self._on_once) + else: + self.__idleId = gobject.idle_add(self.__func) + + def is_running(self): + return self.__idleId is not None + + def cancel(self): + if self.__idleId is not None: + gobject.source_remove(self.__idleId) + self.__idleId = None + + def __call__(self): + return self.start() + + @misc.log_exception(_moduleLogger) + def _on_once(self): + self.cancel() + try: + self.__func() + except Exception: + pass + return False + + +class Timeout(object): + + def __init__(self, func, once = True): + self.__func = func + self.__timeoutId = None + self.__once = once + + def start(self, **kwds): + assert self.__timeoutId is None + + callback = self._on_once if self.__once else self.__func + + assert len(kwds) == 1 + timeoutInSeconds = kwds["seconds"] + assert 0 <= timeoutInSeconds + + if timeoutInSeconds == 0: + self.__timeoutId = gobject.idle_add(callback) + else: + self.__timeoutId = timeout_add_seconds(timeoutInSeconds, callback) + + def is_running(self): + return self.__timeoutId is not None + + def cancel(self): + if self.__timeoutId is not None: + gobject.source_remove(self.__timeoutId) + self.__timeoutId = None + + def __call__(self, **kwds): + return self.start(**kwds) + + @misc.log_exception(_moduleLogger) + def _on_once(self): + self.cancel() + try: + self.__func() + except Exception: + pass + return False + + +_QUEUE_EMPTY = object() + + +class FutureThread(object): + + def __init__(self): + self.__workQueue = Queue.Queue() + self.__thread = threading.Thread( + name = type(self).__name__, + target = self.__consume_queue, + ) + self.__isRunning = True + + def start(self): + self.__thread.start() + + def stop(self): + self.__isRunning = False + for _ in algorithms.itr_available(self.__workQueue): + pass # eat up queue to cut down dumb work + self.__workQueue.put(_QUEUE_EMPTY) + + def clear_tasks(self): + for _ in algorithms.itr_available(self.__workQueue): + pass # eat up queue to cut down dumb work + + def add_task(self, func, args, kwds, on_success, on_error): + task = func, args, kwds, on_success, on_error + self.__workQueue.put(task) + + @misc.log_exception(_moduleLogger) + def __trampoline_callback(self, on_success, on_error, isError, result): + if not self.__isRunning: + if isError: + _moduleLogger.error("Masking: %s" % (result, )) + isError = True + result = StopIteration("Cancelling all callbacks") + callback = on_success if not isError else on_error + try: + callback(result) + except Exception: + _moduleLogger.exception("Callback errored") + return False + + @misc.log_exception(_moduleLogger) + def __consume_queue(self): + while True: + task = self.__workQueue.get() + if task is _QUEUE_EMPTY: + break + func, args, kwds, on_success, on_error = task + + try: + result = func(*args, **kwds) + isError = False + except Exception, e: + _moduleLogger.error("Error, passing it back to the main thread") + result = e + isError = True + self.__workQueue.task_done() + + gobject.idle_add(self.__trampoline_callback, on_success, on_error, isError, result) + _moduleLogger.debug("Shutting down worker thread") + + +class AutoSignal(object): + + def __init__(self, toplevel): + self.__disconnectPool = [] + toplevel.connect("destroy", self.__on_destroy) + + def connect_auto(self, widget, *args): + id = widget.connect(*args) + self.__disconnectPool.append((widget, id)) + + @misc.log_exception(_moduleLogger) + def __on_destroy(self, widget): + _moduleLogger.info("Destroy: %r (%s to clean up)" % (self, len(self.__disconnectPool))) + for widget, id in self.__disconnectPool: + widget.disconnect(id) + del self.__disconnectPool[:] + + +def throttled(minDelay, queue): + """ + Throttle the calls to a function by queueing all the calls that happen + before the minimum delay + + >>> import misc + >>> import Queue + >>> misc.validate_decorator(throttled(0, Queue.Queue())) + """ + + def actual_decorator(func): + + lastCallTime = [None] + + def process_queue(): + if 0 < len(queue): + func, args, kwargs = queue.pop(0) + lastCallTime[0] = time.time() * 1000 + func(*args, **kwargs) + return False + + @functools.wraps(func) + def new_function(*args, **kwargs): + now = time.time() * 1000 + if ( + lastCallTime[0] is None or + (now - lastCallTime >= minDelay) + ): + lastCallTime[0] = now + func(*args, **kwargs) + else: + queue.append((func, args, kwargs)) + lastCallDelta = now - lastCallTime[0] + processQueueTimeout = int(minDelay * len(queue) - lastCallDelta) + gobject.timeout_add(processQueueTimeout, process_queue) + + return new_function + + return actual_decorator + + +def _old_timeout_add_seconds(timeout, callback): + return gobject.timeout_add(timeout * 1000, callback) + + +def _timeout_add_seconds(timeout, callback): + return gobject.timeout_add_seconds(timeout, callback) + + +try: + gobject.timeout_add_seconds + timeout_add_seconds = _timeout_add_seconds +except AttributeError: + timeout_add_seconds = _old_timeout_add_seconds diff --git a/dialcentral/util/io.py b/dialcentral/util/io.py new file mode 100644 index 0000000..4198f4b --- /dev/null +++ b/dialcentral/util/io.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python + + +from __future__ import with_statement + +import os +import pickle +import contextlib +import itertools +import codecs +from xml.sax import saxutils +import csv +try: + import cStringIO as StringIO +except ImportError: + import StringIO + + +@contextlib.contextmanager +def change_directory(directory): + previousDirectory = os.getcwd() + os.chdir(directory) + currentDirectory = os.getcwd() + + try: + yield previousDirectory, currentDirectory + finally: + os.chdir(previousDirectory) + + +@contextlib.contextmanager +def pickled(filename): + """ + Here is an example usage: + with pickled("foo.db") as p: + p("users", list).append(["srid", "passwd", 23]) + """ + + if os.path.isfile(filename): + data = pickle.load(open(filename)) + else: + data = {} + + def getter(item, factory): + if item in data: + return data[item] + else: + data[item] = factory() + return data[item] + + yield getter + + pickle.dump(data, open(filename, "w")) + + +@contextlib.contextmanager +def redirect(object_, attr, value): + """ + >>> import sys + ... with redirect(sys, 'stdout', open('stdout', 'w')): + ... print "hello" + ... + >>> print "we're back" + we're back + """ + orig = getattr(object_, attr) + setattr(object_, attr, value) + try: + yield + finally: + setattr(object_, attr, orig) + + +def pathsplit(path): + """ + >>> pathsplit("/a/b/c") + ['', 'a', 'b', 'c'] + >>> pathsplit("./plugins/builtins.ini") + ['.', 'plugins', 'builtins.ini'] + """ + pathParts = path.split(os.path.sep) + return pathParts + + +def commonpath(l1, l2, common=None): + """ + >>> commonpath(pathsplit('/a/b/c/d'), pathsplit('/a/b/c1/d1')) + (['', 'a', 'b'], ['c', 'd'], ['c1', 'd1']) + >>> commonpath(pathsplit("./plugins/"), pathsplit("./plugins/builtins.ini")) + (['.', 'plugins'], [''], ['builtins.ini']) + >>> commonpath(pathsplit("./plugins/builtins"), pathsplit("./plugins")) + (['.', 'plugins'], ['builtins'], []) + """ + if common is None: + common = [] + + if l1 == l2: + return l1, [], [] + + for i, (leftDir, rightDir) in enumerate(zip(l1, l2)): + if leftDir != rightDir: + return l1[0:i], l1[i:], l2[i:] + else: + if leftDir == rightDir: + i += 1 + return l1[0:i], l1[i:], l2[i:] + + +def relpath(p1, p2): + """ + >>> relpath('/', '/') + './' + >>> relpath('/a/b/c/d', '/') + '../../../../' + >>> relpath('/a/b/c/d', '/a/b/c1/d1') + '../../c1/d1' + >>> relpath('/a/b/c/d', '/a/b/c1/d1/') + '../../c1/d1' + >>> relpath("./plugins/builtins", "./plugins") + '../' + >>> relpath("./plugins/", "./plugins/builtins.ini") + 'builtins.ini' + """ + sourcePath = os.path.normpath(p1) + destPath = os.path.normpath(p2) + + (common, sourceOnly, destOnly) = commonpath(pathsplit(sourcePath), pathsplit(destPath)) + if len(sourceOnly) or len(destOnly): + relParts = itertools.chain( + (('..' + os.sep) * len(sourceOnly), ), + destOnly, + ) + return os.path.join(*relParts) + else: + return "."+os.sep + + +class UTF8Recoder(object): + """ + Iterator that reads an encoded stream and reencodes the input to UTF-8 + """ + def __init__(self, f, encoding): + self.reader = codecs.getreader(encoding)(f) + + def __iter__(self): + return self + + def next(self): + return self.reader.next().encode("utf-8") + + +class UnicodeReader(object): + """ + A CSV reader which will iterate over lines in the CSV file "f", + which is encoded in the given encoding. + """ + + def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): + f = UTF8Recoder(f, encoding) + self.reader = csv.reader(f, dialect=dialect, **kwds) + + def next(self): + row = self.reader.next() + return [unicode(s, "utf-8") for s in row] + + def __iter__(self): + return self + +class UnicodeWriter(object): + """ + A CSV writer which will write rows to CSV file "f", + which is encoded in the given encoding. + """ + + def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): + # Redirect output to a queue + self.queue = StringIO.StringIO() + self.writer = csv.writer(self.queue, dialect=dialect, **kwds) + self.stream = f + self.encoder = codecs.getincrementalencoder(encoding)() + + def writerow(self, row): + self.writer.writerow([s.encode("utf-8") for s in row]) + # Fetch UTF-8 output from the queue ... + data = self.queue.getvalue() + data = data.decode("utf-8") + # ... and reencode it into the target encoding + data = self.encoder.encode(data) + # write to the target stream + self.stream.write(data) + # empty queue + self.queue.truncate(0) + + def writerows(self, rows): + for row in rows: + self.writerow(row) + + +def unicode_csv_reader(unicode_csv_data, dialect=csv.excel, **kwargs): + # csv.py doesn't do Unicode; encode temporarily as UTF-8: + csv_reader = csv.reader(utf_8_encoder(unicode_csv_data), + dialect=dialect, **kwargs) + for row in csv_reader: + # decode UTF-8 back to Unicode, cell by cell: + yield [unicode(cell, 'utf-8') for cell in row] + + +def utf_8_encoder(unicode_csv_data): + for line in unicode_csv_data: + yield line.encode('utf-8') + + +_UNESCAPE_ENTITIES = { + """: '"', + " ": " ", + "'": "'", +} + + +_ESCAPE_ENTITIES = dict((v, k) for (v, k) in zip(_UNESCAPE_ENTITIES.itervalues(), _UNESCAPE_ENTITIES.iterkeys())) +del _ESCAPE_ENTITIES[" "] + + +def unescape(text): + plain = saxutils.unescape(text, _UNESCAPE_ENTITIES) + return plain + + +def escape(text): + fancy = saxutils.escape(text, _ESCAPE_ENTITIES) + return fancy diff --git a/dialcentral/util/linux.py b/dialcentral/util/linux.py new file mode 100644 index 0000000..4e77445 --- /dev/null +++ b/dialcentral/util/linux.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python + + +import os +import logging + +try: + from xdg import BaseDirectory as _BaseDirectory + BaseDirectory = _BaseDirectory +except ImportError: + BaseDirectory = None + + +_moduleLogger = logging.getLogger(__name__) + + +_libc = None + + +def set_process_name(name): + try: # change process name for killall + global _libc + if _libc is None: + import ctypes + _libc = ctypes.CDLL('libc.so.6') + _libc.prctl(15, name, 0, 0, 0) + except Exception, e: + _moduleLogger.warning('Unable to set processName: %s" % e') + + +def get_new_resource(resourceType, resource, name): + if BaseDirectory is not None: + if resourceType == "data": + base = BaseDirectory.xdg_data_home + if base == "/usr/share/mime": + # Ugly hack because somehow Maemo 4.1 seems to be set to this + base = os.path.join(os.path.expanduser("~"), ".%s" % resource) + elif resourceType == "config": + base = BaseDirectory.xdg_config_home + elif resourceType == "cache": + base = BaseDirectory.xdg_cache_home + else: + raise RuntimeError("Unknown type: "+resourceType) + else: + base = os.path.join(os.path.expanduser("~"), ".%s" % resource) + + filePath = os.path.join(base, resource, name) + dirPath = os.path.dirname(filePath) + if not os.path.exists(dirPath): + # Looking before I leap to not mask errors + os.makedirs(dirPath) + + return filePath + + +def get_existing_resource(resourceType, resource, name): + if BaseDirectory is not None: + if resourceType == "data": + base = BaseDirectory.xdg_data_home + elif resourceType == "config": + base = BaseDirectory.xdg_config_home + elif resourceType == "cache": + base = BaseDirectory.xdg_cache_home + else: + raise RuntimeError("Unknown type: "+resourceType) + else: + base = None + + if base is not None: + finalPath = os.path.join(base, name) + if os.path.exists(finalPath): + return finalPath + + altBase = os.path.join(os.path.expanduser("~"), ".%s" % resource) + finalPath = os.path.join(altBase, name) + if os.path.exists(finalPath): + return finalPath + else: + raise RuntimeError("Resource not found: %r" % ((resourceType, resource, name), )) diff --git a/dialcentral/util/misc.py b/dialcentral/util/misc.py new file mode 100644 index 0000000..9b8d88c --- /dev/null +++ b/dialcentral/util/misc.py @@ -0,0 +1,900 @@ +#!/usr/bin/env python + +from __future__ import with_statement + +import sys +import re +import cPickle + +import functools +import contextlib +import inspect + +import optparse +import traceback +import warnings +import string + + +class AnyData(object): + + pass + + +_indentationLevel = [0] + + +def log_call(logger): + + def log_call_decorator(func): + + @functools.wraps(func) + def wrapper(*args, **kwds): + logger.debug("%s> %s" % (" " * _indentationLevel[0], func.__name__, )) + _indentationLevel[0] += 1 + try: + return func(*args, **kwds) + finally: + _indentationLevel[0] -= 1 + logger.debug("%s< %s" % (" " * _indentationLevel[0], func.__name__, )) + + return wrapper + + return log_call_decorator + + +def log_exception(logger): + + def log_exception_decorator(func): + + @functools.wraps(func) + def wrapper(*args, **kwds): + try: + return func(*args, **kwds) + except Exception: + logger.exception(func.__name__) + raise + + return wrapper + + return log_exception_decorator + + +def printfmt(template): + """ + This hides having to create the Template object and call substitute/safe_substitute on it. For example: + + >>> num = 10 + >>> word = "spam" + >>> printfmt("I would like to order $num units of $word, please") #doctest: +SKIP + I would like to order 10 units of spam, please + """ + frame = inspect.stack()[-1][0] + try: + print string.Template(template).safe_substitute(frame.f_locals) + finally: + del frame + + +def is_special(name): + return name.startswith("__") and name.endswith("__") + + +def is_private(name): + return name.startswith("_") and not is_special(name) + + +def privatize(clsName, attributeName): + """ + At runtime, make an attributeName private + + Example: + >>> class Test(object): + ... pass + ... + >>> try: + ... dir(Test).index("_Test__me") + ... print dir(Test) + ... except: + ... print "Not Found" + Not Found + >>> setattr(Test, privatize(Test.__name__, "me"), "Hello World") + >>> try: + ... dir(Test).index("_Test__me") + ... print "Found" + ... except: + ... print dir(Test) + 0 + Found + >>> print getattr(Test, obfuscate(Test.__name__, "__me")) + Hello World + >>> + >>> is_private(privatize(Test.__name__, "me")) + True + >>> is_special(privatize(Test.__name__, "me")) + False + """ + return "".join(["_", clsName, "__", attributeName]) + + +def obfuscate(clsName, attributeName): + """ + At runtime, turn a private name into the obfuscated form + + Example: + >>> class Test(object): + ... __me = "Hello World" + ... + >>> try: + ... dir(Test).index("_Test__me") + ... print "Found" + ... except: + ... print dir(Test) + 0 + Found + >>> print getattr(Test, obfuscate(Test.__name__, "__me")) + Hello World + >>> is_private(obfuscate(Test.__name__, "__me")) + True + >>> is_special(obfuscate(Test.__name__, "__me")) + False + """ + return "".join(["_", clsName, attributeName]) + + +class PAOptionParser(optparse.OptionParser, object): + """ + >>> if __name__ == '__main__': + ... #parser = PAOptionParser("My usage str") + ... parser = PAOptionParser() + ... parser.add_posarg("Foo", help="Foo usage") + ... parser.add_posarg("Bar", dest="bar_dest") + ... parser.add_posarg("Language", dest='tr_type', type="choice", choices=("Python", "Other")) + ... parser.add_option('--stocksym', dest='symbol') + ... values, args = parser.parse_args() + ... print values, args + ... + + python mycp.py -h + python mycp.py + python mycp.py foo + python mycp.py foo bar + + python mycp.py foo bar lava + Usage: pa.py [options] + + Positional Arguments: + Foo: Foo usage + Bar: + Language: + + pa.py: error: option --Language: invalid choice: 'lava' (choose from 'Python', 'Other' + """ + + def __init__(self, *args, **kw): + self.posargs = [] + super(PAOptionParser, self).__init__(*args, **kw) + + def add_posarg(self, *args, **kw): + pa_help = kw.get("help", "") + kw["help"] = optparse.SUPPRESS_HELP + o = self.add_option("--%s" % args[0], *args[1:], **kw) + self.posargs.append((args[0], pa_help)) + + def get_usage(self, *args, **kwargs): + params = (' '.join(["<%s>" % arg[0] for arg in self.posargs]), '\n '.join(["%s: %s" % (arg) for arg in self.posargs])) + self.usage = "%%prog %s [options]\n\nPositional Arguments:\n %s" % params + return super(PAOptionParser, self).get_usage(*args, **kwargs) + + def parse_args(self, *args, **kwargs): + args = sys.argv[1:] + args0 = [] + for p, v in zip(self.posargs, args): + args0.append("--%s" % p[0]) + args0.append(v) + args = args0 + args + options, args = super(PAOptionParser, self).parse_args(args, **kwargs) + if len(args) < len(self.posargs): + msg = 'Missing value(s) for "%s"\n' % ", ".join([arg[0] for arg in self.posargs][len(args):]) + self.error(msg) + return options, args + + +def explicitly(name, stackadd=0): + """ + This is an alias for adding to '__all__'. Less error-prone than using + __all__ itself, since setting __all__ directly is prone to stomping on + things implicitly exported via L{alias}. + + @note Taken from PyExport (which could turn out pretty cool): + @li @a http://codebrowse.launchpad.net/~glyph/ + @li @a http://glyf.livejournal.com/74356.html + """ + packageVars = sys._getframe(1+stackadd).f_locals + globalAll = packageVars.setdefault('__all__', []) + globalAll.append(name) + + +def public(thunk): + """ + This is a decorator, for convenience. Rather than typing the name of your + function twice, you can decorate a function with this. + + To be real, @public would need to work on methods as well, which gets into + supporting types... + + @note Taken from PyExport (which could turn out pretty cool): + @li @a http://codebrowse.launchpad.net/~glyph/ + @li @a http://glyf.livejournal.com/74356.html + """ + explicitly(thunk.__name__, 1) + return thunk + + +def _append_docstring(obj, message): + if obj.__doc__ is None: + obj.__doc__ = message + else: + obj.__doc__ += message + + +def validate_decorator(decorator): + + def simple(x): + return x + + f = simple + f.__name__ = "name" + f.__doc__ = "doc" + f.__dict__["member"] = True + + g = decorator(f) + + if f.__name__ != g.__name__: + print f.__name__, "!=", g.__name__ + + if g.__doc__ is None: + print decorator.__name__, "has no doc string" + elif not g.__doc__.startswith(f.__doc__): + print g.__doc__, "didn't start with", f.__doc__ + + if not ("member" in g.__dict__ and g.__dict__["member"]): + print "'member' not in ", g.__dict__ + + +def deprecated_api(func): + """ + This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used. + + >>> validate_decorator(deprecated_api) + """ + + @functools.wraps(func) + def newFunc(*args, **kwargs): + warnings.warn("Call to deprecated function %s." % func.__name__, category=DeprecationWarning) + return func(*args, **kwargs) + + _append_docstring(newFunc, "\n@deprecated") + return newFunc + + +def unstable_api(func): + """ + This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used. + + >>> validate_decorator(unstable_api) + """ + + @functools.wraps(func) + def newFunc(*args, **kwargs): + warnings.warn("Call to unstable API function %s." % func.__name__, category=FutureWarning) + return func(*args, **kwargs) + _append_docstring(newFunc, "\n@unstable") + return newFunc + + +def enabled(func): + """ + This decorator doesn't add any behavior + + >>> validate_decorator(enabled) + """ + return func + + +def disabled(func): + """ + This decorator disables the provided function, and does nothing + + >>> validate_decorator(disabled) + """ + + @functools.wraps(func) + def emptyFunc(*args, **kargs): + pass + _append_docstring(emptyFunc, "\n@note Temporarily Disabled") + return emptyFunc + + +def metadata(document=True, **kwds): + """ + >>> validate_decorator(metadata(author="Ed")) + """ + + def decorate(func): + for k, v in kwds.iteritems(): + setattr(func, k, v) + if document: + _append_docstring(func, "\n@"+k+" "+v) + return func + return decorate + + +def prop(func): + """Function decorator for defining property attributes + + The decorated function is expected to return a dictionary + containing one or more of the following pairs: + fget - function for getting attribute value + fset - function for setting attribute value + fdel - function for deleting attribute + This can be conveniently constructed by the locals() builtin + function; see: + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/205183 + @author http://kbyanc.blogspot.com/2007/06/python-property-attribute-tricks.html + + Example: + >>> #Due to transformation from function to property, does not need to be validated + >>> #validate_decorator(prop) + >>> class MyExampleClass(object): + ... @prop + ... def foo(): + ... "The foo property attribute's doc-string" + ... def fget(self): + ... print "GET" + ... return self._foo + ... def fset(self, value): + ... print "SET" + ... self._foo = value + ... return locals() + ... + >>> me = MyExampleClass() + >>> me.foo = 10 + SET + >>> print me.foo + GET + 10 + """ + return property(doc=func.__doc__, **func()) + + +def print_handler(e): + """ + @see ExpHandler + """ + print "%s: %s" % (type(e).__name__, e) + + +def print_ignore(e): + """ + @see ExpHandler + """ + print 'Ignoring %s exception: %s' % (type(e).__name__, e) + + +def print_traceback(e): + """ + @see ExpHandler + """ + #print sys.exc_info() + traceback.print_exc(file=sys.stdout) + + +def ExpHandler(handler = print_handler, *exceptions): + """ + An exception handling idiom using decorators + Examples + Specify exceptions in order, first one is handled first + last one last. + + >>> validate_decorator(ExpHandler()) + >>> @ExpHandler(print_ignore, ZeroDivisionError) + ... @ExpHandler(None, AttributeError, ValueError) + ... def f1(): + ... 1/0 + >>> @ExpHandler(print_traceback, ZeroDivisionError) + ... def f2(): + ... 1/0 + >>> @ExpHandler() + ... def f3(*pargs): + ... l = pargs + ... return l[10] + >>> @ExpHandler(print_traceback, ZeroDivisionError) + ... def f4(): + ... return 1 + >>> + >>> + >>> f1() + Ignoring ZeroDivisionError exception: integer division or modulo by zero + >>> f2() # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + ZeroDivisionError: integer division or modulo by zero + >>> f3() + IndexError: tuple index out of range + >>> f4() + 1 + """ + + def wrapper(f): + localExceptions = exceptions + if not localExceptions: + localExceptions = [Exception] + t = [(ex, handler) for ex in localExceptions] + t.reverse() + + def newfunc(t, *args, **kwargs): + ex, handler = t[0] + try: + if len(t) == 1: + return f(*args, **kwargs) + else: + #Recurse for embedded try/excepts + dec_func = functools.partial(newfunc, t[1:]) + dec_func = functools.update_wrapper(dec_func, f) + return dec_func(*args, **kwargs) + except ex, e: + return handler(e) + + dec_func = functools.partial(newfunc, t) + dec_func = functools.update_wrapper(dec_func, f) + return dec_func + return wrapper + + +def into_debugger(func): + """ + >>> validate_decorator(into_debugger) + """ + + @functools.wraps(func) + def newFunc(*args, **kwargs): + try: + return func(*args, **kwargs) + except: + import pdb + pdb.post_mortem() + + return newFunc + + +class bindclass(object): + """ + >>> validate_decorator(bindclass) + >>> class Foo(BoundObject): + ... @bindclass + ... def foo(this_class, self): + ... return this_class, self + ... + >>> class Bar(Foo): + ... @bindclass + ... def bar(this_class, self): + ... return this_class, self + ... + >>> f = Foo() + >>> b = Bar() + >>> + >>> f.foo() # doctest: +ELLIPSIS + (, <...Foo object at ...>) + >>> b.foo() # doctest: +ELLIPSIS + (, <...Bar object at ...>) + >>> b.bar() # doctest: +ELLIPSIS + (, <...Bar object at ...>) + """ + + def __init__(self, f): + self.f = f + self.__name__ = f.__name__ + self.__doc__ = f.__doc__ + self.__dict__.update(f.__dict__) + self.m = None + + def bind(self, cls, attr): + + def bound_m(*args, **kwargs): + return self.f(cls, *args, **kwargs) + bound_m.__name__ = attr + self.m = bound_m + + def __get__(self, obj, objtype=None): + return self.m.__get__(obj, objtype) + + +class ClassBindingSupport(type): + "@see bindclass" + + def __init__(mcs, name, bases, attrs): + type.__init__(mcs, name, bases, attrs) + for attr, val in attrs.iteritems(): + if isinstance(val, bindclass): + val.bind(mcs, attr) + + +class BoundObject(object): + "@see bindclass" + __metaclass__ = ClassBindingSupport + + +def bindfunction(f): + """ + >>> validate_decorator(bindfunction) + >>> @bindfunction + ... def factorial(thisfunction, n): + ... # Within this function the name 'thisfunction' refers to the factorial + ... # function(with only one argument), even after 'factorial' is bound + ... # to another object + ... if n > 0: + ... return n * thisfunction(n - 1) + ... else: + ... return 1 + ... + >>> factorial(3) + 6 + """ + + @functools.wraps(f) + def bound_f(*args, **kwargs): + return f(bound_f, *args, **kwargs) + return bound_f + + +class Memoize(object): + """ + Memoize(fn) - an instance which acts like fn but memoizes its arguments + Will only work on functions with non-mutable arguments + @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52201 + + >>> validate_decorator(Memoize) + """ + + def __init__(self, fn): + self.fn = fn + self.__name__ = fn.__name__ + self.__doc__ = fn.__doc__ + self.__dict__.update(fn.__dict__) + self.memo = {} + + def __call__(self, *args): + if args not in self.memo: + self.memo[args] = self.fn(*args) + return self.memo[args] + + +class MemoizeMutable(object): + """Memoize(fn) - an instance which acts like fn but memoizes its arguments + Will work on functions with mutable arguments(slower than Memoize) + @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52201 + + >>> validate_decorator(MemoizeMutable) + """ + + def __init__(self, fn): + self.fn = fn + self.__name__ = fn.__name__ + self.__doc__ = fn.__doc__ + self.__dict__.update(fn.__dict__) + self.memo = {} + + def __call__(self, *args, **kw): + text = cPickle.dumps((args, kw)) + if text not in self.memo: + self.memo[text] = self.fn(*args, **kw) + return self.memo[text] + + +callTraceIndentationLevel = 0 + + +def call_trace(f): + """ + Synchronization decorator. + + >>> validate_decorator(call_trace) + >>> @call_trace + ... def a(a, b, c): + ... pass + >>> a(1, 2, c=3) + Entering a((1, 2), {'c': 3}) + Exiting a((1, 2), {'c': 3}) + """ + + @functools.wraps(f) + def verboseTrace(*args, **kw): + global callTraceIndentationLevel + + print "%sEntering %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) + callTraceIndentationLevel += 1 + try: + result = f(*args, **kw) + except: + callTraceIndentationLevel -= 1 + print "%sException %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) + raise + callTraceIndentationLevel -= 1 + print "%sExiting %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) + return result + + @functools.wraps(f) + def smallTrace(*args, **kw): + global callTraceIndentationLevel + + print "%sEntering %s" % ("\t"*callTraceIndentationLevel, f.__name__) + callTraceIndentationLevel += 1 + try: + result = f(*args, **kw) + except: + callTraceIndentationLevel -= 1 + print "%sException %s" % ("\t"*callTraceIndentationLevel, f.__name__) + raise + callTraceIndentationLevel -= 1 + print "%sExiting %s" % ("\t"*callTraceIndentationLevel, f.__name__) + return result + + #return smallTrace + return verboseTrace + + +@contextlib.contextmanager +def nested_break(): + """ + >>> with nested_break() as mylabel: + ... for i in xrange(3): + ... print "Outer", i + ... for j in xrange(3): + ... if i == 2: raise mylabel + ... if j == 2: break + ... print "Inner", j + ... print "more processing" + Outer 0 + Inner 0 + Inner 1 + Outer 1 + Inner 0 + Inner 1 + Outer 2 + """ + + class NestedBreakException(Exception): + pass + + try: + yield NestedBreakException + except NestedBreakException: + pass + + +@contextlib.contextmanager +def lexical_scope(*args): + """ + @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/520586 + Example: + >>> b = 0 + >>> with lexical_scope(1) as (a): + ... print a + ... + 1 + >>> with lexical_scope(1,2,3) as (a,b,c): + ... print a,b,c + ... + 1 2 3 + >>> with lexical_scope(): + ... d = 10 + ... def foo(): + ... pass + ... + >>> print b + 2 + """ + + frame = inspect.currentframe().f_back.f_back + saved = frame.f_locals.keys() + try: + if not args: + yield + elif len(args) == 1: + yield args[0] + else: + yield args + finally: + f_locals = frame.f_locals + for key in (x for x in f_locals.keys() if x not in saved): + del f_locals[key] + del frame + + +def normalize_number(prettynumber): + """ + function to take a phone number and strip out all non-numeric + characters + + >>> normalize_number("+012-(345)-678-90") + '+01234567890' + >>> normalize_number("1-(345)-678-9000") + '+13456789000' + >>> normalize_number("+1-(345)-678-9000") + '+13456789000' + """ + uglynumber = re.sub('[^0-9+]', '', prettynumber) + if uglynumber.startswith("+"): + pass + elif uglynumber.startswith("1"): + uglynumber = "+"+uglynumber + elif 10 <= len(uglynumber): + assert uglynumber[0] not in ("+", "1"), "Number format confusing" + uglynumber = "+1"+uglynumber + else: + pass + + return uglynumber + + +_VALIDATE_RE = re.compile("^\+?[0-9]{10,}$") + + +def is_valid_number(number): + """ + @returns If This number be called ( syntax validation only ) + """ + return _VALIDATE_RE.match(number) is not None + + +def make_ugly(prettynumber): + """ + function to take a phone number and strip out all non-numeric + characters + + >>> make_ugly("+012-(345)-678-90") + '+01234567890' + """ + return normalize_number(prettynumber) + + +def _make_pretty_with_areacode(phonenumber): + prettynumber = "(%s)" % (phonenumber[0:3], ) + if 3 < len(phonenumber): + prettynumber += " %s" % (phonenumber[3:6], ) + if 6 < len(phonenumber): + prettynumber += "-%s" % (phonenumber[6:], ) + return prettynumber + + +def _make_pretty_local(phonenumber): + prettynumber = "%s" % (phonenumber[0:3], ) + if 3 < len(phonenumber): + prettynumber += "-%s" % (phonenumber[3:], ) + return prettynumber + + +def _make_pretty_international(phonenumber): + prettynumber = phonenumber + if phonenumber.startswith("1"): + prettynumber = "1 " + prettynumber += _make_pretty_with_areacode(phonenumber[1:]) + return prettynumber + + +def make_pretty(phonenumber): + """ + Function to take a phone number and return the pretty version + pretty numbers: + if phonenumber begins with 0: + ...-(...)-...-.... + if phonenumber begins with 1: ( for gizmo callback numbers ) + 1 (...)-...-.... + if phonenumber is 13 digits: + (...)-...-.... + if phonenumber is 10 digits: + ...-.... + >>> make_pretty("12") + '12' + >>> make_pretty("1234567") + '123-4567' + >>> make_pretty("2345678901") + '+1 (234) 567-8901' + >>> make_pretty("12345678901") + '+1 (234) 567-8901' + >>> make_pretty("01234567890") + '+012 (345) 678-90' + >>> make_pretty("+01234567890") + '+012 (345) 678-90' + >>> make_pretty("+12") + '+1 (2)' + >>> make_pretty("+123") + '+1 (23)' + >>> make_pretty("+1234") + '+1 (234)' + """ + if phonenumber is None or phonenumber == "": + return "" + + phonenumber = normalize_number(phonenumber) + + if phonenumber == "": + return "" + elif phonenumber[0] == "+": + prettynumber = _make_pretty_international(phonenumber[1:]) + if not prettynumber.startswith("+"): + prettynumber = "+"+prettynumber + elif 8 < len(phonenumber) and phonenumber[0] in ("1", ): + prettynumber = _make_pretty_international(phonenumber) + elif 7 < len(phonenumber): + prettynumber = _make_pretty_with_areacode(phonenumber) + elif 3 < len(phonenumber): + prettynumber = _make_pretty_local(phonenumber) + else: + prettynumber = phonenumber + return prettynumber.strip() + + +def similar_ugly_numbers(lhs, rhs): + return ( + lhs == rhs or + lhs[1:] == rhs and lhs.startswith("1") or + lhs[2:] == rhs and lhs.startswith("+1") or + lhs == rhs[1:] and rhs.startswith("1") or + lhs == rhs[2:] and rhs.startswith("+1") + ) + + +def abbrev_relative_date(date): + """ + >>> abbrev_relative_date("42 hours ago") + '42 h' + >>> abbrev_relative_date("2 days ago") + '2 d' + >>> abbrev_relative_date("4 weeks ago") + '4 w' + """ + parts = date.split(" ") + return "%s %s" % (parts[0], parts[1][0]) + + +def parse_version(versionText): + """ + >>> parse_version("0.5.2") + [0, 5, 2] + """ + return [ + int(number) + for number in versionText.split(".") + ] + + +def compare_versions(leftParsedVersion, rightParsedVersion): + """ + >>> compare_versions([0, 1, 2], [0, 1, 2]) + 0 + >>> compare_versions([0, 1, 2], [0, 1, 3]) + -1 + >>> compare_versions([0, 1, 2], [0, 2, 2]) + -1 + >>> compare_versions([0, 1, 2], [1, 1, 2]) + -1 + >>> compare_versions([0, 1, 3], [0, 1, 2]) + 1 + >>> compare_versions([0, 2, 2], [0, 1, 2]) + 1 + >>> compare_versions([1, 1, 2], [0, 1, 2]) + 1 + """ + for left, right in zip(leftParsedVersion, rightParsedVersion): + if left < right: + return -1 + elif right < left: + return 1 + else: + return 0 diff --git a/dialcentral/util/overloading.py b/dialcentral/util/overloading.py new file mode 100644 index 0000000..89cb738 --- /dev/null +++ b/dialcentral/util/overloading.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python +import new + +# Make the environment more like Python 3.0 +__metaclass__ = type +from itertools import izip as zip +import textwrap +import inspect + + +__all__ = [ + "AnyType", + "overloaded" +] + + +AnyType = object + + +class overloaded: + """ + Dynamically overloaded functions. + + This is an implementation of (dynamically, or run-time) overloaded + functions; also known as generic functions or multi-methods. + + The dispatch algorithm uses the types of all argument for dispatch, + similar to (compile-time) overloaded functions or methods in C++ and + Java. + + Most of the complexity in the algorithm comes from the need to support + subclasses in call signatures. For example, if an function is + registered for a signature (T1, T2), then a call with a signature (S1, + S2) is acceptable, assuming that S1 is a subclass of T1, S2 a subclass + of T2, and there are no other more specific matches (see below). + + If there are multiple matches and one of those doesn't *dominate* all + others, the match is deemed ambiguous and an exception is raised. A + subtlety here: if, after removing the dominated matches, there are + still multiple matches left, but they all map to the same function, + then the match is not deemed ambiguous and that function is used. + Read the method find_func() below for details. + + @note Python 2.5 is required due to the use of predicates any() and all(). + @note only supports positional arguments + + @author http://www.artima.com/weblogs/viewpost.jsp?thread=155514 + + >>> import misc + >>> misc.validate_decorator (overloaded) + >>> + >>> + >>> + >>> + >>> ################# + >>> #Basics, with reusing names and without + >>> @overloaded + ... def foo(x): + ... "prints x" + ... print x + ... + >>> @foo.register(int) + ... def foo(x): + ... "prints the hex representation of x" + ... print hex(x) + ... + >>> from types import DictType + >>> @foo.register(DictType) + ... def foo_dict(x): + ... "prints the keys of x" + ... print [k for k in x.iterkeys()] + ... + >>> #combines all of the doc strings to help keep track of the specializations + >>> foo.__doc__ # doctest: +ELLIPSIS + "prints x\\n\\n...overloading.foo ():\\n\\tprints the hex representation of x\\n\\n...overloading.foo_dict ():\\n\\tprints the keys of x" + >>> foo ("text") + text + >>> foo (10) #calling the specialized foo + 0xa + >>> foo ({3:5, 6:7}) #calling the specialization foo_dict + [3, 6] + >>> foo_dict ({3:5, 6:7}) #with using a unique name, you still have the option of calling the function directly + [3, 6] + >>> + >>> + >>> + >>> + >>> ################# + >>> #Multiple arguments, accessing the default, and function finding + >>> @overloaded + ... def two_arg (x, y): + ... print x,y + ... + >>> @two_arg.register(int, int) + ... def two_arg_int_int (x, y): + ... print hex(x), hex(y) + ... + >>> @two_arg.register(float, int) + ... def two_arg_float_int (x, y): + ... print x, hex(y) + ... + >>> @two_arg.register(int, float) + ... def two_arg_int_float (x, y): + ... print hex(x), y + ... + >>> two_arg.__doc__ # doctest: +ELLIPSIS + "...overloading.two_arg_int_int (, ):\\n\\n...overloading.two_arg_float_int (, ):\\n\\n...overloading.two_arg_int_float (, ):" + >>> two_arg(9, 10) + 0x9 0xa + >>> two_arg(9.0, 10) + 9.0 0xa + >>> two_arg(15, 16.0) + 0xf 16.0 + >>> two_arg.default_func(9, 10) + 9 10 + >>> two_arg.find_func ((int, float)) == two_arg_int_float + True + >>> (int, float) in two_arg + True + >>> (str, int) in two_arg + False + >>> + >>> + >>> + >>> ################# + >>> #wildcard + >>> @two_arg.register(AnyType, str) + ... def two_arg_any_str (x, y): + ... print x, y.lower() + ... + >>> two_arg("Hello", "World") + Hello world + >>> two_arg(500, "World") + 500 world + """ + + def __init__(self, default_func): + # Decorator to declare new overloaded function. + self.registry = {} + self.cache = {} + self.default_func = default_func + self.__name__ = self.default_func.__name__ + self.__doc__ = self.default_func.__doc__ + self.__dict__.update (self.default_func.__dict__) + + def __get__(self, obj, type=None): + if obj is None: + return self + return new.instancemethod(self, obj) + + def register(self, *types): + """ + Decorator to register an implementation for a specific set of types. + + .register(t1, t2)(f) is equivalent to .register_func((t1, t2), f). + """ + + def helper(func): + self.register_func(types, func) + + originalDoc = self.__doc__ if self.__doc__ is not None else "" + typeNames = ", ".join ([str(type) for type in types]) + typeNames = "".join ([func.__module__+".", func.__name__, " (", typeNames, "):"]) + overloadedDoc = "" + if func.__doc__ is not None: + overloadedDoc = textwrap.fill (func.__doc__, width=60, initial_indent="\t", subsequent_indent="\t") + self.__doc__ = "\n".join ([originalDoc, "", typeNames, overloadedDoc]).strip() + + new_func = func + + #Masking the function, so we want to take on its traits + if func.__name__ == self.__name__: + self.__dict__.update (func.__dict__) + new_func = self + return new_func + + return helper + + def register_func(self, types, func): + """Helper to register an implementation.""" + self.registry[tuple(types)] = func + self.cache = {} # Clear the cache (later we can optimize this). + + def __call__(self, *args): + """Call the overloaded function.""" + types = tuple(map(type, args)) + func = self.cache.get(types) + if func is None: + self.cache[types] = func = self.find_func(types) + return func(*args) + + def __contains__ (self, types): + return self.find_func(types) is not self.default_func + + def find_func(self, types): + """Find the appropriate overloaded function; don't call it. + + @note This won't work for old-style classes or classes without __mro__ + """ + func = self.registry.get(types) + if func is not None: + # Easy case -- direct hit in registry. + return func + + # Phillip Eby suggests to use issubclass() instead of __mro__. + # There are advantages and disadvantages. + + # I can't help myself -- this is going to be intense functional code. + # Find all possible candidate signatures. + mros = tuple(inspect.getmro(t) for t in types) + n = len(mros) + candidates = [sig for sig in self.registry + if len(sig) == n and + all(t in mro for t, mro in zip(sig, mros))] + + if not candidates: + # No match at all -- use the default function. + return self.default_func + elif len(candidates) == 1: + # Unique match -- that's an easy case. + return self.registry[candidates[0]] + + # More than one match -- weed out the subordinate ones. + + def dominates(dom, sub, + orders=tuple(dict((t, i) for i, t in enumerate(mro)) + for mro in mros)): + # Predicate to decide whether dom strictly dominates sub. + # Strict domination is defined as domination without equality. + # The arguments dom and sub are type tuples of equal length. + # The orders argument is a precomputed auxiliary data structure + # giving dicts of ordering information corresponding to the + # positions in the type tuples. + # A type d dominates a type s iff order[d] <= order[s]. + # A type tuple (d1, d2, ...) dominates a type tuple of equal length + # (s1, s2, ...) iff d1 dominates s1, d2 dominates s2, etc. + if dom is sub: + return False + return all(order[d] <= order[s] for d, s, order in zip(dom, sub, orders)) + + # I suppose I could inline dominates() but it wouldn't get any clearer. + candidates = [cand + for cand in candidates + if not any(dominates(dom, cand) for dom in candidates)] + if len(candidates) == 1: + # There's exactly one candidate left. + return self.registry[candidates[0]] + + # Perhaps these multiple candidates all have the same implementation? + funcs = set(self.registry[cand] for cand in candidates) + if len(funcs) == 1: + return funcs.pop() + + # No, the situation is irreducibly ambiguous. + raise TypeError("ambigous call; types=%r; candidates=%r" % + (types, candidates)) diff --git a/dialcentral/util/qore_utils.py b/dialcentral/util/qore_utils.py new file mode 100644 index 0000000..153558d --- /dev/null +++ b/dialcentral/util/qore_utils.py @@ -0,0 +1,99 @@ +import logging + +import qt_compat +QtCore = qt_compat.QtCore + +import misc + + +_moduleLogger = logging.getLogger(__name__) + + +class QThread44(QtCore.QThread): + """ + This is to imitate QThread in Qt 4.4+ for when running on older version + See http://labs.trolltech.com/blogs/2010/06/17/youre-doing-it-wrong + (On Lucid I have Qt 4.7 and this is still an issue) + """ + + def __init__(self, parent = None): + QtCore.QThread.__init__(self, parent) + + def run(self): + self.exec_() + + +class _WorkerThread(QtCore.QObject): + + _taskComplete = qt_compat.Signal(object) + + def __init__(self, futureThread): + QtCore.QObject.__init__(self) + self._futureThread = futureThread + self._futureThread._addTask.connect(self._on_task_added) + self._taskComplete.connect(self._futureThread._on_task_complete) + + @qt_compat.Slot(object) + def _on_task_added(self, task): + self.__on_task_added(task) + + @misc.log_exception(_moduleLogger) + def __on_task_added(self, task): + if not self._futureThread._isRunning: + _moduleLogger.error("Dropping task") + + func, args, kwds, on_success, on_error = task + + try: + result = func(*args, **kwds) + isError = False + except Exception, e: + _moduleLogger.error("Error, passing it back to the main thread") + result = e + isError = True + + taskResult = on_success, on_error, isError, result + self._taskComplete.emit(taskResult) + + +class FutureThread(QtCore.QObject): + + _addTask = qt_compat.Signal(object) + + def __init__(self): + QtCore.QObject.__init__(self) + self._thread = QThread44() + self._isRunning = False + self._worker = _WorkerThread(self) + self._worker.moveToThread(self._thread) + + def start(self): + self._thread.start() + self._isRunning = True + + def stop(self): + self._isRunning = False + self._thread.quit() + + def add_task(self, func, args, kwds, on_success, on_error): + assert self._isRunning, "Task queue not started" + task = func, args, kwds, on_success, on_error + self._addTask.emit(task) + + @qt_compat.Slot(object) + def _on_task_complete(self, taskResult): + self.__on_task_complete(taskResult) + + @misc.log_exception(_moduleLogger) + def __on_task_complete(self, taskResult): + on_success, on_error, isError, result = taskResult + if not self._isRunning: + if isError: + _moduleLogger.error("Masking: %s" % (result, )) + isError = True + result = StopIteration("Cancelling all callbacks") + callback = on_success if not isError else on_error + try: + callback(result) + except Exception: + _moduleLogger.exception("Callback errored") diff --git a/dialcentral/util/qt_compat.py b/dialcentral/util/qt_compat.py new file mode 100644 index 0000000..2ab7fa4 --- /dev/null +++ b/dialcentral/util/qt_compat.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +#try: +# import PySide.QtCore as _QtCore +# QtCore = _QtCore +# USES_PYSIDE = True +#except ImportError: +if True: + import sip + sip.setapi('QString', 2) + sip.setapi('QVariant', 2) + import PyQt4.QtCore as _QtCore + QtCore = _QtCore + USES_PYSIDE = False + + +def _pyside_import_module(moduleName): + pyside = __import__('PySide', globals(), locals(), [moduleName], -1) + return getattr(pyside, moduleName) + + +def _pyqt4_import_module(moduleName): + pyside = __import__('PyQt4', globals(), locals(), [moduleName], -1) + return getattr(pyside, moduleName) + + +if USES_PYSIDE: + import_module = _pyside_import_module + + Signal = QtCore.Signal + Slot = QtCore.Slot + Property = QtCore.Property +else: + import_module = _pyqt4_import_module + + Signal = QtCore.pyqtSignal + Slot = QtCore.pyqtSlot + Property = QtCore.pyqtProperty + + +if __name__ == "__main__": + pass + diff --git a/dialcentral/util/qtpie.py b/dialcentral/util/qtpie.py new file mode 100755 index 0000000..6b77d5d --- /dev/null +++ b/dialcentral/util/qtpie.py @@ -0,0 +1,1094 @@ +#!/usr/bin/env python + +import math +import logging + +import qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +import misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +_TWOPI = 2 * math.pi + + +def _radius_at(center, pos): + delta = pos - center + xDelta = delta.x() + yDelta = delta.y() + + radius = math.sqrt(xDelta ** 2 + yDelta ** 2) + return radius + + +def _angle_at(center, pos): + delta = pos - center + xDelta = delta.x() + yDelta = delta.y() + + radius = math.sqrt(xDelta ** 2 + yDelta ** 2) + angle = math.acos(xDelta / radius) + if 0 <= yDelta: + angle = _TWOPI - angle + + return angle + + +class QActionPieItem(object): + + def __init__(self, action, weight = 1): + self._action = action + self._weight = weight + + def action(self): + return self._action + + def setWeight(self, weight): + self._weight = weight + + def weight(self): + return self._weight + + def setEnabled(self, enabled = True): + self._action.setEnabled(enabled) + + def isEnabled(self): + return self._action.isEnabled() + + +class PieFiling(object): + + INNER_RADIUS_DEFAULT = 64 + OUTER_RADIUS_DEFAULT = 192 + + SELECTION_CENTER = -1 + SELECTION_NONE = -2 + + NULL_CENTER = QActionPieItem(QtGui.QAction(None)) + + def __init__(self): + self._innerRadius = self.INNER_RADIUS_DEFAULT + self._outerRadius = self.OUTER_RADIUS_DEFAULT + self._children = [] + self._center = self.NULL_CENTER + + self._cacheIndexToAngle = {} + self._cacheTotalWeight = 0 + + def insertItem(self, item, index = -1): + self._children.insert(index, item) + self._invalidate_cache() + + def removeItemAt(self, index): + item = self._children.pop(index) + self._invalidate_cache() + + def set_center(self, item): + if item is None: + item = self.NULL_CENTER + self._center = item + + def center(self): + return self._center + + def clear(self): + del self._children[:] + self._center = self.NULL_CENTER + self._invalidate_cache() + + def itemAt(self, index): + return self._children[index] + + def indexAt(self, center, point): + return self._angle_to_index(_angle_at(center, point)) + + def innerRadius(self): + return self._innerRadius + + def setInnerRadius(self, radius): + self._innerRadius = radius + + def outerRadius(self): + return self._outerRadius + + def setOuterRadius(self, radius): + self._outerRadius = radius + + def __iter__(self): + return iter(self._children) + + def __len__(self): + return len(self._children) + + def __getitem__(self, index): + return self._children[index] + + def _invalidate_cache(self): + self._cacheIndexToAngle.clear() + self._cacheTotalWeight = sum(child.weight() for child in self._children) + if self._cacheTotalWeight == 0: + self._cacheTotalWeight = 1 + + def _index_to_angle(self, index, isShifted): + key = index, isShifted + if key in self._cacheIndexToAngle: + return self._cacheIndexToAngle[key] + index = index % len(self._children) + + baseAngle = _TWOPI / self._cacheTotalWeight + + angle = math.pi / 2 + if isShifted: + if self._children: + angle -= (self._children[0].weight() * baseAngle) / 2 + else: + angle -= baseAngle / 2 + while angle < 0: + angle += _TWOPI + + for i, child in enumerate(self._children): + if index < i: + break + angle += child.weight() * baseAngle + while _TWOPI < angle: + angle -= _TWOPI + + self._cacheIndexToAngle[key] = angle + return angle + + def _angle_to_index(self, angle): + numChildren = len(self._children) + if numChildren == 0: + return self.SELECTION_CENTER + + baseAngle = _TWOPI / self._cacheTotalWeight + + iterAngle = math.pi / 2 - (self.itemAt(0).weight() * baseAngle) / 2 + while iterAngle < 0: + iterAngle += _TWOPI + + oldIterAngle = iterAngle + for index, child in enumerate(self._children): + iterAngle += child.weight() * baseAngle + if oldIterAngle < angle and angle <= iterAngle: + return index - 1 if index != 0 else numChildren - 1 + elif oldIterAngle < (angle + _TWOPI) and (angle + _TWOPI <= iterAngle): + return index - 1 if index != 0 else numChildren - 1 + oldIterAngle = iterAngle + + +class PieArtist(object): + + ICON_SIZE_DEFAULT = 48 + + SHAPE_CIRCLE = "circle" + SHAPE_SQUARE = "square" + DEFAULT_SHAPE = SHAPE_SQUARE + + BACKGROUND_FILL = "fill" + BACKGROUND_NOFILL = "no fill" + + def __init__(self, filing, background = BACKGROUND_FILL): + self._filing = filing + + self._cachedOuterRadius = self._filing.outerRadius() + self._cachedInnerRadius = self._filing.innerRadius() + canvasSize = self._cachedOuterRadius * 2 + 1 + self._canvas = QtGui.QPixmap(canvasSize, canvasSize) + self._mask = None + self._backgroundState = background + self.palette = None + + def pieSize(self): + diameter = self._filing.outerRadius() * 2 + 1 + return QtCore.QSize(diameter, diameter) + + def centerSize(self): + painter = QtGui.QPainter(self._canvas) + text = self._filing.center().action().text() + fontMetrics = painter.fontMetrics() + if text: + textBoundingRect = fontMetrics.boundingRect(text) + else: + textBoundingRect = QtCore.QRect() + textWidth = textBoundingRect.width() + textHeight = textBoundingRect.height() + + return QtCore.QSize( + textWidth + self.ICON_SIZE_DEFAULT, + max(textHeight, self.ICON_SIZE_DEFAULT), + ) + + def show(self, palette): + self.palette = palette + + if ( + self._cachedOuterRadius != self._filing.outerRadius() or + self._cachedInnerRadius != self._filing.innerRadius() + ): + self._cachedOuterRadius = self._filing.outerRadius() + self._cachedInnerRadius = self._filing.innerRadius() + self._canvas = self._canvas.scaled(self.pieSize()) + + if self._mask is None: + self._mask = QtGui.QBitmap(self._canvas.size()) + self._mask.fill(QtCore.Qt.color0) + self._generate_mask(self._mask) + self._canvas.setMask(self._mask) + return self._mask + + def hide(self): + self.palette = None + + def paint(self, selectionIndex): + painter = QtGui.QPainter(self._canvas) + painter.setRenderHint(QtGui.QPainter.Antialiasing, True) + + self.paintPainter(selectionIndex, painter) + + return self._canvas + + def paintPainter(self, selectionIndex, painter): + adjustmentRect = painter.viewport().adjusted(0, 0, -1, -1) + + numChildren = len(self._filing) + if numChildren == 0: + self._paint_center_background(painter, adjustmentRect, selectionIndex) + self._paint_center_foreground(painter, adjustmentRect, selectionIndex) + return self._canvas + else: + for i in xrange(len(self._filing)): + self._paint_slice_background(painter, adjustmentRect, i, selectionIndex) + + self._paint_center_background(painter, adjustmentRect, selectionIndex) + self._paint_center_foreground(painter, adjustmentRect, selectionIndex) + + for i in xrange(len(self._filing)): + self._paint_slice_foreground(painter, adjustmentRect, i, selectionIndex) + + def _generate_mask(self, mask): + """ + Specifies on the mask the shape of the pie menu + """ + painter = QtGui.QPainter(mask) + painter.setPen(QtCore.Qt.color1) + painter.setBrush(QtCore.Qt.color1) + if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: + painter.drawRect(mask.rect()) + elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: + painter.drawEllipse(mask.rect().adjusted(0, 0, -1, -1)) + else: + raise NotImplementedError(self.DEFAULT_SHAPE) + + def _paint_slice_background(self, painter, adjustmentRect, i, selectionIndex): + if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: + currentWidth = adjustmentRect.width() + newWidth = math.sqrt(2) * currentWidth + dx = (newWidth - currentWidth) / 2 + adjustmentRect = adjustmentRect.adjusted(-dx, -dx, dx, dx) + elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: + pass + else: + raise NotImplementedError(self.DEFAULT_SHAPE) + + if self._backgroundState == self.BACKGROUND_NOFILL: + painter.setBrush(QtGui.QBrush(QtCore.Qt.transparent)) + painter.setPen(self.palette.highlight().color()) + else: + if i == selectionIndex and self._filing[i].isEnabled(): + painter.setBrush(self.palette.highlight()) + painter.setPen(self.palette.highlight().color()) + else: + painter.setBrush(self.palette.window()) + painter.setPen(self.palette.window().color()) + + a = self._filing._index_to_angle(i, True) + b = self._filing._index_to_angle(i + 1, True) + if b < a: + b += _TWOPI + size = b - a + if size < 0: + size += _TWOPI + + startAngleInDeg = (a * 360 * 16) / _TWOPI + sizeInDeg = (size * 360 * 16) / _TWOPI + painter.drawPie(adjustmentRect, int(startAngleInDeg), int(sizeInDeg)) + + def _paint_slice_foreground(self, painter, adjustmentRect, i, selectionIndex): + child = self._filing[i] + + a = self._filing._index_to_angle(i, True) + b = self._filing._index_to_angle(i + 1, True) + if b < a: + b += _TWOPI + middleAngle = (a + b) / 2 + averageRadius = (self._cachedInnerRadius + self._cachedOuterRadius) / 2 + + sliceX = averageRadius * math.cos(middleAngle) + sliceY = - averageRadius * math.sin(middleAngle) + + piePos = adjustmentRect.center() + pieX = piePos.x() + pieY = piePos.y() + self._paint_label( + painter, child.action(), i == selectionIndex, pieX+sliceX, pieY+sliceY + ) + + def _paint_label(self, painter, action, isSelected, x, y): + text = action.text() + fontMetrics = painter.fontMetrics() + if text: + textBoundingRect = fontMetrics.boundingRect(text) + else: + textBoundingRect = QtCore.QRect() + textWidth = textBoundingRect.width() + textHeight = textBoundingRect.height() + + icon = action.icon().pixmap( + QtCore.QSize(self.ICON_SIZE_DEFAULT, self.ICON_SIZE_DEFAULT), + QtGui.QIcon.Normal, + QtGui.QIcon.On, + ) + iconWidth = icon.width() + iconHeight = icon.width() + averageWidth = (iconWidth + textWidth)/2 + if not icon.isNull(): + iconRect = QtCore.QRect( + x - averageWidth, + y - iconHeight/2, + iconWidth, + iconHeight, + ) + + painter.drawPixmap(iconRect, icon) + + if text: + if isSelected: + if action.isEnabled(): + pen = self.palette.highlightedText() + brush = self.palette.highlight() + else: + pen = self.palette.mid() + brush = self.palette.window() + else: + if action.isEnabled(): + pen = self.palette.windowText() + else: + pen = self.palette.mid() + brush = self.palette.window() + + leftX = x - averageWidth + iconWidth + topY = y + textHeight/2 + painter.setPen(pen.color()) + painter.setBrush(brush) + painter.drawText(leftX, topY, text) + + def _paint_center_background(self, painter, adjustmentRect, selectionIndex): + if self._backgroundState == self.BACKGROUND_NOFILL: + return + if len(self._filing) == 0: + if self._backgroundState == self.BACKGROUND_NOFILL: + painter.setBrush(QtGui.QBrush(QtCore.Qt.transparent)) + else: + if selectionIndex == PieFiling.SELECTION_CENTER and self._filing.center().isEnabled(): + painter.setBrush(self.palette.highlight()) + else: + painter.setBrush(self.palette.window()) + painter.setPen(self.palette.mid().color()) + + painter.drawRect(adjustmentRect) + else: + dark = self.palette.mid().color() + light = self.palette.light().color() + if self._backgroundState == self.BACKGROUND_NOFILL: + background = QtGui.QBrush(QtCore.Qt.transparent) + else: + if selectionIndex == PieFiling.SELECTION_CENTER and self._filing.center().isEnabled(): + background = self.palette.highlight().color() + else: + background = self.palette.window().color() + + innerRadius = self._cachedInnerRadius + adjustmentCenterPos = adjustmentRect.center() + innerRect = QtCore.QRect( + adjustmentCenterPos.x() - innerRadius, + adjustmentCenterPos.y() - innerRadius, + innerRadius * 2 + 1, + innerRadius * 2 + 1, + ) + + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(background) + painter.drawPie(innerRect, 0, 360 * 16) + + if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: + pass + elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: + painter.setPen(QtGui.QPen(dark, 1)) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawEllipse(adjustmentRect) + else: + raise NotImplementedError(self.DEFAULT_SHAPE) + + def _paint_center_foreground(self, painter, adjustmentRect, selectionIndex): + centerPos = adjustmentRect.center() + pieX = centerPos.x() + pieY = centerPos.y() + + x = pieX + y = pieY + + self._paint_label( + painter, + self._filing.center().action(), + selectionIndex == PieFiling.SELECTION_CENTER, + x, y + ) + + +class QPieDisplay(QtGui.QWidget): + + def __init__(self, filing, parent = None, flags = QtCore.Qt.Window): + QtGui.QWidget.__init__(self, parent, flags) + self._filing = filing + self._artist = PieArtist(self._filing) + self._selectionIndex = PieFiling.SELECTION_NONE + + def popup(self, pos): + self._update_selection(pos) + self.show() + + def sizeHint(self): + return self._artist.pieSize() + + @misc_utils.log_exception(_moduleLogger) + def showEvent(self, showEvent): + mask = self._artist.show(self.palette()) + self.setMask(mask) + + QtGui.QWidget.showEvent(self, showEvent) + + @misc_utils.log_exception(_moduleLogger) + def hideEvent(self, hideEvent): + self._artist.hide() + self._selectionIndex = PieFiling.SELECTION_NONE + QtGui.QWidget.hideEvent(self, hideEvent) + + @misc_utils.log_exception(_moduleLogger) + def paintEvent(self, paintEvent): + canvas = self._artist.paint(self._selectionIndex) + offset = (self.size() - canvas.size()) / 2 + + screen = QtGui.QPainter(self) + screen.drawPixmap(QtCore.QPoint(offset.width(), offset.height()), canvas) + + QtGui.QWidget.paintEvent(self, paintEvent) + + def selectAt(self, index): + oldIndex = self._selectionIndex + self._selectionIndex = index + if self.isVisible(): + self.update() + + +class QPieButton(QtGui.QWidget): + + activated = qt_compat.Signal(int) + highlighted = qt_compat.Signal(int) + canceled = qt_compat.Signal() + aboutToShow = qt_compat.Signal() + aboutToHide = qt_compat.Signal() + + BUTTON_RADIUS = 24 + DELAY = 250 + + def __init__(self, buttonSlice, parent = None, buttonSlices = None): + # @bug Artifacts on Maemo 5 due to window 3D effects, find way to disable them for just these? + # @bug The pie's are being pushed back on screen on Maemo, leading to coordinate issues + QtGui.QWidget.__init__(self, parent) + self._cachedCenterPosition = self.rect().center() + + self._filing = PieFiling() + self._display = QPieDisplay(self._filing, None, QtCore.Qt.SplashScreen) + self._selectionIndex = PieFiling.SELECTION_NONE + + self._buttonFiling = PieFiling() + self._buttonFiling.set_center(buttonSlice) + if buttonSlices is not None: + for slice in buttonSlices: + self._buttonFiling.insertItem(slice) + self._buttonFiling.setOuterRadius(self.BUTTON_RADIUS) + self._buttonArtist = PieArtist(self._buttonFiling, PieArtist.BACKGROUND_NOFILL) + self._poppedUp = False + self._pressed = False + + self._delayPopupTimer = QtCore.QTimer() + self._delayPopupTimer.setInterval(self.DELAY) + self._delayPopupTimer.setSingleShot(True) + self._delayPopupTimer.timeout.connect(self._on_delayed_popup) + self._popupLocation = None + + self._mousePosition = None + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setSizePolicy( + QtGui.QSizePolicy( + QtGui.QSizePolicy.MinimumExpanding, + QtGui.QSizePolicy.MinimumExpanding, + ) + ) + + def insertItem(self, item, index = -1): + self._filing.insertItem(item, index) + + def removeItemAt(self, index): + self._filing.removeItemAt(index) + + def set_center(self, item): + self._filing.set_center(item) + + def set_button(self, item): + self.update() + + def clear(self): + self._filing.clear() + + def itemAt(self, index): + return self._filing.itemAt(index) + + def indexAt(self, point): + return self._filing.indexAt(self._cachedCenterPosition, point) + + def innerRadius(self): + return self._filing.innerRadius() + + def setInnerRadius(self, radius): + self._filing.setInnerRadius(radius) + + def outerRadius(self): + return self._filing.outerRadius() + + def setOuterRadius(self, radius): + self._filing.setOuterRadius(radius) + + def buttonRadius(self): + return self._buttonFiling.outerRadius() + + def setButtonRadius(self, radius): + self._buttonFiling.setOuterRadius(radius) + self._buttonFiling.setInnerRadius(radius / 2) + self._buttonArtist.show(self.palette()) + + def minimumSizeHint(self): + return self._buttonArtist.centerSize() + + @misc_utils.log_exception(_moduleLogger) + def mousePressEvent(self, mouseEvent): + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + self._mousePosition = lastMousePos + self._update_selection(self._cachedCenterPosition) + + self.highlighted.emit(self._selectionIndex) + + self._display.selectAt(self._selectionIndex) + self._pressed = True + self.update() + self._popupLocation = mouseEvent.globalPos() + self._delayPopupTimer.start() + + @misc_utils.log_exception(_moduleLogger) + def _on_delayed_popup(self): + assert self._popupLocation is not None, "Widget location abuse" + self._popup_child(self._popupLocation) + + @misc_utils.log_exception(_moduleLogger) + def mouseMoveEvent(self, mouseEvent): + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + if self._mousePosition is None: + # Absolute + self._update_selection(lastMousePos) + else: + # Relative + self._update_selection( + self._cachedCenterPosition + (lastMousePos - self._mousePosition), + ignoreOuter = True, + ) + + if lastSelection != self._selectionIndex: + self.highlighted.emit(self._selectionIndex) + self._display.selectAt(self._selectionIndex) + + if self._selectionIndex != PieFiling.SELECTION_CENTER and self._delayPopupTimer.isActive(): + self._on_delayed_popup() + + @misc_utils.log_exception(_moduleLogger) + def mouseReleaseEvent(self, mouseEvent): + self._delayPopupTimer.stop() + self._popupLocation = None + + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + if self._mousePosition is None: + # Absolute + self._update_selection(lastMousePos) + else: + # Relative + self._update_selection( + self._cachedCenterPosition + (lastMousePos - self._mousePosition), + ignoreOuter = True, + ) + self._mousePosition = None + + self._activate_at(self._selectionIndex) + self._pressed = False + self.update() + self._hide_child() + + @misc_utils.log_exception(_moduleLogger) + def keyPressEvent(self, keyEvent): + if keyEvent.key() in [QtCore.Qt.Key_Right, QtCore.Qt.Key_Down, QtCore.Qt.Key_Tab]: + self._popup_child(QtGui.QCursor.pos()) + if self._selectionIndex != len(self._filing) - 1: + nextSelection = self._selectionIndex + 1 + else: + nextSelection = 0 + self._select_at(nextSelection) + self._display.selectAt(self._selectionIndex) + elif keyEvent.key() in [QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Backtab]: + self._popup_child(QtGui.QCursor.pos()) + if 0 < self._selectionIndex: + nextSelection = self._selectionIndex - 1 + else: + nextSelection = len(self._filing) - 1 + self._select_at(nextSelection) + self._display.selectAt(self._selectionIndex) + elif keyEvent.key() in [QtCore.Qt.Key_Space]: + self._popup_child(QtGui.QCursor.pos()) + self._select_at(PieFiling.SELECTION_CENTER) + self._display.selectAt(self._selectionIndex) + elif keyEvent.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]: + self._delayPopupTimer.stop() + self._popupLocation = None + self._activate_at(self._selectionIndex) + self._hide_child() + elif keyEvent.key() in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Backspace]: + self._delayPopupTimer.stop() + self._popupLocation = None + self._activate_at(PieFiling.SELECTION_NONE) + self._hide_child() + else: + QtGui.QWidget.keyPressEvent(self, keyEvent) + + @misc_utils.log_exception(_moduleLogger) + def resizeEvent(self, resizeEvent): + self.setButtonRadius(min(resizeEvent.size().width(), resizeEvent.size().height()) / 2 - 1) + QtGui.QWidget.resizeEvent(self, resizeEvent) + + @misc_utils.log_exception(_moduleLogger) + def showEvent(self, showEvent): + self._buttonArtist.show(self.palette()) + self._cachedCenterPosition = self.rect().center() + + QtGui.QWidget.showEvent(self, showEvent) + + @misc_utils.log_exception(_moduleLogger) + def hideEvent(self, hideEvent): + self._display.hide() + self._select_at(PieFiling.SELECTION_NONE) + QtGui.QWidget.hideEvent(self, hideEvent) + + @misc_utils.log_exception(_moduleLogger) + def paintEvent(self, paintEvent): + self.setButtonRadius(min(self.rect().width(), self.rect().height()) / 2 - 1) + if self._poppedUp: + selectionIndex = PieFiling.SELECTION_CENTER + else: + selectionIndex = PieFiling.SELECTION_NONE + + screen = QtGui.QStylePainter(self) + screen.setRenderHint(QtGui.QPainter.Antialiasing, True) + option = QtGui.QStyleOptionButton() + option.initFrom(self) + option.state = QtGui.QStyle.State_Sunken if self._pressed else QtGui.QStyle.State_Raised + + screen.drawControl(QtGui.QStyle.CE_PushButton, option) + self._buttonArtist.paintPainter(selectionIndex, screen) + + QtGui.QWidget.paintEvent(self, paintEvent) + + def __iter__(self): + return iter(self._filing) + + def __len__(self): + return len(self._filing) + + def _popup_child(self, position): + self._poppedUp = True + self.aboutToShow.emit() + + self._delayPopupTimer.stop() + self._popupLocation = None + + position = position - QtCore.QPoint(self._filing.outerRadius(), self._filing.outerRadius()) + self._display.move(position) + self._display.show() + + self.update() + + def _hide_child(self): + self._poppedUp = False + self.aboutToHide.emit() + self._display.hide() + self.update() + + def _select_at(self, index): + self._selectionIndex = index + + def _update_selection(self, lastMousePos, ignoreOuter = False): + radius = _radius_at(self._cachedCenterPosition, lastMousePos) + if radius < self._filing.innerRadius(): + self._select_at(PieFiling.SELECTION_CENTER) + elif radius <= self._filing.outerRadius() or ignoreOuter: + self._select_at(self.indexAt(lastMousePos)) + else: + self._select_at(PieFiling.SELECTION_NONE) + + def _activate_at(self, index): + if index == PieFiling.SELECTION_NONE: + self.canceled.emit() + return + elif index == PieFiling.SELECTION_CENTER: + child = self._filing.center() + else: + child = self.itemAt(index) + + if child.action().isEnabled(): + child.action().trigger() + self.activated.emit(index) + else: + self.canceled.emit() + + +class QPieMenu(QtGui.QWidget): + + activated = qt_compat.Signal(int) + highlighted = qt_compat.Signal(int) + canceled = qt_compat.Signal() + aboutToShow = qt_compat.Signal() + aboutToHide = qt_compat.Signal() + + def __init__(self, parent = None): + QtGui.QWidget.__init__(self, parent) + self._cachedCenterPosition = self.rect().center() + + self._filing = PieFiling() + self._artist = PieArtist(self._filing) + self._selectionIndex = PieFiling.SELECTION_NONE + + self._mousePosition = () + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def popup(self, pos): + self._update_selection(pos) + self.show() + + def insertItem(self, item, index = -1): + self._filing.insertItem(item, index) + self.update() + + def removeItemAt(self, index): + self._filing.removeItemAt(index) + self.update() + + def set_center(self, item): + self._filing.set_center(item) + self.update() + + def clear(self): + self._filing.clear() + self.update() + + def itemAt(self, index): + return self._filing.itemAt(index) + + def indexAt(self, point): + return self._filing.indexAt(self._cachedCenterPosition, point) + + def innerRadius(self): + return self._filing.innerRadius() + + def setInnerRadius(self, radius): + self._filing.setInnerRadius(radius) + self.update() + + def outerRadius(self): + return self._filing.outerRadius() + + def setOuterRadius(self, radius): + self._filing.setOuterRadius(radius) + self.update() + + def sizeHint(self): + return self._artist.pieSize() + + @misc_utils.log_exception(_moduleLogger) + def mousePressEvent(self, mouseEvent): + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + self._update_selection(lastMousePos) + self._mousePosition = lastMousePos + + if lastSelection != self._selectionIndex: + self.highlighted.emit(self._selectionIndex) + self.update() + + @misc_utils.log_exception(_moduleLogger) + def mouseMoveEvent(self, mouseEvent): + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + self._update_selection(lastMousePos) + + if lastSelection != self._selectionIndex: + self.highlighted.emit(self._selectionIndex) + self.update() + + @misc_utils.log_exception(_moduleLogger) + def mouseReleaseEvent(self, mouseEvent): + lastSelection = self._selectionIndex + + lastMousePos = mouseEvent.pos() + self._update_selection(lastMousePos) + self._mousePosition = () + + self._activate_at(self._selectionIndex) + self.update() + + @misc_utils.log_exception(_moduleLogger) + def keyPressEvent(self, keyEvent): + if keyEvent.key() in [QtCore.Qt.Key_Right, QtCore.Qt.Key_Down, QtCore.Qt.Key_Tab]: + if self._selectionIndex != len(self._filing) - 1: + nextSelection = self._selectionIndex + 1 + else: + nextSelection = 0 + self._select_at(nextSelection) + self.update() + elif keyEvent.key() in [QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Backtab]: + if 0 < self._selectionIndex: + nextSelection = self._selectionIndex - 1 + else: + nextSelection = len(self._filing) - 1 + self._select_at(nextSelection) + self.update() + elif keyEvent.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]: + self._activate_at(self._selectionIndex) + elif keyEvent.key() in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Backspace]: + self._activate_at(PieFiling.SELECTION_NONE) + else: + QtGui.QWidget.keyPressEvent(self, keyEvent) + + @misc_utils.log_exception(_moduleLogger) + def showEvent(self, showEvent): + self.aboutToShow.emit() + self._cachedCenterPosition = self.rect().center() + + mask = self._artist.show(self.palette()) + self.setMask(mask) + + lastMousePos = self.mapFromGlobal(QtGui.QCursor.pos()) + self._update_selection(lastMousePos) + + QtGui.QWidget.showEvent(self, showEvent) + + @misc_utils.log_exception(_moduleLogger) + def hideEvent(self, hideEvent): + self._artist.hide() + self._selectionIndex = PieFiling.SELECTION_NONE + QtGui.QWidget.hideEvent(self, hideEvent) + + @misc_utils.log_exception(_moduleLogger) + def paintEvent(self, paintEvent): + canvas = self._artist.paint(self._selectionIndex) + + screen = QtGui.QPainter(self) + screen.drawPixmap(QtCore.QPoint(0, 0), canvas) + + QtGui.QWidget.paintEvent(self, paintEvent) + + def __iter__(self): + return iter(self._filing) + + def __len__(self): + return len(self._filing) + + def _select_at(self, index): + self._selectionIndex = index + + def _update_selection(self, lastMousePos): + radius = _radius_at(self._cachedCenterPosition, lastMousePos) + if radius < self._filing.innerRadius(): + self._selectionIndex = PieFiling.SELECTION_CENTER + elif radius <= self._filing.outerRadius(): + self._select_at(self.indexAt(lastMousePos)) + else: + self._selectionIndex = PieFiling.SELECTION_NONE + + def _activate_at(self, index): + if index == PieFiling.SELECTION_NONE: + self.canceled.emit() + self.aboutToHide.emit() + self.hide() + return + elif index == PieFiling.SELECTION_CENTER: + child = self._filing.center() + else: + child = self.itemAt(index) + + if child.isEnabled(): + child.action().trigger() + self.activated.emit(index) + else: + self.canceled.emit() + self.aboutToHide.emit() + self.hide() + + +def init_pies(): + PieFiling.NULL_CENTER.setEnabled(False) + + +def _print(msg): + print msg + + +def _on_about_to_hide(app): + app.exit() + + +if __name__ == "__main__": + app = QtGui.QApplication([]) + init_pies() + + if False: + pie = QPieMenu() + pie.show() + + if False: + singleAction = QtGui.QAction(None) + singleAction.setText("Boo") + singleItem = QActionPieItem(singleAction) + spie = QPieMenu() + spie.insertItem(singleItem) + spie.show() + + if False: + oneAction = QtGui.QAction(None) + oneAction.setText("Chew") + oneItem = QActionPieItem(oneAction) + twoAction = QtGui.QAction(None) + twoAction.setText("Foo") + twoItem = QActionPieItem(twoAction) + iconTextAction = QtGui.QAction(None) + iconTextAction.setText("Icon") + iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) + iconTextItem = QActionPieItem(iconTextAction) + mpie = QPieMenu() + mpie.insertItem(oneItem) + mpie.insertItem(twoItem) + mpie.insertItem(oneItem) + mpie.insertItem(iconTextItem) + mpie.show() + + if True: + oneAction = QtGui.QAction(None) + oneAction.setText("Chew") + oneAction.triggered.connect(lambda: _print("Chew")) + oneItem = QActionPieItem(oneAction) + twoAction = QtGui.QAction(None) + twoAction.setText("Foo") + twoAction.triggered.connect(lambda: _print("Foo")) + twoItem = QActionPieItem(twoAction) + iconAction = QtGui.QAction(None) + iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) + iconAction.triggered.connect(lambda: _print("Icon")) + iconItem = QActionPieItem(iconAction) + iconTextAction = QtGui.QAction(None) + iconTextAction.setText("Icon") + iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) + iconTextAction.triggered.connect(lambda: _print("Icon and text")) + iconTextItem = QActionPieItem(iconTextAction) + mpie = QPieMenu() + mpie.set_center(iconItem) + mpie.insertItem(oneItem) + mpie.insertItem(twoItem) + mpie.insertItem(oneItem) + mpie.insertItem(iconTextItem) + mpie.show() + mpie.aboutToHide.connect(lambda: _on_about_to_hide(app)) + mpie.canceled.connect(lambda: _print("Canceled")) + + if False: + oneAction = QtGui.QAction(None) + oneAction.setText("Chew") + oneAction.triggered.connect(lambda: _print("Chew")) + oneItem = QActionPieItem(oneAction) + twoAction = QtGui.QAction(None) + twoAction.setText("Foo") + twoAction.triggered.connect(lambda: _print("Foo")) + twoItem = QActionPieItem(twoAction) + iconAction = QtGui.QAction(None) + iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) + iconAction.triggered.connect(lambda: _print("Icon")) + iconItem = QActionPieItem(iconAction) + iconTextAction = QtGui.QAction(None) + iconTextAction.setText("Icon") + iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) + iconTextAction.triggered.connect(lambda: _print("Icon and text")) + iconTextItem = QActionPieItem(iconTextAction) + pieFiling = PieFiling() + pieFiling.set_center(iconItem) + pieFiling.insertItem(oneItem) + pieFiling.insertItem(twoItem) + pieFiling.insertItem(oneItem) + pieFiling.insertItem(iconTextItem) + mpie = QPieDisplay(pieFiling) + mpie.show() + + if False: + oneAction = QtGui.QAction(None) + oneAction.setText("Chew") + oneAction.triggered.connect(lambda: _print("Chew")) + oneItem = QActionPieItem(oneAction) + twoAction = QtGui.QAction(None) + twoAction.setText("Foo") + twoAction.triggered.connect(lambda: _print("Foo")) + twoItem = QActionPieItem(twoAction) + iconAction = QtGui.QAction(None) + iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) + iconAction.triggered.connect(lambda: _print("Icon")) + iconItem = QActionPieItem(iconAction) + iconTextAction = QtGui.QAction(None) + iconTextAction.setText("Icon") + iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) + iconTextAction.triggered.connect(lambda: _print("Icon and text")) + iconTextItem = QActionPieItem(iconTextAction) + mpie = QPieButton(iconItem) + mpie.set_center(iconItem) + mpie.insertItem(oneItem) + mpie.insertItem(twoItem) + mpie.insertItem(oneItem) + mpie.insertItem(iconTextItem) + mpie.show() + mpie.aboutToHide.connect(lambda: _on_about_to_hide(app)) + mpie.canceled.connect(lambda: _print("Canceled")) + + app.exec_() diff --git a/dialcentral/util/qtpieboard.py b/dialcentral/util/qtpieboard.py new file mode 100755 index 0000000..50ae9ae --- /dev/null +++ b/dialcentral/util/qtpieboard.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + + +from __future__ import division + +import os +import warnings + +import qt_compat +QtGui = qt_compat.import_module("QtGui") + +import qtpie + + +class PieKeyboard(object): + + SLICE_CENTER = -1 + SLICE_NORTH = 0 + SLICE_NORTH_WEST = 1 + SLICE_WEST = 2 + SLICE_SOUTH_WEST = 3 + SLICE_SOUTH = 4 + SLICE_SOUTH_EAST = 5 + SLICE_EAST = 6 + SLICE_NORTH_EAST = 7 + + MAX_ANGULAR_SLICES = 8 + + SLICE_DIRECTIONS = [ + SLICE_CENTER, + SLICE_NORTH, + SLICE_NORTH_WEST, + SLICE_WEST, + SLICE_SOUTH_WEST, + SLICE_SOUTH, + SLICE_SOUTH_EAST, + SLICE_EAST, + SLICE_NORTH_EAST, + ] + + SLICE_DIRECTION_NAMES = [ + "CENTER", + "NORTH", + "NORTH_WEST", + "WEST", + "SOUTH_WEST", + "SOUTH", + "SOUTH_EAST", + "EAST", + "NORTH_EAST", + ] + + def __init__(self): + self._layout = QtGui.QGridLayout() + self._widget = QtGui.QWidget() + self._widget.setLayout(self._layout) + + self.__cells = {} + + @property + def toplevel(self): + return self._widget + + def add_pie(self, row, column, pieButton): + assert len(pieButton) == 8 + self._layout.addWidget(pieButton, row, column) + self.__cells[(row, column)] = pieButton + + def get_pie(self, row, column): + return self.__cells[(row, column)] + + +class KeyboardModifier(object): + + def __init__(self, name): + self.name = name + self.lock = False + self.once = False + + @property + def isActive(self): + return self.lock or self.once + + def on_toggle_lock(self, *args, **kwds): + self.lock = not self.lock + + def on_toggle_once(self, *args, **kwds): + self.once = not self.once + + def reset_once(self): + self.once = False + + +def parse_keyboard_data(text): + return eval(text) + + +def _enumerate_pie_slices(pieData, iconPaths): + for direction, directionName in zip( + PieKeyboard.SLICE_DIRECTIONS, PieKeyboard.SLICE_DIRECTION_NAMES + ): + if directionName in pieData: + sliceData = pieData[directionName] + + action = QtGui.QAction(None) + try: + action.setText(sliceData["text"]) + except KeyError: + pass + try: + relativeIconPath = sliceData["path"] + except KeyError: + pass + else: + for iconPath in iconPaths: + absIconPath = os.path.join(iconPath, relativeIconPath) + if os.path.exists(absIconPath): + action.setIcon(QtGui.QIcon(absIconPath)) + break + pieItem = qtpie.QActionPieItem(action) + actionToken = sliceData["action"] + else: + pieItem = qtpie.PieFiling.NULL_CENTER + actionToken = "" + yield direction, pieItem, actionToken + + +def load_keyboard(keyboardName, dataTree, keyboard, keyboardHandler, iconPaths): + for (row, column), pieData in dataTree.iteritems(): + pieItems = list(_enumerate_pie_slices(pieData, iconPaths)) + assert pieItems[0][0] == PieKeyboard.SLICE_CENTER, pieItems[0] + _, center, centerAction = pieItems.pop(0) + + pieButton = qtpie.QPieButton(center) + pieButton.set_center(center) + keyboardHandler.map_slice_action(center, centerAction) + for direction, pieItem, action in pieItems: + pieButton.insertItem(pieItem) + keyboardHandler.map_slice_action(pieItem, action) + keyboard.add_pie(row, column, pieButton) + + +class KeyboardHandler(object): + + def __init__(self, keyhandler): + self.__keyhandler = keyhandler + self.__commandHandlers = {} + self.__modifiers = {} + self.__sliceActions = {} + + self.register_modifier("Shift") + self.register_modifier("Super") + self.register_modifier("Control") + self.register_modifier("Alt") + + def register_command_handler(self, command, handler): + # @todo Look into hooking these up directly to the pie actions + self.__commandHandlers["[%s]" % command] = handler + + def unregister_command_handler(self, command): + # @todo Look into hooking these up directly to the pie actions + del self.__commandHandlers["[%s]" % command] + + def register_modifier(self, modifierName): + mod = KeyboardModifier(modifierName) + self.register_command_handler(modifierName, mod.on_toggle_lock) + self.__modifiers["<%s>" % modifierName] = mod + + def unregister_modifier(self, modifierName): + self.unregister_command_handler(modifierName) + del self.__modifiers["<%s>" % modifierName] + + def map_slice_action(self, slice, action): + callback = lambda direction: self(direction, action) + slice.action().triggered.connect(callback) + self.__sliceActions[slice] = (action, callback) + + def __call__(self, direction, action): + activeModifiers = [ + mod.name + for mod in self.__modifiers.itervalues() + if mod.isActive + ] + + needResetOnce = False + if action.startswith("[") and action.endswith("]"): + commandName = action[1:-1] + if action in self.__commandHandlers: + self.__commandHandlers[action](commandName, activeModifiers) + needResetOnce = True + else: + warnings.warn("Unknown command: [%s]" % commandName) + elif action.startswith("<") and action.endswith(">"): + modName = action[1:-1] + for mod in self.__modifiers.itervalues(): + if mod.name == modName: + mod.on_toggle_once() + break + else: + warnings.warn("Unknown modifier: <%s>" % modName) + else: + self.__keyhandler(action, activeModifiers) + needResetOnce = True + + if needResetOnce: + for mod in self.__modifiers.itervalues(): + mod.reset_once() diff --git a/dialcentral/util/qui_utils.py b/dialcentral/util/qui_utils.py new file mode 100644 index 0000000..11b3453 --- /dev/null +++ b/dialcentral/util/qui_utils.py @@ -0,0 +1,419 @@ +import sys +import contextlib +import datetime +import logging + +import qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +import misc + + +_moduleLogger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def notify_error(log): + try: + yield + except: + log.push_exception() + + +@contextlib.contextmanager +def notify_busy(log, message): + log.push_busy(message) + try: + yield + finally: + log.pop(message) + + +class ErrorMessage(object): + + LEVEL_ERROR = 0 + LEVEL_BUSY = 1 + LEVEL_INFO = 2 + + def __init__(self, message, level): + self._message = message + self._level = level + self._time = datetime.datetime.now() + + @property + def level(self): + return self._level + + @property + def message(self): + return self._message + + def __repr__(self): + return "%s.%s(%r, %r)" % (__name__, self.__class__.__name__, self._message, self._level) + + +class QErrorLog(QtCore.QObject): + + messagePushed = qt_compat.Signal() + messagePopped = qt_compat.Signal() + + def __init__(self): + QtCore.QObject.__init__(self) + self._messages = [] + + def push_busy(self, message): + _moduleLogger.info("Entering state: %s" % message) + self._push_message(message, ErrorMessage.LEVEL_BUSY) + + def push_message(self, message): + self._push_message(message, ErrorMessage.LEVEL_INFO) + + def push_error(self, message): + self._push_message(message, ErrorMessage.LEVEL_ERROR) + + def push_exception(self): + userMessage = str(sys.exc_info()[1]) + _moduleLogger.exception(userMessage) + self.push_error(userMessage) + + def pop(self, message = None): + if message is None: + del self._messages[0] + else: + _moduleLogger.info("Exiting state: %s" % message) + messageIndex = [ + i + for (i, error) in enumerate(self._messages) + if error.message == message + ] + # Might be removed out of order + if messageIndex: + del self._messages[messageIndex[0]] + self.messagePopped.emit() + + def peek_message(self): + return self._messages[0] + + def _push_message(self, message, level): + self._messages.append(ErrorMessage(message, level)) + # Sort is defined as stable, so this should be fine + self._messages.sort(key=lambda x: x.level) + self.messagePushed.emit() + + def __len__(self): + return len(self._messages) + + +class ErrorDisplay(object): + + _SENTINEL_ICON = QtGui.QIcon() + + def __init__(self, errorLog): + self._errorLog = errorLog + self._errorLog.messagePushed.connect(self._on_message_pushed) + self._errorLog.messagePopped.connect(self._on_message_popped) + + self._icons = None + self._severityLabel = QtGui.QLabel() + self._severityLabel.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + + self._message = QtGui.QLabel() + self._message.setText("Boo") + self._message.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self._message.setWordWrap(True) + + self._closeLabel = None + + self._controlLayout = QtGui.QHBoxLayout() + self._controlLayout.addWidget(self._severityLabel, 1, QtCore.Qt.AlignCenter) + self._controlLayout.addWidget(self._message, 1000) + + self._widget = QtGui.QWidget() + self._widget.setLayout(self._controlLayout) + self._widget.hide() + + @property + def toplevel(self): + return self._widget + + def _show_error(self): + if self._icons is None: + self._icons = { + ErrorMessage.LEVEL_BUSY: + get_theme_icon( + #("process-working", "view-refresh", "general_refresh", "gtk-refresh") + ("view-refresh", "general_refresh", "gtk-refresh", ) + ).pixmap(32, 32), + ErrorMessage.LEVEL_INFO: + get_theme_icon( + ("dialog-information", "general_notes", "gtk-info") + ).pixmap(32, 32), + ErrorMessage.LEVEL_ERROR: + get_theme_icon( + ("dialog-error", "app_install_error", "gtk-dialog-error") + ).pixmap(32, 32), + } + if self._closeLabel is None: + closeIcon = get_theme_icon(("window-close", "general_close", "gtk-close"), self._SENTINEL_ICON) + if closeIcon is not self._SENTINEL_ICON: + self._closeLabel = QtGui.QPushButton(closeIcon, "") + else: + self._closeLabel = QtGui.QPushButton("X") + self._closeLabel.clicked.connect(self._on_close) + self._controlLayout.addWidget(self._closeLabel, 1, QtCore.Qt.AlignCenter) + error = self._errorLog.peek_message() + self._message.setText(error.message) + self._severityLabel.setPixmap(self._icons[error.level]) + self._widget.show() + + @qt_compat.Slot() + @qt_compat.Slot(bool) + @misc.log_exception(_moduleLogger) + def _on_close(self, checked = False): + self._errorLog.pop() + + @qt_compat.Slot() + @misc.log_exception(_moduleLogger) + def _on_message_pushed(self): + self._show_error() + + @qt_compat.Slot() + @misc.log_exception(_moduleLogger) + def _on_message_popped(self): + if len(self._errorLog) == 0: + self._message.setText("") + self._widget.hide() + else: + self._show_error() + + +class QHtmlDelegate(QtGui.QStyledItemDelegate): + + UNDEFINED_SIZE = -1 + + def __init__(self, *args, **kwd): + QtGui.QStyledItemDelegate.__init__(*((self, ) + args), **kwd) + self._width = self.UNDEFINED_SIZE + + def paint(self, painter, option, index): + newOption = QtGui.QStyleOptionViewItemV4(option) + self.initStyleOption(newOption, index) + if newOption.widget is not None: + style = newOption.widget.style() + else: + style = QtGui.QApplication.style() + + doc = QtGui.QTextDocument() + doc.setHtml(newOption.text) + doc.setTextWidth(newOption.rect.width()) + + newOption.text = "" + style.drawControl(QtGui.QStyle.CE_ItemViewItem, newOption, painter) + + ctx = QtGui.QAbstractTextDocumentLayout.PaintContext() + if newOption.state & QtGui.QStyle.State_Selected: + ctx.palette.setColor( + QtGui.QPalette.Text, + newOption.palette.color( + QtGui.QPalette.Active, + QtGui.QPalette.HighlightedText + ) + ) + else: + ctx.palette.setColor( + QtGui.QPalette.Text, + newOption.palette.color( + QtGui.QPalette.Active, + QtGui.QPalette.Text + ) + ) + + textRect = style.subElementRect(QtGui.QStyle.SE_ItemViewItemText, newOption) + painter.save() + painter.translate(textRect.topLeft()) + painter.setClipRect(textRect.translated(-textRect.topLeft())) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + def setWidth(self, width, model): + if self._width == width: + return + self._width = width + for c in xrange(model.rowCount()): + cItem = model.item(c, 0) + for r in xrange(model.rowCount()): + rItem = cItem.child(r, 0) + rIndex = model.indexFromItem(rItem) + self.sizeHintChanged.emit(rIndex) + return + + def sizeHint(self, option, index): + newOption = QtGui.QStyleOptionViewItemV4(option) + self.initStyleOption(newOption, index) + + doc = QtGui.QTextDocument() + doc.setHtml(newOption.text) + if self._width != self.UNDEFINED_SIZE: + width = self._width + else: + width = newOption.rect.width() + doc.setTextWidth(width) + size = QtCore.QSize(doc.idealWidth(), doc.size().height()) + return size + + +class QSignalingMainWindow(QtGui.QMainWindow): + + closed = qt_compat.Signal() + hidden = qt_compat.Signal() + shown = qt_compat.Signal() + resized = qt_compat.Signal() + + def __init__(self, *args, **kwd): + QtGui.QMainWindow.__init__(*((self, )+args), **kwd) + + def closeEvent(self, event): + val = QtGui.QMainWindow.closeEvent(self, event) + self.closed.emit() + return val + + def hideEvent(self, event): + val = QtGui.QMainWindow.hideEvent(self, event) + self.hidden.emit() + return val + + def showEvent(self, event): + val = QtGui.QMainWindow.showEvent(self, event) + self.shown.emit() + return val + + def resizeEvent(self, event): + val = QtGui.QMainWindow.resizeEvent(self, event) + self.resized.emit() + return val + +def set_current_index(selector, itemText, default = 0): + for i in xrange(selector.count()): + if selector.itemText(i) == itemText: + selector.setCurrentIndex(i) + break + else: + itemText.setCurrentIndex(default) + + +def _null_set_stackable(window, isStackable): + pass + + +def _maemo_set_stackable(window, isStackable): + window.setAttribute(QtCore.Qt.WA_Maemo5StackedWindow, isStackable) + + +try: + QtCore.Qt.WA_Maemo5StackedWindow + set_stackable = _maemo_set_stackable +except AttributeError: + set_stackable = _null_set_stackable + + +def _null_set_autorient(window, doAutoOrient): + pass + + +def _maemo_set_autorient(window, doAutoOrient): + window.setAttribute(QtCore.Qt.WA_Maemo5AutoOrientation, doAutoOrient) + + +try: + QtCore.Qt.WA_Maemo5AutoOrientation + set_autorient = _maemo_set_autorient +except AttributeError: + set_autorient = _null_set_autorient + + +def screen_orientation(): + geom = QtGui.QApplication.desktop().screenGeometry() + if geom.width() <= geom.height(): + return QtCore.Qt.Vertical + else: + return QtCore.Qt.Horizontal + + +def _null_set_window_orientation(window, orientation): + pass + + +def _maemo_set_window_orientation(window, orientation): + if orientation == QtCore.Qt.Vertical: + window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, False) + window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, True) + elif orientation == QtCore.Qt.Horizontal: + window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, True) + window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, False) + elif orientation is None: + window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, False) + window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, False) + else: + raise RuntimeError("Unknown orientation: %r" % orientation) + + +try: + QtCore.Qt.WA_Maemo5LandscapeOrientation + QtCore.Qt.WA_Maemo5PortraitOrientation + set_window_orientation = _maemo_set_window_orientation +except AttributeError: + set_window_orientation = _null_set_window_orientation + + +def _null_show_progress_indicator(window, isStackable): + pass + + +def _maemo_show_progress_indicator(window, isStackable): + window.setAttribute(QtCore.Qt.WA_Maemo5ShowProgressIndicator, isStackable) + + +try: + QtCore.Qt.WA_Maemo5ShowProgressIndicator + show_progress_indicator = _maemo_show_progress_indicator +except AttributeError: + show_progress_indicator = _null_show_progress_indicator + + +def _null_mark_numbers_preferred(widget): + pass + + +def _newqt_mark_numbers_preferred(widget): + widget.setInputMethodHints(QtCore.Qt.ImhPreferNumbers) + + +try: + QtCore.Qt.ImhPreferNumbers + mark_numbers_preferred = _newqt_mark_numbers_preferred +except AttributeError: + mark_numbers_preferred = _null_mark_numbers_preferred + + +def _null_get_theme_icon(iconNames, fallback = None): + icon = fallback if fallback is not None else QtGui.QIcon() + return icon + + +def _newqt_get_theme_icon(iconNames, fallback = None): + for iconName in iconNames: + if QtGui.QIcon.hasThemeIcon(iconName): + icon = QtGui.QIcon.fromTheme(iconName) + break + else: + icon = fallback if fallback is not None else QtGui.QIcon() + return icon + + +try: + QtGui.QIcon.fromTheme + get_theme_icon = _newqt_get_theme_icon +except AttributeError: + get_theme_icon = _null_get_theme_icon + diff --git a/dialcentral/util/qwrappers.py b/dialcentral/util/qwrappers.py new file mode 100644 index 0000000..2c50c8a --- /dev/null +++ b/dialcentral/util/qwrappers.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python + +from __future__ import with_statement +from __future__ import division + +import logging + +import qt_compat +QtCore = qt_compat.QtCore +QtGui = qt_compat.import_module("QtGui") + +from util import qui_utils +from util import misc as misc_utils + + +_moduleLogger = logging.getLogger(__name__) + + +class ApplicationWrapper(object): + + DEFAULT_ORIENTATION = "Default" + AUTO_ORIENTATION = "Auto" + LANDSCAPE_ORIENTATION = "Landscape" + PORTRAIT_ORIENTATION = "Portrait" + + def __init__(self, qapp, constants): + self._constants = constants + self._qapp = qapp + self._clipboard = QtGui.QApplication.clipboard() + + self._errorLog = qui_utils.QErrorLog() + self._mainWindow = None + + self._fullscreenAction = QtGui.QAction(None) + self._fullscreenAction.setText("Fullscreen") + self._fullscreenAction.setCheckable(True) + self._fullscreenAction.setShortcut(QtGui.QKeySequence("CTRL+Enter")) + self._fullscreenAction.toggled.connect(self._on_toggle_fullscreen) + + self._orientation = self.DEFAULT_ORIENTATION + self._orientationAction = QtGui.QAction(None) + self._orientationAction.setText("Next Orientation") + self._orientationAction.setCheckable(True) + self._orientationAction.setShortcut(QtGui.QKeySequence("CTRL+o")) + self._orientationAction.triggered.connect(self._on_next_orientation) + + self._logAction = QtGui.QAction(None) + self._logAction.setText("Log") + self._logAction.setShortcut(QtGui.QKeySequence("CTRL+l")) + self._logAction.triggered.connect(self._on_log) + + self._quitAction = QtGui.QAction(None) + self._quitAction.setText("Quit") + self._quitAction.setShortcut(QtGui.QKeySequence("CTRL+q")) + self._quitAction.triggered.connect(self._on_quit) + + self._aboutAction = QtGui.QAction(None) + self._aboutAction.setText("About") + self._aboutAction.triggered.connect(self._on_about) + + self._qapp.lastWindowClosed.connect(self._on_app_quit) + self._mainWindow = self._new_main_window() + self._mainWindow.window.destroyed.connect(self._on_child_close) + + self.load_settings() + + self._mainWindow.show() + self._idleDelay = QtCore.QTimer() + self._idleDelay.setSingleShot(True) + self._idleDelay.setInterval(0) + self._idleDelay.timeout.connect(self._on_delayed_start) + self._idleDelay.start() + + def load_settings(self): + raise NotImplementedError("Booh") + + def save_settings(self): + raise NotImplementedError("Booh") + + def _new_main_window(self): + raise NotImplementedError("Booh") + + @property + def qapp(self): + return self._qapp + + @property + def constants(self): + return self._constants + + @property + def errorLog(self): + return self._errorLog + + @property + def fullscreenAction(self): + return self._fullscreenAction + + @property + def orientationAction(self): + return self._orientationAction + + @property + def orientation(self): + return self._orientation + + @property + def logAction(self): + return self._logAction + + @property + def aboutAction(self): + return self._aboutAction + + @property + def quitAction(self): + return self._quitAction + + def set_orientation(self, orientation): + self._orientation = orientation + self._mainWindow.update_orientation(self._orientation) + + @classmethod + def _next_orientation(cls, current): + return { + cls.DEFAULT_ORIENTATION: cls.AUTO_ORIENTATION, + cls.AUTO_ORIENTATION: cls.LANDSCAPE_ORIENTATION, + cls.LANDSCAPE_ORIENTATION: cls.PORTRAIT_ORIENTATION, + cls.PORTRAIT_ORIENTATION: cls.DEFAULT_ORIENTATION, + }[current] + + def _close_windows(self): + if self._mainWindow is not None: + self.save_settings() + self._mainWindow.window.destroyed.disconnect(self._on_child_close) + self._mainWindow.close() + self._mainWindow = None + + @misc_utils.log_exception(_moduleLogger) + def _on_delayed_start(self): + self._mainWindow.start() + + @misc_utils.log_exception(_moduleLogger) + def _on_app_quit(self, checked = False): + if self._mainWindow is not None: + self.save_settings() + self._mainWindow.destroy() + + @misc_utils.log_exception(_moduleLogger) + def _on_child_close(self, obj = None): + if self._mainWindow is not None: + self.save_settings() + self._mainWindow = None + + @misc_utils.log_exception(_moduleLogger) + def _on_toggle_fullscreen(self, checked = False): + with qui_utils.notify_error(self._errorLog): + self._mainWindow.set_fullscreen(checked) + + @misc_utils.log_exception(_moduleLogger) + def _on_next_orientation(self, checked = False): + with qui_utils.notify_error(self._errorLog): + self.set_orientation(self._next_orientation(self._orientation)) + + @misc_utils.log_exception(_moduleLogger) + def _on_about(self, checked = True): + raise NotImplementedError("Booh") + + @misc_utils.log_exception(_moduleLogger) + def _on_log(self, checked = False): + with qui_utils.notify_error(self._errorLog): + with open(self._constants._user_logpath_, "r") as f: + logLines = f.xreadlines() + log = "".join(logLines) + self._clipboard.setText(log) + + @misc_utils.log_exception(_moduleLogger) + def _on_quit(self, checked = False): + with qui_utils.notify_error(self._errorLog): + self._close_windows() + + +class WindowWrapper(object): + + def __init__(self, parent, app): + self._app = app + + self._errorDisplay = qui_utils.ErrorDisplay(self._app.errorLog) + + self._layout = QtGui.QBoxLayout(QtGui.QBoxLayout.LeftToRight) + self._layout.setContentsMargins(0, 0, 0, 0) + + self._superLayout = QtGui.QVBoxLayout() + self._superLayout.addWidget(self._errorDisplay.toplevel) + self._superLayout.setContentsMargins(0, 0, 0, 0) + self._superLayout.addLayout(self._layout) + + centralWidget = QtGui.QWidget() + centralWidget.setLayout(self._superLayout) + centralWidget.setContentsMargins(0, 0, 0, 0) + + self._window = qui_utils.QSignalingMainWindow(parent) + self._window.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + qui_utils.set_stackable(self._window, True) + self._window.setCentralWidget(centralWidget) + + self._closeWindowAction = QtGui.QAction(None) + self._closeWindowAction.setText("Close") + self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) + self._closeWindowAction.triggered.connect(self._on_close_window) + + self._window.addAction(self._closeWindowAction) + self._window.addAction(self._app.quitAction) + self._window.addAction(self._app.fullscreenAction) + self._window.addAction(self._app.orientationAction) + self._window.addAction(self._app.logAction) + + @property + def window(self): + return self._window + + @property + def windowOrientation(self): + geom = self._window.size() + if geom.width() <= geom.height(): + return QtCore.Qt.Vertical + else: + return QtCore.Qt.Horizontal + + @property + def idealWindowOrientation(self): + if self._app.orientation == self._app.AUTO_ORIENTATION: + windowOrientation = self.windowOrientation + elif self._app.orientation == self._app.DEFAULT_ORIENTATION: + windowOrientation = qui_utils.screen_orientation() + elif self._app.orientation == self._app.LANDSCAPE_ORIENTATION: + windowOrientation = QtCore.Qt.Horizontal + elif self._app.orientation == self._app.PORTRAIT_ORIENTATION: + windowOrientation = QtCore.Qt.Vertical + else: + raise RuntimeError("Bad! No %r for you" % self._app.orientation) + return windowOrientation + + def walk_children(self): + return () + + def start(self): + pass + + def close(self): + for child in self.walk_children(): + child.window.destroyed.disconnect(self._on_child_close) + child.close() + self._window.close() + + def destroy(self): + pass + + def show(self): + self._window.show() + for child in self.walk_children(): + child.show() + self.set_fullscreen(self._app.fullscreenAction.isChecked()) + + def hide(self): + for child in self.walk_children(): + child.hide() + self._window.hide() + + def set_fullscreen(self, isFullscreen): + if self._window.isVisible(): + if isFullscreen: + self._window.showFullScreen() + else: + self._window.showNormal() + for child in self.walk_children(): + child.set_fullscreen(isFullscreen) + + def update_orientation(self, orientation): + if orientation == self._app.DEFAULT_ORIENTATION: + qui_utils.set_autorient(self.window, False) + qui_utils.set_window_orientation(self.window, None) + elif orientation == self._app.AUTO_ORIENTATION: + qui_utils.set_autorient(self.window, True) + qui_utils.set_window_orientation(self.window, None) + elif orientation == self._app.LANDSCAPE_ORIENTATION: + qui_utils.set_autorient(self.window, False) + qui_utils.set_window_orientation(self.window, QtCore.Qt.Horizontal) + elif orientation == self._app.PORTRAIT_ORIENTATION: + qui_utils.set_autorient(self.window, False) + qui_utils.set_window_orientation(self.window, QtCore.Qt.Vertical) + else: + raise RuntimeError("Unknown orientation: %r" % orientation) + for child in self.walk_children(): + child.update_orientation(orientation) + + @misc_utils.log_exception(_moduleLogger) + def _on_child_close(self, obj = None): + raise NotImplementedError("Booh") + + @misc_utils.log_exception(_moduleLogger) + def _on_close_window(self, checked = True): + with qui_utils.notify_error(self._errorLog): + self.close() + + +class AutoFreezeWindowFeature(object): + + def __init__(self, app, window): + self._app = app + self._window = window + self._app.qapp.focusChanged.connect(self._on_focus_changed) + if self._app.qapp.focusWidget() is not None: + self._window.setUpdatesEnabled(True) + else: + self._window.setUpdatesEnabled(False) + + def close(self): + self._app.qapp.focusChanged.disconnect(self._on_focus_changed) + self._window.setUpdatesEnabled(True) + + @misc_utils.log_exception(_moduleLogger) + def _on_focus_changed(self, oldWindow, newWindow): + with qui_utils.notify_error(self._app.errorLog): + if oldWindow is None and newWindow is not None: + self._window.setUpdatesEnabled(True) + elif oldWindow is not None and newWindow is None: + self._window.setUpdatesEnabled(False) diff --git a/dialcentral/util/time_utils.py b/dialcentral/util/time_utils.py new file mode 100644 index 0000000..90ec84d --- /dev/null +++ b/dialcentral/util/time_utils.py @@ -0,0 +1,94 @@ +from datetime import tzinfo, timedelta, datetime + +ZERO = timedelta(0) +HOUR = timedelta(hours=1) + + +def first_sunday_on_or_after(dt): + days_to_go = 6 - dt.weekday() + if days_to_go: + dt += timedelta(days_to_go) + return dt + + +# US DST Rules +# +# This is a simplified (i.e., wrong for a few cases) set of rules for US +# DST start and end times. For a complete and up-to-date set of DST rules +# and timezone definitions, visit the Olson Database (or try pytz): +# http://www.twinsun.com/tz/tz-link.htm +# http://sourceforge.net/projects/pytz/ (might not be up-to-date) +# +# In the US, since 2007, DST starts at 2am (standard time) on the second +# Sunday in March, which is the first Sunday on or after Mar 8. +DSTSTART_2007 = datetime(1, 3, 8, 2) +# and ends at 2am (DST time; 1am standard time) on the first Sunday of Nov. +DSTEND_2007 = datetime(1, 11, 1, 1) +# From 1987 to 2006, DST used to start at 2am (standard time) on the first +# Sunday in April and to end at 2am (DST time; 1am standard time) on the last +# Sunday of October, which is the first Sunday on or after Oct 25. +DSTSTART_1987_2006 = datetime(1, 4, 1, 2) +DSTEND_1987_2006 = datetime(1, 10, 25, 1) +# From 1967 to 1986, DST used to start at 2am (standard time) on the last +# Sunday in April (the one on or after April 24) and to end at 2am (DST time; +# 1am standard time) on the last Sunday of October, which is the first Sunday +# on or after Oct 25. +DSTSTART_1967_1986 = datetime(1, 4, 24, 2) +DSTEND_1967_1986 = DSTEND_1987_2006 + + +class USTimeZone(tzinfo): + + def __init__(self, hours, reprname, stdname, dstname): + self.stdoffset = timedelta(hours=hours) + self.reprname = reprname + self.stdname = stdname + self.dstname = dstname + + def __repr__(self): + return self.reprname + + def tzname(self, dt): + if self.dst(dt): + return self.dstname + else: + return self.stdname + + def utcoffset(self, dt): + return self.stdoffset + self.dst(dt) + + def dst(self, dt): + if dt is None or dt.tzinfo is None: + # An exception may be sensible here, in one or both cases. + # It depends on how you want to treat them. The default + # fromutc() implementation (called by the default astimezone() + # implementation) passes a datetime with dt.tzinfo is self. + return ZERO + assert dt.tzinfo is self + + # Find start and end times for US DST. For years before 1967, return + # ZERO for no DST. + if 2006 < dt.year: + dststart, dstend = DSTSTART_2007, DSTEND_2007 + elif 1986 < dt.year < 2007: + dststart, dstend = DSTSTART_1987_2006, DSTEND_1987_2006 + elif 1966 < dt.year < 1987: + dststart, dstend = DSTSTART_1967_1986, DSTEND_1967_1986 + else: + return ZERO + + start = first_sunday_on_or_after(dststart.replace(year=dt.year)) + end = first_sunday_on_or_after(dstend.replace(year=dt.year)) + + # Can't compare naive to aware objects, so strip the timezone from + # dt first. + if start <= dt.replace(tzinfo=None) < end: + return HOUR + else: + return ZERO + + +Eastern = USTimeZone(-5, "Eastern", "EST", "EDT") +Central = USTimeZone(-6, "Central", "CST", "CDT") +Mountain = USTimeZone(-7, "Mountain", "MST", "MDT") +Pacific = USTimeZone(-8, "Pacific", "PST", "PDT") diff --git a/dialcentral/util/tp_utils.py b/dialcentral/util/tp_utils.py new file mode 100644 index 0000000..7c55c42 --- /dev/null +++ b/dialcentral/util/tp_utils.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python + +import logging + +import dbus +import telepathy + +import util.go_utils as gobject_utils +import misc + + +_moduleLogger = logging.getLogger(__name__) +DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties' + + +class WasMissedCall(object): + + def __init__(self, bus, conn, chan, on_success, on_error): + self.__on_success = on_success + self.__on_error = on_error + + self._requested = None + self._didMembersChange = False + self._didClose = False + self._didReport = False + + self._onTimeout = gobject_utils.Timeout(self._on_timeout) + self._onTimeout.start(seconds=60) + + chan[telepathy.interfaces.CHANNEL_INTERFACE_GROUP].connect_to_signal( + "MembersChanged", + self._on_members_changed, + ) + + chan[telepathy.interfaces.CHANNEL].connect_to_signal( + "Closed", + self._on_closed, + ) + + chan[DBUS_PROPERTIES].GetAll( + telepathy.interfaces.CHANNEL_INTERFACE, + reply_handler = self._on_got_all, + error_handler = self._on_error, + ) + + def cancel(self): + self._report_error("by request") + + def _report_missed_if_ready(self): + if self._didReport: + pass + elif self._requested is not None and (self._didMembersChange or self._didClose): + if self._requested: + self._report_error("wrong direction") + elif self._didClose: + self._report_success() + else: + self._report_error("members added") + else: + if self._didClose: + self._report_error("closed too early") + + def _report_success(self): + assert not self._didReport, "Double reporting a missed call" + self._didReport = True + self._onTimeout.cancel() + self.__on_success(self) + + def _report_error(self, reason): + assert not self._didReport, "Double reporting a missed call" + self._didReport = True + self._onTimeout.cancel() + self.__on_error(self, reason) + + @misc.log_exception(_moduleLogger) + def _on_got_all(self, properties): + self._requested = properties["Requested"] + self._report_missed_if_ready() + + @misc.log_exception(_moduleLogger) + def _on_members_changed(self, message, added, removed, lp, rp, actor, reason): + if added: + self._didMembersChange = True + self._report_missed_if_ready() + + @misc.log_exception(_moduleLogger) + def _on_closed(self): + self._didClose = True + self._report_missed_if_ready() + + @misc.log_exception(_moduleLogger) + def _on_error(self, *args): + self._report_error(args) + + @misc.log_exception(_moduleLogger) + def _on_timeout(self): + self._report_error("timeout") + return False + + +class NewChannelSignaller(object): + + def __init__(self, on_new_channel): + self._sessionBus = dbus.SessionBus() + self._on_user_new_channel = on_new_channel + + def start(self): + self._sessionBus.add_signal_receiver( + self._on_new_channel, + "NewChannel", + "org.freedesktop.Telepathy.Connection", + None, + None + ) + + def stop(self): + self._sessionBus.remove_signal_receiver( + self._on_new_channel, + "NewChannel", + "org.freedesktop.Telepathy.Connection", + None, + None + ) + + @misc.log_exception(_moduleLogger) + def _on_new_channel( + self, channelObjectPath, channelType, handleType, handle, supressHandler + ): + connObjectPath = channel_path_to_conn_path(channelObjectPath) + serviceName = path_to_service_name(channelObjectPath) + try: + self._on_user_new_channel( + self._sessionBus, serviceName, connObjectPath, channelObjectPath, channelType + ) + except Exception: + _moduleLogger.exception("Blocking exception from being passed up") + + +class EnableSystemContactIntegration(object): + + ACCOUNT_MGR_NAME = "org.freedesktop.Telepathy.AccountManager" + ACCOUNT_MGR_PATH = "/org/freedesktop/Telepathy/AccountManager" + ACCOUNT_MGR_IFACE_QUERY = "com.nokia.AccountManager.Interface.Query" + ACCOUNT_IFACE_COMPAT = "com.nokia.Account.Interface.Compat" + ACCOUNT_IFACE_COMPAT_PROFILE = "com.nokia.Account.Interface.Compat.Profile" + DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties' + + def __init__(self, profileName): + self._bus = dbus.SessionBus() + self._profileName = profileName + + def start(self): + self._accountManager = self._bus.get_object( + self.ACCOUNT_MGR_NAME, + self.ACCOUNT_MGR_PATH, + ) + self._accountManagerQuery = dbus.Interface( + self._accountManager, + dbus_interface=self.ACCOUNT_MGR_IFACE_QUERY, + ) + + self._accountManagerQuery.FindAccounts( + { + self.ACCOUNT_IFACE_COMPAT_PROFILE: self._profileName, + }, + reply_handler = self._on_found_accounts_reply, + error_handler = self._on_error, + ) + + @misc.log_exception(_moduleLogger) + def _on_found_accounts_reply(self, accountObjectPaths): + for accountObjectPath in accountObjectPaths: + print accountObjectPath + account = self._bus.get_object( + self.ACCOUNT_MGR_NAME, + accountObjectPath, + ) + accountProperties = dbus.Interface( + account, + self.DBUS_PROPERTIES, + ) + accountProperties.Set( + self.ACCOUNT_IFACE_COMPAT, + "SecondaryVCardFields", + ["TEL"], + reply_handler = self._on_field_set, + error_handler = self._on_error, + ) + + @misc.log_exception(_moduleLogger) + def _on_field_set(self): + _moduleLogger.info("SecondaryVCardFields Set") + + @misc.log_exception(_moduleLogger) + def _on_error(self, error): + _moduleLogger.error("%r" % (error, )) + + +def channel_path_to_conn_path(channelObjectPath): + """ + >>> channel_path_to_conn_path("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") + '/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME' + """ + return channelObjectPath.rsplit("/", 1)[0] + + +def path_to_service_name(path): + """ + >>> path_to_service_name("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") + 'org.freedesktop.Telepathy.ConnectionManager.theonering.gv.USERNAME' + """ + return ".".join(path[1:].split("/")[0:7]) + + +def cm_from_path(path): + """ + >>> cm_from_path("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") + 'theonering' + """ + return path[1:].split("/")[4] diff --git a/src b/src new file mode 120000 index 0000000..14858ed --- /dev/null +++ b/src @@ -0,0 +1 @@ +dialcentral/ \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 4265cc3..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/env python diff --git a/src/alarm_handler.py b/src/alarm_handler.py deleted file mode 100644 index a79f992..0000000 --- a/src/alarm_handler.py +++ /dev/null @@ -1,460 +0,0 @@ -#!/usr/bin/env python - -import os -import time -import datetime -import ConfigParser -import logging - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore -import dbus - - -_FREMANTLE_ALARM = "Fremantle" -_DIABLO_ALARM = "Diablo" -_NO_ALARM = "None" - - -try: - import alarm - ALARM_TYPE = _FREMANTLE_ALARM -except (ImportError, OSError): - try: - import osso.alarmd as alarmd - ALARM_TYPE = _DIABLO_ALARM - except (ImportError, OSError): - ALARM_TYPE = _NO_ALARM - - -_moduleLogger = logging.getLogger(__name__) - - -def _get_start_time(recurrence): - now = datetime.datetime.now() - startTimeMinute = now.minute + max(recurrence, 5) # being safe - startTimeHour = now.hour + int(startTimeMinute / 60) - startTimeMinute = startTimeMinute % 59 - now.replace(minute=startTimeMinute) - timestamp = int(time.mktime(now.timetuple())) - return timestamp - - -def _create_recurrence_mask(recurrence, base): - """ - >>> bin(_create_recurrence_mask(60, 60)) - '0b1' - >>> bin(_create_recurrence_mask(30, 60)) - '0b1000000000000000000000000000001' - >>> bin(_create_recurrence_mask(2, 60)) - '0b10101010101010101010101010101010101010101010101010101010101' - >>> bin(_create_recurrence_mask(1, 60)) - '0b111111111111111111111111111111111111111111111111111111111111' - """ - mask = 0 - for i in xrange(base / recurrence): - mask |= 1 << (recurrence * i) - return mask - - -def _unpack_minutes(recurrence): - """ - >>> _unpack_minutes(0) - (0, 0, 0) - >>> _unpack_minutes(1) - (0, 0, 1) - >>> _unpack_minutes(59) - (0, 0, 59) - >>> _unpack_minutes(60) - (0, 1, 0) - >>> _unpack_minutes(129) - (0, 2, 9) - >>> _unpack_minutes(5 * 60 * 24 + 3 * 60 + 2) - (5, 3, 2) - >>> _unpack_minutes(12 * 60 * 24 + 3 * 60 + 2) - (5, 3, 2) - """ - minutesInAnHour = 60 - minutesInDay = 24 * minutesInAnHour - minutesInAWeek = minutesInDay * 7 - - days = recurrence / minutesInDay - daysOfWeek = days % 7 - recurrence -= days * minutesInDay - hours = recurrence / minutesInAnHour - recurrence -= hours * minutesInAnHour - mins = recurrence % minutesInAnHour - recurrence -= mins - assert recurrence == 0, "Recurrence %d" % recurrence - return daysOfWeek, hours, mins - - -class _FremantleAlarmHandler(object): - - _INVALID_COOKIE = -1 - _REPEAT_FOREVER = -1 - _TITLE = "Dialcentral Notifications" - _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py")) - - def __init__(self): - self._recurrence = 5 - - self._alarmCookie = self._INVALID_COOKIE - self._launcher = self._LAUNCHER - - def load_settings(self, config, sectionName): - try: - self._recurrence = config.getint(sectionName, "recurrence") - self._alarmCookie = config.getint(sectionName, "alarmCookie") - launcher = config.get(sectionName, "notifier") - if launcher: - self._launcher = launcher - except ConfigParser.NoOptionError: - pass - except ConfigParser.NoSectionError: - pass - - def save_settings(self, config, sectionName): - try: - config.set(sectionName, "recurrence", str(self._recurrence)) - config.set(sectionName, "alarmCookie", str(self._alarmCookie)) - launcher = self._launcher if self._launcher != self._LAUNCHER else "" - config.set(sectionName, "notifier", launcher) - except ConfigParser.NoOptionError: - pass - except ConfigParser.NoSectionError: - pass - - def apply_settings(self, enabled, recurrence): - if recurrence != self._recurrence or enabled != self.isEnabled: - if self.isEnabled: - self._clear_alarm() - if enabled: - self._set_alarm(recurrence) - self._recurrence = int(recurrence) - - @property - def recurrence(self): - return self._recurrence - - @property - def isEnabled(self): - return self._alarmCookie != self._INVALID_COOKIE - - def _set_alarm(self, recurrenceMins): - assert 1 <= recurrenceMins, "Notifications set to occur too frequently: %d" % recurrenceMins - alarmTime = _get_start_time(recurrenceMins) - - event = alarm.Event() - event.appid = self._TITLE - event.alarm_time = alarmTime - event.recurrences_left = self._REPEAT_FOREVER - - action = event.add_actions(1)[0] - action.flags |= alarm.ACTION_TYPE_EXEC | alarm.ACTION_WHEN_TRIGGERED - action.command = self._launcher - - recurrence = event.add_recurrences(1)[0] - recurrence.mask_min |= _create_recurrence_mask(recurrenceMins, 60) - recurrence.mask_hour |= alarm.RECUR_HOUR_DONTCARE - recurrence.mask_mday |= alarm.RECUR_MDAY_DONTCARE - recurrence.mask_wday |= alarm.RECUR_WDAY_DONTCARE - recurrence.mask_mon |= alarm.RECUR_MON_DONTCARE - recurrence.special |= alarm.RECUR_SPECIAL_NONE - - assert event.is_sane() - self._alarmCookie = alarm.add_event(event) - - def _clear_alarm(self): - if self._alarmCookie == self._INVALID_COOKIE: - return - alarm.delete_event(self._alarmCookie) - self._alarmCookie = self._INVALID_COOKIE - - -class _DiabloAlarmHandler(object): - - _INVALID_COOKIE = -1 - _TITLE = "Dialcentral Notifications" - _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py")) - _REPEAT_FOREVER = -1 - - def __init__(self): - self._recurrence = 5 - - bus = dbus.SystemBus() - self._alarmdDBus = bus.get_object("com.nokia.alarmd", "/com/nokia/alarmd"); - self._alarmCookie = self._INVALID_COOKIE - self._launcher = self._LAUNCHER - - def load_settings(self, config, sectionName): - try: - self._recurrence = config.getint(sectionName, "recurrence") - self._alarmCookie = config.getint(sectionName, "alarmCookie") - launcher = config.get(sectionName, "notifier") - if launcher: - self._launcher = launcher - except ConfigParser.NoOptionError: - pass - except ConfigParser.NoSectionError: - pass - - def save_settings(self, config, sectionName): - config.set(sectionName, "recurrence", str(self._recurrence)) - config.set(sectionName, "alarmCookie", str(self._alarmCookie)) - launcher = self._launcher if self._launcher != self._LAUNCHER else "" - config.set(sectionName, "notifier", launcher) - - def apply_settings(self, enabled, recurrence): - if recurrence != self._recurrence or enabled != self.isEnabled: - if self.isEnabled: - self._clear_alarm() - if enabled: - self._set_alarm(recurrence) - self._recurrence = int(recurrence) - - @property - def recurrence(self): - return self._recurrence - - @property - def isEnabled(self): - return self._alarmCookie != self._INVALID_COOKIE - - def _set_alarm(self, recurrence): - assert 1 <= recurrence, "Notifications set to occur too frequently: %d" % recurrence - alarmTime = _get_start_time(recurrence) - - #Setup the alarm arguments so that they can be passed to the D-Bus add_event method - _DEFAULT_FLAGS = ( - alarmd.ALARM_EVENT_NO_DIALOG | - alarmd.ALARM_EVENT_NO_SNOOZE | - alarmd.ALARM_EVENT_CONNECTED - ) - action = [] - action.extend(['flags', _DEFAULT_FLAGS]) - action.extend(['title', self._TITLE]) - action.extend(['path', self._launcher]) - action.extend([ - 'arguments', - dbus.Array( - [alarmTime, int(27)], - signature=dbus.Signature('v') - ) - ]) #int(27) used in place of alarm_index - - event = [] - event.extend([dbus.ObjectPath('/AlarmdEventRecurring'), dbus.UInt32(4)]) - event.extend(['action', dbus.ObjectPath('/AlarmdActionExec')]) #use AlarmdActionExec instead of AlarmdActionDbus - event.append(dbus.UInt32(len(action) / 2)) - event.extend(action) - event.extend(['time', dbus.Int64(alarmTime)]) - event.extend(['recurr_interval', dbus.UInt32(recurrence)]) - event.extend(['recurr_count', dbus.Int32(self._REPEAT_FOREVER)]) - - self._alarmCookie = self._alarmdDBus.add_event(*event); - - def _clear_alarm(self): - if self._alarmCookie == self._INVALID_COOKIE: - return - deleteResult = self._alarmdDBus.del_event(dbus.Int32(self._alarmCookie)) - self._alarmCookie = self._INVALID_COOKIE - assert deleteResult != -1, "Deleting of alarm event failed" - - -class _ApplicationAlarmHandler(object): - - _REPEAT_FOREVER = -1 - _MIN_TO_MS_FACTORY = 1000 * 60 - - def __init__(self): - self._timer = QtCore.QTimer() - self._timer.setSingleShot(False) - self._timer.setInterval(5 * self._MIN_TO_MS_FACTORY) - - def load_settings(self, config, sectionName): - try: - self._timer.setInterval(config.getint(sectionName, "recurrence") * self._MIN_TO_MS_FACTORY) - except ConfigParser.NoOptionError: - pass - except ConfigParser.NoSectionError: - pass - self._timer.start() - - def save_settings(self, config, sectionName): - config.set(sectionName, "recurrence", str(self.recurrence)) - - def apply_settings(self, enabled, recurrence): - self._timer.setInterval(recurrence * self._MIN_TO_MS_FACTORY) - if enabled: - self._timer.start() - else: - self._timer.stop() - - @property - def notifySignal(self): - return self._timer.timeout - - @property - def recurrence(self): - return int(self._timer.interval() / self._MIN_TO_MS_FACTORY) - - @property - def isEnabled(self): - return self._timer.isActive() - - -class _NoneAlarmHandler(object): - - def __init__(self): - self._enabled = False - self._recurrence = 5 - - def load_settings(self, config, sectionName): - try: - self._recurrence = config.getint(sectionName, "recurrence") - self._enabled = True - except ConfigParser.NoOptionError: - pass - except ConfigParser.NoSectionError: - pass - - def save_settings(self, config, sectionName): - config.set(sectionName, "recurrence", str(self.recurrence)) - - def apply_settings(self, enabled, recurrence): - self._enabled = enabled - - @property - def recurrence(self): - return self._recurrence - - @property - def isEnabled(self): - return self._enabled - - -_BACKGROUND_ALARM_FACTORY = { - _FREMANTLE_ALARM: _FremantleAlarmHandler, - _DIABLO_ALARM: _DiabloAlarmHandler, - _NO_ALARM: None, -}[ALARM_TYPE] - - -class AlarmHandler(object): - - ALARM_NONE = "No Alert" - ALARM_BACKGROUND = "Background Alert" - ALARM_APPLICATION = "Application Alert" - ALARM_TYPES = [ALARM_NONE, ALARM_BACKGROUND, ALARM_APPLICATION] - - ALARM_FACTORY = { - ALARM_NONE: _NoneAlarmHandler, - ALARM_BACKGROUND: _BACKGROUND_ALARM_FACTORY, - ALARM_APPLICATION: _ApplicationAlarmHandler, - } - - def __init__(self): - self._alarms = {self.ALARM_NONE: _NoneAlarmHandler()} - self._currentAlarmType = self.ALARM_NONE - - def load_settings(self, config, sectionName): - try: - self._currentAlarmType = config.get(sectionName, "alarm") - except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): - _moduleLogger.exception("Falling back to old style") - self._currentAlarmType = self.ALARM_BACKGROUND - if self._currentAlarmType not in self.ALARM_TYPES: - self._currentAlarmType = self.ALARM_NONE - - self._init_alarm(self._currentAlarmType) - if self._currentAlarmType in self._alarms: - self._alarms[self._currentAlarmType].load_settings(config, sectionName) - if not self._alarms[self._currentAlarmType].isEnabled: - _moduleLogger.info("Config file lied, not actually enabled") - self._currentAlarmType = self.ALARM_NONE - else: - _moduleLogger.info("Background alerts not supported") - self._currentAlarmType = self.ALARM_NONE - - def save_settings(self, config, sectionName): - config.set(sectionName, "alarm", self._currentAlarmType) - self._alarms[self._currentAlarmType].save_settings(config, sectionName) - - def apply_settings(self, t, recurrence): - self._init_alarm(t) - newHandler = self._alarms[t] - oldHandler = self._alarms[self._currentAlarmType] - if newHandler != oldHandler: - oldHandler.apply_settings(False, 0) - newHandler.apply_settings(True, recurrence) - self._currentAlarmType = t - - @property - def alarmType(self): - return self._currentAlarmType - - @property - def backgroundNotificationsSupported(self): - return self.ALARM_FACTORY[self.ALARM_BACKGROUND] is not None - - @property - def applicationNotifySignal(self): - self._init_alarm(self.ALARM_APPLICATION) - return self._alarms[self.ALARM_APPLICATION].notifySignal - - @property - def recurrence(self): - return self._alarms[self._currentAlarmType].recurrence - - @property - def isEnabled(self): - return self._currentAlarmType != self.ALARM_NONE - - def _init_alarm(self, t): - if t not in self._alarms and self.ALARM_FACTORY[t] is not None: - self._alarms[t] = self.ALARM_FACTORY[t]() - - -def main(): - logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' - logging.basicConfig(level=logging.DEBUG, format=logFormat) - import constants - try: - import optparse - except ImportError: - return - - parser = optparse.OptionParser() - parser.add_option("-x", "--display", action="store_true", dest="display", help="Display data") - parser.add_option("-e", "--enable", action="store_true", dest="enabled", help="Whether the alarm should be enabled or not", default=False) - parser.add_option("-d", "--disable", action="store_false", dest="enabled", help="Whether the alarm should be enabled or not", default=False) - parser.add_option("-r", "--recurrence", action="store", type="int", dest="recurrence", help="How often the alarm occurs", default=5) - (commandOptions, commandArgs) = parser.parse_args() - - alarmHandler = AlarmHandler() - config = ConfigParser.SafeConfigParser() - config.read(constants._user_settings_) - alarmHandler.load_settings(config, "alarm") - - if commandOptions.display: - print "Alarm (%s) is %s for every %d minutes" % ( - alarmHandler._alarmCookie, - "enabled" if alarmHandler.isEnabled else "disabled", - alarmHandler.recurrence, - ) - else: - isEnabled = commandOptions.enabled - recurrence = commandOptions.recurrence - alarmHandler.apply_settings(isEnabled, recurrence) - - alarmHandler.save_settings(config, "alarm") - configFile = open(constants._user_settings_, "wb") - try: - config.write(configFile) - finally: - configFile.close() - - -if __name__ == "__main__": - main() diff --git a/src/alarm_notify.py b/src/alarm_notify.py deleted file mode 100755 index bc6240e..0000000 --- a/src/alarm_notify.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python - -import os -import filecmp -import ConfigParser -import pprint -import logging -import logging.handlers - -import constants -from backends.gvoice import gvoice - - -def get_missed(backend): - missedPage = backend._browser.download(backend._XML_MISSED_URL) - missedJson = backend._grab_json(missedPage) - return missedJson - - -def get_voicemail(backend): - voicemailPage = backend._browser.download(backend._XML_VOICEMAIL_URL) - voicemailJson = backend._grab_json(voicemailPage) - return voicemailJson - - -def get_sms(backend): - smsPage = backend._browser.download(backend._XML_SMS_URL) - smsJson = backend._grab_json(smsPage) - return smsJson - - -def remove_reltime(data): - for messageData in data["messages"].itervalues(): - for badPart in [ - "relTime", - "relativeStartTime", - "time", - "star", - "isArchived", - "isRead", - "isSpam", - "isTrash", - "labels", - ]: - if badPart in messageData: - del messageData[badPart] - for globalBad in ["unreadCounts", "totalSize", "resultsPerPage"]: - if globalBad in data: - del data[globalBad] - - -def is_type_changed(backend, type, get_material): - jsonMaterial = get_material(backend) - unreadCount = jsonMaterial["unreadCounts"][type] - - previousSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.old.json" % type) - currentSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.json" % type) - - try: - os.remove(previousSnapshotPath) - except OSError, e: - # check if failed purely because the old file didn't exist, which is fine - if e.errno != 2: - raise - try: - os.rename(currentSnapshotPath, previousSnapshotPath) - previousExists = True - except OSError, e: - # check if failed purely because the new old file didn't exist, which is fine - if e.errno != 2: - raise - previousExists = False - - remove_reltime(jsonMaterial) - textMaterial = pprint.pformat(jsonMaterial) - currentSnapshot = file(currentSnapshotPath, "w") - try: - currentSnapshot.write(textMaterial) - finally: - currentSnapshot.close() - - if unreadCount == 0 or not previousExists: - return False - - seemEqual = filecmp.cmp(previousSnapshotPath, currentSnapshotPath) - return not seemEqual - - -def create_backend(config): - gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt") - backend = gvoice.GVoiceBackend(gvCookiePath) - - loggedIn = False - - if not loggedIn: - loggedIn = backend.refresh_account_info() is not None - - if not loggedIn: - import base64 - try: - blobs = ( - config.get(constants.__pretty_app_name__, "bin_blob_%i" % i) - for i in xrange(2) - ) - creds = ( - base64.b64decode(blob) - for blob in blobs - ) - username, password = tuple(creds) - loggedIn = backend.login(username, password) is not None - except ConfigParser.NoOptionError, e: - pass - except ConfigParser.NoSectionError, e: - pass - - assert loggedIn - return backend - - -def is_changed(config, backend): - try: - notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed") - notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail") - notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms") - except ConfigParser.NoOptionError, e: - notifyOnMissed = False - notifyOnVoicemail = False - notifyOnSms = False - except ConfigParser.NoSectionError, e: - notifyOnMissed = False - notifyOnVoicemail = False - notifyOnSms = False - logging.debug( - "Missed: %s, Voicemail: %s, SMS: %s" % (notifyOnMissed, notifyOnVoicemail, notifyOnSms) - ) - - notifySources = [] - if notifyOnMissed: - notifySources.append(("missed", get_missed)) - if notifyOnVoicemail: - notifySources.append(("voicemail", get_voicemail)) - if notifyOnSms: - notifySources.append(("sms", get_sms)) - - notifyUser = False - for type, get_material in notifySources: - if is_type_changed(backend, type, get_material): - notifyUser = True - return notifyUser - - -def notify_on_change(): - config = ConfigParser.SafeConfigParser() - config.read(constants._user_settings_) - backend = create_backend(config) - notifyUser = is_changed(config, backend) - - if notifyUser: - logging.info("Changed") - import led_handler - led = led_handler.LedHandler() - led.on() - else: - logging.info("No Change") - - -if __name__ == "__main__": - logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' - logging.basicConfig(level=logging.DEBUG, format=logFormat) - rotating = logging.handlers.RotatingFileHandler(constants._notifier_logpath_, maxBytes=512*1024, backupCount=1) - rotating.setFormatter(logging.Formatter(logFormat)) - root = logging.getLogger() - root.addHandler(rotating) - logging.info("Notifier %s-%s" % (constants.__version__, constants.__build__)) - logging.info("OS: %s" % (os.uname()[0], )) - logging.info("Kernel: %s (%s) for %s" % os.uname()[2:]) - logging.info("Hostname: %s" % os.uname()[1]) - try: - notify_on_change() - except: - logging.exception("Error") - raise diff --git a/src/backends/__init__.py b/src/backends/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/backends/file_backend.py b/src/backends/file_backend.py deleted file mode 100644 index 9f8927a..0000000 --- a/src/backends/file_backend.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/python - -""" -DialCentral - Front end for Google's Grand Central service. -Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library 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 -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Filesystem backend for contact support -""" - -from __future__ import with_statement - -import os -import csv - - -def try_unicode(s): - try: - return s.decode("UTF-8") - except UnicodeDecodeError: - return s - - -class CsvAddressBook(object): - """ - Currently supported file format - @li Has the first line as a header - @li Escapes with quotes - @li Comma as delimiter - @li Column 0 is name, column 1 is number - """ - - def __init__(self, name, csvPath): - self._name = name - self._csvPath = csvPath - self._contacts = {} - - @property - def name(self): - return self._name - - def update_account(self, force = True): - if not force or not self._contacts: - return - self._contacts = dict( - self._read_csv(self._csvPath) - ) - - def get_contacts(self): - """ - @returns Iterable of (contact id, contact name) - """ - if not self._contacts: - self._contacts = dict( - self._read_csv(self._csvPath) - ) - return self._contacts - - def _read_csv(self, csvPath): - try: - f = open(csvPath, "rU") - csvReader = iter(csv.reader(f)) - except IOError, e: - if e.errno == 2: - return - raise - - header = csvReader.next() - nameColumns, nameFallbacks, phoneColumns = self._guess_columns(header) - - yieldCount = 0 - for row in csvReader: - contactDetails = [] - for (phoneType, phoneColumn) in phoneColumns: - try: - if len(row[phoneColumn]) == 0: - continue - contactDetails.append({ - "phoneType": try_unicode(phoneType), - "phoneNumber": row[phoneColumn], - }) - except IndexError: - pass - if 0 < len(contactDetails): - nameParts = (row[i].strip() for i in nameColumns) - nameParts = (part for part in nameParts if part) - fullName = " ".join(nameParts).strip() - if not fullName: - for fallbackColumn in nameFallbacks: - if row[fallbackColumn].strip(): - fullName = row[fallbackColumn].strip() - break - else: - fullName = "Unknown" - fullName = try_unicode(fullName) - yield str(yieldCount), { - "contactId": "%s-%d" % (self._name, yieldCount), - "name": fullName, - "numbers": contactDetails, - } - yieldCount += 1 - - @classmethod - def _guess_columns(cls, row): - firstMiddleLast = [-1, -1, -1] - names = [] - nameFallbacks = [] - phones = [] - for i, item in enumerate(row): - lowerItem = item.lower() - if 0 <= lowerItem.find("name"): - names.append((item, i)) - - if 0 <= lowerItem.find("couple"): - names.insert(0, (item, i)) - - if 0 <= lowerItem.find("first") or 0 <= lowerItem.find("given"): - firstMiddleLast[0] = i - elif 0 <= lowerItem.find("middle"): - firstMiddleLast[1] = i - elif 0 <= lowerItem.find("last") or 0 <= lowerItem.find("family"): - firstMiddleLast[2] = i - elif 0 <= lowerItem.find("phone"): - phones.append((item, i)) - elif 0 <= lowerItem.find("mobile"): - phones.append((item, i)) - elif 0 <= lowerItem.find("email") or 0 <= lowerItem.find("e-mail"): - nameFallbacks.append(i) - if len(names) == 0: - names.append(("Name", 0)) - if len(phones) == 0: - phones.append(("Phone", 1)) - - nameColumns = [i for i in firstMiddleLast if 0 <= i] - if len(nameColumns) < 2: - del nameColumns[:] - nameColumns.append(names[0][1]) - - return nameColumns, nameFallbacks, phones - - -class FilesystemAddressBookFactory(object): - - FILETYPE_SUPPORT = { - "csv": CsvAddressBook, - } - - def __init__(self, path): - self._path = path - - def get_addressbooks(self): - for root, dirs, filenames in os.walk(self._path): - for filename in filenames: - try: - name, ext = filename.rsplit(".", 1) - except ValueError: - continue - - try: - cls = self.FILETYPE_SUPPORT[ext] - except KeyError: - continue - yield cls(name, os.path.join(root, filename)) diff --git a/src/backends/gv_backend.py b/src/backends/gv_backend.py deleted file mode 100644 index 17bbc90..0000000 --- a/src/backends/gv_backend.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/python - -""" -DialCentral - Front end for Google's GoogleVoice service. -Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library 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 -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Google Voice backend code - -Resources - http://thatsmith.com/2009/03/google-voice-addon-for-firefox/ - http://posttopic.com/topic/google-voice-add-on-development -""" - -from __future__ import with_statement - -import itertools -import logging - -from gvoice import gvoice - -from util import io as io_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class GVDialer(object): - - MESSAGE_TEXTS = "Text" - MESSAGE_VOICEMAILS = "Voicemail" - MESSAGE_ALL = "All" - - HISTORY_RECEIVED = "Received" - HISTORY_MISSED = "Missed" - HISTORY_PLACED = "Placed" - HISTORY_ALL = "All" - - def __init__(self, cookieFile = None): - self._gvoice = gvoice.GVoiceBackend(cookieFile) - self._texts = [] - self._voicemails = [] - self._received = [] - self._missed = [] - self._placed = [] - - def is_quick_login_possible(self): - """ - @returns True then refresh_account_info might be enough to login, else full login is required - """ - return self._gvoice.is_quick_login_possible() - - def refresh_account_info(self): - return self._gvoice.refresh_account_info() - - def login(self, username, password): - """ - Attempt to login to GoogleVoice - @returns Whether login was successful or not - """ - return self._gvoice.login(username, password) - - def logout(self): - self._texts = [] - self._voicemails = [] - self._received = [] - self._missed = [] - self._placed = [] - return self._gvoice.logout() - - def persist(self): - return self._gvoice.persist() - - def is_dnd(self): - return self._gvoice.is_dnd() - - def set_dnd(self, doNotDisturb): - return self._gvoice.set_dnd(doNotDisturb) - - def call(self, outgoingNumber): - """ - This is the main function responsible for initating the callback - """ - return self._gvoice.call(outgoingNumber) - - def cancel(self, outgoingNumber=None): - """ - Cancels a call matching outgoing and forwarding numbers (if given). - Will raise an error if no matching call is being placed - """ - return self._gvoice.cancel(outgoingNumber) - - def send_sms(self, phoneNumbers, message): - self._gvoice.send_sms(phoneNumbers, message) - - def search(self, query): - """ - Search your Google Voice Account history for calls, voicemails, and sms - Returns ``Folder`` instance containting matching messages - """ - return self._gvoice.search(query) - - def get_feed(self, feed): - return self._gvoice.get_feed(feed) - - def download(self, messageId, targetPath): - """ - Download a voicemail or recorded call MP3 matching the given ``msg`` - which can either be a ``Message`` instance, or a SHA1 identifier. - Message hashes can be found in ``self.voicemail().messages`` for example. - Returns location of saved file. - """ - self._gvoice.download(messageId, targetPath) - - def is_valid_syntax(self, number): - """ - @returns If This number be called ( syntax validation only ) - """ - return self._gvoice.is_valid_syntax(number) - - def get_account_number(self): - """ - @returns The GoogleVoice phone number - """ - return self._gvoice.get_account_number() - - def get_callback_numbers(self): - """ - @returns a dictionary mapping call back numbers to descriptions - @note These results are cached for 30 minutes. - """ - return self._gvoice.get_callback_numbers() - - def set_callback_number(self, callbacknumber): - """ - Set the number that GoogleVoice calls - @param callbacknumber should be a proper 10 digit number - """ - return self._gvoice.set_callback_number(callbacknumber) - - def get_callback_number(self): - """ - @returns Current callback number or None - """ - return self._gvoice.get_callback_number() - - def get_call_history(self, historyType): - """ - @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) - """ - history = list(self._get_call_history(historyType)) - history.sort(key=lambda item: item["time"]) - return history - - def _get_call_history(self, historyType): - """ - @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) - """ - if historyType in [self.HISTORY_RECEIVED, self.HISTORY_ALL] or not self._received: - self._received = list(self._gvoice.get_received_calls()) - for item in self._received: - item["action"] = self.HISTORY_RECEIVED - if historyType in [self.HISTORY_MISSED, self.HISTORY_ALL] or not self._missed: - self._missed = list(self._gvoice.get_missed_calls()) - for item in self._missed: - item["action"] = self.HISTORY_MISSED - if historyType in [self.HISTORY_PLACED, self.HISTORY_ALL] or not self._placed: - self._placed = list(self._gvoice.get_placed_calls()) - for item in self._placed: - item["action"] = self.HISTORY_PLACED - received = self._received - missed = self._missed - placed = self._placed - for item in received: - yield item - for item in missed: - yield item - for item in placed: - yield item - - def get_messages(self, messageType): - messages = list(self._get_messages(messageType)) - messages.sort(key=lambda message: message["time"]) - return messages - - def _get_messages(self, messageType): - if messageType in [self.MESSAGE_VOICEMAILS, self.MESSAGE_ALL] or not self._voicemails: - self._voicemails = list(self._gvoice.get_voicemails()) - if messageType in [self.MESSAGE_TEXTS, self.MESSAGE_ALL] or not self._texts: - self._texts = list(self._gvoice.get_texts()) - voicemails = self._voicemails - smss = self._texts - - conversations = itertools.chain(voicemails, smss) - for conversation in conversations: - messages = conversation.messages - messageParts = [ - (message.whoFrom, self._format_message(message), message.when) - for message in messages - ] - - messageDetails = { - "id": conversation.id, - "contactId": conversation.contactId, - "name": conversation.name, - "time": conversation.time, - "relTime": conversation.relTime, - "prettyNumber": conversation.prettyNumber, - "number": conversation.number, - "location": conversation.location, - "messageParts": messageParts, - "type": conversation.type, - "isRead": conversation.isRead, - "isTrash": conversation.isTrash, - "isSpam": conversation.isSpam, - "isArchived": conversation.isArchived, - } - yield messageDetails - - def clear_caches(self): - pass - - def get_addressbooks(self): - """ - @returns Iterable of (Address Book Factory, Book Id, Book Name) - """ - yield self, "", "" - - def open_addressbook(self, bookId): - return self - - @staticmethod - def contact_source_short_name(contactId): - return "GV" - - @staticmethod - def factory_name(): - return "Google Voice" - - def _format_message(self, message): - messagePartFormat = { - "med1": "%s", - "med2": "%s", - "high": "%s", - } - return " ".join( - messagePartFormat[text.accuracy] % io_utils.escape(text.text) - for text in message.body - ) - - -def sort_messages(allMessages): - sortableAllMessages = [ - (message["time"], message) - for message in allMessages - ] - sortableAllMessages.sort(reverse=True) - return ( - message - for (exactTime, message) in sortableAllMessages - ) - - -def decorate_recent(recentCallData): - """ - @returns (personsName, phoneNumber, date, action) - """ - contactId = recentCallData["contactId"] - if recentCallData["name"]: - header = recentCallData["name"] - elif recentCallData["prettyNumber"]: - header = recentCallData["prettyNumber"] - elif recentCallData["location"]: - header = recentCallData["location"] - else: - header = "Unknown" - - number = recentCallData["number"] - relTime = recentCallData["relTime"] - action = recentCallData["action"] - return contactId, header, number, relTime, action - - -def decorate_message(messageData): - contactId = messageData["contactId"] - exactTime = messageData["time"] - if messageData["name"]: - header = messageData["name"] - elif messageData["prettyNumber"]: - header = messageData["prettyNumber"] - else: - header = "Unknown" - number = messageData["number"] - relativeTime = messageData["relTime"] - - messageParts = list(messageData["messageParts"]) - if len(messageParts) == 0: - messages = ("No Transcription", ) - elif len(messageParts) == 1: - messages = (messageParts[0][1], ) - else: - messages = [ - "%s: %s" % (messagePart[0], messagePart[1]) - for messagePart in messageParts - ] - - decoratedResults = contactId, header, number, relativeTime, messages - return decoratedResults diff --git a/src/backends/gvoice/__init__.py b/src/backends/gvoice/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/backends/gvoice/browser_emu.py b/src/backends/gvoice/browser_emu.py deleted file mode 100644 index 4fef6e8..0000000 --- a/src/backends/gvoice/browser_emu.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -@author: Laszlo Nagy -@copyright: (c) 2005 by Szoftver Messias Bt. -@licence: BSD style - -Objects of the MozillaEmulator class can emulate a browser that is capable of: - - - cookie management - - configurable user agent string - - GET and POST - - multipart POST (send files) - - receive content into file - -I have seen many requests on the python mailing list about how to emulate a browser. I'm using this class for years now, without any problems. This is how you can use it: - - 1. Use firefox - 2. Install and open the livehttpheaders plugin - 3. Use the website manually with firefox - 4. Check the GET and POST requests in the livehttpheaders capture window - 5. Create an instance of the above class and send the same GET and POST requests to the server. - -Optional steps: - - - You can change user agent string in the build_opened method - - The "encode_multipart_formdata" function can be used alone to create POST data from a list of field values and files -""" - -import urllib2 -import cookielib -import logging - -import socket - - -_moduleLogger = logging.getLogger(__name__) -socket.setdefaulttimeout(25) - - -def add_proxy(protocol, url, port): - proxyInfo = "%s:%s" % (url, port) - proxy = urllib2.ProxyHandler( - {protocol: proxyInfo} - ) - opener = urllib2.build_opener(proxy) - urllib2.install_opener(opener) - - -class MozillaEmulator(object): - - USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.4) Gecko/20091016 Firefox/3.5.4 (.NET CLR 3.5.30729)' - #USER_AGENT = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 3_0 like Mac OS X; en-us) AppleWebKit/528.18 (KHTML, like Gecko) Version/4.0 Mobile/7A341 Safari/528.16" - - def __init__(self, trycount = 1): - """Create a new MozillaEmulator object. - - @param trycount: The download() method will retry the operation if it - fails. You can specify -1 for infinite retrying. A value of 0 means no - retrying. A value of 1 means one retry. etc.""" - self.debug = False - self.trycount = trycount - self._cookies = cookielib.LWPCookieJar() - self._loadedFromCookies = False - self._storeCookies = False - - def load_cookies(self, path): - assert not self._loadedFromCookies, "Load cookies only once" - if path is None: - return - - self._cookies.filename = path - try: - self._cookies.load() - except cookielib.LoadError: - _moduleLogger.exception("Bad cookie file") - except IOError: - _moduleLogger.exception("No cookie file") - except Exception, e: - _moduleLogger.exception("Unknown error with cookies") - else: - self._loadedFromCookies = True - self._storeCookies = True - - return self._loadedFromCookies - - def save_cookies(self): - if self._storeCookies: - self._cookies.save() - - def clear_cookies(self): - if self._storeCookies: - self._cookies.clear() - - def download(self, url, - postdata = None, extraheaders = None, forbidRedirect = False, - trycount = None, only_head = False, - ): - """Download an URL with GET or POST methods. - - @param postdata: It can be a string that will be POST-ed to the URL. - When None is given, the method will be GET instead. - @param extraheaders: You can add/modify HTTP headers with a dict here. - @param forbidRedirect: Set this flag if you do not want to handle - HTTP 301 and 302 redirects. - @param trycount: Specify the maximum number of retries here. - 0 means no retry on error. Using -1 means infinite retring. - None means the default value (that is self.trycount). - @param only_head: Create the openerdirector and return it. In other - words, this will not retrieve any content except HTTP headers. - - @return: The raw HTML page data - """ - _moduleLogger.debug("Performing download of %s" % url) - - if extraheaders is None: - extraheaders = {} - if trycount is None: - trycount = self.trycount - cnt = 0 - - while True: - try: - req, u = self._build_opener(url, postdata, extraheaders, forbidRedirect) - openerdirector = u.open(req) - if self.debug: - _moduleLogger.info("%r - %r" % (req.get_method(), url)) - _moduleLogger.info("%r - %r" % (openerdirector.code, openerdirector.msg)) - _moduleLogger.info("%r" % (openerdirector.headers)) - self._cookies.extract_cookies(openerdirector, req) - if only_head: - return openerdirector - - return self._read(openerdirector, trycount) - except urllib2.URLError, e: - _moduleLogger.debug("%s: %s" % (e, url)) - cnt += 1 - if (-1 < trycount) and (trycount < cnt): - raise - - # Retry :-) - _moduleLogger.debug("MozillaEmulator: urllib2.URLError, retrying %d" % cnt) - - def _build_opener(self, url, postdata = None, extraheaders = None, forbidRedirect = False): - if extraheaders is None: - extraheaders = {} - - txheaders = { - 'Accept': 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png', - 'Accept-Language': 'en,en-us;q=0.5', - 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', - 'User-Agent': self.USER_AGENT, - } - for key, value in extraheaders.iteritems(): - txheaders[key] = value - req = urllib2.Request(url, postdata, txheaders) - self._cookies.add_cookie_header(req) - if forbidRedirect: - redirector = HTTPNoRedirector() - #_moduleLogger.info("Redirection disabled") - else: - redirector = urllib2.HTTPRedirectHandler() - #_moduleLogger.info("Redirection enabled") - - http_handler = urllib2.HTTPHandler(debuglevel=self.debug) - https_handler = urllib2.HTTPSHandler(debuglevel=self.debug) - - u = urllib2.build_opener( - http_handler, - https_handler, - urllib2.HTTPCookieProcessor(self._cookies), - redirector - ) - if not postdata is None: - req.add_data(postdata) - return (req, u) - - def _read(self, openerdirector, trycount): - chunks = [] - - chunk = openerdirector.read() - chunks.append(chunk) - #while chunk and cnt < trycount: - # time.sleep(1) - # cnt += 1 - # chunk = openerdirector.read() - # chunks.append(chunk) - - data = "".join(chunks) - - if "Content-Length" in openerdirector.info(): - assert len(data) == int(openerdirector.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % ( - openerdirector.info()["Content-Length"], - len(data), - ) - - return data - - -class HTTPNoRedirector(urllib2.HTTPRedirectHandler): - """This is a custom http redirect handler that FORBIDS redirection.""" - - def http_error_302(self, req, fp, code, msg, headers): - e = urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) - if e.code in (301, 302): - if 'location' in headers: - newurl = headers.getheaders('location')[0] - elif 'uri' in headers: - newurl = headers.getheaders('uri')[0] - e.newurl = newurl - _moduleLogger.info("New url: %s" % e.newurl) - raise e diff --git a/src/backends/gvoice/gvoice.py b/src/backends/gvoice/gvoice.py deleted file mode 100755 index b0825ef..0000000 --- a/src/backends/gvoice/gvoice.py +++ /dev/null @@ -1,1050 +0,0 @@ -#!/usr/bin/python - -""" -DialCentral - Front end for Google's GoogleVoice service. -Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library 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 -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Google Voice backend code - -Resources - http://thatsmith.com/2009/03/google-voice-addon-for-firefox/ - http://posttopic.com/topic/google-voice-add-on-development -""" - -from __future__ import with_statement - -import os -import re -import urllib -import urllib2 -import time -import datetime -import itertools -import logging -import inspect - -from xml.sax import saxutils -from xml.etree import ElementTree - -try: - import simplejson as _simplejson - simplejson = _simplejson -except ImportError: - simplejson = None - -import browser_emu - - -_moduleLogger = logging.getLogger(__name__) - - -class NetworkError(RuntimeError): - pass - - -class MessageText(object): - - ACCURACY_LOW = "med1" - ACCURACY_MEDIUM = "med2" - ACCURACY_HIGH = "high" - - def __init__(self): - self.accuracy = None - self.text = None - - def __str__(self): - return self.text - - def to_dict(self): - return to_dict(self) - - def __eq__(self, other): - return self.accuracy == other.accuracy and self.text == other.text - - -class Message(object): - - def __init__(self): - self.whoFrom = None - self.body = None - self.when = None - - def __str__(self): - return "%s (%s): %s" % ( - self.whoFrom, - self.when, - "".join(unicode(part) for part in self.body) - ) - - def to_dict(self): - selfDict = to_dict(self) - selfDict["body"] = [text.to_dict() for text in self.body] if self.body is not None else None - return selfDict - - def __eq__(self, other): - return self.whoFrom == other.whoFrom and self.when == other.when and self.body == other.body - - -class Conversation(object): - - TYPE_VOICEMAIL = "Voicemail" - TYPE_SMS = "SMS" - - def __init__(self): - self.type = None - self.id = None - self.contactId = None - self.name = None - self.location = None - self.prettyNumber = None - self.number = None - - self.time = None - self.relTime = None - self.messages = None - self.isRead = None - self.isSpam = None - self.isTrash = None - self.isArchived = None - - def __cmp__(self, other): - cmpValue = cmp(self.contactId, other.contactId) - if cmpValue != 0: - return cmpValue - - cmpValue = cmp(self.time, other.time) - if cmpValue != 0: - return cmpValue - - cmpValue = cmp(self.id, other.id) - if cmpValue != 0: - return cmpValue - - def to_dict(self): - selfDict = to_dict(self) - selfDict["messages"] = [message.to_dict() for message in self.messages] if self.messages is not None else None - return selfDict - - -class GVoiceBackend(object): - """ - This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers - the functions include login, setting up a callback number, and initalting a callback - """ - - PHONE_TYPE_HOME = 1 - PHONE_TYPE_MOBILE = 2 - PHONE_TYPE_WORK = 3 - PHONE_TYPE_GIZMO = 7 - - def __init__(self, cookieFile = None): - # Important items in this function are the setup of the browser emulation and cookie file - self._browser = browser_emu.MozillaEmulator(1) - self._loadedFromCookies = self._browser.load_cookies(cookieFile) - - self._token = "" - self._accountNum = "" - self._lastAuthed = 0.0 - self._callbackNumber = "" - self._callbackNumbers = {} - - # Suprisingly, moving all of these from class to self sped up startup time - - self._validateRe = re.compile("^\+?[0-9]{10,}$") - - self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth" - - SECURE_URL_BASE = "https://www.google.com/voice/" - SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/" - self._tokenURL = SECURE_URL_BASE + "m" - self._callUrl = SECURE_URL_BASE + "call/connect" - self._callCancelURL = SECURE_URL_BASE + "call/cancel" - self._sendSmsURL = SECURE_URL_BASE + "sms/send" - - self._isDndURL = "https://www.google.com/voice/m/donotdisturb" - self._isDndRe = re.compile(r"""""") - self._setDndURL = "https://www.google.com/voice/m/savednd" - - self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/" - self._markAsReadURL = SECURE_URL_BASE + "m/mark" - self._archiveMessageURL = SECURE_URL_BASE + "m/archive" - - self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/" - self._XML_ACCOUNT_URL = SECURE_URL_BASE + "contacts/" - # HACK really this redirects to the main pge and we are grabbing some javascript - self._XML_CONTACTS_URL = "http://www.google.com/voice/inbox/search/contact" - self._CSV_CONTACTS_URL = "http://mail.google.com/mail/contacts/data/export" - self._JSON_CONTACTS_URL = SECURE_URL_BASE + "b/0/request/user" - self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/" - - self.XML_FEEDS = ( - 'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms', - 'recorded', 'placed', 'received', 'missed' - ) - self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox" - self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred" - self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all" - self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam" - self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash" - self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/" - self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/" - self._JSON_SMS_URL = SECURE_URL_BASE + "b/0/request/messages/" - self._JSON_SMS_COUNT_URL = SECURE_URL_BASE + "b/0/request/unread" - self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/" - self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/" - self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/" - self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/" - - self._galxRe = re.compile(r"""""", re.MULTILINE | re.DOTALL) - - self._seperateVoicemailsRegex = re.compile(r"""^\s*
""", re.MULTILINE | re.DOTALL) - self._exactVoicemailTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE) - self._relativeVoicemailTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE) - self._voicemailNameRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) - self._voicemailNumberRegex = re.compile(r"""""", re.MULTILINE) - self._prettyVoicemailNumberRegex = re.compile(r"""(.*?)""", re.MULTILINE) - self._voicemailLocationRegex = re.compile(r""".*?(.*?)""", re.MULTILINE) - self._messagesContactIDRegex = re.compile(r""".*?\s*?(.*?)""", re.MULTILINE) - self._voicemailMessageRegex = re.compile(r"""((.*?)|(.*?))""", re.MULTILINE) - self._smsFromRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) - self._smsTimeRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) - self._smsTextRegex = re.compile(r"""(.*?)""", re.MULTILINE | re.DOTALL) - - def is_quick_login_possible(self): - """ - @returns True then refresh_account_info might be enough to login, else full login is required - """ - return self._loadedFromCookies or 0.0 < self._lastAuthed - - def refresh_account_info(self): - try: - page = self._get_page(self._JSON_CONTACTS_URL) - accountData = self._grab_account_info(page) - except Exception, e: - _moduleLogger.exception(str(e)) - return None - - self._browser.save_cookies() - self._lastAuthed = time.time() - return accountData - - def _get_token(self): - tokenPage = self._get_page(self._tokenURL) - - galxTokens = self._galxRe.search(tokenPage) - if galxTokens is not None: - galxToken = galxTokens.group(1) - else: - galxToken = "" - _moduleLogger.debug("Could not grab GALX token") - return galxToken - - def _login(self, username, password, token): - loginData = { - 'Email' : username, - 'Passwd' : password, - 'service': "grandcentral", - "ltmpl": "mobile", - "btmpl": "mobile", - "PersistentCookie": "yes", - "GALX": token, - "continue": self._JSON_CONTACTS_URL, - } - - loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData) - return loginSuccessOrFailurePage - - def login(self, username, password): - """ - Attempt to login to GoogleVoice - @returns Whether login was successful or not - @blocks - """ - self.logout() - galxToken = self._get_token() - loginSuccessOrFailurePage = self._login(username, password, galxToken) - - try: - accountData = self._grab_account_info(loginSuccessOrFailurePage) - except Exception, e: - # Retry in case the redirect failed - # luckily refresh_account_info does everything we need for a retry - accountData = self.refresh_account_info() - if accountData is None: - _moduleLogger.exception(str(e)) - return None - _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this") - - self._browser.save_cookies() - self._lastAuthed = time.time() - return accountData - - def persist(self): - self._browser.save_cookies() - - def shutdown(self): - self._browser.save_cookies() - self._token = None - self._lastAuthed = 0.0 - - def logout(self): - self._browser.clear_cookies() - self._browser.save_cookies() - self._token = None - self._lastAuthed = 0.0 - self._callbackNumbers = {} - - def is_dnd(self): - """ - @blocks - """ - isDndPage = self._get_page(self._isDndURL) - - dndGroup = self._isDndRe.search(isDndPage) - if dndGroup is None: - return False - dndStatus = dndGroup.group(1) - isDnd = True if dndStatus.strip().lower() == "true" else False - return isDnd - - def set_dnd(self, doNotDisturb): - """ - @blocks - """ - dndPostData = { - "doNotDisturb": 1 if doNotDisturb else 0, - } - - dndPage = self._get_page_with_token(self._setDndURL, dndPostData) - - def call(self, outgoingNumber): - """ - This is the main function responsible for initating the callback - @blocks - """ - outgoingNumber = self._send_validation(outgoingNumber) - subscriberNumber = None - phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack - - callData = { - 'outgoingNumber': outgoingNumber, - 'forwardingNumber': self._callbackNumber, - 'subscriberNumber': subscriberNumber or 'undefined', - 'phoneType': str(phoneType), - 'remember': '1', - } - _moduleLogger.info("%r" % callData) - - page = self._get_page_with_token( - self._callUrl, - callData, - ) - self._parse_with_validation(page) - return True - - def cancel(self, outgoingNumber=None): - """ - Cancels a call matching outgoing and forwarding numbers (if given). - Will raise an error if no matching call is being placed - @blocks - """ - page = self._get_page_with_token( - self._callCancelURL, - { - 'outgoingNumber': outgoingNumber or 'undefined', - 'forwardingNumber': self._callbackNumber or 'undefined', - 'cancelType': 'C2C', - }, - ) - self._parse_with_validation(page) - - def send_sms(self, phoneNumbers, message): - """ - @blocks - """ - validatedPhoneNumbers = [ - self._send_validation(phoneNumber) - for phoneNumber in phoneNumbers - ] - flattenedPhoneNumbers = ",".join(validatedPhoneNumbers) - page = self._get_page_with_token( - self._sendSmsURL, - { - 'phoneNumber': flattenedPhoneNumbers, - 'text': unicode(message).encode("utf-8"), - }, - ) - self._parse_with_validation(page) - - def search(self, query): - """ - Search your Google Voice Account history for calls, voicemails, and sms - Returns ``Folder`` instance containting matching messages - @blocks - """ - page = self._get_page( - self._XML_SEARCH_URL, - {"q": query}, - ) - json, html = extract_payload(page) - return json - - def get_feed(self, feed): - """ - @blocks - """ - actualFeed = "_XML_%s_URL" % feed.upper() - feedUrl = getattr(self, actualFeed) - - page = self._get_page(feedUrl) - json, html = extract_payload(page) - - return json - - def recording_url(self, messageId): - url = self._downloadVoicemailURL+messageId - return url - - def download(self, messageId, targetPath): - """ - Download a voicemail or recorded call MP3 matching the given ``msg`` - which can either be a ``Message`` instance, or a SHA1 identifier. - Message hashes can be found in ``self.voicemail().messages`` for example. - @returns location of saved file. - @blocks - """ - page = self._get_page(self.recording_url(messageId)) - with open(targetPath, 'wb') as fo: - fo.write(page) - - def is_valid_syntax(self, number): - """ - @returns If This number be called ( syntax validation only ) - """ - return self._validateRe.match(number) is not None - - def get_account_number(self): - """ - @returns The GoogleVoice phone number - """ - return self._accountNum - - def get_callback_numbers(self): - """ - @returns a dictionary mapping call back numbers to descriptions - @note These results are cached for 30 minutes. - """ - return self._callbackNumbers - - def set_callback_number(self, callbacknumber): - """ - Set the number that GoogleVoice calls - @param callbacknumber should be a proper 10 digit number - """ - self._callbackNumber = callbacknumber - _moduleLogger.info("Callback number changed: %r" % self._callbackNumber) - return True - - def get_callback_number(self): - """ - @returns Current callback number or None - """ - return self._callbackNumber - - def get_received_calls(self): - """ - @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) - @blocks - """ - return self._parse_recent(self._get_page(self._XML_RECEIVED_URL)) - - def get_missed_calls(self): - """ - @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) - @blocks - """ - return self._parse_recent(self._get_page(self._XML_MISSED_URL)) - - def get_placed_calls(self): - """ - @returns Iterable of (personsName, phoneNumber, exact date, relative date, action) - @blocks - """ - return self._parse_recent(self._get_page(self._XML_PLACED_URL)) - - def get_csv_contacts(self): - data = { - "groupToExport": "mine", - "exportType": "ALL", - "out": "OUTLOOK_CSV", - } - encodedData = urllib.urlencode(data) - contacts = self._get_page(self._CSV_CONTACTS_URL+"?"+encodedData) - return contacts - - def get_voicemails(self): - """ - @blocks - """ - voicemailPage = self._get_page(self._XML_VOICEMAIL_URL) - voicemailHtml = self._grab_html(voicemailPage) - voicemailJson = self._grab_json(voicemailPage) - if voicemailJson is None: - return () - parsedVoicemail = self._parse_voicemail(voicemailHtml) - voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson) - return voicemails - - def get_texts(self): - """ - @blocks - """ - smsPage = self._get_page(self._XML_SMS_URL) - smsHtml = self._grab_html(smsPage) - smsJson = self._grab_json(smsPage) - if smsJson is None: - return () - parsedSms = self._parse_sms(smsHtml) - smss = self._merge_conversation_sources(parsedSms, smsJson) - return smss - - def get_unread_counts(self): - countPage = self._get_page(self._JSON_SMS_COUNT_URL) - counts = parse_json(countPage) - counts = counts["unreadCounts"] - return counts - - def mark_message(self, messageId, asRead): - """ - @blocks - """ - postData = { - "read": 1 if asRead else 0, - "id": messageId, - } - - markPage = self._get_page(self._markAsReadURL, postData) - - def archive_message(self, messageId): - """ - @blocks - """ - postData = { - "id": messageId, - } - - markPage = self._get_page(self._archiveMessageURL, postData) - - def _grab_json(self, flatXml): - xmlTree = ElementTree.fromstring(flatXml) - jsonElement = xmlTree.getchildren()[0] - flatJson = jsonElement.text - jsonTree = parse_json(flatJson) - return jsonTree - - def _grab_html(self, flatXml): - xmlTree = ElementTree.fromstring(flatXml) - htmlElement = xmlTree.getchildren()[1] - flatHtml = htmlElement.text - return flatHtml - - def _grab_account_info(self, page): - accountData = parse_json(page) - self._token = accountData["r"] - self._accountNum = accountData["number"]["raw"] - for callback in accountData["phones"].itervalues(): - self._callbackNumbers[callback["phoneNumber"]] = callback["name"] - if len(self._callbackNumbers) == 0: - _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page) - return accountData - - def _send_validation(self, number): - if not self.is_valid_syntax(number): - raise ValueError('Number is not valid: "%s"' % number) - return number - - def _parse_recent(self, recentPage): - allRecentHtml = self._grab_html(recentPage) - allRecentData = self._parse_history(allRecentHtml) - for recentCallData in allRecentData: - yield recentCallData - - def _parse_history(self, historyHtml): - splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml) - for messageId, messageHtml in itergroup(splitVoicemail[1:], 2): - exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) - exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else "" - exactTime = google_strptime(exactTime) - relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) - relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" - locationGroup = self._voicemailLocationRegex.search(messageHtml) - location = locationGroup.group(1).strip() if locationGroup else "" - - nameGroup = self._voicemailNameRegex.search(messageHtml) - name = nameGroup.group(1).strip() if nameGroup else "" - numberGroup = self._voicemailNumberRegex.search(messageHtml) - number = numberGroup.group(1).strip() if numberGroup else "" - prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) - prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" - contactIdGroup = self._messagesContactIDRegex.search(messageHtml) - contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" - - yield { - "id": messageId.strip(), - "contactId": contactId, - "name": unescape(name), - "time": exactTime, - "relTime": relativeTime, - "prettyNumber": prettyNumber, - "number": number, - "location": unescape(location), - } - - @staticmethod - def _interpret_voicemail_regex(group): - quality, content, number = group.group(2), group.group(3), group.group(4) - text = MessageText() - if quality is not None and content is not None: - text.accuracy = quality - text.text = unescape(content) - return text - elif number is not None: - text.accuracy = MessageText.ACCURACY_HIGH - text.text = number - return text - - def _parse_voicemail(self, voicemailHtml): - splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml) - for messageId, messageHtml in itergroup(splitVoicemail[1:], 2): - conv = Conversation() - conv.type = Conversation.TYPE_VOICEMAIL - conv.id = messageId.strip() - - exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) - exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else "" - conv.time = google_strptime(exactTimeText) - relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) - conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" - locationGroup = self._voicemailLocationRegex.search(messageHtml) - conv.location = unescape(locationGroup.group(1).strip() if locationGroup else "") - - nameGroup = self._voicemailNameRegex.search(messageHtml) - conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "") - numberGroup = self._voicemailNumberRegex.search(messageHtml) - conv.number = numberGroup.group(1).strip() if numberGroup else "" - prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) - conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" - contactIdGroup = self._messagesContactIDRegex.search(messageHtml) - conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" - - messageGroups = self._voicemailMessageRegex.finditer(messageHtml) - messageParts = [ - self._interpret_voicemail_regex(group) - for group in messageGroups - ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), ) - message = Message() - message.body = messageParts - message.whoFrom = conv.name - try: - message.when = conv.time.strftime("%I:%M %p") - except ValueError: - _moduleLogger.exception("Confusing time provided: %r" % conv.time) - message.when = "Unknown" - conv.messages = (message, ) - - yield conv - - @staticmethod - def _interpret_sms_message_parts(fromPart, textPart, timePart): - text = MessageText() - text.accuracy = MessageText.ACCURACY_MEDIUM - text.text = unescape(textPart) - - message = Message() - message.body = (text, ) - message.whoFrom = fromPart - message.when = timePart - - return message - - def _parse_sms(self, smsHtml): - splitSms = self._seperateVoicemailsRegex.split(smsHtml) - for messageId, messageHtml in itergroup(splitSms[1:], 2): - conv = Conversation() - conv.type = Conversation.TYPE_SMS - conv.id = messageId.strip() - - exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml) - exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else "" - conv.time = google_strptime(exactTimeText) - relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml) - conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else "" - conv.location = "" - - nameGroup = self._voicemailNameRegex.search(messageHtml) - conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "") - numberGroup = self._voicemailNumberRegex.search(messageHtml) - conv.number = numberGroup.group(1).strip() if numberGroup else "" - prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml) - conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else "" - contactIdGroup = self._messagesContactIDRegex.search(messageHtml) - conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else "" - - fromGroups = self._smsFromRegex.finditer(messageHtml) - fromParts = (group.group(1).strip() for group in fromGroups) - textGroups = self._smsTextRegex.finditer(messageHtml) - textParts = (group.group(1).strip() for group in textGroups) - timeGroups = self._smsTimeRegex.finditer(messageHtml) - timeParts = (group.group(1).strip() for group in timeGroups) - - messageParts = itertools.izip(fromParts, textParts, timeParts) - messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts] - conv.messages = messages - - yield conv - - @staticmethod - def _merge_conversation_sources(parsedMessages, json): - for message in parsedMessages: - jsonItem = json["messages"][message.id] - message.isRead = jsonItem["isRead"] - message.isSpam = jsonItem["isSpam"] - message.isTrash = jsonItem["isTrash"] - message.isArchived = "inbox" not in jsonItem["labels"] - yield message - - def _get_page(self, url, data = None, refererUrl = None): - headers = {} - if refererUrl is not None: - headers["Referer"] = refererUrl - - encodedData = urllib.urlencode(data) if data is not None else None - - try: - page = self._browser.download(url, encodedData, None, headers) - except urllib2.URLError, e: - _moduleLogger.error("Translating error: %s" % str(e)) - raise NetworkError("%s is not accesible" % url) - - return page - - def _get_page_with_token(self, url, data = None, refererUrl = None): - if data is None: - data = {} - data['_rnr_se'] = self._token - - page = self._get_page(url, data, refererUrl) - - return page - - def _parse_with_validation(self, page): - json = parse_json(page) - self._validate_response(json) - return json - - def _validate_response(self, response): - """ - Validates that the JSON response is A-OK - """ - try: - assert response is not None, "Response not provided" - assert 'ok' in response, "Response lacks status" - assert response['ok'], "Response not good" - except AssertionError: - try: - if response["data"]["code"] == 20: - raise RuntimeError( -"""Ambiguous error 20 returned by Google Voice. -Please verify you have configured your callback number (currently "%s"). If it is configured some other suspected causes are: non-verified callback numbers, and Gizmo5 callback numbers.""" % self._callbackNumber) - except KeyError: - pass - raise RuntimeError('There was a problem with GV: %s' % response) - - -_UNESCAPE_ENTITIES = { - """: '"', - " ": " ", - "'": "'", -} - - -def unescape(text): - plain = saxutils.unescape(text, _UNESCAPE_ENTITIES) - return plain - - -def google_strptime(time): - """ - Hack: Google always returns the time in the same locale. Sadly if the - local system's locale is different, there isn't a way to perfectly handle - the time. So instead we handle implement some time formatting - """ - abbrevTime = time[:-3] - parsedTime = datetime.datetime.strptime(abbrevTime, "%m/%d/%y %I:%M") - if time.endswith("PM"): - parsedTime += datetime.timedelta(hours=12) - return parsedTime - - -def itergroup(iterator, count, padValue = None): - """ - Iterate in groups of 'count' values. If there - aren't enough values, the last result is padded with - None. - - >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): - ... print tuple(val) - (1, 2, 3) - (4, 5, 6) - >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): - ... print list(val) - [1, 2, 3] - [4, 5, 6] - >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3): - ... print tuple(val) - (1, 2, 3) - (4, 5, 6) - (7, None, None) - >>> for val in itergroup("123456", 3): - ... print tuple(val) - ('1', '2', '3') - ('4', '5', '6') - >>> for val in itergroup("123456", 3): - ... print repr("".join(val)) - '123' - '456' - """ - paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1)) - nIterators = (paddedIterator, ) * count - return itertools.izip(*nIterators) - - -def safe_eval(s): - _TRUE_REGEX = re.compile("true") - _FALSE_REGEX = re.compile("false") - _COMMENT_REGEX = re.compile("^\s+//.*$", re.M) - s = _TRUE_REGEX.sub("True", s) - s = _FALSE_REGEX.sub("False", s) - s = _COMMENT_REGEX.sub("#", s) - try: - results = eval(s, {}, {}) - except SyntaxError: - _moduleLogger.exception("Oops") - results = None - return results - - -def _fake_parse_json(flattened): - return safe_eval(flattened) - - -def _actual_parse_json(flattened): - return simplejson.loads(flattened) - - -if simplejson is None: - parse_json = _fake_parse_json -else: - parse_json = _actual_parse_json - - -def extract_payload(flatXml): - xmlTree = ElementTree.fromstring(flatXml) - - jsonElement = xmlTree.getchildren()[0] - flatJson = jsonElement.text - jsonTree = parse_json(flatJson) - - htmlElement = xmlTree.getchildren()[1] - flatHtml = htmlElement.text - - return jsonTree, flatHtml - - -def guess_phone_type(number): - if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"): - return GVoiceBackend.PHONE_TYPE_GIZMO - else: - return GVoiceBackend.PHONE_TYPE_MOBILE - - -def get_sane_callback(backend): - """ - Try to set a sane default callback number on these preferences - 1) 1747 numbers ( Gizmo ) - 2) anything with gizmo in the name - 3) anything with computer in the name - 4) the first value - """ - numbers = backend.get_callback_numbers() - - priorityOrderedCriteria = [ - ("\+1747", None), - ("1747", None), - ("747", None), - (None, "gizmo"), - (None, "computer"), - (None, "sip"), - (None, None), - ] - - for numberCriteria, descriptionCriteria in priorityOrderedCriteria: - numberMatcher = None - descriptionMatcher = None - if numberCriteria is not None: - numberMatcher = re.compile(numberCriteria) - elif descriptionCriteria is not None: - descriptionMatcher = re.compile(descriptionCriteria, re.I) - - for number, description in numbers.iteritems(): - if numberMatcher is not None and numberMatcher.match(number) is None: - continue - if descriptionMatcher is not None and descriptionMatcher.match(description) is None: - continue - return number - - -def set_sane_callback(backend): - """ - Try to set a sane default callback number on these preferences - 1) 1747 numbers ( Gizmo ) - 2) anything with gizmo in the name - 3) anything with computer in the name - 4) the first value - """ - number = get_sane_callback(backend) - backend.set_callback_number(number) - - -def _is_not_special(name): - return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name - - -def to_dict(obj): - members = inspect.getmembers(obj) - return dict((name, value) for (name, value) in members if _is_not_special(name)) - - -def grab_debug_info(username, password): - cookieFile = os.path.join(".", "raw_cookies.txt") - try: - os.remove(cookieFile) - except OSError: - pass - - backend = GVoiceBackend(cookieFile) - browser = backend._browser - - _TEST_WEBPAGES = [ - ("token", backend._tokenURL), - ("login", backend._loginURL), - ("isdnd", backend._isDndURL), - ("account", backend._XML_ACCOUNT_URL), - ("html_contacts", backend._XML_CONTACTS_URL), - ("contacts", backend._JSON_CONTACTS_URL), - ("csv", backend._CSV_CONTACTS_URL), - - ("voicemail", backend._XML_VOICEMAIL_URL), - ("html_sms", backend._XML_SMS_URL), - ("sms", backend._JSON_SMS_URL), - ("count", backend._JSON_SMS_COUNT_URL), - - ("recent", backend._XML_RECENT_URL), - ("placed", backend._XML_PLACED_URL), - ("recieved", backend._XML_RECEIVED_URL), - ("missed", backend._XML_MISSED_URL), - ] - - # Get Pages - print "Grabbing pre-login pages" - for name, url in _TEST_WEBPAGES: - try: - page = browser.download(url) - except StandardError, e: - print e.message - continue - print "\tWriting to file" - with open("not_loggedin_%s.txt" % name, "w") as f: - f.write(page) - - # Login - print "Attempting login" - galxToken = backend._get_token() - loginSuccessOrFailurePage = backend._login(username, password, galxToken) - with open("loggingin.txt", "w") as f: - print "\tWriting to file" - f.write(loginSuccessOrFailurePage) - try: - backend._grab_account_info(loginSuccessOrFailurePage) - except Exception: - # Retry in case the redirect failed - # luckily refresh_account_info does everything we need for a retry - loggedIn = backend.refresh_account_info() is not None - if not loggedIn: - raise - - # Get Pages - print "Grabbing post-login pages" - for name, url in _TEST_WEBPAGES: - try: - page = browser.download(url) - except StandardError, e: - print str(e) - continue - print "\tWriting to file" - with open("loggedin_%s.txt" % name, "w") as f: - f.write(page) - - # Cookies - browser.save_cookies() - print "\tWriting cookies to file" - with open("cookies.txt", "w") as f: - f.writelines( - "%s: %s\n" % (c.name, c.value) - for c in browser._cookies - ) - - -def grab_voicemails(username, password): - cookieFile = os.path.join(".", "raw_cookies.txt") - try: - os.remove(cookieFile) - except OSError: - pass - - backend = GVoiceBackend(cookieFile) - backend.login(username, password) - voicemails = list(backend.get_voicemails()) - for voicemail in voicemails: - print voicemail.id - backend.download(voicemail.id, ".") - - -def main(): - import sys - logging.basicConfig(level=logging.DEBUG) - args = sys.argv - if 3 <= len(args): - username = args[1] - password = args[2] - - grab_debug_info(username, password) - grab_voicemails(username, password) - - -if __name__ == "__main__": - main() diff --git a/src/backends/null_backend.py b/src/backends/null_backend.py deleted file mode 100644 index ebaa932..0000000 --- a/src/backends/null_backend.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/python - -""" -DialCentral - Front end for Google's Grand Central service. -Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library 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 -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - - -class NullAddressBook(object): - - @property - def name(self): - return "None" - - def update_account(self, force = True): - pass - - def get_contacts(self): - return {} - - -class NullAddressBookFactory(object): - - def get_addressbooks(self): - yield NullAddressBook() diff --git a/src/backends/qt_backend.py b/src/backends/qt_backend.py deleted file mode 100644 index 88e52fa..0000000 --- a/src/backends/qt_backend.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import logging - -import util.qt_compat as qt_compat -if qt_compat.USES_PYSIDE: - try: - import QtMobility.Contacts as _QtContacts - QtContacts = _QtContacts - except ImportError: - QtContacts = None -else: - QtContacts = None - -import null_backend - - -_moduleLogger = logging.getLogger(__name__) - - -class QtContactsAddressBook(object): - - def __init__(self, name, uri): - self._name = name - self._uri = uri - self._manager = QtContacts.QContactManager.fromUri(uri) - self._contacts = None - - @property - def name(self): - return self._name - - @property - def error(self): - return self._manager.error() - - def update_account(self, force = True): - if not force and self._contacts is not None: - return - self._contacts = dict(self._get_contacts()) - - def get_contacts(self): - if self._contacts is None: - self._contacts = dict(self._get_contacts()) - return self._contacts - - def _get_contacts(self): - contacts = self._manager.contacts() - for contact in contacts: - contactId = contact.localId() - contactName = contact.displayLabel() - phoneDetails = contact.details(QtContacts.QContactPhoneNumber().DefinitionName) - phones = [{"phoneType": "Phone", "phoneNumber": phone.value(QtContacts.QContactPhoneNumber().FieldNumber)} for phone in phoneDetails] - contactDetails = phones - if 0 < len(contactDetails): - yield str(contactId), { - "contactId": str(contactId), - "name": contactName, - "numbers": contactDetails, - } - - -class _QtContactsAddressBookFactory(object): - - def __init__(self): - self._availableManagers = {} - - availableMgrs = QtContacts.QContactManager.availableManagers() - availableMgrs.remove("invalid") - for managerName in availableMgrs: - params = {} - managerUri = QtContacts.QContactManager.buildUri(managerName, params) - self._availableManagers[managerName] = managerUri - - def get_addressbooks(self): - for name, uri in self._availableManagers.iteritems(): - book = QtContactsAddressBook(name, uri) - if book.error: - _moduleLogger.info("Could not load %r due to %r" % (name, book.error)) - else: - yield book - - -class _EmptyAddressBookFactory(object): - - def get_addressbooks(self): - if False: - yield None - - -if QtContacts is not None: - QtContactsAddressBookFactory = _QtContactsAddressBookFactory -else: - QtContactsAddressBookFactory = _EmptyAddressBookFactory - _moduleLogger.info("QtContacts support not available") - - -if __name__ == "__main__": - factory = QtContactsAddressBookFactory() - books = factory.get_addressbooks() - for book in books: - print book.name - print book.get_contacts() diff --git a/src/call_handler.py b/src/call_handler.py deleted file mode 100644 index 9b9c47d..0000000 --- a/src/call_handler.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import logging - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore -import dbus -try: - import telepathy as _telepathy - import util.tp_utils as telepathy_utils - telepathy = _telepathy -except ImportError: - telepathy = None - -import util.misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class _FakeSignaller(object): - - def start(self): - pass - - def stop(self): - pass - - -class _MissedCallWatcher(QtCore.QObject): - - callMissed = qt_compat.Signal() - - def __init__(self): - QtCore.QObject.__init__(self) - self._isStarted = False - self._isSupported = True - - self._newChannelSignaller = telepathy_utils.NewChannelSignaller(self._on_new_channel) - self._outstandingRequests = [] - - @property - def isSupported(self): - return self._isSupported - - @property - def isStarted(self): - return self._isStarted - - def start(self): - if self._isStarted: - _moduleLogger.info("voicemail monitor already started") - return - try: - self._newChannelSignaller.start() - except RuntimeError: - _moduleLogger.exception("Missed call detection not supported") - self._newChannelSignaller = _FakeSignaller() - self._isSupported = False - self._isStarted = True - - def stop(self): - if not self._isStarted: - _moduleLogger.info("voicemail monitor stopped without starting") - return - _moduleLogger.info("Stopping voicemail refresh") - self._newChannelSignaller.stop() - - # I don't want to trust whether the cancel happens within the current - # callback or not which could be the deciding factor between invalid - # iterators or infinite loops - localRequests = [r for r in self._outstandingRequests] - for request in localRequests: - localRequests.cancel() - - self._isStarted = False - - @misc_utils.log_exception(_moduleLogger) - def _on_new_channel(self, bus, serviceName, connObjectPath, channelObjectPath, channelType): - if channelType != telepathy.interfaces.CHANNEL_TYPE_STREAMED_MEDIA: - return - - conn = telepathy.client.Connection(serviceName, connObjectPath) - try: - chan = telepathy.client.Channel(serviceName, channelObjectPath) - except dbus.exceptions.UnknownMethodException: - _moduleLogger.exception("Client might not have implemented a deprecated method") - return - missDetection = telepathy_utils.WasMissedCall( - bus, conn, chan, self._on_missed_call, self._on_error_for_missed - ) - self._outstandingRequests.append(missDetection) - - @misc_utils.log_exception(_moduleLogger) - def _on_missed_call(self, missDetection): - _moduleLogger.info("Missed a call") - self.callMissed.emit() - self._outstandingRequests.remove(missDetection) - - @misc_utils.log_exception(_moduleLogger) - def _on_error_for_missed(self, missDetection, reason): - _moduleLogger.debug("Error: %r claims %r" % (missDetection, reason)) - self._outstandingRequests.remove(missDetection) - - -class _DummyMissedCallWatcher(QtCore.QObject): - - callMissed = qt_compat.Signal() - - def __init__(self): - QtCore.QObject.__init__(self) - self._isStarted = False - - @property - def isSupported(self): - return False - - @property - def isStarted(self): - return self._isStarted - - def start(self): - self._isStarted = True - - def stop(self): - if not self._isStarted: - _moduleLogger.info("voicemail monitor stopped without starting") - return - _moduleLogger.info("Stopping voicemail refresh") - self._isStarted = False - - -if telepathy is not None: - MissedCallWatcher = _MissedCallWatcher -else: - MissedCallWatcher = _DummyMissedCallWatcher - - -if __name__ == "__main__": - pass - diff --git a/src/constants.py b/src/constants.py deleted file mode 100644 index b9d3c79..0000000 --- a/src/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -__pretty_app_name__ = "DialCentral" -__app_name__ = "dialcentral" -__version__ = "1.3.6" -__build__ = 0 -__app_magic__ = 0xdeadbeef -_data_path_ = os.path.join(os.path.expanduser("~"), ".%s" % __app_name__) -_user_settings_ = "%s/settings.ini" % _data_path_ -_custom_notifier_settings_ = "%s/notifier.ini" % _data_path_ -_user_logpath_ = "%s/%s.log" % (_data_path_, __app_name__) -_notifier_logpath_ = "%s/notifier.log" % _data_path_ -IS_MAEMO = True diff --git a/src/dialcentral.py b/src/dialcentral.py deleted file mode 100755 index a20d4fe..0000000 --- a/src/dialcentral.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -import sys - - -sys.path.append("/opt/dialcentral/lib") - - -import dialcentral_qt - - -if __name__ == "__main__": - dialcentral_qt.run() diff --git a/src/dialcentral_qt.py b/src/dialcentral_qt.py deleted file mode 100755 index a464ad6..0000000 --- a/src/dialcentral_qt.py +++ /dev/null @@ -1,812 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF8 -*- - -from __future__ import with_statement - -import os -import base64 -import ConfigParser -import functools -import logging -import logging.handlers - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -import constants -import alarm_handler -from util import qtpie -from util import qwrappers -from util import qui_utils -from util import misc as misc_utils - -import session - - -_moduleLogger = logging.getLogger(__name__) - - -class Dialcentral(qwrappers.ApplicationWrapper): - - _DATA_PATHS = [ - os.path.join(os.path.dirname(__file__), "../share"), - os.path.join(os.path.dirname(__file__), "../data"), - ] - - def __init__(self, app): - self._dataPath = None - self._aboutDialog = None - self.notifyOnMissed = False - self.notifyOnVoicemail = False - self.notifyOnSms = False - - self._streamHandler = None - self._ledHandler = None - self._alarmHandler = alarm_handler.AlarmHandler() - - qwrappers.ApplicationWrapper.__init__(self, app, constants) - - def load_settings(self): - try: - config = ConfigParser.SafeConfigParser() - config.read(constants._user_settings_) - except IOError, e: - _moduleLogger.info("No settings") - return - except ValueError: - _moduleLogger.info("Settings were corrupt") - return - except ConfigParser.MissingSectionHeaderError: - _moduleLogger.info("Settings were corrupt") - return - except Exception: - _moduleLogger.exception("Unknown loading error") - - self._mainWindow.load_settings(config) - - def save_settings(self): - _moduleLogger.info("Saving settings") - config = ConfigParser.SafeConfigParser() - - self._mainWindow.save_settings(config) - - with open(constants._user_settings_, "wb") as configFile: - config.write(configFile) - - def get_icon(self, name): - if self._dataPath is None: - for path in self._DATA_PATHS: - if os.path.exists(os.path.join(path, name)): - self._dataPath = path - break - if self._dataPath is not None: - icon = QtGui.QIcon(os.path.join(self._dataPath, name)) - return icon - else: - return None - - def get_resource(self, name): - if self._dataPath is None: - for path in self._DATA_PATHS: - if os.path.exists(os.path.join(path, name)): - self._dataPath = path - break - if self._dataPath is not None: - return os.path.join(self._dataPath, name) - else: - return None - - def _close_windows(self): - qwrappers.ApplicationWrapper._close_windows(self) - if self._aboutDialog is not None: - self._aboutDialog.close() - - @property - def fsContactsPath(self): - return os.path.join(constants._data_path_, "contacts") - - @property - def streamHandler(self): - if self._streamHandler is None: - import stream_handler - self._streamHandler = stream_handler.StreamHandler() - return self._streamHandler - - @property - def alarmHandler(self): - return self._alarmHandler - - @property - def ledHandler(self): - if self._ledHandler is None: - import led_handler - self._ledHandler = led_handler.LedHandler() - return self._ledHandler - - def _new_main_window(self): - return MainWindow(None, self) - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_about(self, checked = True): - with qui_utils.notify_error(self._errorLog): - if self._aboutDialog is None: - import dialogs - self._aboutDialog = dialogs.AboutDialog(self) - response = self._aboutDialog.run(self._mainWindow.window) - - -class DelayedWidget(object): - - def __init__(self, app, settingsNames): - self._layout = QtGui.QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self._widget = QtGui.QWidget() - self._widget.setContentsMargins(0, 0, 0, 0) - self._widget.setLayout(self._layout) - self._settings = dict((name, "") for name in settingsNames) - - self._child = None - self._isEnabled = True - - @property - def toplevel(self): - return self._widget - - def has_child(self): - return self._child is not None - - def set_child(self, child): - if self._child is not None: - self._layout.removeWidget(self._child.toplevel) - self._child = child - if self._child is not None: - self._layout.addWidget(self._child.toplevel) - - self._child.set_settings(self._settings) - - if self._isEnabled: - self._child.enable() - else: - self._child.disable() - - @property - def child(self): - return self._child - - def enable(self): - self._isEnabled = True - if self._child is not None: - self._child.enable() - - def disable(self): - self._isEnabled = False - if self._child is not None: - self._child.disable() - - def clear(self): - if self._child is not None: - self._child.clear() - - def refresh(self, force=True): - if self._child is not None: - self._child.refresh(force) - - def get_settings(self): - if self._child is not None: - return self._child.get_settings() - else: - return self._settings - - def set_settings(self, settings): - if self._child is not None: - self._child.set_settings(settings) - else: - self._settings = settings - - -def _tab_factory(tab, app, session, errorLog): - import gv_views - return gv_views.__dict__[tab](app, session, errorLog) - - -class MainWindow(qwrappers.WindowWrapper): - - KEYPAD_TAB = 0 - RECENT_TAB = 1 - MESSAGES_TAB = 2 - CONTACTS_TAB = 3 - MAX_TABS = 4 - - _TAB_TITLES = [ - "Dialpad", - "History", - "Messages", - "Contacts", - ] - assert len(_TAB_TITLES) == MAX_TABS - - _TAB_ICONS = [ - "dialpad.png", - "history.png", - "messages.png", - "contacts.png", - ] - assert len(_TAB_ICONS) == MAX_TABS - - _TAB_CLASS = [ - functools.partial(_tab_factory, "Dialpad"), - functools.partial(_tab_factory, "History"), - functools.partial(_tab_factory, "Messages"), - functools.partial(_tab_factory, "Contacts"), - ] - assert len(_TAB_CLASS) == MAX_TABS - - # Hack to allow delay importing/loading of tabs - _TAB_SETTINGS_NAMES = [ - (), - ("filter", ), - ("status", "type"), - ("selectedAddressbook", ), - ] - assert len(_TAB_SETTINGS_NAMES) == MAX_TABS - - def __init__(self, parent, app): - qwrappers.WindowWrapper.__init__(self, parent, app) - self._window.setWindowTitle("%s" % constants.__pretty_app_name__) - self._window.resized.connect(self._on_window_resized) - self._errorLog = self._app.errorLog - - self._session = session.Session(self._errorLog, constants._data_path_) - self._session.error.connect(self._on_session_error) - self._session.loggedIn.connect(self._on_login) - self._session.loggedOut.connect(self._on_logout) - self._session.draft.recipientsChanged.connect(self._on_recipients_changed) - self._session.newMessages.connect(self._on_new_message_alert) - self._app.alarmHandler.applicationNotifySignal.connect(self._on_app_alert) - self._voicemailRefreshDelay = QtCore.QTimer() - self._voicemailRefreshDelay.setInterval(30 * 1000) - self._voicemailRefreshDelay.timeout.connect(self._on_call_missed) - self._voicemailRefreshDelay.setSingleShot(True) - self._callHandler = None - self._updateVoicemailOnMissedCall = False - - self._defaultCredentials = "", "" - self._curentCredentials = "", "" - self._currentTab = 0 - - self._credentialsDialog = None - self._smsEntryDialog = None - self._accountDialog = None - - self._tabsContents = [ - DelayedWidget(self._app, self._TAB_SETTINGS_NAMES[i]) - for i in xrange(self.MAX_TABS) - ] - for tab in self._tabsContents: - tab.disable() - - self._tabWidget = QtGui.QTabWidget() - if qui_utils.screen_orientation() == QtCore.Qt.Vertical: - self._tabWidget.setTabPosition(QtGui.QTabWidget.South) - else: - self._tabWidget.setTabPosition(QtGui.QTabWidget.West) - defaultTabIconSize = self._tabWidget.iconSize() - defaultTabIconWidth, defaultTabIconHeight = defaultTabIconSize.width(), defaultTabIconSize.height() - for tabIndex, (tabTitle, tabIcon) in enumerate( - zip(self._TAB_TITLES, self._TAB_ICONS) - ): - icon = self._app.get_icon(tabIcon) - if constants.IS_MAEMO and icon is not None: - tabTitle = "" - - if icon is None: - self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, tabTitle) - else: - iconSize = icon.availableSizes()[0] - defaultTabIconWidth = max(defaultTabIconWidth, iconSize.width()) - defaultTabIconHeight = max(defaultTabIconHeight, iconSize.height()) - self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, icon, tabTitle) - defaultTabIconWidth = max(defaultTabIconWidth, 32) - defaultTabIconHeight = max(defaultTabIconHeight, 32) - self._tabWidget.setIconSize(QtCore.QSize(defaultTabIconWidth, defaultTabIconHeight)) - self._tabWidget.currentChanged.connect(self._on_tab_changed) - self._tabWidget.setContentsMargins(0, 0, 0, 0) - - self._layout.addWidget(self._tabWidget) - - self._loginAction = QtGui.QAction(None) - self._loginAction.setText("Login") - self._loginAction.triggered.connect(self._on_login_requested) - - self._importAction = QtGui.QAction(None) - self._importAction.setText("Import") - self._importAction.triggered.connect(self._on_import) - - self._accountAction = QtGui.QAction(None) - self._accountAction.setText("Account") - self._accountAction.triggered.connect(self._on_account) - - self._refreshConnectionAction = QtGui.QAction(None) - self._refreshConnectionAction.setText("Refresh Connection") - self._refreshConnectionAction.setShortcut(QtGui.QKeySequence("CTRL+a")) - self._refreshConnectionAction.triggered.connect(self._on_refresh_connection) - - self._refreshTabAction = QtGui.QAction(None) - self._refreshTabAction.setText("Refresh Tab") - self._refreshTabAction.setShortcut(QtGui.QKeySequence("CTRL+r")) - self._refreshTabAction.triggered.connect(self._on_refresh) - - fileMenu = self._window.menuBar().addMenu("&File") - fileMenu.addAction(self._loginAction) - fileMenu.addAction(self._refreshTabAction) - fileMenu.addAction(self._refreshConnectionAction) - - toolsMenu = self._window.menuBar().addMenu("&Tools") - toolsMenu.addAction(self._accountAction) - toolsMenu.addAction(self._importAction) - toolsMenu.addAction(self._app.aboutAction) - - self._initialize_tab(self._tabWidget.currentIndex()) - self.set_fullscreen(self._app.fullscreenAction.isChecked()) - self.update_orientation(self._app.orientation) - - def _init_call_handler(self): - if self._callHandler is not None: - return - import call_handler - self._callHandler = call_handler.MissedCallWatcher() - self._callHandler.callMissed.connect(self._voicemailRefreshDelay.start) - - def set_default_credentials(self, username, password): - self._defaultCredentials = username, password - - def get_default_credentials(self): - return self._defaultCredentials - - def walk_children(self): - if self._smsEntryDialog is not None: - return (self._smsEntryDialog, ) - else: - return () - - def start(self): - qwrappers.WindowWrapper.start(self) - assert self._session.state == self._session.LOGGEDOUT_STATE, "Initialization messed up" - if self._defaultCredentials != ("", ""): - username, password = self._defaultCredentials[0], self._defaultCredentials[1] - self._curentCredentials = username, password - self._session.login(username, password) - else: - self._prompt_for_login() - - def close(self): - for diag in ( - self._credentialsDialog, - self._accountDialog, - ): - if diag is not None: - diag.close() - for child in self.walk_children(): - child.window.destroyed.disconnect(self._on_child_close) - child.window.closed.disconnect(self._on_child_close) - child.close() - self._window.close() - - def destroy(self): - qwrappers.WindowWrapper.destroy(self) - if self._session.state != self._session.LOGGEDOUT_STATE: - self._session.logout() - - def get_current_tab(self): - return self._currentTab - - def set_current_tab(self, tabIndex): - self._tabWidget.setCurrentIndex(tabIndex) - - def load_settings(self, config): - blobs = "", "" - isFullscreen = False - orientation = self._app.orientation - tabIndex = 0 - try: - blobs = [ - config.get(constants.__pretty_app_name__, "bin_blob_%i" % i) - for i in xrange(len(self.get_default_credentials())) - ] - isFullscreen = config.getboolean(constants.__pretty_app_name__, "fullscreen") - tabIndex = config.getint(constants.__pretty_app_name__, "tab") - orientation = config.get(constants.__pretty_app_name__, "orientation") - except ConfigParser.NoOptionError, e: - _moduleLogger.info( - "Settings file %s is missing option %s" % ( - constants._user_settings_, - e.option, - ), - ) - except ConfigParser.NoSectionError, e: - _moduleLogger.info( - "Settings file %s is missing section %s" % ( - constants._user_settings_, - e.section, - ), - ) - except Exception: - _moduleLogger.exception("Unknown loading error") - - try: - self._app.alarmHandler.load_settings(config, "alarm") - self._app.notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed") - self._app.notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail") - self._app.notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms") - self._updateVoicemailOnMissedCall = config.getboolean("2 - Account Info", "updateVoicemailOnMissedCall") - except ConfigParser.NoOptionError, e: - _moduleLogger.info( - "Settings file %s is missing option %s" % ( - constants._user_settings_, - e.option, - ), - ) - except ConfigParser.NoSectionError, e: - _moduleLogger.info( - "Settings file %s is missing section %s" % ( - constants._user_settings_, - e.section, - ), - ) - except Exception: - _moduleLogger.exception("Unknown loading error") - - creds = ( - base64.b64decode(blob) - for blob in blobs - ) - self.set_default_credentials(*creds) - self._app.fullscreenAction.setChecked(isFullscreen) - self._app.set_orientation(orientation) - self.set_current_tab(tabIndex) - - backendId = 2 # For backwards compatibility - for tabIndex, tabTitle in enumerate(self._TAB_TITLES): - sectionName = "%s - %s" % (backendId, tabTitle) - settings = self._tabsContents[tabIndex].get_settings() - for settingName in settings.iterkeys(): - try: - settingValue = config.get(sectionName, settingName) - except ConfigParser.NoOptionError, e: - _moduleLogger.info( - "Settings file %s is missing section %s" % ( - constants._user_settings_, - e.section, - ), - ) - return - except ConfigParser.NoSectionError, e: - _moduleLogger.info( - "Settings file %s is missing section %s" % ( - constants._user_settings_, - e.section, - ), - ) - return - except Exception: - _moduleLogger.exception("Unknown loading error") - return - settings[settingName] = settingValue - self._tabsContents[tabIndex].set_settings(settings) - - def save_settings(self, config): - config.add_section(constants.__pretty_app_name__) - config.set(constants.__pretty_app_name__, "tab", str(self.get_current_tab())) - config.set(constants.__pretty_app_name__, "fullscreen", str(self._app.fullscreenAction.isChecked())) - config.set(constants.__pretty_app_name__, "orientation", str(self._app.orientation)) - for i, value in enumerate(self.get_default_credentials()): - blob = base64.b64encode(value) - config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob) - - config.add_section("alarm") - self._app.alarmHandler.save_settings(config, "alarm") - config.add_section("2 - Account Info") - config.set("2 - Account Info", "notifyOnMissed", repr(self._app.notifyOnMissed)) - config.set("2 - Account Info", "notifyOnVoicemail", repr(self._app.notifyOnVoicemail)) - config.set("2 - Account Info", "notifyOnSms", repr(self._app.notifyOnSms)) - config.set("2 - Account Info", "updateVoicemailOnMissedCall", repr(self._updateVoicemailOnMissedCall)) - - backendId = 2 # For backwards compatibility - for tabIndex, tabTitle in enumerate(self._TAB_TITLES): - sectionName = "%s - %s" % (backendId, tabTitle) - config.add_section(sectionName) - tabSettings = self._tabsContents[tabIndex].get_settings() - for settingName, settingValue in tabSettings.iteritems(): - config.set(sectionName, settingName, settingValue) - - def update_orientation(self, orientation): - qwrappers.WindowWrapper.update_orientation(self, orientation) - windowOrientation = self.idealWindowOrientation - if windowOrientation == QtCore.Qt.Horizontal: - self._tabWidget.setTabPosition(QtGui.QTabWidget.West) - else: - self._tabWidget.setTabPosition(QtGui.QTabWidget.South) - - def _initialize_tab(self, index): - assert index < self.MAX_TABS, "Invalid tab" - if not self._tabsContents[index].has_child(): - tab = self._TAB_CLASS[index](self._app, self._session, self._errorLog) - self._tabsContents[index].set_child(tab) - self._tabsContents[index].refresh(force=False) - - def _prompt_for_login(self): - if self._credentialsDialog is None: - import dialogs - self._credentialsDialog = dialogs.CredentialsDialog(self._app) - credentials = self._credentialsDialog.run( - self._defaultCredentials[0], self._defaultCredentials[1], self.window - ) - if credentials is None: - return - username, password = credentials - self._curentCredentials = username, password - self._session.login(username, password) - - def _show_account_dialog(self): - if self._accountDialog is None: - import dialogs - self._accountDialog = dialogs.AccountDialog(self._window, self._app, self._app.errorLog) - self._accountDialog.setIfNotificationsSupported(self._app.alarmHandler.backgroundNotificationsSupported) - self._accountDialog.settingsApproved.connect(self._on_settings_approved) - - if self._callHandler is not None and not self._callHandler.isSupported: - self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED - elif self._updateVoicemailOnMissedCall: - self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_ENABLED - else: - self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_DISABLED - self._accountDialog.notifications = self._app.alarmHandler.alarmType - self._accountDialog.notificationTime = self._app.alarmHandler.recurrence - self._accountDialog.notifyOnMissed = self._app.notifyOnMissed - self._accountDialog.notifyOnVoicemail = self._app.notifyOnVoicemail - self._accountDialog.notifyOnSms = self._app.notifyOnSms - self._accountDialog.set_callbacks( - self._session.get_callback_numbers(), self._session.get_callback_number() - ) - accountNumberToDisplay = self._session.get_account_number() - if not accountNumberToDisplay: - accountNumberToDisplay = "Not Available (%s)" % self._session.state - self._accountDialog.set_account_number(accountNumberToDisplay) - self._accountDialog.orientation = self._app.orientation - - self._accountDialog.run() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_settings_approved(self): - if self._accountDialog.doClear: - self._session.logout_and_clear() - self._defaultCredentials = "", "" - self._curentCredentials = "", "" - for tab in self._tabsContents: - tab.disable() - else: - callbackNumber = self._accountDialog.selectedCallback - self._session.set_callback_number(callbackNumber) - - if self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED: - pass - elif self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_ENABLED: - self._updateVoicemailOnMissedCall = True - self._init_call_handler() - self._callHandler.start() - else: - self._updateVoicemailOnMissedCall = False - if self._callHandler is not None: - self._callHandler.stop() - if ( - self._accountDialog.notifyOnMissed or - self._accountDialog.notifyOnVoicemail or - self._accountDialog.notifyOnSms - ): - notifications = self._accountDialog.notifications - else: - notifications = self._accountDialog.ALARM_NONE - self._app.alarmHandler.apply_settings(notifications, self._accountDialog.notificationTime) - - self._app.notifyOnMissed = self._accountDialog.notifyOnMissed - self._app.notifyOnVoicemail = self._accountDialog.notifyOnVoicemail - self._app.notifyOnSms = self._accountDialog.notifyOnSms - self._app.set_orientation(self._accountDialog.orientation) - self._app.save_settings() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_window_resized(self): - with qui_utils.notify_error(self._app.errorLog): - windowOrientation = self.idealWindowOrientation - if windowOrientation == QtCore.Qt.Horizontal: - self._tabWidget.setTabPosition(QtGui.QTabWidget.West) - else: - self._tabWidget.setTabPosition(QtGui.QTabWidget.South) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_new_message_alert(self): - with qui_utils.notify_error(self._errorLog): - if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION: - if self._currentTab == self.MESSAGES_TAB or not self._app.ledHandler.isReal: - self._errorLog.push_message("New messages") - else: - self._app.ledHandler.on() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_call_missed(self): - with qui_utils.notify_error(self._errorLog): - self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force=True) - - @qt_compat.Slot(str) - @misc_utils.log_exception(_moduleLogger) - def _on_session_error(self, message): - with qui_utils.notify_error(self._errorLog): - self._errorLog.push_error(message) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_login(self): - with qui_utils.notify_error(self._errorLog): - changedAccounts = self._defaultCredentials != self._curentCredentials - noCallback = not self._session.get_callback_number() - if changedAccounts or noCallback: - self._show_account_dialog() - - self._defaultCredentials = self._curentCredentials - - for tab in self._tabsContents: - tab.enable() - self._initialize_tab(self._currentTab) - if self._updateVoicemailOnMissedCall: - self._init_call_handler() - self._callHandler.start() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_logout(self): - with qui_utils.notify_error(self._errorLog): - for tab in self._tabsContents: - tab.disable() - if self._callHandler is not None: - self._callHandler.stop() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_app_alert(self): - with qui_utils.notify_error(self._errorLog): - if self._session.state == self._session.LOGGEDIN_STATE: - messageType = { - (True, True): self._session.MESSAGE_ALL, - (True, False): self._session.MESSAGE_TEXTS, - (False, True): self._session.MESSAGE_VOICEMAILS, - }[(self._app.notifyOnSms, self._app.notifyOnVoicemail)] - self._session.update_messages(messageType, force=True) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_recipients_changed(self): - with qui_utils.notify_error(self._errorLog): - if self._session.draft.get_num_contacts() == 0: - return - - if self._smsEntryDialog is None: - import dialogs - self._smsEntryDialog = dialogs.SMSEntryWindow(self.window, self._app, self._session, self._errorLog) - self._smsEntryDialog.window.destroyed.connect(self._on_child_close) - self._smsEntryDialog.window.closed.connect(self._on_child_close) - self._smsEntryDialog.window.show() - - @misc_utils.log_exception(_moduleLogger) - def _on_child_close(self, obj = None): - self._smsEntryDialog = None - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_login_requested(self, checked = True): - with qui_utils.notify_error(self._errorLog): - self._prompt_for_login() - - @qt_compat.Slot(int) - @misc_utils.log_exception(_moduleLogger) - def _on_tab_changed(self, index): - with qui_utils.notify_error(self._errorLog): - self._currentTab = index - self._initialize_tab(index) - if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION: - self._app.ledHandler.off() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_refresh(self, checked = True): - with qui_utils.notify_error(self._errorLog): - self._tabsContents[self._currentTab].refresh(force=True) - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_refresh_connection(self, checked = True): - with qui_utils.notify_error(self._errorLog): - self._session.refresh_connection() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_import(self, checked = True): - with qui_utils.notify_error(self._errorLog): - csvName = QtGui.QFileDialog.getOpenFileName(self._window, caption="Import", filter="CSV Files (*.csv)") - csvName = unicode(csvName) - if not csvName: - return - import shutil - shutil.copy2(csvName, self._app.fsContactsPath) - if self._tabsContents[self.CONTACTS_TAB].has_child: - self._tabsContents[self.CONTACTS_TAB].child.update_addressbooks() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_account(self, checked = True): - with qui_utils.notify_error(self._errorLog): - assert self._session.state == self._session.LOGGEDIN_STATE, "Must be logged in for settings" - self._show_account_dialog() - - -def run(): - try: - os.makedirs(constants._data_path_) - except OSError, e: - if e.errno != 17: - raise - - logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s' - logging.basicConfig(level=logging.DEBUG, format=logFormat) - rotating = logging.handlers.RotatingFileHandler(constants._user_logpath_, maxBytes=512*1024, backupCount=1) - rotating.setFormatter(logging.Formatter(logFormat)) - root = logging.getLogger() - root.addHandler(rotating) - _moduleLogger.info("%s %s-%s" % (constants.__app_name__, constants.__version__, constants.__build__)) - _moduleLogger.info("OS: %s" % (os.uname()[0], )) - _moduleLogger.info("Kernel: %s (%s) for %s" % os.uname()[2:]) - _moduleLogger.info("Hostname: %s" % os.uname()[1]) - - try: - import gobject - gobject.threads_init() - except ImportError: - _moduleLogger.info("GObject support not available") - try: - import dbus - try: - from dbus.mainloop.qt import DBusQtMainLoop - DBusQtMainLoop(set_as_default=True) - _moduleLogger.info("Using Qt mainloop") - except ImportError: - try: - from dbus.mainloop.glib import DBusGMainLoop - DBusGMainLoop(set_as_default=True) - _moduleLogger.info("Using GObject mainloop") - except ImportError: - _moduleLogger.info("Mainloop not available") - except ImportError: - _moduleLogger.info("DBus support not available") - - app = QtGui.QApplication([]) - handle = Dialcentral(app) - qtpie.init_pies() - return app.exec_() - - -if __name__ == "__main__": - import sys - - val = run() - sys.exit(val) diff --git a/src/dialogs.py b/src/dialogs.py deleted file mode 100644 index 8fbf328..0000000 --- a/src/dialogs.py +++ /dev/null @@ -1,1192 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import functools -import copy -import logging - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -import constants -from util import qwrappers -from util import qui_utils -from util import misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class CredentialsDialog(object): - - def __init__(self, app): - self._app = app - self._usernameField = QtGui.QLineEdit() - self._passwordField = QtGui.QLineEdit() - self._passwordField.setEchoMode(QtGui.QLineEdit.Password) - - self._credLayout = QtGui.QGridLayout() - self._credLayout.addWidget(QtGui.QLabel("Username"), 0, 0) - self._credLayout.addWidget(self._usernameField, 0, 1) - self._credLayout.addWidget(QtGui.QLabel("Password"), 1, 0) - self._credLayout.addWidget(self._passwordField, 1, 1) - - self._loginButton = QtGui.QPushButton("&Login") - self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel) - self._buttonLayout.addButton(self._loginButton, QtGui.QDialogButtonBox.AcceptRole) - - self._layout = QtGui.QVBoxLayout() - self._layout.addLayout(self._credLayout) - self._layout.addWidget(self._buttonLayout) - - self._dialog = QtGui.QDialog() - self._dialog.setWindowTitle("Login") - self._dialog.setLayout(self._layout) - self._dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) - self._buttonLayout.accepted.connect(self._dialog.accept) - self._buttonLayout.rejected.connect(self._dialog.reject) - - self._closeWindowAction = QtGui.QAction(None) - self._closeWindowAction.setText("Close") - self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) - self._closeWindowAction.triggered.connect(self._on_close_window) - - self._dialog.addAction(self._closeWindowAction) - self._dialog.addAction(app.quitAction) - self._dialog.addAction(app.fullscreenAction) - - def run(self, defaultUsername, defaultPassword, parent=None): - self._dialog.setParent(parent, QtCore.Qt.Dialog) - try: - self._usernameField.setText(defaultUsername) - self._passwordField.setText(defaultPassword) - - response = self._dialog.exec_() - if response == QtGui.QDialog.Accepted: - return str(self._usernameField.text()), str(self._passwordField.text()) - elif response == QtGui.QDialog.Rejected: - return None - else: - _moduleLogger.error("Unknown response") - return None - finally: - self._dialog.setParent(None, QtCore.Qt.Dialog) - - def close(self): - try: - self._dialog.reject() - except RuntimeError: - _moduleLogger.exception("Oh well") - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_close_window(self, checked = True): - with qui_utils.notify_error(self._app.errorLog): - self._dialog.reject() - - -class AboutDialog(object): - - def __init__(self, app): - self._app = app - self._title = QtGui.QLabel( - "

%s

Version: %s

" % ( - constants.__pretty_app_name__, constants.__version__ - ) - ) - self._title.setTextFormat(QtCore.Qt.RichText) - self._title.setAlignment(QtCore.Qt.AlignCenter) - self._copyright = QtGui.QLabel("
Developed by Ed Page
Icons: See website
") - self._copyright.setTextFormat(QtCore.Qt.RichText) - self._copyright.setAlignment(QtCore.Qt.AlignCenter) - self._link = QtGui.QLabel('DialCentral Website') - self._link.setTextFormat(QtCore.Qt.RichText) - self._link.setAlignment(QtCore.Qt.AlignCenter) - self._link.setOpenExternalLinks(True) - - self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel) - - self._layout = QtGui.QVBoxLayout() - self._layout.addWidget(self._title) - self._layout.addWidget(self._copyright) - self._layout.addWidget(self._link) - self._layout.addWidget(self._buttonLayout) - - self._dialog = QtGui.QDialog() - self._dialog.setWindowTitle("About") - self._dialog.setLayout(self._layout) - self._buttonLayout.rejected.connect(self._dialog.reject) - - self._closeWindowAction = QtGui.QAction(None) - self._closeWindowAction.setText("Close") - self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) - self._closeWindowAction.triggered.connect(self._on_close_window) - - self._dialog.addAction(self._closeWindowAction) - self._dialog.addAction(app.quitAction) - self._dialog.addAction(app.fullscreenAction) - - def run(self, parent=None): - self._dialog.setParent(parent, QtCore.Qt.Dialog) - - response = self._dialog.exec_() - return response - - def close(self): - try: - self._dialog.reject() - except RuntimeError: - _moduleLogger.exception("Oh well") - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_close_window(self, checked = True): - with qui_utils.notify_error(self._app.errorLog): - self._dialog.reject() - - -class AccountDialog(QtCore.QObject, qwrappers.WindowWrapper): - - # @bug Can't enter custom callback numbers - - _RECURRENCE_CHOICES = [ - (1, "1 minute"), - (2, "2 minutes"), - (3, "3 minutes"), - (5, "5 minutes"), - (8, "8 minutes"), - (10, "10 minutes"), - (15, "15 minutes"), - (30, "30 minutes"), - (45, "45 minutes"), - (60, "1 hour"), - (3*60, "3 hours"), - (6*60, "6 hours"), - (12*60, "12 hours"), - ] - - ALARM_NONE = "No Alert" - ALARM_BACKGROUND = "Background Alert" - ALARM_APPLICATION = "Application Alert" - - VOICEMAIL_CHECK_NOT_SUPPORTED = "Not Supported" - VOICEMAIL_CHECK_DISABLED = "Disabled" - VOICEMAIL_CHECK_ENABLED = "Enabled" - - settingsApproved = qt_compat.Signal() - - def __init__(self, parent, app, errorLog): - QtCore.QObject.__init__(self) - qwrappers.WindowWrapper.__init__(self, parent, app) - self._app = app - self._doClear = False - - self._accountNumberLabel = QtGui.QLabel("NUMBER NOT SET") - self._notificationSelecter = QtGui.QComboBox() - self._notificationSelecter.currentIndexChanged.connect(self._on_notification_change) - self._notificationTimeSelector = QtGui.QComboBox() - #self._notificationTimeSelector.setEditable(True) - self._notificationTimeSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop) - for _, label in self._RECURRENCE_CHOICES: - self._notificationTimeSelector.addItem(label) - self._missedCallsNotificationButton = QtGui.QCheckBox("Missed Calls") - self._voicemailNotificationButton = QtGui.QCheckBox("Voicemail") - self._smsNotificationButton = QtGui.QCheckBox("SMS") - self._voicemailOnMissedButton = QtGui.QCheckBox("Voicemail Update on Missed Calls") - self._clearButton = QtGui.QPushButton("Clear Account") - self._clearButton.clicked.connect(self._on_clear) - self._callbackSelector = QtGui.QComboBox() - #self._callbackSelector.setEditable(True) - self._callbackSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop) - self._orientationSelector = QtGui.QComboBox() - for orientationMode in [ - self._app.DEFAULT_ORIENTATION, - self._app.AUTO_ORIENTATION, - self._app.LANDSCAPE_ORIENTATION, - self._app.PORTRAIT_ORIENTATION, - ]: - self._orientationSelector.addItem(orientationMode) - - self._update_notification_state() - - self._credLayout = QtGui.QGridLayout() - self._credLayout.addWidget(QtGui.QLabel("Account"), 0, 0) - self._credLayout.addWidget(self._accountNumberLabel, 0, 1) - self._credLayout.addWidget(QtGui.QLabel("Callback"), 1, 0) - self._credLayout.addWidget(self._callbackSelector, 1, 1) - self._credLayout.addWidget(self._notificationSelecter, 2, 0) - self._credLayout.addWidget(self._notificationTimeSelector, 2, 1) - self._credLayout.addWidget(QtGui.QLabel(""), 3, 0) - self._credLayout.addWidget(self._missedCallsNotificationButton, 3, 1) - self._credLayout.addWidget(QtGui.QLabel(""), 4, 0) - self._credLayout.addWidget(self._voicemailNotificationButton, 4, 1) - self._credLayout.addWidget(QtGui.QLabel(""), 5, 0) - self._credLayout.addWidget(self._smsNotificationButton, 5, 1) - self._credLayout.addWidget(QtGui.QLabel("Other"), 6, 0) - self._credLayout.addWidget(self._voicemailOnMissedButton, 6, 1) - self._credLayout.addWidget(QtGui.QLabel("Orientation"), 7, 0) - self._credLayout.addWidget(self._orientationSelector, 7, 1) - self._credLayout.addWidget(QtGui.QLabel(""), 8, 0) - self._credLayout.addWidget(QtGui.QLabel(""), 9, 0) - self._credLayout.addWidget(self._clearButton, 9, 1) - - self._credWidget = QtGui.QWidget() - self._credWidget.setLayout(self._credLayout) - self._credWidget.setContentsMargins(0, 0, 0, 0) - self._scrollSettings = QtGui.QScrollArea() - self._scrollSettings.setWidget(self._credWidget) - self._scrollSettings.setWidgetResizable(True) - self._scrollSettings.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self._scrollSettings.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - - self._applyButton = QtGui.QPushButton("&Apply") - self._applyButton.clicked.connect(self._on_settings_apply) - self._cancelButton = QtGui.QPushButton("&Cancel") - self._cancelButton.clicked.connect(self._on_settings_cancel) - self._buttonLayout = QtGui.QHBoxLayout() - self._buttonLayout.addStretch() - self._buttonLayout.addWidget(self._cancelButton) - self._buttonLayout.addStretch() - self._buttonLayout.addWidget(self._applyButton) - self._buttonLayout.addStretch() - - self._layout.addWidget(self._scrollSettings) - self._layout.addLayout(self._buttonLayout) - self._layout.setDirection(QtGui.QBoxLayout.TopToBottom) - - self._window.setWindowTitle("Account") - self._window.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) - - @property - def doClear(self): - return self._doClear - - def setIfNotificationsSupported(self, isSupported): - if isSupported: - self._notificationSelecter.clear() - self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION, self.ALARM_BACKGROUND]) - self._notificationTimeSelector.setEnabled(False) - self._missedCallsNotificationButton.setEnabled(False) - self._voicemailNotificationButton.setEnabled(False) - self._smsNotificationButton.setEnabled(False) - else: - self._notificationSelecter.clear() - self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION]) - self._notificationTimeSelector.setEnabled(False) - self._missedCallsNotificationButton.setEnabled(False) - self._voicemailNotificationButton.setEnabled(False) - self._smsNotificationButton.setEnabled(False) - - def set_account_number(self, num): - self._accountNumberLabel.setText(num) - - orientation = property( - lambda self: str(self._orientationSelector.currentText()), - lambda self, mode: qui_utils.set_current_index(self._orientationSelector, mode), - ) - - def _set_voicemail_on_missed(self, status): - if status == self.VOICEMAIL_CHECK_NOT_SUPPORTED: - self._voicemailOnMissedButton.setChecked(False) - self._voicemailOnMissedButton.hide() - elif status == self.VOICEMAIL_CHECK_DISABLED: - self._voicemailOnMissedButton.setChecked(False) - self._voicemailOnMissedButton.show() - elif status == self.VOICEMAIL_CHECK_ENABLED: - self._voicemailOnMissedButton.setChecked(True) - self._voicemailOnMissedButton.show() - else: - raise RuntimeError("Unsupported option for updating voicemail on missed calls %r" % status) - - def _get_voicemail_on_missed(self): - if not self._voicemailOnMissedButton.isVisible(): - return self.VOICEMAIL_CHECK_NOT_SUPPORTED - elif self._voicemailOnMissedButton.isChecked(): - return self.VOICEMAIL_CHECK_ENABLED - else: - return self.VOICEMAIL_CHECK_DISABLED - - updateVMOnMissedCall = property(_get_voicemail_on_missed, _set_voicemail_on_missed) - - notifications = property( - lambda self: str(self._notificationSelecter.currentText()), - lambda self, enabled: qui_utils.set_current_index(self._notificationSelecter, enabled), - ) - - notifyOnMissed = property( - lambda self: self._missedCallsNotificationButton.isChecked(), - lambda self, enabled: self._missedCallsNotificationButton.setChecked(enabled), - ) - - notifyOnVoicemail = property( - lambda self: self._voicemailNotificationButton.isChecked(), - lambda self, enabled: self._voicemailNotificationButton.setChecked(enabled), - ) - - notifyOnSms = property( - lambda self: self._smsNotificationButton.isChecked(), - lambda self, enabled: self._smsNotificationButton.setChecked(enabled), - ) - - def _get_notification_time(self): - index = self._notificationTimeSelector.currentIndex() - minutes = self._RECURRENCE_CHOICES[index][0] - return minutes - - def _set_notification_time(self, minutes): - for i, (time, _) in enumerate(self._RECURRENCE_CHOICES): - if time == minutes: - self._notificationTimeSelector.setCurrentIndex(i) - break - else: - self._notificationTimeSelector.setCurrentIndex(0) - - notificationTime = property(_get_notification_time, _set_notification_time) - - @property - def selectedCallback(self): - index = self._callbackSelector.currentIndex() - data = str(self._callbackSelector.itemData(index)) - return data - - def set_callbacks(self, choices, default): - self._callbackSelector.clear() - - self._callbackSelector.addItem("Not Set", "") - - uglyDefault = misc_utils.make_ugly(default) - if not uglyDefault: - uglyDefault = default - for number, description in choices.iteritems(): - prettyNumber = misc_utils.make_pretty(number) - uglyNumber = misc_utils.make_ugly(number) - if not uglyNumber: - prettyNumber = number - uglyNumber = number - - self._callbackSelector.addItem("%s - %s" % (prettyNumber, description), uglyNumber) - if uglyNumber == uglyDefault: - self._callbackSelector.setCurrentIndex(self._callbackSelector.count() - 1) - - def run(self): - self._doClear = False - self._window.show() - - def close(self): - try: - self._window.hide() - except RuntimeError: - _moduleLogger.exception("Oh well") - - def _update_notification_state(self): - currentText = str(self._notificationSelecter.currentText()) - if currentText == self.ALARM_BACKGROUND: - self._notificationTimeSelector.setEnabled(True) - - self._missedCallsNotificationButton.setEnabled(True) - self._voicemailNotificationButton.setEnabled(True) - self._smsNotificationButton.setEnabled(True) - elif currentText == self.ALARM_APPLICATION: - self._notificationTimeSelector.setEnabled(True) - - self._missedCallsNotificationButton.setEnabled(False) - self._voicemailNotificationButton.setEnabled(True) - self._smsNotificationButton.setEnabled(True) - - self._missedCallsNotificationButton.setChecked(False) - else: - self._notificationTimeSelector.setEnabled(False) - - self._missedCallsNotificationButton.setEnabled(False) - self._voicemailNotificationButton.setEnabled(False) - self._smsNotificationButton.setEnabled(False) - - self._missedCallsNotificationButton.setChecked(False) - self._voicemailNotificationButton.setChecked(False) - self._smsNotificationButton.setChecked(False) - - @qt_compat.Slot(int) - @misc_utils.log_exception(_moduleLogger) - def _on_notification_change(self, index): - with qui_utils.notify_error(self._app.errorLog): - self._update_notification_state() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_settings_cancel(self, checked = False): - with qui_utils.notify_error(self._app.errorLog): - self.hide() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - def _on_settings_apply(self, checked = False): - self.__on_settings_apply(checked) - - @misc_utils.log_exception(_moduleLogger) - def __on_settings_apply(self, checked = False): - with qui_utils.notify_error(self._app.errorLog): - self.settingsApproved.emit() - self.hide() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_clear(self, checked = False): - with qui_utils.notify_error(self._app.errorLog): - self._doClear = True - self.settingsApproved.emit() - self.hide() - - -class ContactList(object): - - _SENTINEL_ICON = QtGui.QIcon() - - def __init__(self, app, session): - self._app = app - self._session = session - self._targetLayout = QtGui.QVBoxLayout() - self._targetList = QtGui.QWidget() - self._targetList.setLayout(self._targetLayout) - self._uiItems = [] - self._closeIcon = qui_utils.get_theme_icon(("window-close", "general_close", "gtk-close"), self._SENTINEL_ICON) - - @property - def toplevel(self): - return self._targetList - - def setVisible(self, isVisible): - self._targetList.setVisible(isVisible) - - def update(self): - cids = list(self._session.draft.get_contacts()) - amountCommon = min(len(cids), len(self._uiItems)) - - # Run through everything in common - for i in xrange(0, amountCommon): - cid = cids[i] - uiItem = self._uiItems[i] - title = self._session.draft.get_title(cid) - description = self._session.draft.get_description(cid) - numbers = self._session.draft.get_numbers(cid) - uiItem["cid"] = cid - uiItem["title"] = title - uiItem["description"] = description - uiItem["numbers"] = numbers - uiItem["label"].setText(title) - self._populate_number_selector(uiItem["selector"], cid, i, numbers) - uiItem["rowWidget"].setVisible(True) - - # More contacts than ui items - for i in xrange(amountCommon, len(cids)): - cid = cids[i] - title = self._session.draft.get_title(cid) - description = self._session.draft.get_description(cid) - numbers = self._session.draft.get_numbers(cid) - - titleLabel = QtGui.QLabel(title) - titleLabel.setWordWrap(True) - numberSelector = QtGui.QComboBox() - self._populate_number_selector(numberSelector, cid, i, numbers) - - callback = functools.partial( - self._on_change_number, - i - ) - callback.__name__ = "thanks partials for not having names and pyqt for requiring them" - numberSelector.activated.connect( - qt_compat.Slot(int)(callback) - ) - - if self._closeIcon is self._SENTINEL_ICON: - deleteButton = QtGui.QPushButton("Delete") - else: - deleteButton = QtGui.QPushButton(self._closeIcon, "") - deleteButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.PushButton, - )) - callback = functools.partial( - self._on_remove_contact, - i - ) - callback.__name__ = "thanks partials for not having names and pyqt for requiring them" - deleteButton.clicked.connect(callback) - - rowLayout = QtGui.QHBoxLayout() - rowLayout.addWidget(titleLabel, 1000) - rowLayout.addWidget(numberSelector, 0) - rowLayout.addWidget(deleteButton, 0) - rowWidget = QtGui.QWidget() - rowWidget.setLayout(rowLayout) - self._targetLayout.addWidget(rowWidget) - - uiItem = {} - uiItem["cid"] = cid - uiItem["title"] = title - uiItem["description"] = description - uiItem["numbers"] = numbers - uiItem["label"] = titleLabel - uiItem["selector"] = numberSelector - uiItem["rowWidget"] = rowWidget - self._uiItems.append(uiItem) - amountCommon = i+1 - - # More UI items than contacts - for i in xrange(amountCommon, len(self._uiItems)): - uiItem = self._uiItems[i] - uiItem["rowWidget"].setVisible(False) - amountCommon = i+1 - - def _populate_number_selector(self, selector, cid, cidIndex, numbers): - selector.clear() - - selectedNumber = self._session.draft.get_selected_number(cid) - if len(numbers) == 1: - # If no alt numbers available, check the address book - numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1]) - else: - defaultIndex = _index_number(numbers, selectedNumber) - - for number, description in numbers: - if description: - label = "%s - %s" % (number, description) - else: - label = number - selector.addItem(label) - selector.setVisible(True) - if 1 < len(numbers): - selector.setEnabled(True) - selector.setCurrentIndex(defaultIndex) - else: - selector.setEnabled(False) - - @misc_utils.log_exception(_moduleLogger) - def _on_change_number(self, cidIndex, index): - with qui_utils.notify_error(self._app.errorLog): - # Exception thrown when the first item is removed - try: - cid = self._uiItems[cidIndex]["cid"] - numbers = self._session.draft.get_numbers(cid) - except IndexError: - _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) - return - except KeyError: - _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) - return - number = numbers[index][0] - self._session.draft.set_selected_number(cid, number) - - @misc_utils.log_exception(_moduleLogger) - def _on_remove_contact(self, index, toggled): - with qui_utils.notify_error(self._app.errorLog): - self._session.draft.remove_contact(self._uiItems[index]["cid"]) - - -class VoicemailPlayer(object): - - def __init__(self, app, session, errorLog): - self._app = app - self._session = session - self._errorLog = errorLog - self._token = None - self._session.voicemailAvailable.connect(self._on_voicemail_downloaded) - self._session.draft.recipientsChanged.connect(self._on_recipients_changed) - - self._playButton = QtGui.QPushButton("Play") - self._playButton.clicked.connect(self._on_voicemail_play) - self._pauseButton = QtGui.QPushButton("Pause") - self._pauseButton.clicked.connect(self._on_voicemail_pause) - self._pauseButton.hide() - self._resumeButton = QtGui.QPushButton("Resume") - self._resumeButton.clicked.connect(self._on_voicemail_resume) - self._resumeButton.hide() - self._stopButton = QtGui.QPushButton("Stop") - self._stopButton.clicked.connect(self._on_voicemail_stop) - self._stopButton.hide() - - self._downloadButton = QtGui.QPushButton("Download Voicemail") - self._downloadButton.clicked.connect(self._on_voicemail_download) - self._downloadLayout = QtGui.QHBoxLayout() - self._downloadLayout.addWidget(self._downloadButton) - self._downloadWidget = QtGui.QWidget() - self._downloadWidget.setLayout(self._downloadLayout) - - self._playLabel = QtGui.QLabel("Voicemail") - self._saveButton = QtGui.QPushButton("Save") - self._saveButton.clicked.connect(self._on_voicemail_save) - self._playerLayout = QtGui.QHBoxLayout() - self._playerLayout.addWidget(self._playLabel) - self._playerLayout.addWidget(self._playButton) - self._playerLayout.addWidget(self._pauseButton) - self._playerLayout.addWidget(self._resumeButton) - self._playerLayout.addWidget(self._stopButton) - self._playerLayout.addWidget(self._saveButton) - self._playerWidget = QtGui.QWidget() - self._playerWidget.setLayout(self._playerLayout) - - self._visibleWidget = None - self._layout = QtGui.QHBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - self._update_state() - - @property - def toplevel(self): - return self._widget - - def destroy(self): - self._session.voicemailAvailable.disconnect(self._on_voicemail_downloaded) - self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed) - self._invalidate_token() - - def _invalidate_token(self): - if self._token is not None: - self._token.invalidate() - self._token.error.disconnect(self._on_play_error) - self._token.stateChange.connect(self._on_play_state) - self._token.invalidated.connect(self._on_play_invalidated) - - def _show_download(self, messageId): - if self._visibleWidget is self._downloadWidget: - return - self._hide() - self._layout.addWidget(self._downloadWidget) - self._visibleWidget = self._downloadWidget - self._visibleWidget.show() - - def _show_player(self, messageId): - if self._visibleWidget is self._playerWidget: - return - self._hide() - self._layout.addWidget(self._playerWidget) - self._visibleWidget = self._playerWidget - self._visibleWidget.show() - - def _hide(self): - if self._visibleWidget is None: - return - self._visibleWidget.hide() - self._layout.removeWidget(self._visibleWidget) - self._visibleWidget = None - - def _update_play_state(self): - if self._token is not None and self._token.isValid: - self._playButton.setText("Stop") - else: - self._playButton.setText("Play") - - def _update_state(self): - if self._session.draft.get_num_contacts() != 1: - self._hide() - return - - (cid, ) = self._session.draft.get_contacts() - messageId = self._session.draft.get_message_id(cid) - if messageId is None: - self._hide() - return - - if self._session.is_available(messageId): - self._show_player(messageId) - else: - self._show_download(messageId) - if self._token is not None: - self._token.invalidate() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_save(self, arg): - with qui_utils.notify_error(self._app.errorLog): - targetPath = QtGui.QFileDialog.getSaveFileName(None, caption="Save Voicemail", filter="Audio File (*.mp3)") - targetPath = unicode(targetPath) - if not targetPath: - return - - (cid, ) = self._session.draft.get_contacts() - messageId = self._session.draft.get_message_id(cid) - sourcePath = self._session.voicemail_path(messageId) - import shutil - shutil.copy2(sourcePath, targetPath) - - @misc_utils.log_exception(_moduleLogger) - def _on_play_error(self, error): - with qui_utils.notify_error(self._app.errorLog): - self._app.errorLog.push_error(error) - - @misc_utils.log_exception(_moduleLogger) - def _on_play_invalidated(self): - with qui_utils.notify_error(self._app.errorLog): - self._playButton.show() - self._pauseButton.hide() - self._resumeButton.hide() - self._stopButton.hide() - self._invalidate_token() - - @misc_utils.log_exception(_moduleLogger) - def _on_play_state(self, state): - with qui_utils.notify_error(self._app.errorLog): - if state == self._token.STATE_PLAY: - self._playButton.hide() - self._pauseButton.show() - self._resumeButton.hide() - self._stopButton.show() - elif state == self._token.STATE_PAUSE: - self._playButton.hide() - self._pauseButton.hide() - self._resumeButton.show() - self._stopButton.show() - elif state == self._token.STATE_STOP: - self._playButton.show() - self._pauseButton.hide() - self._resumeButton.hide() - self._stopButton.hide() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_play(self, arg): - with qui_utils.notify_error(self._app.errorLog): - (cid, ) = self._session.draft.get_contacts() - messageId = self._session.draft.get_message_id(cid) - sourcePath = self._session.voicemail_path(messageId) - - self._invalidate_token() - uri = "file://%s" % sourcePath - self._token = self._app.streamHandler.set_file(uri) - self._token.stateChange.connect(self._on_play_state) - self._token.invalidated.connect(self._on_play_invalidated) - self._token.error.connect(self._on_play_error) - self._token.play() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_pause(self, arg): - with qui_utils.notify_error(self._app.errorLog): - self._token.pause() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_resume(self, arg): - with qui_utils.notify_error(self._app.errorLog): - self._token.play() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_stop(self, arg): - with qui_utils.notify_error(self._app.errorLog): - self._token.stop() - - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_download(self, arg): - with qui_utils.notify_error(self._app.errorLog): - (cid, ) = self._session.draft.get_contacts() - messageId = self._session.draft.get_message_id(cid) - self._session.download_voicemail(messageId) - self._hide() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_recipients_changed(self): - with qui_utils.notify_error(self._app.errorLog): - self._update_state() - - @qt_compat.Slot(str, str) - @misc_utils.log_exception(_moduleLogger) - def _on_voicemail_downloaded(self, messageId, filepath): - with qui_utils.notify_error(self._app.errorLog): - self._update_state() - - -class SMSEntryWindow(qwrappers.WindowWrapper): - - MAX_CHAR = 160 - # @bug Somehow a window is being destroyed on object creation which causes glitches on Maemo 5 - - def __init__(self, parent, app, session, errorLog): - qwrappers.WindowWrapper.__init__(self, parent, app) - self._session = session - self._session.messagesUpdated.connect(self._on_refresh_history) - self._session.historyUpdated.connect(self._on_refresh_history) - self._session.draft.recipientsChanged.connect(self._on_recipients_changed) - - self._session.draft.sendingMessage.connect(self._on_op_started) - self._session.draft.calling.connect(self._on_op_started) - self._session.draft.calling.connect(self._on_calling_started) - self._session.draft.cancelling.connect(self._on_op_started) - - self._session.draft.sentMessage.connect(self._on_op_finished) - self._session.draft.called.connect(self._on_op_finished) - self._session.draft.cancelled.connect(self._on_op_finished) - self._session.draft.error.connect(self._on_op_error) - - self._errorLog = errorLog - - self._targetList = ContactList(self._app, self._session) - self._history = QtGui.QLabel() - self._history.setTextFormat(QtCore.Qt.RichText) - self._history.setWordWrap(True) - self._voicemailPlayer = VoicemailPlayer(self._app, self._session, self._errorLog) - self._smsEntry = QtGui.QTextEdit() - self._smsEntry.textChanged.connect(self._on_letter_count_changed) - - self._entryLayout = QtGui.QVBoxLayout() - self._entryLayout.addWidget(self._targetList.toplevel) - self._entryLayout.addWidget(self._history) - self._entryLayout.addWidget(self._voicemailPlayer.toplevel, 0) - self._entryLayout.addWidget(self._smsEntry) - self._entryLayout.setContentsMargins(0, 0, 0, 0) - self._entryWidget = QtGui.QWidget() - self._entryWidget.setLayout(self._entryLayout) - self._entryWidget.setContentsMargins(0, 0, 0, 0) - self._scrollEntry = QtGui.QScrollArea() - self._scrollEntry.setWidget(self._entryWidget) - self._scrollEntry.setWidgetResizable(True) - self._scrollEntry.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom) - self._scrollEntry.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self._scrollEntry.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - - self._characterCountLabel = QtGui.QLabel("") - self._singleNumberSelector = QtGui.QComboBox() - self._cids = [] - self._singleNumberSelector.activated.connect(self._on_single_change_number) - self._smsButton = QtGui.QPushButton("SMS") - self._smsButton.clicked.connect(self._on_sms_clicked) - self._smsButton.setEnabled(False) - self._dialButton = QtGui.QPushButton("Dial") - self._dialButton.clicked.connect(self._on_call_clicked) - self._cancelButton = QtGui.QPushButton("Cancel Call") - self._cancelButton.clicked.connect(self._on_cancel_clicked) - self._cancelButton.setVisible(False) - - self._buttonLayout = QtGui.QHBoxLayout() - self._buttonLayout.addWidget(self._characterCountLabel) - self._buttonLayout.addStretch() - self._buttonLayout.addWidget(self._singleNumberSelector) - self._buttonLayout.addStretch() - self._buttonLayout.addWidget(self._smsButton) - self._buttonLayout.addWidget(self._dialButton) - self._buttonLayout.addWidget(self._cancelButton) - - self._layout.addWidget(self._errorDisplay.toplevel) - self._layout.addWidget(self._scrollEntry) - self._layout.addLayout(self._buttonLayout) - self._layout.setDirection(QtGui.QBoxLayout.TopToBottom) - - self._window.setWindowTitle("Contact") - self._window.closed.connect(self._on_close_window) - self._window.hidden.connect(self._on_close_window) - self._window.resized.connect(self._on_window_resized) - - self._scrollTimer = QtCore.QTimer() - self._scrollTimer.setInterval(100) - self._scrollTimer.setSingleShot(True) - self._scrollTimer.timeout.connect(self._on_delayed_scroll_to_bottom) - - self._smsEntry.setPlainText(self._session.draft.message) - self._update_letter_count() - self._update_target_fields() - self.set_fullscreen(self._app.fullscreenAction.isChecked()) - self.update_orientation(self._app.orientation) - - def close(self): - if self._window is None: - # Already closed - return - window = self._window - try: - message = unicode(self._smsEntry.toPlainText()) - self._session.draft.message = message - self.hide() - except AttributeError: - _moduleLogger.exception("Oh well") - except RuntimeError: - _moduleLogger.exception("Oh well") - - def destroy(self): - self._session.messagesUpdated.disconnect(self._on_refresh_history) - self._session.historyUpdated.disconnect(self._on_refresh_history) - self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed) - self._session.draft.sendingMessage.disconnect(self._on_op_started) - self._session.draft.calling.disconnect(self._on_op_started) - self._session.draft.calling.disconnect(self._on_calling_started) - self._session.draft.cancelling.disconnect(self._on_op_started) - self._session.draft.sentMessage.disconnect(self._on_op_finished) - self._session.draft.called.disconnect(self._on_op_finished) - self._session.draft.cancelled.disconnect(self._on_op_finished) - self._session.draft.error.disconnect(self._on_op_error) - self._voicemailPlayer.destroy() - window = self._window - self._window = None - try: - window.close() - window.destroy() - except AttributeError: - _moduleLogger.exception("Oh well") - except RuntimeError: - _moduleLogger.exception("Oh well") - - def update_orientation(self, orientation): - qwrappers.WindowWrapper.update_orientation(self, orientation) - self._scroll_to_bottom() - - def _update_letter_count(self): - count = len(self._smsEntry.toPlainText()) - numTexts, numCharInText = divmod(count, self.MAX_CHAR) - numTexts += 1 - numCharsLeftInText = self.MAX_CHAR - numCharInText - self._characterCountLabel.setText("%d (%d)" % (numCharsLeftInText, numTexts)) - - def _update_button_state(self): - self._cancelButton.setEnabled(True) - if self._session.draft.get_num_contacts() == 0: - self._dialButton.setEnabled(False) - self._smsButton.setEnabled(False) - elif self._session.draft.get_num_contacts() == 1: - count = len(self._smsEntry.toPlainText()) - if count == 0: - self._dialButton.setEnabled(True) - self._smsButton.setEnabled(False) - else: - self._dialButton.setEnabled(False) - self._smsButton.setEnabled(True) - else: - self._dialButton.setEnabled(False) - count = len(self._smsEntry.toPlainText()) - if count == 0: - self._smsButton.setEnabled(False) - else: - self._smsButton.setEnabled(True) - - def _update_history(self, cid): - draftContactsCount = self._session.draft.get_num_contacts() - if draftContactsCount != 1: - self._history.setVisible(False) - else: - description = self._session.draft.get_description(cid) - - self._targetList.setVisible(False) - if description: - self._history.setText(description) - self._history.setVisible(True) - else: - self._history.setText("") - self._history.setVisible(False) - - def _update_target_fields(self): - draftContactsCount = self._session.draft.get_num_contacts() - if draftContactsCount == 0: - self.hide() - del self._cids[:] - elif draftContactsCount == 1: - (cid, ) = self._session.draft.get_contacts() - title = self._session.draft.get_title(cid) - numbers = self._session.draft.get_numbers(cid) - - self._targetList.setVisible(False) - self._update_history(cid) - self._populate_number_selector(self._singleNumberSelector, cid, 0, numbers) - self._cids = [cid] - - self._scroll_to_bottom() - self._window.setWindowTitle(title) - self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason) - self.show() - self._window.raise_() - else: - self._targetList.setVisible(True) - self._targetList.update() - self._history.setText("") - self._history.setVisible(False) - self._singleNumberSelector.setVisible(False) - - self._scroll_to_bottom() - self._window.setWindowTitle("Contacts") - self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason) - self.show() - self._window.raise_() - - def _populate_number_selector(self, selector, cid, cidIndex, numbers): - selector.clear() - - selectedNumber = self._session.draft.get_selected_number(cid) - if len(numbers) == 1: - # If no alt numbers available, check the address book - numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1]) - else: - defaultIndex = _index_number(numbers, selectedNumber) - - for number, description in numbers: - if description: - label = "%s - %s" % (number, description) - else: - label = number - selector.addItem(label) - selector.setVisible(True) - if 1 < len(numbers): - selector.setEnabled(True) - selector.setCurrentIndex(defaultIndex) - else: - selector.setEnabled(False) - - def _scroll_to_bottom(self): - self._scrollTimer.start() - - @misc_utils.log_exception(_moduleLogger) - def _on_delayed_scroll_to_bottom(self): - with qui_utils.notify_error(self._app.errorLog): - self._scrollEntry.ensureWidgetVisible(self._smsEntry) - - @misc_utils.log_exception(_moduleLogger) - def _on_sms_clicked(self, arg): - with qui_utils.notify_error(self._app.errorLog): - message = unicode(self._smsEntry.toPlainText()) - self._session.draft.message = message - self._session.draft.send() - - @misc_utils.log_exception(_moduleLogger) - def _on_call_clicked(self, arg): - with qui_utils.notify_error(self._app.errorLog): - message = unicode(self._smsEntry.toPlainText()) - self._session.draft.message = message - self._session.draft.call() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_cancel_clicked(self, message): - with qui_utils.notify_error(self._app.errorLog): - self._session.draft.cancel() - - @misc_utils.log_exception(_moduleLogger) - def _on_single_change_number(self, index): - with qui_utils.notify_error(self._app.errorLog): - # Exception thrown when the first item is removed - cid = self._cids[0] - try: - numbers = self._session.draft.get_numbers(cid) - except KeyError: - _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index)) - return - number = numbers[index][0] - self._session.draft.set_selected_number(cid, number) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_refresh_history(self): - with qui_utils.notify_error(self._app.errorLog): - draftContactsCount = self._session.draft.get_num_contacts() - if draftContactsCount != 1: - # Changing contact count will automatically refresh it - return - (cid, ) = self._session.draft.get_contacts() - self._update_history(cid) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_recipients_changed(self): - with qui_utils.notify_error(self._app.errorLog): - self._update_target_fields() - self._update_button_state() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_op_started(self): - with qui_utils.notify_error(self._app.errorLog): - self._smsEntry.setReadOnly(True) - self._smsButton.setVisible(False) - self._dialButton.setVisible(False) - self.show() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_calling_started(self): - with qui_utils.notify_error(self._app.errorLog): - self._cancelButton.setVisible(True) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_op_finished(self): - with qui_utils.notify_error(self._app.errorLog): - self._smsEntry.setPlainText("") - self._smsEntry.setReadOnly(False) - self._cancelButton.setVisible(False) - self._smsButton.setVisible(True) - self._dialButton.setVisible(True) - self.close() - self.destroy() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_op_error(self, message): - with qui_utils.notify_error(self._app.errorLog): - self._smsEntry.setReadOnly(False) - self._cancelButton.setVisible(False) - self._smsButton.setVisible(True) - self._dialButton.setVisible(True) - - self._errorLog.push_error(message) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_letter_count_changed(self): - with qui_utils.notify_error(self._app.errorLog): - self._update_letter_count() - self._update_button_state() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_window_resized(self): - with qui_utils.notify_error(self._app.errorLog): - self._scroll_to_bottom() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_close_window(self, checked = True): - with qui_utils.notify_error(self._app.errorLog): - self.close() - - -def _index_number(numbers, default): - uglyDefault = misc_utils.make_ugly(default) - uglyContactNumbers = list( - misc_utils.make_ugly(contactNumber) - for (contactNumber, _) in numbers - ) - defaultMatches = [ - misc_utils.similar_ugly_numbers(uglyDefault, contactNumber) - for contactNumber in uglyContactNumbers - ] - try: - defaultIndex = defaultMatches.index(True) - except ValueError: - defaultIndex = -1 - _moduleLogger.warn( - "Could not find contact number %s among %r" % ( - default, numbers - ) - ) - return defaultIndex - - -def _get_contact_numbers(session, contactId, number, description): - contactPhoneNumbers = [] - if contactId and contactId != "0": - try: - contactDetails = copy.deepcopy(session.get_contacts()[contactId]) - contactPhoneNumbers = contactDetails["numbers"] - except KeyError: - contactPhoneNumbers = [] - contactPhoneNumbers = [ - (contactPhoneNumber["phoneNumber"], contactPhoneNumber.get("phoneType", "Unknown")) - for contactPhoneNumber in contactPhoneNumbers - ] - defaultIndex = _index_number(contactPhoneNumbers, number) - - if not contactPhoneNumbers or defaultIndex == -1: - contactPhoneNumbers += [(number, description)] - defaultIndex = 0 - - return contactPhoneNumbers, defaultIndex diff --git a/src/examples/log_notifier.py b/src/examples/log_notifier.py deleted file mode 100644 index 541ac18..0000000 --- a/src/examples/log_notifier.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement - -import sys -import datetime -import ConfigParser - - -sys.path.insert(0,"/usr/lib/dialcentral/") - - -import constants -import alarm_notify - - -def notify_on_change(): - with open(constants._notifier_logpath_, "a") as file: - file.write("Notification: %r\n" % (datetime.datetime.now(), )) - - config = ConfigParser.SafeConfigParser() - config.read(constants._user_settings_) - backend = alarm_notify.create_backend(config) - notifyUser = alarm_notify.is_changed(config, backend) - - if notifyUser: - file.write("\tChange occurred\n") - - -if __name__ == "__main__": - notify_on_change() diff --git a/src/examples/sound_notifier.py b/src/examples/sound_notifier.py deleted file mode 100644 index c31e413..0000000 --- a/src/examples/sound_notifier.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -import ConfigParser -import logging - - -sys.path.insert(0,"/usr/lib/dialcentral/") - - -import constants -import alarm_notify - - -def notify_on_change(): - config = ConfigParser.SafeConfigParser() - config.read(constants._user_settings_) - backend = alarm_notify.create_backend(config) - notifyUser = alarm_notify.is_changed(config, backend) - - config = ConfigParser.SafeConfigParser() - config.read(constants._custom_notifier_settings_) - soundFile = config.get("Sound Notifier", "soundfile") - soundFile = "/usr/lib/gv-notifier/alert.mp3" - - if notifyUser: - import subprocess - import led_handler - logging.info("Changed, playing %s" % soundFile) - led = led_handler.LedHandler() - led.on() - soundOn = subprocess.call("/usr/bin/dbus-send --dest=com.nokia.osso_media_server --print-reply /com/nokia/osso_media_server com.nokia.osso_media_server.music.play_media string:file://%s",shell=True) - else: - logging.info("No Change") - - -if __name__ == "__main__": - logging.basicConfig(level=logging.WARNING, filename=constants._notifier_logpath_) - logging.info("Sound Notifier %s-%s" % (constants.__version__, constants.__build__)) - logging.info("OS: %s" % (os.uname()[0], )) - logging.info("Kernel: %s (%s) for %s" % os.uname()[2:]) - logging.info("Hostname: %s" % os.uname()[1]) - try: - notify_on_change() - except: - logging.exception("Error") - raise diff --git a/src/gv_views.py b/src/gv_views.py deleted file mode 100644 index 2bd0663..0000000 --- a/src/gv_views.py +++ /dev/null @@ -1,977 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import datetime -import string -import itertools -import logging - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -from util import qtpie -from util import qui_utils -from util import misc as misc_utils - -import backends.null_backend as null_backend -import backends.file_backend as file_backend -import backends.qt_backend as qt_backend - - -_moduleLogger = logging.getLogger(__name__) - - -_SENTINEL_ICON = QtGui.QIcon() - - -class Dialpad(object): - - def __init__(self, app, session, errorLog): - self._app = app - self._session = session - self._errorLog = errorLog - - self._plus = QtGui.QPushButton("+") - self._plus.clicked.connect(lambda: self._on_keypress("+")) - self._entry = QtGui.QLineEdit() - - backAction = QtGui.QAction(None) - backAction.setText("Back") - backAction.triggered.connect(self._on_backspace) - backPieItem = qtpie.QActionPieItem(backAction) - clearAction = QtGui.QAction(None) - clearAction.setText("Clear") - clearAction.triggered.connect(self._on_clear_text) - clearPieItem = qtpie.QActionPieItem(clearAction) - backSlices = [ - qtpie.PieFiling.NULL_CENTER, - clearPieItem, - qtpie.PieFiling.NULL_CENTER, - qtpie.PieFiling.NULL_CENTER, - ] - self._back = qtpie.QPieButton(backPieItem) - self._back.set_center(backPieItem) - for slice in backSlices: - self._back.insertItem(slice) - - self._entryLayout = QtGui.QHBoxLayout() - self._entryLayout.addWidget(self._plus, 1, QtCore.Qt.AlignCenter) - self._entryLayout.addWidget(self._entry, 1000) - self._entryLayout.addWidget(self._back, 1, QtCore.Qt.AlignCenter) - - smsIcon = self._app.get_icon("messages.png") - self._smsButton = QtGui.QPushButton(smsIcon, "SMS") - self._smsButton.clicked.connect(self._on_sms_clicked) - self._smsButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.PushButton, - )) - callIcon = self._app.get_icon("dialpad.png") - self._callButton = QtGui.QPushButton(callIcon, "Call") - self._callButton.clicked.connect(self._on_call_clicked) - self._callButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.PushButton, - )) - - self._padLayout = QtGui.QGridLayout() - rows = [0, 0, 0, 1, 1, 1, 2, 2, 2] - columns = [0, 1, 2] * 3 - keys = [ - ("1", ""), - ("2", "ABC"), - ("3", "DEF"), - ("4", "GHI"), - ("5", "JKL"), - ("6", "MNO"), - ("7", "PQRS"), - ("8", "TUV"), - ("9", "WXYZ"), - ] - for (num, letters), (row, column) in zip(keys, zip(rows, columns)): - self._padLayout.addWidget(self._generate_key_button(num, letters), row, column) - self._zerothButton = QtGui.QPushButton("0") - self._zerothButton.clicked.connect(lambda: self._on_keypress("0")) - self._zerothButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.PushButton, - )) - self._padLayout.addWidget(self._smsButton, 3, 0) - self._padLayout.addWidget(self._zerothButton) - self._padLayout.addWidget(self._callButton, 3, 2) - - self._layout = QtGui.QVBoxLayout() - self._layout.addLayout(self._entryLayout, 0) - self._layout.addLayout(self._padLayout, 1000000) - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - - @property - def toplevel(self): - return self._widget - - def enable(self): - self._smsButton.setEnabled(True) - self._callButton.setEnabled(True) - - def disable(self): - self._smsButton.setEnabled(False) - self._callButton.setEnabled(False) - - def get_settings(self): - return {} - - def set_settings(self, settings): - pass - - def clear(self): - pass - - def refresh(self, force = True): - pass - - def _generate_key_button(self, center, letters): - button = QtGui.QPushButton("%s\n%s" % (center, letters)) - button.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.PushButton, - )) - button.clicked.connect(lambda: self._on_keypress(center)) - return button - - @misc_utils.log_exception(_moduleLogger) - def _on_keypress(self, key): - with qui_utils.notify_error(self._errorLog): - self._entry.insert(key) - - @misc_utils.log_exception(_moduleLogger) - def _on_backspace(self, toggled = False): - with qui_utils.notify_error(self._errorLog): - self._entry.backspace() - - @misc_utils.log_exception(_moduleLogger) - def _on_clear_text(self, toggled = False): - with qui_utils.notify_error(self._errorLog): - self._entry.clear() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_sms_clicked(self, checked = False): - with qui_utils.notify_error(self._errorLog): - number = misc_utils.make_ugly(str(self._entry.text())) - self._entry.clear() - - contactId = number - title = misc_utils.make_pretty(number) - description = misc_utils.make_pretty(number) - numbersWithDescriptions = [(number, "")] - self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc_utils.log_exception(_moduleLogger) - def _on_call_clicked(self, checked = False): - with qui_utils.notify_error(self._errorLog): - number = misc_utils.make_ugly(str(self._entry.text())) - self._entry.clear() - - contactId = number - title = misc_utils.make_pretty(number) - description = misc_utils.make_pretty(number) - numbersWithDescriptions = [(number, "")] - self._session.draft.clear() - self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) - self._session.draft.call() - - -class TimeCategories(object): - - _NOW_SECTION = 0 - _TODAY_SECTION = 1 - _WEEK_SECTION = 2 - _MONTH_SECTION = 3 - _REST_SECTION = 4 - _MAX_SECTIONS = 5 - - _NO_ELAPSED = datetime.timedelta(hours=1) - _WEEK_ELAPSED = datetime.timedelta(weeks=1) - _MONTH_ELAPSED = datetime.timedelta(days=30) - - def __init__(self, parentItem): - self._timeItems = [ - QtGui.QStandardItem(description) - for (i, description) in zip( - xrange(self._MAX_SECTIONS), - ["Now", "Today", "Week", "Month", "Past"], - ) - ] - for item in self._timeItems: - item.setEditable(False) - item.setCheckable(False) - row = (item, ) - parentItem.appendRow(row) - - self._today = datetime.datetime(1900, 1, 1) - - self.prepare_for_update(self._today) - - def prepare_for_update(self, newToday): - self._today = newToday - for item in self._timeItems: - item.removeRows(0, item.rowCount()) - try: - hour = self._today.strftime("%X") - day = self._today.strftime("%x") - except ValueError: - _moduleLogger.exception("Can't format times") - hour = "Now" - day = "Today" - self._timeItems[self._NOW_SECTION].setText(hour) - self._timeItems[self._TODAY_SECTION].setText(day) - - def add_row(self, rowDate, row): - elapsedTime = self._today - rowDate - todayTuple = self._today.timetuple() - rowTuple = rowDate.timetuple() - if elapsedTime < self._NO_ELAPSED: - section = self._NOW_SECTION - elif todayTuple[0:3] == rowTuple[0:3]: - section = self._TODAY_SECTION - elif elapsedTime < self._WEEK_ELAPSED: - section = self._WEEK_SECTION - elif elapsedTime < self._MONTH_ELAPSED: - section = self._MONTH_SECTION - else: - section = self._REST_SECTION - self._timeItems[section].appendRow(row) - - def get_item(self, timeIndex, rowIndex, column): - timeItem = self._timeItems[timeIndex] - item = timeItem.child(rowIndex, column) - return item - - -class History(object): - - DETAILS_IDX = 0 - FROM_IDX = 1 - MAX_IDX = 2 - - HISTORY_RECEIVED = "Received" - HISTORY_MISSED = "Missed" - HISTORY_PLACED = "Placed" - HISTORY_ALL = "All" - - HISTORY_ITEM_TYPES = [HISTORY_RECEIVED, HISTORY_MISSED, HISTORY_PLACED, HISTORY_ALL] - HISTORY_COLUMNS = ["", "From"] - assert len(HISTORY_COLUMNS) == MAX_IDX - - def __init__(self, app, session, errorLog): - self._selectedFilter = self.HISTORY_ITEM_TYPES[-1] - self._app = app - self._session = session - self._session.historyUpdated.connect(self._on_history_updated) - self._errorLog = errorLog - - self._typeSelection = QtGui.QComboBox() - self._typeSelection.addItems(self.HISTORY_ITEM_TYPES) - self._typeSelection.setCurrentIndex( - self.HISTORY_ITEM_TYPES.index(self._selectedFilter) - ) - self._typeSelection.currentIndexChanged[str].connect(self._on_filter_changed) - refreshIcon = qui_utils.get_theme_icon( - ("view-refresh", "general_refresh", "gtk-refresh", ), - _SENTINEL_ICON - ) - if refreshIcon is not _SENTINEL_ICON: - self._refreshButton = QtGui.QPushButton(refreshIcon, "") - else: - self._refreshButton = QtGui.QPushButton("Refresh") - self._refreshButton.clicked.connect(self._on_refresh_clicked) - self._refreshButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.PushButton, - )) - self._managerLayout = QtGui.QHBoxLayout() - self._managerLayout.addWidget(self._typeSelection, 1000) - self._managerLayout.addWidget(self._refreshButton, 0) - - self._itemStore = QtGui.QStandardItemModel() - self._itemStore.setHorizontalHeaderLabels(self.HISTORY_COLUMNS) - self._categoryManager = TimeCategories(self._itemStore) - - self._itemView = QtGui.QTreeView() - self._itemView.setModel(self._itemStore) - self._itemView.setUniformRowHeights(True) - self._itemView.setRootIsDecorated(False) - self._itemView.setIndentation(0) - self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self._itemView.setHeaderHidden(True) - self._itemView.setItemsExpandable(False) - self._itemView.header().setResizeMode(QtGui.QHeaderView.ResizeToContents) - self._itemView.activated.connect(self._on_row_activated) - - self._layout = QtGui.QVBoxLayout() - self._layout.addLayout(self._managerLayout) - self._layout.addWidget(self._itemView) - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - - self._actionIcon = { - "Placed": self._app.get_icon("placed.png"), - "Missed": self._app.get_icon("missed.png"), - "Received": self._app.get_icon("received.png"), - } - - self._populate_items() - - @property - def toplevel(self): - return self._widget - - def enable(self): - self._itemView.setEnabled(True) - - def disable(self): - self._itemView.setEnabled(False) - - def get_settings(self): - return { - "filter": self._selectedFilter, - } - - def set_settings(self, settings): - selectedFilter = settings.get("filter", self.HISTORY_ITEM_TYPES[-1]) - if selectedFilter in self.HISTORY_ITEM_TYPES: - self._selectedFilter = selectedFilter - self._typeSelection.setCurrentIndex( - self.HISTORY_ITEM_TYPES.index(selectedFilter) - ) - - def clear(self): - self._itemView.clear() - - def refresh(self, force=True): - self._itemView.setFocus(QtCore.Qt.OtherFocusReason) - - if self._selectedFilter == self.HISTORY_RECEIVED: - self._session.update_history(self._session.HISTORY_RECEIVED, force) - elif self._selectedFilter == self.HISTORY_MISSED: - self._session.update_history(self._session.HISTORY_MISSED, force) - elif self._selectedFilter == self.HISTORY_PLACED: - self._session.update_history(self._session.HISTORY_PLACED, force) - elif self._selectedFilter == self.HISTORY_ALL: - self._session.update_history(self._session.HISTORY_ALL, force) - else: - assert False, "How did we get here?" - - if self._app.notifyOnMissed and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE: - self._app.ledHandler.off() - - def _populate_items(self): - self._categoryManager.prepare_for_update(self._session.get_when_history_updated()) - - history = self._session.get_history() - history.sort(key=lambda item: item["time"], reverse=True) - for event in history: - if self._selectedFilter not in [self.HISTORY_ITEM_TYPES[-1], event["action"]]: - continue - - relTime = event["relTime"] - action = event["action"] - number = event["number"] - prettyNumber = misc_utils.make_pretty(number) - if prettyNumber.startswith("+1 "): - prettyNumber = prettyNumber[len("+1 "):] - name = event["name"] - if not name or name == number: - name = event["location"] - if not name: - name = "Unknown" - - detailsItem = QtGui.QStandardItem(self._actionIcon[action], "%s\n%s" % (prettyNumber, relTime)) - detailsFont = detailsItem.font() - detailsFont.setPointSize(max(detailsFont.pointSize() - 6, 5)) - detailsItem.setFont(detailsFont) - nameItem = QtGui.QStandardItem(name) - nameFont = nameItem.font() - nameFont.setPointSize(nameFont.pointSize() + 4) - nameItem.setFont(nameFont) - row = detailsItem, nameItem - for item in row: - item.setEditable(False) - item.setCheckable(False) - row[self.DETAILS_IDX].setData(event) - self._categoryManager.add_row(event["time"], row) - self._itemView.expandAll() - - @qt_compat.Slot(str) - @misc_utils.log_exception(_moduleLogger) - def _on_filter_changed(self, newItem): - with qui_utils.notify_error(self._errorLog): - self._selectedFilter = str(newItem) - self._populate_items() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_history_updated(self): - with qui_utils.notify_error(self._errorLog): - self._populate_items() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_refresh_clicked(self, arg = None): - with qui_utils.notify_error(self._errorLog): - self.refresh(force=True) - - @qt_compat.Slot(QtCore.QModelIndex) - @misc_utils.log_exception(_moduleLogger) - def _on_row_activated(self, index): - with qui_utils.notify_error(self._errorLog): - timeIndex = index.parent() - if not timeIndex.isValid(): - return - timeRow = timeIndex.row() - row = index.row() - detailsItem = self._categoryManager.get_item(timeRow, row, self.DETAILS_IDX) - fromItem = self._categoryManager.get_item(timeRow, row, self.FROM_IDX) - contactDetails = detailsItem.data() - - title = unicode(fromItem.text()) - number = str(contactDetails["number"]) - contactId = number # ids don't seem too unique so using numbers - - descriptionRows = [] - for t in xrange(self._itemStore.rowCount()): - randomTimeItem = self._itemStore.item(t, 0) - for i in xrange(randomTimeItem.rowCount()): - iItem = randomTimeItem.child(i, 0) - iContactDetails = iItem.data() - iNumber = str(iContactDetails["number"]) - if number != iNumber: - continue - relTime = misc_utils.abbrev_relative_date(iContactDetails["relTime"]) - action = str(iContactDetails["action"]) - number = str(iContactDetails["number"]) - prettyNumber = misc_utils.make_pretty(number) - rowItems = relTime, action, prettyNumber - descriptionRows.append("%s" % "".join(rowItems)) - description = "%s
" % "".join(descriptionRows) - numbersWithDescriptions = [(str(contactDetails["number"]), "")] - self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) - - -class Messages(object): - - NO_MESSAGES = "None" - VOICEMAIL_MESSAGES = "Voicemail" - TEXT_MESSAGES = "SMS" - ALL_TYPES = "All Messages" - MESSAGE_TYPES = [NO_MESSAGES, VOICEMAIL_MESSAGES, TEXT_MESSAGES, ALL_TYPES] - - UNREAD_STATUS = "Unread" - UNARCHIVED_STATUS = "Inbox" - ALL_STATUS = "Any" - MESSAGE_STATUSES = [UNREAD_STATUS, UNARCHIVED_STATUS, ALL_STATUS] - - _MIN_MESSAGES_SHOWN = 1 - - def __init__(self, app, session, errorLog): - self._selectedTypeFilter = self.ALL_TYPES - self._selectedStatusFilter = self.ALL_STATUS - self._app = app - self._session = session - self._session.messagesUpdated.connect(self._on_messages_updated) - self._errorLog = errorLog - - self._typeSelection = QtGui.QComboBox() - self._typeSelection.addItems(self.MESSAGE_TYPES) - self._typeSelection.setCurrentIndex( - self.MESSAGE_TYPES.index(self._selectedTypeFilter) - ) - self._typeSelection.currentIndexChanged[str].connect(self._on_type_filter_changed) - - self._statusSelection = QtGui.QComboBox() - self._statusSelection.addItems(self.MESSAGE_STATUSES) - self._statusSelection.setCurrentIndex( - self.MESSAGE_STATUSES.index(self._selectedStatusFilter) - ) - self._statusSelection.currentIndexChanged[str].connect(self._on_status_filter_changed) - - refreshIcon = qui_utils.get_theme_icon( - ("view-refresh", "general_refresh", "gtk-refresh", ), - _SENTINEL_ICON - ) - if refreshIcon is not _SENTINEL_ICON: - self._refreshButton = QtGui.QPushButton(refreshIcon, "") - else: - self._refreshButton = QtGui.QPushButton("Refresh") - self._refreshButton.clicked.connect(self._on_refresh_clicked) - self._refreshButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.PushButton, - )) - - self._selectionLayout = QtGui.QHBoxLayout() - self._selectionLayout.addWidget(self._typeSelection, 1000) - self._selectionLayout.addWidget(self._statusSelection, 1000) - self._selectionLayout.addWidget(self._refreshButton, 0) - - self._itemStore = QtGui.QStandardItemModel() - self._itemStore.setHorizontalHeaderLabels(["Messages"]) - self._categoryManager = TimeCategories(self._itemStore) - - self._htmlDelegate = qui_utils.QHtmlDelegate() - self._itemView = QtGui.QTreeView() - self._itemView.setModel(self._itemStore) - self._itemView.setUniformRowHeights(False) - self._itemView.setRootIsDecorated(False) - self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self._itemView.setHeaderHidden(True) - self._itemView.setItemsExpandable(False) - self._itemView.setItemDelegate(self._htmlDelegate) - self._itemView.activated.connect(self._on_row_activated) - self._itemView.header().sectionResized.connect(self._on_column_resized) - - self._layout = QtGui.QVBoxLayout() - self._layout.addLayout(self._selectionLayout) - self._layout.addWidget(self._itemView) - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - - self._populate_items() - - @property - def toplevel(self): - return self._widget - - def enable(self): - self._itemView.setEnabled(True) - - def disable(self): - self._itemView.setEnabled(False) - - def get_settings(self): - return { - "type": self._selectedTypeFilter, - "status": self._selectedStatusFilter, - } - - def set_settings(self, settings): - selectedType = settings.get("type", self.ALL_TYPES) - if selectedType in self.MESSAGE_TYPES: - self._selectedTypeFilter = selectedType - self._typeSelection.setCurrentIndex( - self.MESSAGE_TYPES.index(self._selectedTypeFilter) - ) - - selectedStatus = settings.get("status", self.ALL_STATUS) - if selectedStatus in self.MESSAGE_STATUSES: - self._selectedStatusFilter = selectedStatus - self._statusSelection.setCurrentIndex( - self.MESSAGE_STATUSES.index(self._selectedStatusFilter) - ) - - def clear(self): - self._itemView.clear() - - def refresh(self, force=True): - self._itemView.setFocus(QtCore.Qt.OtherFocusReason) - - if self._selectedTypeFilter == self.NO_MESSAGES: - pass - elif self._selectedTypeFilter == self.TEXT_MESSAGES: - self._session.update_messages(self._session.MESSAGE_TEXTS, force) - elif self._selectedTypeFilter == self.VOICEMAIL_MESSAGES: - self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force) - elif self._selectedTypeFilter == self.ALL_TYPES: - self._session.update_messages(self._session.MESSAGE_ALL, force) - else: - assert False, "How did we get here?" - - if (self._app.notifyOnSms or self._app.notifyOnVoicemail) and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE: - self._app.ledHandler.off() - - def _populate_items(self): - self._categoryManager.prepare_for_update(self._session.get_when_messages_updated()) - - rawMessages = self._session.get_messages() - rawMessages.sort(key=lambda item: item["time"], reverse=True) - for item in rawMessages: - isUnarchived = not item["isArchived"] - isUnread = not item["isRead"] - visibleStatus = { - self.UNREAD_STATUS: isUnarchived and isUnread, - self.UNARCHIVED_STATUS: isUnarchived, - self.ALL_STATUS: True, - }[self._selectedStatusFilter] - visibleType = self._selectedTypeFilter in [item["type"], self.ALL_TYPES] - if not (visibleType and visibleStatus): - continue - - relTime = misc_utils.abbrev_relative_date(item["relTime"]) - number = item["number"] - prettyNumber = misc_utils.make_pretty(number) - name = item["name"] - if not name or name == number: - name = item["location"] - if not name: - name = "Unknown" - - messageParts = list(item["messageParts"]) - if len(messageParts) == 0: - messages = ("No Transcription", ) - elif len(messageParts) == 1: - if messageParts[0][1]: - messages = (messageParts[0][1], ) - else: - messages = ("No Transcription", ) - else: - messages = [ - "%s: %s" % (messagePart[0], messagePart[1]) - for messagePart in messageParts - ] - - firstMessage = "%s
%s
(%s)" % (name, prettyNumber, relTime) - - expandedMessages = [firstMessage] - expandedMessages.extend(messages) - if self._MIN_MESSAGES_SHOWN < len(messages): - secondMessage = "%d Messages Hidden..." % (len(messages) - self._MIN_MESSAGES_SHOWN, ) - collapsedMessages = [firstMessage, secondMessage] - collapsedMessages.extend(messages[-(self._MIN_MESSAGES_SHOWN+0):]) - else: - collapsedMessages = expandedMessages - - item = dict(item.iteritems()) - item["collapsedMessages"] = "
\n".join(collapsedMessages) - item["expandedMessages"] = "
\n".join(expandedMessages) - - messageItem = QtGui.QStandardItem(item["collapsedMessages"]) - messageItem.setData(item) - messageItem.setEditable(False) - messageItem.setCheckable(False) - row = (messageItem, ) - self._categoryManager.add_row(item["time"], row) - self._itemView.expandAll() - - @qt_compat.Slot(str) - @misc_utils.log_exception(_moduleLogger) - def _on_type_filter_changed(self, newItem): - with qui_utils.notify_error(self._errorLog): - self._selectedTypeFilter = str(newItem) - self._populate_items() - - @qt_compat.Slot(str) - @misc_utils.log_exception(_moduleLogger) - def _on_status_filter_changed(self, newItem): - with qui_utils.notify_error(self._errorLog): - self._selectedStatusFilter = str(newItem) - self._populate_items() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_refresh_clicked(self, arg = None): - with qui_utils.notify_error(self._errorLog): - self.refresh(force=True) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_messages_updated(self): - with qui_utils.notify_error(self._errorLog): - self._populate_items() - - @qt_compat.Slot(QtCore.QModelIndex) - @misc_utils.log_exception(_moduleLogger) - def _on_row_activated(self, index): - with qui_utils.notify_error(self._errorLog): - timeIndex = index.parent() - if not timeIndex.isValid(): - return - timeRow = timeIndex.row() - row = index.row() - item = self._categoryManager.get_item(timeRow, row, 0) - contactDetails = item.data() - - name = unicode(contactDetails["name"]) - number = str(contactDetails["number"]) - if not name or name == number: - name = unicode(contactDetails["location"]) - if not name: - name = "Unknown" - - if str(contactDetails["type"]) == "Voicemail": - messageId = str(contactDetails["id"]) - else: - messageId = None - contactId = str(contactDetails["contactId"]) - title = name - description = unicode(contactDetails["expandedMessages"]) - numbersWithDescriptions = [(number, "")] - self._session.draft.add_contact(contactId, messageId, title, description, numbersWithDescriptions) - - @qt_compat.Slot(QtCore.QModelIndex) - @misc_utils.log_exception(_moduleLogger) - def _on_column_resized(self, index, oldSize, newSize): - self._htmlDelegate.setWidth(newSize, self._itemStore) - - -class Contacts(object): - - # @todo Provide some sort of letter jump - - def __init__(self, app, session, errorLog): - self._app = app - self._session = session - self._session.accountUpdated.connect(self._on_contacts_updated) - self._errorLog = errorLog - self._addressBookFactories = [ - null_backend.NullAddressBookFactory(), - file_backend.FilesystemAddressBookFactory(app.fsContactsPath), - qt_backend.QtContactsAddressBookFactory(), - ] - self._addressBooks = [] - - self._listSelection = QtGui.QComboBox() - self._listSelection.addItems([]) - self._listSelection.currentIndexChanged[str].connect(self._on_filter_changed) - self._activeList = "None" - refreshIcon = qui_utils.get_theme_icon( - ("view-refresh", "general_refresh", "gtk-refresh", ), - _SENTINEL_ICON - ) - if refreshIcon is not _SENTINEL_ICON: - self._refreshButton = QtGui.QPushButton(refreshIcon, "") - else: - self._refreshButton = QtGui.QPushButton("Refresh") - self._refreshButton.clicked.connect(self._on_refresh_clicked) - self._refreshButton.setSizePolicy(QtGui.QSizePolicy( - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.Minimum, - QtGui.QSizePolicy.PushButton, - )) - self._managerLayout = QtGui.QHBoxLayout() - self._managerLayout.addWidget(self._listSelection, 1000) - self._managerLayout.addWidget(self._refreshButton, 0) - - self._itemStore = QtGui.QStandardItemModel() - self._itemStore.setHorizontalHeaderLabels(["Contacts"]) - self._alphaItem = {} - - self._itemView = QtGui.QTreeView() - self._itemView.setModel(self._itemStore) - self._itemView.setUniformRowHeights(True) - self._itemView.setRootIsDecorated(False) - self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self._itemView.setHeaderHidden(True) - self._itemView.setItemsExpandable(False) - self._itemView.activated.connect(self._on_row_activated) - - self._layout = QtGui.QVBoxLayout() - self._layout.addLayout(self._managerLayout) - self._layout.addWidget(self._itemView) - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - - self.update_addressbooks() - self._populate_items() - - @property - def toplevel(self): - return self._widget - - def enable(self): - self._itemView.setEnabled(True) - - def disable(self): - self._itemView.setEnabled(False) - - def get_settings(self): - return { - "selectedAddressbook": self._activeList, - } - - def set_settings(self, settings): - currentItem = settings.get("selectedAddressbook", "None") - bookNames = [book["name"] for book in self._addressBooks] - try: - newIndex = bookNames.index(currentItem) - except ValueError: - # Switch over to None for the user - newIndex = 0 - self._listSelection.setCurrentIndex(newIndex) - self._activeList = currentItem - - def clear(self): - self._itemView.clear() - - def refresh(self, force=True): - self._itemView.setFocus(QtCore.Qt.OtherFocusReason) - self._backend.update_account(force) - - @property - def _backend(self): - return self._addressBooks[self._listSelection.currentIndex()]["book"] - - def update_addressbooks(self): - self._addressBooks = [ - {"book": book, "name": book.name} - for factory in self._addressBookFactories - for book in factory.get_addressbooks() - ] - self._addressBooks.append( - { - "book": self._session, - "name": "Google Voice", - } - ) - - currentItem = str(self._listSelection.currentText()) - self._activeList = currentItem - if currentItem == "": - # Not loaded yet - currentItem = "None" - self._listSelection.clear() - bookNames = [book["name"] for book in self._addressBooks] - try: - newIndex = bookNames.index(currentItem) - except ValueError: - # Switch over to None for the user - newIndex = 0 - self._itemStore.clear() - _moduleLogger.info("Addressbook %r doesn't exist anymore, switching to None" % currentItem) - self._listSelection.addItems(bookNames) - self._listSelection.setCurrentIndex(newIndex) - - def _populate_items(self): - self._itemStore.clear() - self._alphaItem = dict( - (letter, QtGui.QStandardItem(letter)) - for letter in self._prefixes() - ) - for letter in self._prefixes(): - item = self._alphaItem[letter] - item.setEditable(False) - item.setCheckable(False) - row = (item, ) - self._itemStore.appendRow(row) - - for item in self._get_contacts(): - name = item["name"] - if not name: - name = "Unknown" - numbers = item["numbers"] - - nameItem = QtGui.QStandardItem(name) - nameItem.setEditable(False) - nameItem.setCheckable(False) - nameItem.setData(item) - nameItemFont = nameItem.font() - nameItemFont.setPointSize(max(nameItemFont.pointSize() + 4, 5)) - nameItem.setFont(nameItemFont) - - row = (nameItem, ) - rowKey = name[0].upper() - rowKey = rowKey if rowKey in self._alphaItem else "#" - self._alphaItem[rowKey].appendRow(row) - self._itemView.expandAll() - - def _prefixes(self): - return itertools.chain(string.ascii_uppercase, ("#", )) - - def _jump_to_prefix(self, letter): - i = list(self._prefixes()).index(letter) - rootIndex = self._itemView.rootIndex() - currentIndex = self._itemView.model().index(i, 0, rootIndex) - self._itemView.scrollTo(currentIndex) - self._itemView.setItemSelected(self._itemView.topLevelItem(i), True) - - def _get_contacts(self): - contacts = list(self._backend.get_contacts().itervalues()) - contacts.sort(key=lambda contact: contact["name"].lower()) - return contacts - - @qt_compat.Slot(str) - @misc_utils.log_exception(_moduleLogger) - def _on_filter_changed(self, newItem): - with qui_utils.notify_error(self._errorLog): - self._activeList = str(newItem) - self.refresh(force=False) - self._populate_items() - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_refresh_clicked(self, arg = None): - with qui_utils.notify_error(self._errorLog): - self.refresh(force=True) - - @qt_compat.Slot() - @misc_utils.log_exception(_moduleLogger) - def _on_contacts_updated(self): - with qui_utils.notify_error(self._errorLog): - self._populate_items() - - @qt_compat.Slot(QtCore.QModelIndex) - @misc_utils.log_exception(_moduleLogger) - def _on_row_activated(self, index): - with qui_utils.notify_error(self._errorLog): - letterIndex = index.parent() - if not letterIndex.isValid(): - return - letterRow = letterIndex.row() - letter = list(self._prefixes())[letterRow] - letterItem = self._alphaItem[letter] - rowIndex = index.row() - item = letterItem.child(rowIndex, 0) - contactDetails = item.data() - - name = unicode(contactDetails["name"]) - if not name: - name = unicode(contactDetails["location"]) - if not name: - name = "Unknown" - - contactId = str(contactDetails["contactId"]) - numbers = contactDetails["numbers"] - numbers = [ - dict( - (str(k), str(v)) - for (k, v) in number.iteritems() - ) - for number in numbers - ] - numbersWithDescriptions = [ - ( - number["phoneNumber"], - self._choose_phonetype(number), - ) - for number in numbers - ] - title = name - description = name - self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions) - - @staticmethod - def _choose_phonetype(numberDetails): - if "phoneTypeName" in numberDetails: - return numberDetails["phoneTypeName"] - elif "phoneType" in numberDetails: - return numberDetails["phoneType"] - else: - return "" diff --git a/src/led_handler.py b/src/led_handler.py deleted file mode 100755 index 0914105..0000000 --- a/src/led_handler.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python - -import dbus - - -class _NokiaLedHandler(object): - - def __init__(self): - self._bus = dbus.SystemBus() - self._rawMceRequest = self._bus.get_object("com.nokia.mce", "/com/nokia/mce/request") - self._mceRequest = dbus.Interface(self._rawMceRequest, dbus_interface="com.nokia.mce.request") - - self._ledPattern = "PatternCommunicationChat" - - def on(self): - self._mceRequest.req_led_pattern_activate(self._ledPattern) - - def off(self): - self._mceRequest.req_led_pattern_deactivate(self._ledPattern) - - -class _NoLedHandler(object): - - def __init__(self): - pass - - def on(self): - pass - - def off(self): - pass - - -class LedHandler(object): - - def __init__(self): - self._actual = None - self._isReal = False - - def on(self): - self._lazy_init() - self._actual.on() - - def off(self): - self._lazy_init() - self._actual.off() - - @property - def isReal(self): - self._lazy_init() - self._isReal - - def _lazy_init(self): - if self._actual is not None: - return - try: - self._actual = _NokiaLedHandler() - self._isReal = True - except dbus.DBusException: - self._actual = _NoLedHandler() - self._isReal = False - - -if __name__ == "__main__": - leds = _NokiaLedHandler() - leds.off() diff --git a/src/session.py b/src/session.py deleted file mode 100644 index dbdc3e4..0000000 --- a/src/session.py +++ /dev/null @@ -1,830 +0,0 @@ -from __future__ import with_statement - -import os -import time -import datetime -import contextlib -import logging - -try: - import cPickle - pickle = cPickle -except ImportError: - import pickle - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore - -from util import qore_utils -from util import qui_utils -from util import concurrent -from util import misc as misc_utils - -import constants - - -_moduleLogger = logging.getLogger(__name__) - - -class _DraftContact(object): - - def __init__(self, messageId, title, description, numbersWithDescriptions): - self.messageId = messageId - self.title = title - self.description = description - self.numbers = numbersWithDescriptions - self.selectedNumber = numbersWithDescriptions[0][0] - - -class Draft(QtCore.QObject): - - sendingMessage = qt_compat.Signal() - sentMessage = qt_compat.Signal() - calling = qt_compat.Signal() - called = qt_compat.Signal() - cancelling = qt_compat.Signal() - cancelled = qt_compat.Signal() - error = qt_compat.Signal(str) - - recipientsChanged = qt_compat.Signal() - - def __init__(self, asyncQueue, backend, errorLog): - QtCore.QObject.__init__(self) - self._errorLog = errorLog - self._contacts = {} - self._asyncQueue = asyncQueue - self._backend = backend - self._busyReason = None - self._message = "" - - def send(self): - assert 0 < len(self._contacts), "No contacts selected" - assert 0 < len(self._message), "No message to send" - numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()] - le = self._asyncQueue.add_async(self._send) - le.start(numbers, self._message) - - def call(self): - assert len(self._contacts) == 1, "Must select 1 and only 1 contact" - assert len(self._message) == 0, "Cannot send message with call" - (contact, ) = self._contacts.itervalues() - number = misc_utils.make_ugly(contact.selectedNumber) - le = self._asyncQueue.add_async(self._call) - le.start(number) - - def cancel(self): - le = self._asyncQueue.add_async(self._cancel) - le.start() - - def _get_message(self): - return self._message - - def _set_message(self, message): - self._message = message - - message = property(_get_message, _set_message) - - def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions): - if self._busyReason is not None: - raise RuntimeError("Please wait for %r" % self._busyReason) - # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up - contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions) - self._contacts[contactId] = contactDetails - self.recipientsChanged.emit() - - def remove_contact(self, contactId): - if self._busyReason is not None: - raise RuntimeError("Please wait for %r" % self._busyReason) - assert contactId in self._contacts, "Contact missing" - del self._contacts[contactId] - self.recipientsChanged.emit() - - def get_contacts(self): - return self._contacts.iterkeys() - - def get_num_contacts(self): - return len(self._contacts) - - def get_message_id(self, cid): - return self._contacts[cid].messageId - - def get_title(self, cid): - return self._contacts[cid].title - - def get_description(self, cid): - return self._contacts[cid].description - - def get_numbers(self, cid): - return self._contacts[cid].numbers - - def get_selected_number(self, cid): - return self._contacts[cid].selectedNumber - - def set_selected_number(self, cid, number): - # @note I'm lazy, this isn't firing any kind of signal since only one - # controller right now and that is the viewer - assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable" - self._contacts[cid].selectedNumber = number - - def clear(self): - if self._busyReason is not None: - raise RuntimeError("Please wait for %r" % self._busyReason) - self._clear() - - def _clear(self): - oldContacts = self._contacts - self._contacts = {} - self._message = "" - if oldContacts: - self.recipientsChanged.emit() - - @contextlib.contextmanager - def _busy(self, message): - if self._busyReason is not None: - raise RuntimeError("Already busy doing %r" % self._busyReason) - try: - self._busyReason = message - yield - finally: - self._busyReason = None - - def _send(self, numbers, text): - self.sendingMessage.emit() - try: - with self._busy("Sending Text"): - with qui_utils.notify_busy(self._errorLog, "Sending Text"): - yield ( - self._backend[0].send_sms, - (numbers, text), - {}, - ) - self.sentMessage.emit() - self._clear() - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - - def _call(self, number): - self.calling.emit() - try: - with self._busy("Calling"): - with qui_utils.notify_busy(self._errorLog, "Calling"): - yield ( - self._backend[0].call, - (number, ), - {}, - ) - self.called.emit() - self._clear() - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - - def _cancel(self): - self.cancelling.emit() - try: - with qui_utils.notify_busy(self._errorLog, "Cancelling"): - yield ( - self._backend[0].cancel, - (), - {}, - ) - self.cancelled.emit() - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - - -class Session(QtCore.QObject): - - # @todo Somehow add support for csv contacts - # @BUG When loading without caches, downloads messages twice - - stateChange = qt_compat.Signal(str) - loggedOut = qt_compat.Signal() - loggedIn = qt_compat.Signal() - callbackNumberChanged = qt_compat.Signal(str) - - accountUpdated = qt_compat.Signal() - messagesUpdated = qt_compat.Signal() - newMessages = qt_compat.Signal() - historyUpdated = qt_compat.Signal() - dndStateChange = qt_compat.Signal(bool) - voicemailAvailable = qt_compat.Signal(str, str) - - error = qt_compat.Signal(str) - - LOGGEDOUT_STATE = "logged out" - LOGGINGIN_STATE = "logging in" - LOGGEDIN_STATE = "logged in" - - MESSAGE_TEXTS = "Text" - MESSAGE_VOICEMAILS = "Voicemail" - MESSAGE_ALL = "All" - - HISTORY_RECEIVED = "Received" - HISTORY_MISSED = "Missed" - HISTORY_PLACED = "Placed" - HISTORY_ALL = "All" - - _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0") - - _LOGGEDOUT_TIME = -1 - _LOGGINGIN_TIME = 0 - - def __init__(self, errorLog, cachePath): - QtCore.QObject.__init__(self) - self._errorLog = errorLog - self._pool = qore_utils.FutureThread() - self._asyncQueue = concurrent.AsyncTaskQueue(self._pool) - self._backend = [] - self._loggedInTime = self._LOGGEDOUT_TIME - self._loginOps = [] - self._cachePath = cachePath - self._voicemailCachePath = None - self._username = None - self._password = None - self._draft = Draft(self._asyncQueue, self._backend, self._errorLog) - self._delayedRelogin = QtCore.QTimer() - self._delayedRelogin.setInterval(0) - self._delayedRelogin.setSingleShot(True) - self._delayedRelogin.timeout.connect(self._on_delayed_relogin) - - self._contacts = {} - self._accountUpdateTime = datetime.datetime(1971, 1, 1) - self._messages = [] - self._cleanMessages = [] - self._messageUpdateTime = datetime.datetime(1971, 1, 1) - self._history = [] - self._historyUpdateTime = datetime.datetime(1971, 1, 1) - self._dnd = False - self._callback = "" - - @property - def state(self): - return { - self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE, - self._LOGGINGIN_TIME: self.LOGGINGIN_STATE, - }.get(self._loggedInTime, self.LOGGEDIN_STATE) - - @property - def draft(self): - return self._draft - - def login(self, username, password): - assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state - assert username != "", "No username specified" - if self._cachePath is not None: - cookiePath = os.path.join(self._cachePath, "%s.cookies" % username) - else: - cookiePath = None - - if self._username != username or not self._backend: - from backends import gv_backend - del self._backend[:] - self._backend[0:0] = [gv_backend.GVDialer(cookiePath)] - - self._pool.start() - le = self._asyncQueue.add_async(self._login) - le.start(username, password) - - def logout(self): - assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state - _moduleLogger.info("Logging out") - self._pool.stop() - self._loggedInTime = self._LOGGEDOUT_TIME - self._backend[0].persist() - self._save_to_cache() - self._clear_voicemail_cache() - self.stateChange.emit(self.LOGGEDOUT_STATE) - self.loggedOut.emit() - - def clear(self): - assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state - self._backend[0].logout() - del self._backend[0] - self._clear_cache() - self._draft.clear() - - def logout_and_clear(self): - assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state - _moduleLogger.info("Logging out and clearing the account") - self._pool.stop() - self._loggedInTime = self._LOGGEDOUT_TIME - self.clear() - self.stateChange.emit(self.LOGGEDOUT_STATE) - self.loggedOut.emit() - - def update_account(self, force = True): - if not force and self._contacts: - return - le = self._asyncQueue.add_async(self._update_account), (), {} - self._perform_op_while_loggedin(le) - - def refresh_connection(self): - le = self._asyncQueue.add_async(self._refresh_authentication) - le.start() - - def get_contacts(self): - return self._contacts - - def get_when_contacts_updated(self): - return self._accountUpdateTime - - def update_messages(self, messageType, force = True): - if not force and self._messages: - return - le = self._asyncQueue.add_async(self._update_messages), (messageType, ), {} - self._perform_op_while_loggedin(le) - - def get_messages(self): - return self._messages - - def get_when_messages_updated(self): - return self._messageUpdateTime - - def update_history(self, historyType, force = True): - if not force and self._history: - return - le = self._asyncQueue.add_async(self._update_history), (historyType, ), {} - self._perform_op_while_loggedin(le) - - def get_history(self): - return self._history - - def get_when_history_updated(self): - return self._historyUpdateTime - - def update_dnd(self): - le = self._asyncQueue.add_async(self._update_dnd), (), {} - self._perform_op_while_loggedin(le) - - def set_dnd(self, dnd): - le = self._asyncQueue.add_async(self._set_dnd) - le.start(dnd) - - def is_available(self, messageId): - actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) - return os.path.exists(actualPath) - - def voicemail_path(self, messageId): - actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) - if not os.path.exists(actualPath): - raise RuntimeError("Voicemail not available") - return actualPath - - def download_voicemail(self, messageId): - le = self._asyncQueue.add_async(self._download_voicemail) - le.start(messageId) - - def _set_dnd(self, dnd): - oldDnd = self._dnd - try: - assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state - with qui_utils.notify_busy(self._errorLog, "Setting DND Status"): - yield ( - self._backend[0].set_dnd, - (dnd, ), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - self._dnd = dnd - if oldDnd != self._dnd: - self.dndStateChange.emit(self._dnd) - - def get_dnd(self): - return self._dnd - - def get_account_number(self): - if self.state != self.LOGGEDIN_STATE: - return "" - return self._backend[0].get_account_number() - - def get_callback_numbers(self): - if self.state != self.LOGGEDIN_STATE: - return {} - return self._backend[0].get_callback_numbers() - - def get_callback_number(self): - return self._callback - - def set_callback_number(self, callback): - le = self._asyncQueue.add_async(self._set_callback_number) - le.start(callback) - - def _set_callback_number(self, callback): - oldCallback = self._callback - try: - assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state - yield ( - self._backend[0].set_callback_number, - (callback, ), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - self._callback = callback - if oldCallback != self._callback: - self.callbackNumberChanged.emit(self._callback) - - def _login(self, username, password): - with qui_utils.notify_busy(self._errorLog, "Logging In"): - self._loggedInTime = self._LOGGINGIN_TIME - self.stateChange.emit(self.LOGGINGIN_STATE) - finalState = self.LOGGEDOUT_STATE - accountData = None - try: - if accountData is None and self._backend[0].is_quick_login_possible(): - accountData = yield ( - self._backend[0].refresh_account_info, - (), - {}, - ) - if accountData is not None: - _moduleLogger.info("Logged in through cookies") - else: - # Force a clearing of the cookies - yield ( - self._backend[0].logout, - (), - {}, - ) - - if accountData is None: - accountData = yield ( - self._backend[0].login, - (username, password), - {}, - ) - if accountData is not None: - _moduleLogger.info("Logged in through credentials") - - if accountData is not None: - self._loggedInTime = int(time.time()) - oldUsername = self._username - self._username = username - self._password = password - finalState = self.LOGGEDIN_STATE - if oldUsername != self._username: - needOps = not self._load() - else: - needOps = True - - self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username) - try: - os.makedirs(self._voicemailCachePath) - except OSError, e: - if e.errno != 17: - raise - - self.loggedIn.emit() - self.stateChange.emit(finalState) - finalState = None # Mark it as already set - self._process_account_data(accountData) - - if needOps: - loginOps = self._loginOps[:] - else: - loginOps = [] - del self._loginOps[:] - for asyncOp, args, kwds in loginOps: - asyncOp.start(*args, **kwds) - else: - self._loggedInTime = self._LOGGEDOUT_TIME - self.error.emit("Error logging in") - except Exception, e: - _moduleLogger.exception("Booh") - self._loggedInTime = self._LOGGEDOUT_TIME - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - finally: - if finalState is not None: - self.stateChange.emit(finalState) - if accountData is not None and self._callback: - self.set_callback_number(self._callback) - - def _update_account(self): - try: - with qui_utils.notify_busy(self._errorLog, "Updating Account"): - accountData = yield ( - self._backend[0].refresh_account_info, - (), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - self._loggedInTime = int(time.time()) - self._process_account_data(accountData) - - def _refresh_authentication(self): - try: - with qui_utils.notify_busy(self._errorLog, "Updating Account"): - accountData = yield ( - self._backend[0].refresh_account_info, - (), - {}, - ) - accountData = None - except Exception, e: - _moduleLogger.exception("Passing to user") - self.error.emit(str(e)) - # refresh_account_info does not normally throw, so it is fine if we - # just quit early because something seriously wrong is going on - return - - if accountData is not None: - self._loggedInTime = int(time.time()) - self._process_account_data(accountData) - else: - self._delayedRelogin.start() - - def _load(self): - updateMessages = len(self._messages) != 0 - updateHistory = len(self._history) != 0 - oldDnd = self._dnd - oldCallback = self._callback - - self._messages = [] - self._cleanMessages = [] - self._history = [] - self._dnd = False - self._callback = "" - - loadedFromCache = self._load_from_cache() - if loadedFromCache: - updateMessages = True - updateHistory = True - - if updateMessages: - self.messagesUpdated.emit() - if updateHistory: - self.historyUpdated.emit() - if oldDnd != self._dnd: - self.dndStateChange.emit(self._dnd) - if oldCallback != self._callback: - self.callbackNumberChanged.emit(self._callback) - - return loadedFromCache - - def _load_from_cache(self): - if self._cachePath is None: - return False - cachePath = os.path.join(self._cachePath, "%s.cache" % self._username) - - try: - with open(cachePath, "rb") as f: - dumpedData = pickle.load(f) - except (pickle.PickleError, IOError, EOFError, ValueError, ImportError): - _moduleLogger.exception("Pickle fun loading") - return False - except: - _moduleLogger.exception("Weirdness loading") - return False - - try: - version, build = dumpedData[0:2] - except ValueError: - _moduleLogger.exception("Upgrade/downgrade fun") - return False - except: - _moduleLogger.exception("Weirdlings") - return False - - if misc_utils.compare_versions( - self._OLDEST_COMPATIBLE_FORMAT_VERSION, - misc_utils.parse_version(version), - ) <= 0: - try: - ( - version, build, - messages, messageUpdateTime, - history, historyUpdateTime, - dnd, callback - ) = dumpedData - except ValueError: - _moduleLogger.exception("Upgrade/downgrade fun") - return False - except: - _moduleLogger.exception("Weirdlings") - return False - - _moduleLogger.info("Loaded cache") - self._messages = messages - self._alert_on_messages(self._messages) - self._messageUpdateTime = messageUpdateTime - self._history = history - self._historyUpdateTime = historyUpdateTime - self._dnd = dnd - self._callback = callback - return True - else: - _moduleLogger.debug( - "Skipping cache due to version mismatch (%s-%s)" % ( - version, build - ) - ) - return False - - def _save_to_cache(self): - _moduleLogger.info("Saving cache") - if self._cachePath is None: - return - cachePath = os.path.join(self._cachePath, "%s.cache" % self._username) - - try: - dataToDump = ( - constants.__version__, constants.__build__, - self._messages, self._messageUpdateTime, - self._history, self._historyUpdateTime, - self._dnd, self._callback - ) - with open(cachePath, "wb") as f: - pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL) - _moduleLogger.info("Cache saved") - except (pickle.PickleError, IOError): - _moduleLogger.exception("While saving") - - def _clear_cache(self): - updateMessages = len(self._messages) != 0 - updateHistory = len(self._history) != 0 - oldDnd = self._dnd - oldCallback = self._callback - - self._messages = [] - self._messageUpdateTime = datetime.datetime(1971, 1, 1) - self._history = [] - self._historyUpdateTime = datetime.datetime(1971, 1, 1) - self._dnd = False - self._callback = "" - - if updateMessages: - self.messagesUpdated.emit() - if updateHistory: - self.historyUpdated.emit() - if oldDnd != self._dnd: - self.dndStateChange.emit(self._dnd) - if oldCallback != self._callback: - self.callbackNumberChanged.emit(self._callback) - - self._save_to_cache() - self._clear_voicemail_cache() - - def _clear_voicemail_cache(self): - import shutil - shutil.rmtree(self._voicemailCachePath, True) - - def _update_messages(self, messageType): - try: - assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state - with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType): - self._messages = yield ( - self._backend[0].get_messages, - (messageType, ), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - self._messageUpdateTime = datetime.datetime.now() - self.messagesUpdated.emit() - self._alert_on_messages(self._messages) - - def _update_history(self, historyType): - try: - assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state - with qui_utils.notify_busy(self._errorLog, "Updating '%s' History" % historyType): - self._history = yield ( - self._backend[0].get_call_history, - (historyType, ), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - self._historyUpdateTime = datetime.datetime.now() - self.historyUpdated.emit() - - def _update_dnd(self): - with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"): - oldDnd = self._dnd - try: - assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state - self._dnd = yield ( - self._backend[0].is_dnd, - (), - {}, - ) - except Exception, e: - _moduleLogger.exception("Reporting error to user") - self.error.emit(str(e)) - return - if oldDnd != self._dnd: - self.dndStateChange(self._dnd) - - def _download_voicemail(self, messageId): - actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId) - targetPath = "%s.%s.part" % (actualPath, time.time()) - if os.path.exists(actualPath): - self.voicemailAvailable.emit(messageId, actualPath) - return - with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"): - try: - yield ( - self._backend[0].download, - (messageId, targetPath), - {}, - ) - except Exception, e: - _moduleLogger.exception("Passing to user") - self.error.emit(str(e)) - return - - if os.path.exists(actualPath): - try: - os.remove(targetPath) - except: - _moduleLogger.exception("Ignoring file problems with cache") - self.voicemailAvailable.emit(messageId, actualPath) - return - else: - os.rename(targetPath, actualPath) - self.voicemailAvailable.emit(messageId, actualPath) - - def _perform_op_while_loggedin(self, op): - if self.state == self.LOGGEDIN_STATE: - op, args, kwds = op - op.start(*args, **kwds) - else: - self._push_login_op(op) - - def _push_login_op(self, asyncOp): - assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out" - if asyncOp in self._loginOps: - _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp) - return - self._loginOps.append(asyncOp) - - def _process_account_data(self, accountData): - self._contacts = dict( - (contactId, contactDetails) - for contactId, contactDetails in accountData["contacts"].iteritems() - # A zero contact id is the catch all for unknown contacts - if contactId != "0" - ) - - self._accountUpdateTime = datetime.datetime.now() - self.accountUpdated.emit() - - def _alert_on_messages(self, messages): - cleanNewMessages = list(self._clean_messages(messages)) - cleanNewMessages.sort(key=lambda m: m["contactId"]) - if self._cleanMessages: - if self._cleanMessages != cleanNewMessages: - self.newMessages.emit() - self._cleanMessages = cleanNewMessages - - def _clean_messages(self, messages): - for message in messages: - cleaned = dict( - kv - for kv in message.iteritems() - if kv[0] not in - [ - "relTime", - "time", - "isArchived", - "isRead", - "isSpam", - "isTrash", - ] - ) - - # Don't let outbound messages cause alerts, especially if the package has only outbound - cleaned["messageParts"] = [ - tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:" - ] - if not cleaned["messageParts"]: - continue - - yield cleaned - - @misc_utils.log_exception(_moduleLogger) - def _on_delayed_relogin(self): - try: - username = self._username - password = self._password - self.logout() - self.login(username, password) - except Exception, e: - _moduleLogger.exception("Passing to user") - self.error.emit(str(e)) - return diff --git a/src/stream_gst.py b/src/stream_gst.py deleted file mode 100644 index ce97fb6..0000000 --- a/src/stream_gst.py +++ /dev/null @@ -1,145 +0,0 @@ -import logging - -import gobject -import gst - -import util.misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class Stream(gobject.GObject): - - # @bug Advertising state changes a bit early, should watch for GStreamer state change - - STATE_PLAY = "play" - STATE_PAUSE = "pause" - STATE_STOP = "stop" - - __gsignals__ = { - 'state-change' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_STRING, ), - ), - 'eof' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_STRING, ), - ), - 'error' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT), - ), - } - - def __init__(self): - gobject.GObject.__init__(self) - #Fields - self._uri = "" - self._elapsed = 0 - self._duration = 0 - - #Set up GStreamer - self._player = gst.element_factory_make("playbin2", "player") - bus = self._player.get_bus() - bus.add_signal_watch() - bus.connect("message", self._on_message) - - #Constants - self._timeFormat = gst.Format(gst.FORMAT_TIME) - self._seekFlag = gst.SEEK_FLAG_FLUSH - - @property - def playing(self): - return self.state == self.STATE_PLAY - - @property - def has_file(self): - return 0 < len(self._uri) - - @property - def state(self): - state = self._player.get_state()[1] - return self._translate_state(state) - - def set_file(self, uri): - if self._uri != uri: - self._invalidate_cache() - if self.state != self.STATE_STOP: - self.stop() - - self._uri = uri - self._player.set_property("uri", uri) - - def play(self): - if self.state == self.STATE_PLAY: - _moduleLogger.info("Already play") - return - _moduleLogger.info("Play") - self._player.set_state(gst.STATE_PLAYING) - self.emit("state-change", self.STATE_PLAY) - - def pause(self): - if self.state == self.STATE_PAUSE: - _moduleLogger.info("Already pause") - return - _moduleLogger.info("Pause") - self._player.set_state(gst.STATE_PAUSED) - self.emit("state-change", self.STATE_PAUSE) - - def stop(self): - if self.state == self.STATE_STOP: - _moduleLogger.info("Already stop") - return - self._player.set_state(gst.STATE_NULL) - _moduleLogger.info("Stopped") - self.emit("state-change", self.STATE_STOP) - - @property - def elapsed(self): - try: - self._elapsed = self._player.query_position(self._timeFormat, None)[0] - except: - pass - return self._elapsed - - @property - def duration(self): - try: - self._duration = self._player.query_duration(self._timeFormat, None)[0] - except: - _moduleLogger.exception("Query failed") - return self._duration - - def seek_time(self, ns): - self._elapsed = ns - self._player.seek_simple(self._timeFormat, self._seekFlag, ns) - - def _invalidate_cache(self): - self._elapsed = 0 - self._duration = 0 - - def _translate_state(self, gstState): - return { - gst.STATE_NULL: self.STATE_STOP, - gst.STATE_PAUSED: self.STATE_PAUSE, - gst.STATE_PLAYING: self.STATE_PLAY, - }.get(gstState, self.STATE_STOP) - - @misc_utils.log_exception(_moduleLogger) - def _on_message(self, bus, message): - t = message.type - if t == gst.MESSAGE_EOS: - self._player.set_state(gst.STATE_NULL) - self.emit("eof", self._uri) - elif t == gst.MESSAGE_ERROR: - self._player.set_state(gst.STATE_NULL) - err, debug = message.parse_error() - _moduleLogger.error("Error: %s, (%s)" % (err, debug)) - self.emit("error", err, debug) - - -gobject.type_register(Stream) diff --git a/src/stream_handler.py b/src/stream_handler.py deleted file mode 100644 index 3c0c9e3..0000000 --- a/src/stream_handler.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import logging - -import util.qt_compat as qt_compat -QtCore = qt_compat.QtCore - -import util.misc as misc_utils -try: - import stream_gst - stream = stream_gst -except ImportError: - try: - import stream_osso - stream = stream_osso - except ImportError: - import stream_null - stream = stream_null - - -_moduleLogger = logging.getLogger(__name__) - - -class StreamToken(QtCore.QObject): - - stateChange = qt_compat.Signal(str) - invalidated = qt_compat.Signal() - error = qt_compat.Signal(str) - - STATE_PLAY = stream.Stream.STATE_PLAY - STATE_PAUSE = stream.Stream.STATE_PAUSE - STATE_STOP = stream.Stream.STATE_STOP - - def __init__(self, stream): - QtCore.QObject.__init__(self) - self._stream = stream - self._stream.connect("state-change", self._on_stream_state) - self._stream.connect("eof", self._on_stream_eof) - self._stream.connect("error", self._on_stream_error) - - @property - def state(self): - if self.isValid: - return self._stream.state - else: - return self.STATE_STOP - - @property - def isValid(self): - return self._stream is not None - - def play(self): - self._stream.play() - - def pause(self): - self._stream.pause() - - def stop(self): - self._stream.stop() - - def invalidate(self): - if self._stream is None: - return - _moduleLogger.info("Playback token invalidated") - self._stream = None - - @misc_utils.log_exception(_moduleLogger) - def _on_stream_state(self, s, state): - if not self.isValid: - return - if state == self.STATE_STOP: - self.invalidate() - self.stateChange.emit(state) - - @misc_utils.log_exception(_moduleLogger) - def _on_stream_eof(self, s, uri): - if not self.isValid: - return - self.invalidate() - self.stateChange.emit(self.STATE_STOP) - - @misc_utils.log_exception(_moduleLogger) - def _on_stream_error(self, s, error, debug): - if not self.isValid: - return - _moduleLogger.info("Error %s %s" % (error, debug)) - self.error.emit(str(error)) - - -class StreamHandler(QtCore.QObject): - - def __init__(self): - QtCore.QObject.__init__(self) - self._stream = stream.Stream() - self._token = StreamToken(self._stream) - - def set_file(self, path): - self._token.invalidate() - self._token = StreamToken(self._stream) - self._stream.set_file(path) - return self._token - - @misc_utils.log_exception(_moduleLogger) - def _on_stream_state(self, s, state): - _moduleLogger.info("State change %r" % state) - - -if __name__ == "__main__": - pass - diff --git a/src/stream_null.py b/src/stream_null.py deleted file mode 100644 index 44fbbed..0000000 --- a/src/stream_null.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import logging - - -_moduleLogger = logging.getLogger(__name__) - - -class Stream(object): - - STATE_PLAY = "play" - STATE_PAUSE = "pause" - STATE_STOP = "stop" - - def __init__(self): - pass - - def connect(self, signalName, slot): - pass - - @property - def playing(self): - return False - - @property - def has_file(self): - return False - - @property - def state(self): - return self.STATE_STOP - - def set_file(self, uri): - pass - - def play(self): - pass - - def pause(self): - pass - - def stop(self): - pass - - @property - def elapsed(self): - return 0 - - @property - def duration(self): - return 0 - - def seek_time(self, ns): - pass - - -if __name__ == "__main__": - pass - diff --git a/src/stream_osso.py b/src/stream_osso.py deleted file mode 100644 index abc453f..0000000 --- a/src/stream_osso.py +++ /dev/null @@ -1,181 +0,0 @@ -import logging - -import gobject -import dbus - -import util.misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class Stream(gobject.GObject): - - STATE_PLAY = "play" - STATE_PAUSE = "pause" - STATE_STOP = "stop" - - __gsignals__ = { - 'state-change' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_STRING, ), - ), - 'eof' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_STRING, ), - ), - 'error' : ( - gobject.SIGNAL_RUN_LAST, - gobject.TYPE_NONE, - (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT), - ), - } - - _SERVICE_NAME = "com.nokia.osso_media_server" - _OBJECT_PATH = "/com/nokia/osso_media_server" - _AUDIO_INTERFACE_NAME = "com.nokia.osso_media_server.music" - - def __init__(self): - gobject.GObject.__init__(self) - #Fields - self._state = self.STATE_STOP - self._nextState = self.STATE_STOP - self._uri = "" - self._elapsed = 0 - self._duration = 0 - - session_bus = dbus.SessionBus() - - # Get the osso-media-player proxy object - oms_object = session_bus.get_object( - self._SERVICE_NAME, - self._OBJECT_PATH, - introspect=False, - follow_name_owner_changes=True, - ) - # Use the audio interface - oms_audio_interface = dbus.Interface( - oms_object, - self._AUDIO_INTERFACE_NAME, - ) - self._audioProxy = oms_audio_interface - - self._audioProxy.connect_to_signal("state_changed", self._on_state_changed) - self._audioProxy.connect_to_signal("end_of_stream", self._on_end_of_stream) - - error_signals = [ - "no_media_selected", - "file_not_found", - "type_not_found", - "unsupported_type", - "gstreamer", - "dsp", - "device_unavailable", - "corrupted_file", - "out_of_memory", - "audio_codec_not_supported", - ] - for error in error_signals: - self._audioProxy.connect_to_signal(error, self._on_error) - - @property - def playing(self): - return self.state == self.STATE_PLAY - - @property - def has_file(self): - return 0 < len(self._uri) - - @property - def state(self): - return self._state - - def set_file(self, uri): - if self._uri != uri: - self._invalidate_cache() - if self.state != self.STATE_STOP: - self.stop() - - self._uri = uri - self._audioProxy.set_media_location(self._uri) - - def play(self): - if self._nextState == self.STATE_PLAY: - _moduleLogger.info("Already play") - return - _moduleLogger.info("Play") - self._audioProxy.play() - self._nextState = self.STATE_PLAY - #self.emit("state-change", self.STATE_PLAY) - - def pause(self): - if self._nextState == self.STATE_PAUSE: - _moduleLogger.info("Already pause") - return - _moduleLogger.info("Pause") - self._audioProxy.pause() - self._nextState = self.STATE_PAUSE - #self.emit("state-change", self.STATE_PLAY) - - def stop(self): - if self._nextState == self.STATE_STOP: - _moduleLogger.info("Already stop") - return - self._audioProxy.stop() - _moduleLogger.info("Stopped") - self._nextState = self.STATE_STOP - #self.emit("state-change", self.STATE_STOP) - - @property - def elapsed(self): - pos_info = self._audioProxy.get_position() - if isinstance(pos_info, tuple): - self._elapsed, self._duration = pos_info - return self._elapsed - - @property - def duration(self): - pos_info = self._audioProxy.get_position() - if isinstance(pos_info, tuple): - self._elapsed, self._duration = pos_info - return self._duration - - def seek_time(self, ns): - _moduleLogger.debug("Seeking to: %s", ns) - self._audioProxy.seek( dbus.Int32(1), dbus.Int32(ns) ) - - def _invalidate_cache(self): - self._elapsed = 0 - self._duration = 0 - - @misc_utils.log_exception(_moduleLogger) - def _on_error(self, *args): - err, debug = "", repr(args) - _moduleLogger.error("Error: %s, (%s)" % (err, debug)) - self.emit("error", err, debug) - - @misc_utils.log_exception(_moduleLogger) - def _on_end_of_stream(self, *args): - self._state = self.STATE_STOP - self._nextState = self.STATE_STOP - self.emit("eof", self._uri) - - @misc_utils.log_exception(_moduleLogger) - def _on_state_changed(self, state): - _moduleLogger.info("State: %s", state) - state = { - "playing": self.STATE_PLAY, - "paused": self.STATE_PAUSE, - "stopped": self.STATE_STOP, - }[state] - if self._state == self.STATE_STOP and self._nextState == self.STATE_PLAY and state == self.STATE_STOP: - # They seem to want to advertise stop right as the stream is starting, breaking the owner of this - return - self._state = state - self._nextState = state - self.emit("state-change", state) - - -gobject.type_register(Stream) diff --git a/src/util/__init__.py b/src/util/__init__.py deleted file mode 100644 index 4265cc3..0000000 --- a/src/util/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/env python diff --git a/src/util/algorithms.py b/src/util/algorithms.py deleted file mode 100644 index e94fb61..0000000 --- a/src/util/algorithms.py +++ /dev/null @@ -1,664 +0,0 @@ -#!/usr/bin/env python - -""" -@note Source http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66448 -""" - -import itertools -import functools -import datetime -import types -import array -import random - - -def ordered_itr(collection): - """ - >>> [v for v in ordered_itr({"a": 1, "b": 2})] - [('a', 1), ('b', 2)] - >>> [v for v in ordered_itr([3, 1, 10, -20])] - [-20, 1, 3, 10] - """ - if isinstance(collection, types.DictType): - keys = list(collection.iterkeys()) - keys.sort() - for key in keys: - yield key, collection[key] - else: - values = list(collection) - values.sort() - for value in values: - yield value - - -def itercat(*iterators): - """ - Concatenate several iterators into one. - - >>> [v for v in itercat([1, 2, 3], [4, 1, 3])] - [1, 2, 3, 4, 1, 3] - """ - for i in iterators: - for x in i: - yield x - - -def product(*args, **kwds): - # product('ABCD', 'xy') --> Ax Ay Bx By Cx Cy Dx Dy - # product(range(2), repeat=3) --> 000 001 010 011 100 101 110 111 - pools = map(tuple, args) * kwds.get('repeat', 1) - result = [[]] - for pool in pools: - result = [x+[y] for x in result for y in pool] - for prod in result: - yield tuple(prod) - - -def iterwhile(func, iterator): - """ - Iterate for as long as func(value) returns true. - >>> through = lambda b: b - >>> [v for v in iterwhile(through, [True, True, False])] - [True, True] - """ - iterator = iter(iterator) - while 1: - next = iterator.next() - if not func(next): - raise StopIteration - yield next - - -def iterfirst(iterator, count=1): - """ - Iterate through 'count' first values. - - >>> [v for v in iterfirst([1, 2, 3, 4, 5], 3)] - [1, 2, 3] - """ - iterator = iter(iterator) - for i in xrange(count): - yield iterator.next() - - -def iterstep(iterator, n): - """ - Iterate every nth value. - - >>> [v for v in iterstep([1, 2, 3, 4, 5], 1)] - [1, 2, 3, 4, 5] - >>> [v for v in iterstep([1, 2, 3, 4, 5], 2)] - [1, 3, 5] - >>> [v for v in iterstep([1, 2, 3, 4, 5], 3)] - [1, 4] - """ - iterator = iter(iterator) - while True: - yield iterator.next() - # skip n-1 values - for dummy in xrange(n-1): - iterator.next() - - -def itergroup(iterator, count, padValue = None): - """ - Iterate in groups of 'count' values. If there - aren't enough values, the last result is padded with - None. - - >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): - ... print tuple(val) - (1, 2, 3) - (4, 5, 6) - >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3): - ... print list(val) - [1, 2, 3] - [4, 5, 6] - >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3): - ... print tuple(val) - (1, 2, 3) - (4, 5, 6) - (7, None, None) - >>> for val in itergroup("123456", 3): - ... print tuple(val) - ('1', '2', '3') - ('4', '5', '6') - >>> for val in itergroup("123456", 3): - ... print repr("".join(val)) - '123' - '456' - """ - paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1)) - nIterators = (paddedIterator, ) * count - return itertools.izip(*nIterators) - - -def xzip(*iterators): - """Iterative version of builtin 'zip'.""" - iterators = itertools.imap(iter, iterators) - while 1: - yield tuple([x.next() for x in iterators]) - - -def xmap(func, *iterators): - """Iterative version of builtin 'map'.""" - iterators = itertools.imap(iter, iterators) - values_left = [1] - - def values(): - # Emulate map behaviour, i.e. shorter - # sequences are padded with None when - # they run out of values. - values_left[0] = 0 - for i in range(len(iterators)): - iterator = iterators[i] - if iterator is None: - yield None - else: - try: - yield iterator.next() - values_left[0] = 1 - except StopIteration: - iterators[i] = None - yield None - while 1: - args = tuple(values()) - if not values_left[0]: - raise StopIteration - yield func(*args) - - -def xfilter(func, iterator): - """Iterative version of builtin 'filter'.""" - iterator = iter(iterator) - while 1: - next = iterator.next() - if func(next): - yield next - - -def xreduce(func, iterator, default=None): - """Iterative version of builtin 'reduce'.""" - iterator = iter(iterator) - try: - prev = iterator.next() - except StopIteration: - return default - single = 1 - for next in iterator: - single = 0 - prev = func(prev, next) - if single: - return func(prev, default) - return prev - - -def daterange(begin, end, delta = datetime.timedelta(1)): - """ - Form a range of dates and iterate over them. - - Arguments: - begin -- a date (or datetime) object; the beginning of the range. - end -- a date (or datetime) object; the end of the range. - delta -- (optional) a datetime.timedelta object; how much to step each iteration. - Default step is 1 day. - - Usage: - """ - if not isinstance(delta, datetime.timedelta): - delta = datetime.timedelta(delta) - - ZERO = datetime.timedelta(0) - - if begin < end: - if delta <= ZERO: - raise StopIteration - test = end.__gt__ - else: - if delta >= ZERO: - raise StopIteration - test = end.__lt__ - - while test(begin): - yield begin - begin += delta - - -class LazyList(object): - """ - A Sequence whose values are computed lazily by an iterator. - - Module for the creation and use of iterator-based lazy lists. - this module defines a class LazyList which can be used to represent sequences - of values generated lazily. One can also create recursively defined lazy lists - that generate their values based on ones previously generated. - - Backport to python 2.5 by Michael Pust - """ - - __author__ = 'Dan Spitz' - - def __init__(self, iterable): - self._exhausted = False - self._iterator = iter(iterable) - self._data = [] - - def __len__(self): - """Get the length of a LazyList's computed data.""" - return len(self._data) - - def __getitem__(self, i): - """Get an item from a LazyList. - i should be a positive integer or a slice object.""" - if isinstance(i, int): - #index has not yet been yielded by iterator (or iterator exhausted - #before reaching that index) - if i >= len(self): - self.exhaust(i) - elif i < 0: - raise ValueError('cannot index LazyList with negative number') - return self._data[i] - - #LazyList slices are iterators over a portion of the list. - elif isinstance(i, slice): - start, stop, step = i.start, i.stop, i.step - if any(x is not None and x < 0 for x in (start, stop, step)): - raise ValueError('cannot index or step through a LazyList with' - 'a negative number') - #set start and step to their integer defaults if they are None. - if start is None: - start = 0 - if step is None: - step = 1 - - def LazyListIterator(): - count = start - predicate = ( - (lambda: True) - if stop is None - else (lambda: count < stop) - ) - while predicate(): - try: - yield self[count] - #slices can go out of actual index range without raising an - #error - except IndexError: - break - count += step - return LazyListIterator() - - raise TypeError('i must be an integer or slice') - - def __iter__(self): - """return an iterator over each value in the sequence, - whether it has been computed yet or not.""" - return self[:] - - def computed(self): - """Return an iterator over the values in a LazyList that have - already been computed.""" - return self[:len(self)] - - def exhaust(self, index = None): - """Exhaust the iterator generating this LazyList's values. - if index is None, this will exhaust the iterator completely. - Otherwise, it will iterate over the iterator until either the list - has a value for index or the iterator is exhausted. - """ - if self._exhausted: - return - if index is None: - ind_range = itertools.count(len(self)) - else: - ind_range = range(len(self), index + 1) - - for ind in ind_range: - try: - self._data.append(self._iterator.next()) - except StopIteration: #iterator is fully exhausted - self._exhausted = True - break - - -class RecursiveLazyList(LazyList): - - def __init__(self, prod, *args, **kwds): - super(RecursiveLazyList, self).__init__(prod(self, *args, **kwds)) - - -class RecursiveLazyListFactory: - - def __init__(self, producer): - self._gen = producer - - def __call__(self, *a, **kw): - return RecursiveLazyList(self._gen, *a, **kw) - - -def lazylist(gen): - """ - Decorator for creating a RecursiveLazyList subclass. - This should decorate a generator function taking the LazyList object as its - first argument which yields the contents of the list in order. - - >>> #fibonnacci sequence in a lazy list. - >>> @lazylist - ... def fibgen(lst): - ... yield 0 - ... yield 1 - ... for a, b in itertools.izip(lst, lst[1:]): - ... yield a + b - ... - >>> #now fibs can be indexed or iterated over as if it were an infinitely long list containing the fibonnaci sequence - >>> fibs = fibgen() - >>> - >>> #prime numbers in a lazy list. - >>> @lazylist - ... def primegen(lst): - ... yield 2 - ... for candidate in itertools.count(3): #start at next number after 2 - ... #if candidate is not divisible by any smaller prime numbers, - ... #it is a prime. - ... if all(candidate % p for p in lst.computed()): - ... yield candidate - ... - >>> #same for primes- treat it like an infinitely long list containing all prime numbers. - >>> primes = primegen() - >>> print fibs[0], fibs[1], fibs[2], primes[0], primes[1], primes[2] - 0 1 1 2 3 5 - >>> print list(fibs[:10]), list(primes[:10]) - [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] - """ - return RecursiveLazyListFactory(gen) - - -def map_func(f): - """ - >>> import misc - >>> misc.validate_decorator(map_func) - """ - - @functools.wraps(f) - def wrapper(*args): - result = itertools.imap(f, args) - return result - return wrapper - - -def reduce_func(function): - """ - >>> import misc - >>> misc.validate_decorator(reduce_func(lambda x: x)) - """ - - def decorator(f): - - @functools.wraps(f) - def wrapper(*args): - result = reduce(function, f(args)) - return result - return wrapper - return decorator - - -def any_(iterable): - """ - @note Python Version <2.5 - - >>> any_([True, True]) - True - >>> any_([True, False]) - True - >>> any_([False, False]) - False - """ - - for element in iterable: - if element: - return True - return False - - -def all_(iterable): - """ - @note Python Version <2.5 - - >>> all_([True, True]) - True - >>> all_([True, False]) - False - >>> all_([False, False]) - False - """ - - for element in iterable: - if not element: - return False - return True - - -def for_every(pred, seq): - """ - for_every takes a one argument predicate function and a sequence. - @param pred The predicate function should return true or false. - @returns true if every element in seq returns true for predicate, else returns false. - - >>> for_every (lambda c: c > 5,(6,7,8,9)) - True - - @author Source:http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52907 - """ - - for i in seq: - if not pred(i): - return False - return True - - -def there_exists(pred, seq): - """ - there_exists takes a one argument predicate function and a sequence. - @param pred The predicate function should return true or false. - @returns true if any element in seq returns true for predicate, else returns false. - - >>> there_exists (lambda c: c > 5,(6,7,8,9)) - True - - @author Source:http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52907 - """ - - for i in seq: - if pred(i): - return True - return False - - -def func_repeat(quantity, func, *args, **kwd): - """ - Meant to be in connection with "reduce" - """ - for i in xrange(quantity): - yield func(*args, **kwd) - - -def function_map(preds, item): - """ - Meant to be in connection with "reduce" - """ - results = (pred(item) for pred in preds) - - return results - - -def functional_if(combiner, preds, item): - """ - Combines the result of a list of predicates applied to item according to combiner - - @see any, every for example combiners - """ - pass_bool = lambda b: b - - bool_results = function_map(preds, item) - return combiner(pass_bool, bool_results) - - -def pushback_itr(itr): - """ - >>> list(pushback_itr(xrange(5))) - [0, 1, 2, 3, 4] - >>> - >>> first = True - >>> itr = pushback_itr(xrange(5)) - >>> for i in itr: - ... print i - ... if first and i == 2: - ... first = False - ... print itr.send(i) - 0 - 1 - 2 - None - 2 - 3 - 4 - >>> - >>> first = True - >>> itr = pushback_itr(xrange(5)) - >>> for i in itr: - ... print i - ... if first and i == 2: - ... first = False - ... print itr.send(i) - ... print itr.send(i) - 0 - 1 - 2 - None - None - 2 - 2 - 3 - 4 - >>> - >>> itr = pushback_itr(xrange(5)) - >>> print itr.next() - 0 - >>> print itr.next() - 1 - >>> print itr.send(10) - None - >>> print itr.next() - 10 - >>> print itr.next() - 2 - >>> print itr.send(20) - None - >>> print itr.send(30) - None - >>> print itr.send(40) - None - >>> print itr.next() - 40 - >>> print itr.next() - 30 - >>> print itr.send(50) - None - >>> print itr.next() - 50 - >>> print itr.next() - 20 - >>> print itr.next() - 3 - >>> print itr.next() - 4 - """ - for item in itr: - maybePushedBack = yield item - queue = [] - while queue or maybePushedBack is not None: - if maybePushedBack is not None: - queue.append(maybePushedBack) - maybePushedBack = yield None - else: - item = queue.pop() - maybePushedBack = yield item - - -def itr_available(queue, initiallyBlock = False): - if initiallyBlock: - yield queue.get() - while not queue.empty(): - yield queue.get_nowait() - - -class BloomFilter(object): - """ - http://en.wikipedia.org/wiki/Bloom_filter - Sources: - http://code.activestate.com/recipes/577684-bloom-filter/ - http://code.activestate.com/recipes/577686-bloom-filter/ - - >>> from random import sample - >>> from string import ascii_letters - >>> states = '''Alabama Alaska Arizona Arkansas California Colorado Connecticut - ... Delaware Florida Georgia Hawaii Idaho Illinois Indiana Iowa Kansas - ... Kentucky Louisiana Maine Maryland Massachusetts Michigan Minnesota - ... Mississippi Missouri Montana Nebraska Nevada NewHampshire NewJersey - ... NewMexico NewYork NorthCarolina NorthDakota Ohio Oklahoma Oregon - ... Pennsylvania RhodeIsland SouthCarolina SouthDakota Tennessee Texas Utah - ... Vermont Virginia Washington WestVirginia Wisconsin Wyoming'''.split() - >>> bf = BloomFilter(num_bits=1000, num_probes=14) - >>> for state in states: - ... bf.add(state) - >>> numStatesFound = sum(state in bf for state in states) - >>> numStatesFound, len(states) - (50, 50) - >>> trials = 100 - >>> numGarbageFound = sum(''.join(sample(ascii_letters, 5)) in bf for i in range(trials)) - >>> numGarbageFound, trials - (0, 100) - """ - - def __init__(self, num_bits, num_probes): - num_words = (num_bits + 31) // 32 - self._arr = array.array('B', [0]) * num_words - self._num_probes = num_probes - - def add(self, key): - for i, mask in self._get_probes(key): - self._arr[i] |= mask - - def union(self, bfilter): - if self._match_template(bfilter): - for i, b in enumerate(bfilter._arr): - self._arr[i] |= b - else: - # Union b/w two unrelated bloom filter raises this - raise ValueError("Mismatched bloom filters") - - def intersection(self, bfilter): - if self._match_template(bfilter): - for i, b in enumerate(bfilter._arr): - self._arr[i] &= b - else: - # Intersection b/w two unrelated bloom filter raises this - raise ValueError("Mismatched bloom filters") - - def __contains__(self, key): - return all(self._arr[i] & mask for i, mask in self._get_probes(key)) - - def _match_template(self, bfilter): - return self.num_bits == bfilter.num_bits and self.num_probes == bfilter.num_probes - - def _get_probes(self, key): - hasher = random.Random(key).randrange - for _ in range(self._num_probes): - array_index = hasher(len(self._arr)) - bit_index = hasher(32) - yield array_index, 1 << bit_index - - -if __name__ == "__main__": - import doctest - print doctest.testmod() diff --git a/src/util/concurrent.py b/src/util/concurrent.py deleted file mode 100644 index f5f6e1d..0000000 --- a/src/util/concurrent.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement - -import os -import errno -import time -import functools -import contextlib -import logging - -import misc - - -_moduleLogger = logging.getLogger(__name__) - - -class AsyncTaskQueue(object): - - def __init__(self, taskPool): - self._asyncs = [] - self._taskPool = taskPool - - def add_async(self, func): - self.flush() - a = AsyncGeneratorTask(self._taskPool, func) - self._asyncs.append(a) - return a - - def flush(self): - self._asyncs = [a for a in self._asyncs if not a.isDone] - - -class AsyncGeneratorTask(object): - - def __init__(self, pool, func): - self._pool = pool - self._func = func - self._run = None - self._isDone = False - - @property - def isDone(self): - return self._isDone - - def start(self, *args, **kwds): - assert self._run is None, "Task already started" - self._run = self._func(*args, **kwds) - trampoline, args, kwds = self._run.send(None) # priming the function - self._pool.add_task( - trampoline, - args, - kwds, - self.on_success, - self.on_error, - ) - - @misc.log_exception(_moduleLogger) - def on_success(self, result): - _moduleLogger.debug("Processing success for: %r", self._func) - try: - trampoline, args, kwds = self._run.send(result) - except StopIteration, e: - self._isDone = True - else: - self._pool.add_task( - trampoline, - args, - kwds, - self.on_success, - self.on_error, - ) - - @misc.log_exception(_moduleLogger) - def on_error(self, error): - _moduleLogger.debug("Processing error for: %r", self._func) - try: - trampoline, args, kwds = self._run.throw(error) - except StopIteration, e: - self._isDone = True - else: - self._pool.add_task( - trampoline, - args, - kwds, - self.on_success, - self.on_error, - ) - - def __repr__(self): - return "" % (self._func.__name__, id(self)) - - def __hash__(self): - return hash(self._func) - - def __eq__(self, other): - return self._func == other._func - - def __ne__(self, other): - return self._func != other._func - - -def synchronized(lock): - """ - Synchronization decorator. - - >>> import misc - >>> misc.validate_decorator(synchronized(object())) - """ - - def wrap(f): - - @functools.wraps(f) - def newFunction(*args, **kw): - lock.acquire() - try: - return f(*args, **kw) - finally: - lock.release() - return newFunction - return wrap - - -@contextlib.contextmanager -def qlock(queue, gblock = True, gtimeout = None, pblock = True, ptimeout = None): - """ - Locking with a queue, good for when you want to lock an item passed around - - >>> import Queue - >>> item = 5 - >>> lock = Queue.Queue() - >>> lock.put(item) - >>> with qlock(lock) as i: - ... print i - 5 - """ - item = queue.get(gblock, gtimeout) - try: - yield item - finally: - queue.put(item, pblock, ptimeout) - - -@contextlib.contextmanager -def flock(path, timeout=-1): - WAIT_FOREVER = -1 - DELAY = 0.1 - timeSpent = 0 - - acquired = False - - while timeSpent <= timeout or timeout == WAIT_FOREVER: - try: - fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR) - acquired = True - break - except OSError, e: - if e.errno != errno.EEXIST: - raise - time.sleep(DELAY) - timeSpent += DELAY - - assert acquired, "Failed to grab file-lock %s within timeout %d" % (path, timeout) - - try: - yield fd - finally: - os.unlink(path) diff --git a/src/util/coroutines.py b/src/util/coroutines.py deleted file mode 100755 index b1e539e..0000000 --- a/src/util/coroutines.py +++ /dev/null @@ -1,623 +0,0 @@ -#!/usr/bin/env python - -""" -Uses for generators -* Pull pipelining (iterators) -* Push pipelining (coroutines) -* State machines (coroutines) -* "Cooperative multitasking" (coroutines) -* Algorithm -> Object transform for cohesiveness (for example context managers) (coroutines) - -Design considerations -* When should a stage pass on exceptions or have it thrown within it? -* When should a stage pass on GeneratorExits? -* Is there a way to either turn a push generator into a iterator or to use - comprehensions syntax for push generators (I doubt it) -* When should the stage try and send data in both directions -* Since pull generators (generators), push generators (coroutines), subroutines, and coroutines are all coroutines, maybe we should rename the push generators to not confuse them, like signals/slots? and then refer to two-way generators as coroutines -** If so, make s* and co* implementation of functions -""" - -import threading -import Queue -import pickle -import functools -import itertools -import xml.sax -import xml.parsers.expat - - -def autostart(func): - """ - >>> @autostart - ... def grep_sink(pattern): - ... print "Looking for %s" % pattern - ... while True: - ... line = yield - ... if pattern in line: - ... print line, - >>> g = grep_sink("python") - Looking for python - >>> g.send("Yeah but no but yeah but no") - >>> g.send("A series of tubes") - >>> g.send("python generators rock!") - python generators rock! - >>> g.close() - """ - - @functools.wraps(func) - def start(*args, **kwargs): - cr = func(*args, **kwargs) - cr.next() - return cr - - return start - - -@autostart -def printer_sink(format = "%s"): - """ - >>> pr = printer_sink("%r") - >>> pr.send("Hello") - 'Hello' - >>> pr.send("5") - '5' - >>> pr.send(5) - 5 - >>> p = printer_sink() - >>> p.send("Hello") - Hello - >>> p.send("World") - World - >>> # p.throw(RuntimeError, "Goodbye") - >>> # p.send("Meh") - >>> # p.close() - """ - while True: - item = yield - print format % (item, ) - - -@autostart -def null_sink(): - """ - Good for uses like with cochain to pick up any slack - """ - while True: - item = yield - - -def itr_source(itr, target): - """ - >>> itr_source(xrange(2), printer_sink()) - 0 - 1 - """ - for item in itr: - target.send(item) - - -@autostart -def cofilter(predicate, target): - """ - >>> p = printer_sink() - >>> cf = cofilter(None, p) - >>> cf.send("") - >>> cf.send("Hello") - Hello - >>> cf.send([]) - >>> cf.send([1, 2]) - [1, 2] - >>> cf.send(False) - >>> cf.send(True) - True - >>> cf.send(0) - >>> cf.send(1) - 1 - >>> # cf.throw(RuntimeError, "Goodbye") - >>> # cf.send(False) - >>> # cf.send(True) - >>> # cf.close() - """ - if predicate is None: - predicate = bool - - while True: - try: - item = yield - if predicate(item): - target.send(item) - except StandardError, e: - target.throw(e.__class__, e.message) - - -@autostart -def comap(function, target): - """ - >>> p = printer_sink() - >>> cm = comap(lambda x: x+1, p) - >>> cm.send(0) - 1 - >>> cm.send(1.0) - 2.0 - >>> cm.send(-2) - -1 - >>> # cm.throw(RuntimeError, "Goodbye") - >>> # cm.send(0) - >>> # cm.send(1.0) - >>> # cm.close() - """ - while True: - try: - item = yield - mappedItem = function(item) - target.send(mappedItem) - except StandardError, e: - target.throw(e.__class__, e.message) - - -def func_sink(function): - return comap(function, null_sink()) - - -def expand_positional(function): - - @functools.wraps(function) - def expander(item): - return function(*item) - - return expander - - -@autostart -def append_sink(l): - """ - >>> l = [] - >>> apps = append_sink(l) - >>> apps.send(1) - >>> apps.send(2) - >>> apps.send(3) - >>> print l - [1, 2, 3] - """ - while True: - item = yield - l.append(item) - - -@autostart -def last_n_sink(l, n = 1): - """ - >>> l = [] - >>> lns = last_n_sink(l) - >>> lns.send(1) - >>> lns.send(2) - >>> lns.send(3) - >>> print l - [3] - """ - del l[:] - while True: - item = yield - extraCount = len(l) - n + 1 - if 0 < extraCount: - del l[0:extraCount] - l.append(item) - - -@autostart -def coreduce(target, function, initializer = None): - """ - >>> reduceResult = [] - >>> lns = last_n_sink(reduceResult) - >>> cr = coreduce(lns, lambda x, y: x + y, 0) - >>> cr.send(1) - >>> cr.send(2) - >>> cr.send(3) - >>> print reduceResult - [6] - >>> cr = coreduce(lns, lambda x, y: x + y) - >>> cr.send(1) - >>> cr.send(2) - >>> cr.send(3) - >>> print reduceResult - [6] - """ - isFirst = True - cumulativeRef = initializer - while True: - item = yield - if isFirst and initializer is None: - cumulativeRef = item - else: - cumulativeRef = function(cumulativeRef, item) - target.send(cumulativeRef) - isFirst = False - - -@autostart -def cotee(targets): - """ - Takes a sequence of coroutines and sends the received items to all of them - - >>> ct = cotee((printer_sink("1 %s"), printer_sink("2 %s"))) - >>> ct.send("Hello") - 1 Hello - 2 Hello - >>> ct.send("World") - 1 World - 2 World - >>> # ct.throw(RuntimeError, "Goodbye") - >>> # ct.send("Meh") - >>> # ct.close() - """ - while True: - try: - item = yield - for target in targets: - target.send(item) - except StandardError, e: - for target in targets: - target.throw(e.__class__, e.message) - - -class CoTee(object): - """ - >>> ct = CoTee() - >>> ct.register_sink(printer_sink("1 %s")) - >>> ct.register_sink(printer_sink("2 %s")) - >>> ct.stage.send("Hello") - 1 Hello - 2 Hello - >>> ct.stage.send("World") - 1 World - 2 World - >>> ct.register_sink(printer_sink("3 %s")) - >>> ct.stage.send("Foo") - 1 Foo - 2 Foo - 3 Foo - >>> # ct.stage.throw(RuntimeError, "Goodbye") - >>> # ct.stage.send("Meh") - >>> # ct.stage.close() - """ - - def __init__(self): - self.stage = self._stage() - self._targets = [] - - def register_sink(self, sink): - self._targets.append(sink) - - def unregister_sink(self, sink): - self._targets.remove(sink) - - def restart(self): - self.stage = self._stage() - - @autostart - def _stage(self): - while True: - try: - item = yield - for target in self._targets: - target.send(item) - except StandardError, e: - for target in self._targets: - target.throw(e.__class__, e.message) - - -def _flush_queue(queue): - while not queue.empty(): - yield queue.get() - - -@autostart -def cocount(target, start = 0): - """ - >>> cc = cocount(printer_sink("%s")) - >>> cc.send("a") - 0 - >>> cc.send(None) - 1 - >>> cc.send([]) - 2 - >>> cc.send(0) - 3 - """ - for i in itertools.count(start): - item = yield - target.send(i) - - -@autostart -def coenumerate(target, start = 0): - """ - >>> ce = coenumerate(printer_sink("%r")) - >>> ce.send("a") - (0, 'a') - >>> ce.send(None) - (1, None) - >>> ce.send([]) - (2, []) - >>> ce.send(0) - (3, 0) - """ - for i in itertools.count(start): - item = yield - decoratedItem = i, item - target.send(decoratedItem) - - -@autostart -def corepeat(target, elem): - """ - >>> cr = corepeat(printer_sink("%s"), "Hello World") - >>> cr.send("a") - Hello World - >>> cr.send(None) - Hello World - >>> cr.send([]) - Hello World - >>> cr.send(0) - Hello World - """ - while True: - item = yield - target.send(elem) - - -@autostart -def cointercept(target, elems): - """ - >>> cr = cointercept(printer_sink("%s"), [1, 2, 3, 4]) - >>> cr.send("a") - 1 - >>> cr.send(None) - 2 - >>> cr.send([]) - 3 - >>> cr.send(0) - 4 - >>> cr.send("Bye") - Traceback (most recent call last): - File "/usr/lib/python2.5/doctest.py", line 1228, in __run - compileflags, 1) in test.globs - File "", line 1, in - cr.send("Bye") - StopIteration - """ - item = yield - for elem in elems: - target.send(elem) - item = yield - - -@autostart -def codropwhile(target, pred): - """ - >>> cdw = codropwhile(printer_sink("%s"), lambda x: x) - >>> cdw.send([0, 1, 2]) - >>> cdw.send(1) - >>> cdw.send(True) - >>> cdw.send(False) - >>> cdw.send([0, 1, 2]) - [0, 1, 2] - >>> cdw.send(1) - 1 - >>> cdw.send(True) - True - """ - while True: - item = yield - if not pred(item): - break - - while True: - item = yield - target.send(item) - - -@autostart -def cotakewhile(target, pred): - """ - >>> ctw = cotakewhile(printer_sink("%s"), lambda x: x) - >>> ctw.send([0, 1, 2]) - [0, 1, 2] - >>> ctw.send(1) - 1 - >>> ctw.send(True) - True - >>> ctw.send(False) - >>> ctw.send([0, 1, 2]) - >>> ctw.send(1) - >>> ctw.send(True) - """ - while True: - item = yield - if not pred(item): - break - target.send(item) - - while True: - item = yield - - -@autostart -def coslice(target, lower, upper): - """ - >>> cs = coslice(printer_sink("%r"), 3, 5) - >>> cs.send("0") - >>> cs.send("1") - >>> cs.send("2") - >>> cs.send("3") - '3' - >>> cs.send("4") - '4' - >>> cs.send("5") - >>> cs.send("6") - """ - for i in xrange(lower): - item = yield - for i in xrange(upper - lower): - item = yield - target.send(item) - while True: - item = yield - - -@autostart -def cochain(targets): - """ - >>> cr = cointercept(printer_sink("good %s"), [1, 2, 3, 4]) - >>> cc = cochain([cr, printer_sink("end %s")]) - >>> cc.send("a") - good 1 - >>> cc.send(None) - good 2 - >>> cc.send([]) - good 3 - >>> cc.send(0) - good 4 - >>> cc.send("Bye") - end Bye - """ - behind = [] - for target in targets: - try: - while behind: - item = behind.pop() - target.send(item) - while True: - item = yield - target.send(item) - except StopIteration: - behind.append(item) - - -@autostart -def queue_sink(queue): - """ - >>> q = Queue.Queue() - >>> qs = queue_sink(q) - >>> qs.send("Hello") - >>> qs.send("World") - >>> qs.throw(RuntimeError, "Goodbye") - >>> qs.send("Meh") - >>> qs.close() - >>> print [i for i in _flush_queue(q)] - [(None, 'Hello'), (None, 'World'), (, 'Goodbye'), (None, 'Meh'), (, None)] - """ - while True: - try: - item = yield - queue.put((None, item)) - except StandardError, e: - queue.put((e.__class__, e.message)) - except GeneratorExit: - queue.put((GeneratorExit, None)) - raise - - -def decode_item(item, target): - if item[0] is None: - target.send(item[1]) - return False - elif item[0] is GeneratorExit: - target.close() - return True - else: - target.throw(item[0], item[1]) - return False - - -def queue_source(queue, target): - """ - >>> q = Queue.Queue() - >>> for i in [ - ... (None, 'Hello'), - ... (None, 'World'), - ... (GeneratorExit, None), - ... ]: - ... q.put(i) - >>> qs = queue_source(q, printer_sink()) - Hello - World - """ - isDone = False - while not isDone: - item = queue.get() - isDone = decode_item(item, target) - - -def threaded_stage(target, thread_factory = threading.Thread): - messages = Queue.Queue() - - run_source = functools.partial(queue_source, messages, target) - thread_factory(target=run_source).start() - - # Sink running in current thread - return functools.partial(queue_sink, messages) - - -@autostart -def pickle_sink(f): - while True: - try: - item = yield - pickle.dump((None, item), f) - except StandardError, e: - pickle.dump((e.__class__, e.message), f) - except GeneratorExit: - pickle.dump((GeneratorExit, ), f) - raise - except StopIteration: - f.close() - return - - -def pickle_source(f, target): - try: - isDone = False - while not isDone: - item = pickle.load(f) - isDone = decode_item(item, target) - except EOFError: - target.close() - - -class EventHandler(object, xml.sax.ContentHandler): - - START = "start" - TEXT = "text" - END = "end" - - def __init__(self, target): - object.__init__(self) - xml.sax.ContentHandler.__init__(self) - self._target = target - - def startElement(self, name, attrs): - self._target.send((self.START, (name, attrs._attrs))) - - def characters(self, text): - self._target.send((self.TEXT, text)) - - def endElement(self, name): - self._target.send((self.END, name)) - - -def expat_parse(f, target): - parser = xml.parsers.expat.ParserCreate() - parser.buffer_size = 65536 - parser.buffer_text = True - parser.returns_unicode = False - parser.StartElementHandler = lambda name, attrs: target.send(('start', (name, attrs))) - parser.EndElementHandler = lambda name: target.send(('end', name)) - parser.CharacterDataHandler = lambda data: target.send(('text', data)) - parser.ParseFile(f) - - -if __name__ == "__main__": - import doctest - doctest.testmod() diff --git a/src/util/go_utils.py b/src/util/go_utils.py deleted file mode 100644 index 61e731d..0000000 --- a/src/util/go_utils.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement - -import time -import functools -import threading -import Queue -import logging - -import gobject - -import algorithms -import misc - - -_moduleLogger = logging.getLogger(__name__) - - -def make_idler(func): - """ - Decorator that makes a generator-function into a function that will continue execution on next call - """ - a = [] - - @functools.wraps(func) - def decorated_func(*args, **kwds): - if not a: - a.append(func(*args, **kwds)) - try: - a[0].next() - return True - except StopIteration: - del a[:] - return False - - return decorated_func - - -def async(func): - """ - Make a function mainloop friendly. the function will be called at the - next mainloop idle state. - - >>> import misc - >>> misc.validate_decorator(async) - """ - - @functools.wraps(func) - def new_function(*args, **kwargs): - - def async_function(): - func(*args, **kwargs) - return False - - gobject.idle_add(async_function) - - return new_function - - -class Async(object): - - def __init__(self, func, once = True): - self.__func = func - self.__idleId = None - self.__once = once - - def start(self): - assert self.__idleId is None - if self.__once: - self.__idleId = gobject.idle_add(self._on_once) - else: - self.__idleId = gobject.idle_add(self.__func) - - def is_running(self): - return self.__idleId is not None - - def cancel(self): - if self.__idleId is not None: - gobject.source_remove(self.__idleId) - self.__idleId = None - - def __call__(self): - return self.start() - - @misc.log_exception(_moduleLogger) - def _on_once(self): - self.cancel() - try: - self.__func() - except Exception: - pass - return False - - -class Timeout(object): - - def __init__(self, func, once = True): - self.__func = func - self.__timeoutId = None - self.__once = once - - def start(self, **kwds): - assert self.__timeoutId is None - - callback = self._on_once if self.__once else self.__func - - assert len(kwds) == 1 - timeoutInSeconds = kwds["seconds"] - assert 0 <= timeoutInSeconds - - if timeoutInSeconds == 0: - self.__timeoutId = gobject.idle_add(callback) - else: - self.__timeoutId = timeout_add_seconds(timeoutInSeconds, callback) - - def is_running(self): - return self.__timeoutId is not None - - def cancel(self): - if self.__timeoutId is not None: - gobject.source_remove(self.__timeoutId) - self.__timeoutId = None - - def __call__(self, **kwds): - return self.start(**kwds) - - @misc.log_exception(_moduleLogger) - def _on_once(self): - self.cancel() - try: - self.__func() - except Exception: - pass - return False - - -_QUEUE_EMPTY = object() - - -class FutureThread(object): - - def __init__(self): - self.__workQueue = Queue.Queue() - self.__thread = threading.Thread( - name = type(self).__name__, - target = self.__consume_queue, - ) - self.__isRunning = True - - def start(self): - self.__thread.start() - - def stop(self): - self.__isRunning = False - for _ in algorithms.itr_available(self.__workQueue): - pass # eat up queue to cut down dumb work - self.__workQueue.put(_QUEUE_EMPTY) - - def clear_tasks(self): - for _ in algorithms.itr_available(self.__workQueue): - pass # eat up queue to cut down dumb work - - def add_task(self, func, args, kwds, on_success, on_error): - task = func, args, kwds, on_success, on_error - self.__workQueue.put(task) - - @misc.log_exception(_moduleLogger) - def __trampoline_callback(self, on_success, on_error, isError, result): - if not self.__isRunning: - if isError: - _moduleLogger.error("Masking: %s" % (result, )) - isError = True - result = StopIteration("Cancelling all callbacks") - callback = on_success if not isError else on_error - try: - callback(result) - except Exception: - _moduleLogger.exception("Callback errored") - return False - - @misc.log_exception(_moduleLogger) - def __consume_queue(self): - while True: - task = self.__workQueue.get() - if task is _QUEUE_EMPTY: - break - func, args, kwds, on_success, on_error = task - - try: - result = func(*args, **kwds) - isError = False - except Exception, e: - _moduleLogger.error("Error, passing it back to the main thread") - result = e - isError = True - self.__workQueue.task_done() - - gobject.idle_add(self.__trampoline_callback, on_success, on_error, isError, result) - _moduleLogger.debug("Shutting down worker thread") - - -class AutoSignal(object): - - def __init__(self, toplevel): - self.__disconnectPool = [] - toplevel.connect("destroy", self.__on_destroy) - - def connect_auto(self, widget, *args): - id = widget.connect(*args) - self.__disconnectPool.append((widget, id)) - - @misc.log_exception(_moduleLogger) - def __on_destroy(self, widget): - _moduleLogger.info("Destroy: %r (%s to clean up)" % (self, len(self.__disconnectPool))) - for widget, id in self.__disconnectPool: - widget.disconnect(id) - del self.__disconnectPool[:] - - -def throttled(minDelay, queue): - """ - Throttle the calls to a function by queueing all the calls that happen - before the minimum delay - - >>> import misc - >>> import Queue - >>> misc.validate_decorator(throttled(0, Queue.Queue())) - """ - - def actual_decorator(func): - - lastCallTime = [None] - - def process_queue(): - if 0 < len(queue): - func, args, kwargs = queue.pop(0) - lastCallTime[0] = time.time() * 1000 - func(*args, **kwargs) - return False - - @functools.wraps(func) - def new_function(*args, **kwargs): - now = time.time() * 1000 - if ( - lastCallTime[0] is None or - (now - lastCallTime >= minDelay) - ): - lastCallTime[0] = now - func(*args, **kwargs) - else: - queue.append((func, args, kwargs)) - lastCallDelta = now - lastCallTime[0] - processQueueTimeout = int(minDelay * len(queue) - lastCallDelta) - gobject.timeout_add(processQueueTimeout, process_queue) - - return new_function - - return actual_decorator - - -def _old_timeout_add_seconds(timeout, callback): - return gobject.timeout_add(timeout * 1000, callback) - - -def _timeout_add_seconds(timeout, callback): - return gobject.timeout_add_seconds(timeout, callback) - - -try: - gobject.timeout_add_seconds - timeout_add_seconds = _timeout_add_seconds -except AttributeError: - timeout_add_seconds = _old_timeout_add_seconds diff --git a/src/util/io.py b/src/util/io.py deleted file mode 100644 index 4198f4b..0000000 --- a/src/util/io.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python - - -from __future__ import with_statement - -import os -import pickle -import contextlib -import itertools -import codecs -from xml.sax import saxutils -import csv -try: - import cStringIO as StringIO -except ImportError: - import StringIO - - -@contextlib.contextmanager -def change_directory(directory): - previousDirectory = os.getcwd() - os.chdir(directory) - currentDirectory = os.getcwd() - - try: - yield previousDirectory, currentDirectory - finally: - os.chdir(previousDirectory) - - -@contextlib.contextmanager -def pickled(filename): - """ - Here is an example usage: - with pickled("foo.db") as p: - p("users", list).append(["srid", "passwd", 23]) - """ - - if os.path.isfile(filename): - data = pickle.load(open(filename)) - else: - data = {} - - def getter(item, factory): - if item in data: - return data[item] - else: - data[item] = factory() - return data[item] - - yield getter - - pickle.dump(data, open(filename, "w")) - - -@contextlib.contextmanager -def redirect(object_, attr, value): - """ - >>> import sys - ... with redirect(sys, 'stdout', open('stdout', 'w')): - ... print "hello" - ... - >>> print "we're back" - we're back - """ - orig = getattr(object_, attr) - setattr(object_, attr, value) - try: - yield - finally: - setattr(object_, attr, orig) - - -def pathsplit(path): - """ - >>> pathsplit("/a/b/c") - ['', 'a', 'b', 'c'] - >>> pathsplit("./plugins/builtins.ini") - ['.', 'plugins', 'builtins.ini'] - """ - pathParts = path.split(os.path.sep) - return pathParts - - -def commonpath(l1, l2, common=None): - """ - >>> commonpath(pathsplit('/a/b/c/d'), pathsplit('/a/b/c1/d1')) - (['', 'a', 'b'], ['c', 'd'], ['c1', 'd1']) - >>> commonpath(pathsplit("./plugins/"), pathsplit("./plugins/builtins.ini")) - (['.', 'plugins'], [''], ['builtins.ini']) - >>> commonpath(pathsplit("./plugins/builtins"), pathsplit("./plugins")) - (['.', 'plugins'], ['builtins'], []) - """ - if common is None: - common = [] - - if l1 == l2: - return l1, [], [] - - for i, (leftDir, rightDir) in enumerate(zip(l1, l2)): - if leftDir != rightDir: - return l1[0:i], l1[i:], l2[i:] - else: - if leftDir == rightDir: - i += 1 - return l1[0:i], l1[i:], l2[i:] - - -def relpath(p1, p2): - """ - >>> relpath('/', '/') - './' - >>> relpath('/a/b/c/d', '/') - '../../../../' - >>> relpath('/a/b/c/d', '/a/b/c1/d1') - '../../c1/d1' - >>> relpath('/a/b/c/d', '/a/b/c1/d1/') - '../../c1/d1' - >>> relpath("./plugins/builtins", "./plugins") - '../' - >>> relpath("./plugins/", "./plugins/builtins.ini") - 'builtins.ini' - """ - sourcePath = os.path.normpath(p1) - destPath = os.path.normpath(p2) - - (common, sourceOnly, destOnly) = commonpath(pathsplit(sourcePath), pathsplit(destPath)) - if len(sourceOnly) or len(destOnly): - relParts = itertools.chain( - (('..' + os.sep) * len(sourceOnly), ), - destOnly, - ) - return os.path.join(*relParts) - else: - return "."+os.sep - - -class UTF8Recoder(object): - """ - Iterator that reads an encoded stream and reencodes the input to UTF-8 - """ - def __init__(self, f, encoding): - self.reader = codecs.getreader(encoding)(f) - - def __iter__(self): - return self - - def next(self): - return self.reader.next().encode("utf-8") - - -class UnicodeReader(object): - """ - A CSV reader which will iterate over lines in the CSV file "f", - which is encoded in the given encoding. - """ - - def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): - f = UTF8Recoder(f, encoding) - self.reader = csv.reader(f, dialect=dialect, **kwds) - - def next(self): - row = self.reader.next() - return [unicode(s, "utf-8") for s in row] - - def __iter__(self): - return self - -class UnicodeWriter(object): - """ - A CSV writer which will write rows to CSV file "f", - which is encoded in the given encoding. - """ - - def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): - # Redirect output to a queue - self.queue = StringIO.StringIO() - self.writer = csv.writer(self.queue, dialect=dialect, **kwds) - self.stream = f - self.encoder = codecs.getincrementalencoder(encoding)() - - def writerow(self, row): - self.writer.writerow([s.encode("utf-8") for s in row]) - # Fetch UTF-8 output from the queue ... - data = self.queue.getvalue() - data = data.decode("utf-8") - # ... and reencode it into the target encoding - data = self.encoder.encode(data) - # write to the target stream - self.stream.write(data) - # empty queue - self.queue.truncate(0) - - def writerows(self, rows): - for row in rows: - self.writerow(row) - - -def unicode_csv_reader(unicode_csv_data, dialect=csv.excel, **kwargs): - # csv.py doesn't do Unicode; encode temporarily as UTF-8: - csv_reader = csv.reader(utf_8_encoder(unicode_csv_data), - dialect=dialect, **kwargs) - for row in csv_reader: - # decode UTF-8 back to Unicode, cell by cell: - yield [unicode(cell, 'utf-8') for cell in row] - - -def utf_8_encoder(unicode_csv_data): - for line in unicode_csv_data: - yield line.encode('utf-8') - - -_UNESCAPE_ENTITIES = { - """: '"', - " ": " ", - "'": "'", -} - - -_ESCAPE_ENTITIES = dict((v, k) for (v, k) in zip(_UNESCAPE_ENTITIES.itervalues(), _UNESCAPE_ENTITIES.iterkeys())) -del _ESCAPE_ENTITIES[" "] - - -def unescape(text): - plain = saxutils.unescape(text, _UNESCAPE_ENTITIES) - return plain - - -def escape(text): - fancy = saxutils.escape(text, _ESCAPE_ENTITIES) - return fancy diff --git a/src/util/linux.py b/src/util/linux.py deleted file mode 100644 index 4e77445..0000000 --- a/src/util/linux.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python - - -import os -import logging - -try: - from xdg import BaseDirectory as _BaseDirectory - BaseDirectory = _BaseDirectory -except ImportError: - BaseDirectory = None - - -_moduleLogger = logging.getLogger(__name__) - - -_libc = None - - -def set_process_name(name): - try: # change process name for killall - global _libc - if _libc is None: - import ctypes - _libc = ctypes.CDLL('libc.so.6') - _libc.prctl(15, name, 0, 0, 0) - except Exception, e: - _moduleLogger.warning('Unable to set processName: %s" % e') - - -def get_new_resource(resourceType, resource, name): - if BaseDirectory is not None: - if resourceType == "data": - base = BaseDirectory.xdg_data_home - if base == "/usr/share/mime": - # Ugly hack because somehow Maemo 4.1 seems to be set to this - base = os.path.join(os.path.expanduser("~"), ".%s" % resource) - elif resourceType == "config": - base = BaseDirectory.xdg_config_home - elif resourceType == "cache": - base = BaseDirectory.xdg_cache_home - else: - raise RuntimeError("Unknown type: "+resourceType) - else: - base = os.path.join(os.path.expanduser("~"), ".%s" % resource) - - filePath = os.path.join(base, resource, name) - dirPath = os.path.dirname(filePath) - if not os.path.exists(dirPath): - # Looking before I leap to not mask errors - os.makedirs(dirPath) - - return filePath - - -def get_existing_resource(resourceType, resource, name): - if BaseDirectory is not None: - if resourceType == "data": - base = BaseDirectory.xdg_data_home - elif resourceType == "config": - base = BaseDirectory.xdg_config_home - elif resourceType == "cache": - base = BaseDirectory.xdg_cache_home - else: - raise RuntimeError("Unknown type: "+resourceType) - else: - base = None - - if base is not None: - finalPath = os.path.join(base, name) - if os.path.exists(finalPath): - return finalPath - - altBase = os.path.join(os.path.expanduser("~"), ".%s" % resource) - finalPath = os.path.join(altBase, name) - if os.path.exists(finalPath): - return finalPath - else: - raise RuntimeError("Resource not found: %r" % ((resourceType, resource, name), )) diff --git a/src/util/misc.py b/src/util/misc.py deleted file mode 100644 index 9b8d88c..0000000 --- a/src/util/misc.py +++ /dev/null @@ -1,900 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement - -import sys -import re -import cPickle - -import functools -import contextlib -import inspect - -import optparse -import traceback -import warnings -import string - - -class AnyData(object): - - pass - - -_indentationLevel = [0] - - -def log_call(logger): - - def log_call_decorator(func): - - @functools.wraps(func) - def wrapper(*args, **kwds): - logger.debug("%s> %s" % (" " * _indentationLevel[0], func.__name__, )) - _indentationLevel[0] += 1 - try: - return func(*args, **kwds) - finally: - _indentationLevel[0] -= 1 - logger.debug("%s< %s" % (" " * _indentationLevel[0], func.__name__, )) - - return wrapper - - return log_call_decorator - - -def log_exception(logger): - - def log_exception_decorator(func): - - @functools.wraps(func) - def wrapper(*args, **kwds): - try: - return func(*args, **kwds) - except Exception: - logger.exception(func.__name__) - raise - - return wrapper - - return log_exception_decorator - - -def printfmt(template): - """ - This hides having to create the Template object and call substitute/safe_substitute on it. For example: - - >>> num = 10 - >>> word = "spam" - >>> printfmt("I would like to order $num units of $word, please") #doctest: +SKIP - I would like to order 10 units of spam, please - """ - frame = inspect.stack()[-1][0] - try: - print string.Template(template).safe_substitute(frame.f_locals) - finally: - del frame - - -def is_special(name): - return name.startswith("__") and name.endswith("__") - - -def is_private(name): - return name.startswith("_") and not is_special(name) - - -def privatize(clsName, attributeName): - """ - At runtime, make an attributeName private - - Example: - >>> class Test(object): - ... pass - ... - >>> try: - ... dir(Test).index("_Test__me") - ... print dir(Test) - ... except: - ... print "Not Found" - Not Found - >>> setattr(Test, privatize(Test.__name__, "me"), "Hello World") - >>> try: - ... dir(Test).index("_Test__me") - ... print "Found" - ... except: - ... print dir(Test) - 0 - Found - >>> print getattr(Test, obfuscate(Test.__name__, "__me")) - Hello World - >>> - >>> is_private(privatize(Test.__name__, "me")) - True - >>> is_special(privatize(Test.__name__, "me")) - False - """ - return "".join(["_", clsName, "__", attributeName]) - - -def obfuscate(clsName, attributeName): - """ - At runtime, turn a private name into the obfuscated form - - Example: - >>> class Test(object): - ... __me = "Hello World" - ... - >>> try: - ... dir(Test).index("_Test__me") - ... print "Found" - ... except: - ... print dir(Test) - 0 - Found - >>> print getattr(Test, obfuscate(Test.__name__, "__me")) - Hello World - >>> is_private(obfuscate(Test.__name__, "__me")) - True - >>> is_special(obfuscate(Test.__name__, "__me")) - False - """ - return "".join(["_", clsName, attributeName]) - - -class PAOptionParser(optparse.OptionParser, object): - """ - >>> if __name__ == '__main__': - ... #parser = PAOptionParser("My usage str") - ... parser = PAOptionParser() - ... parser.add_posarg("Foo", help="Foo usage") - ... parser.add_posarg("Bar", dest="bar_dest") - ... parser.add_posarg("Language", dest='tr_type', type="choice", choices=("Python", "Other")) - ... parser.add_option('--stocksym', dest='symbol') - ... values, args = parser.parse_args() - ... print values, args - ... - - python mycp.py -h - python mycp.py - python mycp.py foo - python mycp.py foo bar - - python mycp.py foo bar lava - Usage: pa.py [options] - - Positional Arguments: - Foo: Foo usage - Bar: - Language: - - pa.py: error: option --Language: invalid choice: 'lava' (choose from 'Python', 'Other' - """ - - def __init__(self, *args, **kw): - self.posargs = [] - super(PAOptionParser, self).__init__(*args, **kw) - - def add_posarg(self, *args, **kw): - pa_help = kw.get("help", "") - kw["help"] = optparse.SUPPRESS_HELP - o = self.add_option("--%s" % args[0], *args[1:], **kw) - self.posargs.append((args[0], pa_help)) - - def get_usage(self, *args, **kwargs): - params = (' '.join(["<%s>" % arg[0] for arg in self.posargs]), '\n '.join(["%s: %s" % (arg) for arg in self.posargs])) - self.usage = "%%prog %s [options]\n\nPositional Arguments:\n %s" % params - return super(PAOptionParser, self).get_usage(*args, **kwargs) - - def parse_args(self, *args, **kwargs): - args = sys.argv[1:] - args0 = [] - for p, v in zip(self.posargs, args): - args0.append("--%s" % p[0]) - args0.append(v) - args = args0 + args - options, args = super(PAOptionParser, self).parse_args(args, **kwargs) - if len(args) < len(self.posargs): - msg = 'Missing value(s) for "%s"\n' % ", ".join([arg[0] for arg in self.posargs][len(args):]) - self.error(msg) - return options, args - - -def explicitly(name, stackadd=0): - """ - This is an alias for adding to '__all__'. Less error-prone than using - __all__ itself, since setting __all__ directly is prone to stomping on - things implicitly exported via L{alias}. - - @note Taken from PyExport (which could turn out pretty cool): - @li @a http://codebrowse.launchpad.net/~glyph/ - @li @a http://glyf.livejournal.com/74356.html - """ - packageVars = sys._getframe(1+stackadd).f_locals - globalAll = packageVars.setdefault('__all__', []) - globalAll.append(name) - - -def public(thunk): - """ - This is a decorator, for convenience. Rather than typing the name of your - function twice, you can decorate a function with this. - - To be real, @public would need to work on methods as well, which gets into - supporting types... - - @note Taken from PyExport (which could turn out pretty cool): - @li @a http://codebrowse.launchpad.net/~glyph/ - @li @a http://glyf.livejournal.com/74356.html - """ - explicitly(thunk.__name__, 1) - return thunk - - -def _append_docstring(obj, message): - if obj.__doc__ is None: - obj.__doc__ = message - else: - obj.__doc__ += message - - -def validate_decorator(decorator): - - def simple(x): - return x - - f = simple - f.__name__ = "name" - f.__doc__ = "doc" - f.__dict__["member"] = True - - g = decorator(f) - - if f.__name__ != g.__name__: - print f.__name__, "!=", g.__name__ - - if g.__doc__ is None: - print decorator.__name__, "has no doc string" - elif not g.__doc__.startswith(f.__doc__): - print g.__doc__, "didn't start with", f.__doc__ - - if not ("member" in g.__dict__ and g.__dict__["member"]): - print "'member' not in ", g.__dict__ - - -def deprecated_api(func): - """ - This is a decorator which can be used to mark functions - as deprecated. It will result in a warning being emitted - when the function is used. - - >>> validate_decorator(deprecated_api) - """ - - @functools.wraps(func) - def newFunc(*args, **kwargs): - warnings.warn("Call to deprecated function %s." % func.__name__, category=DeprecationWarning) - return func(*args, **kwargs) - - _append_docstring(newFunc, "\n@deprecated") - return newFunc - - -def unstable_api(func): - """ - This is a decorator which can be used to mark functions - as deprecated. It will result in a warning being emitted - when the function is used. - - >>> validate_decorator(unstable_api) - """ - - @functools.wraps(func) - def newFunc(*args, **kwargs): - warnings.warn("Call to unstable API function %s." % func.__name__, category=FutureWarning) - return func(*args, **kwargs) - _append_docstring(newFunc, "\n@unstable") - return newFunc - - -def enabled(func): - """ - This decorator doesn't add any behavior - - >>> validate_decorator(enabled) - """ - return func - - -def disabled(func): - """ - This decorator disables the provided function, and does nothing - - >>> validate_decorator(disabled) - """ - - @functools.wraps(func) - def emptyFunc(*args, **kargs): - pass - _append_docstring(emptyFunc, "\n@note Temporarily Disabled") - return emptyFunc - - -def metadata(document=True, **kwds): - """ - >>> validate_decorator(metadata(author="Ed")) - """ - - def decorate(func): - for k, v in kwds.iteritems(): - setattr(func, k, v) - if document: - _append_docstring(func, "\n@"+k+" "+v) - return func - return decorate - - -def prop(func): - """Function decorator for defining property attributes - - The decorated function is expected to return a dictionary - containing one or more of the following pairs: - fget - function for getting attribute value - fset - function for setting attribute value - fdel - function for deleting attribute - This can be conveniently constructed by the locals() builtin - function; see: - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/205183 - @author http://kbyanc.blogspot.com/2007/06/python-property-attribute-tricks.html - - Example: - >>> #Due to transformation from function to property, does not need to be validated - >>> #validate_decorator(prop) - >>> class MyExampleClass(object): - ... @prop - ... def foo(): - ... "The foo property attribute's doc-string" - ... def fget(self): - ... print "GET" - ... return self._foo - ... def fset(self, value): - ... print "SET" - ... self._foo = value - ... return locals() - ... - >>> me = MyExampleClass() - >>> me.foo = 10 - SET - >>> print me.foo - GET - 10 - """ - return property(doc=func.__doc__, **func()) - - -def print_handler(e): - """ - @see ExpHandler - """ - print "%s: %s" % (type(e).__name__, e) - - -def print_ignore(e): - """ - @see ExpHandler - """ - print 'Ignoring %s exception: %s' % (type(e).__name__, e) - - -def print_traceback(e): - """ - @see ExpHandler - """ - #print sys.exc_info() - traceback.print_exc(file=sys.stdout) - - -def ExpHandler(handler = print_handler, *exceptions): - """ - An exception handling idiom using decorators - Examples - Specify exceptions in order, first one is handled first - last one last. - - >>> validate_decorator(ExpHandler()) - >>> @ExpHandler(print_ignore, ZeroDivisionError) - ... @ExpHandler(None, AttributeError, ValueError) - ... def f1(): - ... 1/0 - >>> @ExpHandler(print_traceback, ZeroDivisionError) - ... def f2(): - ... 1/0 - >>> @ExpHandler() - ... def f3(*pargs): - ... l = pargs - ... return l[10] - >>> @ExpHandler(print_traceback, ZeroDivisionError) - ... def f4(): - ... return 1 - >>> - >>> - >>> f1() - Ignoring ZeroDivisionError exception: integer division or modulo by zero - >>> f2() # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE - Traceback (most recent call last): - ... - ZeroDivisionError: integer division or modulo by zero - >>> f3() - IndexError: tuple index out of range - >>> f4() - 1 - """ - - def wrapper(f): - localExceptions = exceptions - if not localExceptions: - localExceptions = [Exception] - t = [(ex, handler) for ex in localExceptions] - t.reverse() - - def newfunc(t, *args, **kwargs): - ex, handler = t[0] - try: - if len(t) == 1: - return f(*args, **kwargs) - else: - #Recurse for embedded try/excepts - dec_func = functools.partial(newfunc, t[1:]) - dec_func = functools.update_wrapper(dec_func, f) - return dec_func(*args, **kwargs) - except ex, e: - return handler(e) - - dec_func = functools.partial(newfunc, t) - dec_func = functools.update_wrapper(dec_func, f) - return dec_func - return wrapper - - -def into_debugger(func): - """ - >>> validate_decorator(into_debugger) - """ - - @functools.wraps(func) - def newFunc(*args, **kwargs): - try: - return func(*args, **kwargs) - except: - import pdb - pdb.post_mortem() - - return newFunc - - -class bindclass(object): - """ - >>> validate_decorator(bindclass) - >>> class Foo(BoundObject): - ... @bindclass - ... def foo(this_class, self): - ... return this_class, self - ... - >>> class Bar(Foo): - ... @bindclass - ... def bar(this_class, self): - ... return this_class, self - ... - >>> f = Foo() - >>> b = Bar() - >>> - >>> f.foo() # doctest: +ELLIPSIS - (, <...Foo object at ...>) - >>> b.foo() # doctest: +ELLIPSIS - (, <...Bar object at ...>) - >>> b.bar() # doctest: +ELLIPSIS - (, <...Bar object at ...>) - """ - - def __init__(self, f): - self.f = f - self.__name__ = f.__name__ - self.__doc__ = f.__doc__ - self.__dict__.update(f.__dict__) - self.m = None - - def bind(self, cls, attr): - - def bound_m(*args, **kwargs): - return self.f(cls, *args, **kwargs) - bound_m.__name__ = attr - self.m = bound_m - - def __get__(self, obj, objtype=None): - return self.m.__get__(obj, objtype) - - -class ClassBindingSupport(type): - "@see bindclass" - - def __init__(mcs, name, bases, attrs): - type.__init__(mcs, name, bases, attrs) - for attr, val in attrs.iteritems(): - if isinstance(val, bindclass): - val.bind(mcs, attr) - - -class BoundObject(object): - "@see bindclass" - __metaclass__ = ClassBindingSupport - - -def bindfunction(f): - """ - >>> validate_decorator(bindfunction) - >>> @bindfunction - ... def factorial(thisfunction, n): - ... # Within this function the name 'thisfunction' refers to the factorial - ... # function(with only one argument), even after 'factorial' is bound - ... # to another object - ... if n > 0: - ... return n * thisfunction(n - 1) - ... else: - ... return 1 - ... - >>> factorial(3) - 6 - """ - - @functools.wraps(f) - def bound_f(*args, **kwargs): - return f(bound_f, *args, **kwargs) - return bound_f - - -class Memoize(object): - """ - Memoize(fn) - an instance which acts like fn but memoizes its arguments - Will only work on functions with non-mutable arguments - @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52201 - - >>> validate_decorator(Memoize) - """ - - def __init__(self, fn): - self.fn = fn - self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ - self.__dict__.update(fn.__dict__) - self.memo = {} - - def __call__(self, *args): - if args not in self.memo: - self.memo[args] = self.fn(*args) - return self.memo[args] - - -class MemoizeMutable(object): - """Memoize(fn) - an instance which acts like fn but memoizes its arguments - Will work on functions with mutable arguments(slower than Memoize) - @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52201 - - >>> validate_decorator(MemoizeMutable) - """ - - def __init__(self, fn): - self.fn = fn - self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ - self.__dict__.update(fn.__dict__) - self.memo = {} - - def __call__(self, *args, **kw): - text = cPickle.dumps((args, kw)) - if text not in self.memo: - self.memo[text] = self.fn(*args, **kw) - return self.memo[text] - - -callTraceIndentationLevel = 0 - - -def call_trace(f): - """ - Synchronization decorator. - - >>> validate_decorator(call_trace) - >>> @call_trace - ... def a(a, b, c): - ... pass - >>> a(1, 2, c=3) - Entering a((1, 2), {'c': 3}) - Exiting a((1, 2), {'c': 3}) - """ - - @functools.wraps(f) - def verboseTrace(*args, **kw): - global callTraceIndentationLevel - - print "%sEntering %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) - callTraceIndentationLevel += 1 - try: - result = f(*args, **kw) - except: - callTraceIndentationLevel -= 1 - print "%sException %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) - raise - callTraceIndentationLevel -= 1 - print "%sExiting %s(%s, %s)" % ("\t"*callTraceIndentationLevel, f.__name__, args, kw) - return result - - @functools.wraps(f) - def smallTrace(*args, **kw): - global callTraceIndentationLevel - - print "%sEntering %s" % ("\t"*callTraceIndentationLevel, f.__name__) - callTraceIndentationLevel += 1 - try: - result = f(*args, **kw) - except: - callTraceIndentationLevel -= 1 - print "%sException %s" % ("\t"*callTraceIndentationLevel, f.__name__) - raise - callTraceIndentationLevel -= 1 - print "%sExiting %s" % ("\t"*callTraceIndentationLevel, f.__name__) - return result - - #return smallTrace - return verboseTrace - - -@contextlib.contextmanager -def nested_break(): - """ - >>> with nested_break() as mylabel: - ... for i in xrange(3): - ... print "Outer", i - ... for j in xrange(3): - ... if i == 2: raise mylabel - ... if j == 2: break - ... print "Inner", j - ... print "more processing" - Outer 0 - Inner 0 - Inner 1 - Outer 1 - Inner 0 - Inner 1 - Outer 2 - """ - - class NestedBreakException(Exception): - pass - - try: - yield NestedBreakException - except NestedBreakException: - pass - - -@contextlib.contextmanager -def lexical_scope(*args): - """ - @note Source: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/520586 - Example: - >>> b = 0 - >>> with lexical_scope(1) as (a): - ... print a - ... - 1 - >>> with lexical_scope(1,2,3) as (a,b,c): - ... print a,b,c - ... - 1 2 3 - >>> with lexical_scope(): - ... d = 10 - ... def foo(): - ... pass - ... - >>> print b - 2 - """ - - frame = inspect.currentframe().f_back.f_back - saved = frame.f_locals.keys() - try: - if not args: - yield - elif len(args) == 1: - yield args[0] - else: - yield args - finally: - f_locals = frame.f_locals - for key in (x for x in f_locals.keys() if x not in saved): - del f_locals[key] - del frame - - -def normalize_number(prettynumber): - """ - function to take a phone number and strip out all non-numeric - characters - - >>> normalize_number("+012-(345)-678-90") - '+01234567890' - >>> normalize_number("1-(345)-678-9000") - '+13456789000' - >>> normalize_number("+1-(345)-678-9000") - '+13456789000' - """ - uglynumber = re.sub('[^0-9+]', '', prettynumber) - if uglynumber.startswith("+"): - pass - elif uglynumber.startswith("1"): - uglynumber = "+"+uglynumber - elif 10 <= len(uglynumber): - assert uglynumber[0] not in ("+", "1"), "Number format confusing" - uglynumber = "+1"+uglynumber - else: - pass - - return uglynumber - - -_VALIDATE_RE = re.compile("^\+?[0-9]{10,}$") - - -def is_valid_number(number): - """ - @returns If This number be called ( syntax validation only ) - """ - return _VALIDATE_RE.match(number) is not None - - -def make_ugly(prettynumber): - """ - function to take a phone number and strip out all non-numeric - characters - - >>> make_ugly("+012-(345)-678-90") - '+01234567890' - """ - return normalize_number(prettynumber) - - -def _make_pretty_with_areacode(phonenumber): - prettynumber = "(%s)" % (phonenumber[0:3], ) - if 3 < len(phonenumber): - prettynumber += " %s" % (phonenumber[3:6], ) - if 6 < len(phonenumber): - prettynumber += "-%s" % (phonenumber[6:], ) - return prettynumber - - -def _make_pretty_local(phonenumber): - prettynumber = "%s" % (phonenumber[0:3], ) - if 3 < len(phonenumber): - prettynumber += "-%s" % (phonenumber[3:], ) - return prettynumber - - -def _make_pretty_international(phonenumber): - prettynumber = phonenumber - if phonenumber.startswith("1"): - prettynumber = "1 " - prettynumber += _make_pretty_with_areacode(phonenumber[1:]) - return prettynumber - - -def make_pretty(phonenumber): - """ - Function to take a phone number and return the pretty version - pretty numbers: - if phonenumber begins with 0: - ...-(...)-...-.... - if phonenumber begins with 1: ( for gizmo callback numbers ) - 1 (...)-...-.... - if phonenumber is 13 digits: - (...)-...-.... - if phonenumber is 10 digits: - ...-.... - >>> make_pretty("12") - '12' - >>> make_pretty("1234567") - '123-4567' - >>> make_pretty("2345678901") - '+1 (234) 567-8901' - >>> make_pretty("12345678901") - '+1 (234) 567-8901' - >>> make_pretty("01234567890") - '+012 (345) 678-90' - >>> make_pretty("+01234567890") - '+012 (345) 678-90' - >>> make_pretty("+12") - '+1 (2)' - >>> make_pretty("+123") - '+1 (23)' - >>> make_pretty("+1234") - '+1 (234)' - """ - if phonenumber is None or phonenumber == "": - return "" - - phonenumber = normalize_number(phonenumber) - - if phonenumber == "": - return "" - elif phonenumber[0] == "+": - prettynumber = _make_pretty_international(phonenumber[1:]) - if not prettynumber.startswith("+"): - prettynumber = "+"+prettynumber - elif 8 < len(phonenumber) and phonenumber[0] in ("1", ): - prettynumber = _make_pretty_international(phonenumber) - elif 7 < len(phonenumber): - prettynumber = _make_pretty_with_areacode(phonenumber) - elif 3 < len(phonenumber): - prettynumber = _make_pretty_local(phonenumber) - else: - prettynumber = phonenumber - return prettynumber.strip() - - -def similar_ugly_numbers(lhs, rhs): - return ( - lhs == rhs or - lhs[1:] == rhs and lhs.startswith("1") or - lhs[2:] == rhs and lhs.startswith("+1") or - lhs == rhs[1:] and rhs.startswith("1") or - lhs == rhs[2:] and rhs.startswith("+1") - ) - - -def abbrev_relative_date(date): - """ - >>> abbrev_relative_date("42 hours ago") - '42 h' - >>> abbrev_relative_date("2 days ago") - '2 d' - >>> abbrev_relative_date("4 weeks ago") - '4 w' - """ - parts = date.split(" ") - return "%s %s" % (parts[0], parts[1][0]) - - -def parse_version(versionText): - """ - >>> parse_version("0.5.2") - [0, 5, 2] - """ - return [ - int(number) - for number in versionText.split(".") - ] - - -def compare_versions(leftParsedVersion, rightParsedVersion): - """ - >>> compare_versions([0, 1, 2], [0, 1, 2]) - 0 - >>> compare_versions([0, 1, 2], [0, 1, 3]) - -1 - >>> compare_versions([0, 1, 2], [0, 2, 2]) - -1 - >>> compare_versions([0, 1, 2], [1, 1, 2]) - -1 - >>> compare_versions([0, 1, 3], [0, 1, 2]) - 1 - >>> compare_versions([0, 2, 2], [0, 1, 2]) - 1 - >>> compare_versions([1, 1, 2], [0, 1, 2]) - 1 - """ - for left, right in zip(leftParsedVersion, rightParsedVersion): - if left < right: - return -1 - elif right < left: - return 1 - else: - return 0 diff --git a/src/util/overloading.py b/src/util/overloading.py deleted file mode 100644 index 89cb738..0000000 --- a/src/util/overloading.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python -import new - -# Make the environment more like Python 3.0 -__metaclass__ = type -from itertools import izip as zip -import textwrap -import inspect - - -__all__ = [ - "AnyType", - "overloaded" -] - - -AnyType = object - - -class overloaded: - """ - Dynamically overloaded functions. - - This is an implementation of (dynamically, or run-time) overloaded - functions; also known as generic functions or multi-methods. - - The dispatch algorithm uses the types of all argument for dispatch, - similar to (compile-time) overloaded functions or methods in C++ and - Java. - - Most of the complexity in the algorithm comes from the need to support - subclasses in call signatures. For example, if an function is - registered for a signature (T1, T2), then a call with a signature (S1, - S2) is acceptable, assuming that S1 is a subclass of T1, S2 a subclass - of T2, and there are no other more specific matches (see below). - - If there are multiple matches and one of those doesn't *dominate* all - others, the match is deemed ambiguous and an exception is raised. A - subtlety here: if, after removing the dominated matches, there are - still multiple matches left, but they all map to the same function, - then the match is not deemed ambiguous and that function is used. - Read the method find_func() below for details. - - @note Python 2.5 is required due to the use of predicates any() and all(). - @note only supports positional arguments - - @author http://www.artima.com/weblogs/viewpost.jsp?thread=155514 - - >>> import misc - >>> misc.validate_decorator (overloaded) - >>> - >>> - >>> - >>> - >>> ################# - >>> #Basics, with reusing names and without - >>> @overloaded - ... def foo(x): - ... "prints x" - ... print x - ... - >>> @foo.register(int) - ... def foo(x): - ... "prints the hex representation of x" - ... print hex(x) - ... - >>> from types import DictType - >>> @foo.register(DictType) - ... def foo_dict(x): - ... "prints the keys of x" - ... print [k for k in x.iterkeys()] - ... - >>> #combines all of the doc strings to help keep track of the specializations - >>> foo.__doc__ # doctest: +ELLIPSIS - "prints x\\n\\n...overloading.foo ():\\n\\tprints the hex representation of x\\n\\n...overloading.foo_dict ():\\n\\tprints the keys of x" - >>> foo ("text") - text - >>> foo (10) #calling the specialized foo - 0xa - >>> foo ({3:5, 6:7}) #calling the specialization foo_dict - [3, 6] - >>> foo_dict ({3:5, 6:7}) #with using a unique name, you still have the option of calling the function directly - [3, 6] - >>> - >>> - >>> - >>> - >>> ################# - >>> #Multiple arguments, accessing the default, and function finding - >>> @overloaded - ... def two_arg (x, y): - ... print x,y - ... - >>> @two_arg.register(int, int) - ... def two_arg_int_int (x, y): - ... print hex(x), hex(y) - ... - >>> @two_arg.register(float, int) - ... def two_arg_float_int (x, y): - ... print x, hex(y) - ... - >>> @two_arg.register(int, float) - ... def two_arg_int_float (x, y): - ... print hex(x), y - ... - >>> two_arg.__doc__ # doctest: +ELLIPSIS - "...overloading.two_arg_int_int (, ):\\n\\n...overloading.two_arg_float_int (, ):\\n\\n...overloading.two_arg_int_float (, ):" - >>> two_arg(9, 10) - 0x9 0xa - >>> two_arg(9.0, 10) - 9.0 0xa - >>> two_arg(15, 16.0) - 0xf 16.0 - >>> two_arg.default_func(9, 10) - 9 10 - >>> two_arg.find_func ((int, float)) == two_arg_int_float - True - >>> (int, float) in two_arg - True - >>> (str, int) in two_arg - False - >>> - >>> - >>> - >>> ################# - >>> #wildcard - >>> @two_arg.register(AnyType, str) - ... def two_arg_any_str (x, y): - ... print x, y.lower() - ... - >>> two_arg("Hello", "World") - Hello world - >>> two_arg(500, "World") - 500 world - """ - - def __init__(self, default_func): - # Decorator to declare new overloaded function. - self.registry = {} - self.cache = {} - self.default_func = default_func - self.__name__ = self.default_func.__name__ - self.__doc__ = self.default_func.__doc__ - self.__dict__.update (self.default_func.__dict__) - - def __get__(self, obj, type=None): - if obj is None: - return self - return new.instancemethod(self, obj) - - def register(self, *types): - """ - Decorator to register an implementation for a specific set of types. - - .register(t1, t2)(f) is equivalent to .register_func((t1, t2), f). - """ - - def helper(func): - self.register_func(types, func) - - originalDoc = self.__doc__ if self.__doc__ is not None else "" - typeNames = ", ".join ([str(type) for type in types]) - typeNames = "".join ([func.__module__+".", func.__name__, " (", typeNames, "):"]) - overloadedDoc = "" - if func.__doc__ is not None: - overloadedDoc = textwrap.fill (func.__doc__, width=60, initial_indent="\t", subsequent_indent="\t") - self.__doc__ = "\n".join ([originalDoc, "", typeNames, overloadedDoc]).strip() - - new_func = func - - #Masking the function, so we want to take on its traits - if func.__name__ == self.__name__: - self.__dict__.update (func.__dict__) - new_func = self - return new_func - - return helper - - def register_func(self, types, func): - """Helper to register an implementation.""" - self.registry[tuple(types)] = func - self.cache = {} # Clear the cache (later we can optimize this). - - def __call__(self, *args): - """Call the overloaded function.""" - types = tuple(map(type, args)) - func = self.cache.get(types) - if func is None: - self.cache[types] = func = self.find_func(types) - return func(*args) - - def __contains__ (self, types): - return self.find_func(types) is not self.default_func - - def find_func(self, types): - """Find the appropriate overloaded function; don't call it. - - @note This won't work for old-style classes or classes without __mro__ - """ - func = self.registry.get(types) - if func is not None: - # Easy case -- direct hit in registry. - return func - - # Phillip Eby suggests to use issubclass() instead of __mro__. - # There are advantages and disadvantages. - - # I can't help myself -- this is going to be intense functional code. - # Find all possible candidate signatures. - mros = tuple(inspect.getmro(t) for t in types) - n = len(mros) - candidates = [sig for sig in self.registry - if len(sig) == n and - all(t in mro for t, mro in zip(sig, mros))] - - if not candidates: - # No match at all -- use the default function. - return self.default_func - elif len(candidates) == 1: - # Unique match -- that's an easy case. - return self.registry[candidates[0]] - - # More than one match -- weed out the subordinate ones. - - def dominates(dom, sub, - orders=tuple(dict((t, i) for i, t in enumerate(mro)) - for mro in mros)): - # Predicate to decide whether dom strictly dominates sub. - # Strict domination is defined as domination without equality. - # The arguments dom and sub are type tuples of equal length. - # The orders argument is a precomputed auxiliary data structure - # giving dicts of ordering information corresponding to the - # positions in the type tuples. - # A type d dominates a type s iff order[d] <= order[s]. - # A type tuple (d1, d2, ...) dominates a type tuple of equal length - # (s1, s2, ...) iff d1 dominates s1, d2 dominates s2, etc. - if dom is sub: - return False - return all(order[d] <= order[s] for d, s, order in zip(dom, sub, orders)) - - # I suppose I could inline dominates() but it wouldn't get any clearer. - candidates = [cand - for cand in candidates - if not any(dominates(dom, cand) for dom in candidates)] - if len(candidates) == 1: - # There's exactly one candidate left. - return self.registry[candidates[0]] - - # Perhaps these multiple candidates all have the same implementation? - funcs = set(self.registry[cand] for cand in candidates) - if len(funcs) == 1: - return funcs.pop() - - # No, the situation is irreducibly ambiguous. - raise TypeError("ambigous call; types=%r; candidates=%r" % - (types, candidates)) diff --git a/src/util/qore_utils.py b/src/util/qore_utils.py deleted file mode 100644 index 153558d..0000000 --- a/src/util/qore_utils.py +++ /dev/null @@ -1,99 +0,0 @@ -import logging - -import qt_compat -QtCore = qt_compat.QtCore - -import misc - - -_moduleLogger = logging.getLogger(__name__) - - -class QThread44(QtCore.QThread): - """ - This is to imitate QThread in Qt 4.4+ for when running on older version - See http://labs.trolltech.com/blogs/2010/06/17/youre-doing-it-wrong - (On Lucid I have Qt 4.7 and this is still an issue) - """ - - def __init__(self, parent = None): - QtCore.QThread.__init__(self, parent) - - def run(self): - self.exec_() - - -class _WorkerThread(QtCore.QObject): - - _taskComplete = qt_compat.Signal(object) - - def __init__(self, futureThread): - QtCore.QObject.__init__(self) - self._futureThread = futureThread - self._futureThread._addTask.connect(self._on_task_added) - self._taskComplete.connect(self._futureThread._on_task_complete) - - @qt_compat.Slot(object) - def _on_task_added(self, task): - self.__on_task_added(task) - - @misc.log_exception(_moduleLogger) - def __on_task_added(self, task): - if not self._futureThread._isRunning: - _moduleLogger.error("Dropping task") - - func, args, kwds, on_success, on_error = task - - try: - result = func(*args, **kwds) - isError = False - except Exception, e: - _moduleLogger.error("Error, passing it back to the main thread") - result = e - isError = True - - taskResult = on_success, on_error, isError, result - self._taskComplete.emit(taskResult) - - -class FutureThread(QtCore.QObject): - - _addTask = qt_compat.Signal(object) - - def __init__(self): - QtCore.QObject.__init__(self) - self._thread = QThread44() - self._isRunning = False - self._worker = _WorkerThread(self) - self._worker.moveToThread(self._thread) - - def start(self): - self._thread.start() - self._isRunning = True - - def stop(self): - self._isRunning = False - self._thread.quit() - - def add_task(self, func, args, kwds, on_success, on_error): - assert self._isRunning, "Task queue not started" - task = func, args, kwds, on_success, on_error - self._addTask.emit(task) - - @qt_compat.Slot(object) - def _on_task_complete(self, taskResult): - self.__on_task_complete(taskResult) - - @misc.log_exception(_moduleLogger) - def __on_task_complete(self, taskResult): - on_success, on_error, isError, result = taskResult - if not self._isRunning: - if isError: - _moduleLogger.error("Masking: %s" % (result, )) - isError = True - result = StopIteration("Cancelling all callbacks") - callback = on_success if not isError else on_error - try: - callback(result) - except Exception: - _moduleLogger.exception("Callback errored") diff --git a/src/util/qt_compat.py b/src/util/qt_compat.py deleted file mode 100644 index 2ab7fa4..0000000 --- a/src/util/qt_compat.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -#try: -# import PySide.QtCore as _QtCore -# QtCore = _QtCore -# USES_PYSIDE = True -#except ImportError: -if True: - import sip - sip.setapi('QString', 2) - sip.setapi('QVariant', 2) - import PyQt4.QtCore as _QtCore - QtCore = _QtCore - USES_PYSIDE = False - - -def _pyside_import_module(moduleName): - pyside = __import__('PySide', globals(), locals(), [moduleName], -1) - return getattr(pyside, moduleName) - - -def _pyqt4_import_module(moduleName): - pyside = __import__('PyQt4', globals(), locals(), [moduleName], -1) - return getattr(pyside, moduleName) - - -if USES_PYSIDE: - import_module = _pyside_import_module - - Signal = QtCore.Signal - Slot = QtCore.Slot - Property = QtCore.Property -else: - import_module = _pyqt4_import_module - - Signal = QtCore.pyqtSignal - Slot = QtCore.pyqtSlot - Property = QtCore.pyqtProperty - - -if __name__ == "__main__": - pass - diff --git a/src/util/qtpie.py b/src/util/qtpie.py deleted file mode 100755 index 6b77d5d..0000000 --- a/src/util/qtpie.py +++ /dev/null @@ -1,1094 +0,0 @@ -#!/usr/bin/env python - -import math -import logging - -import qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -import misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -_TWOPI = 2 * math.pi - - -def _radius_at(center, pos): - delta = pos - center - xDelta = delta.x() - yDelta = delta.y() - - radius = math.sqrt(xDelta ** 2 + yDelta ** 2) - return radius - - -def _angle_at(center, pos): - delta = pos - center - xDelta = delta.x() - yDelta = delta.y() - - radius = math.sqrt(xDelta ** 2 + yDelta ** 2) - angle = math.acos(xDelta / radius) - if 0 <= yDelta: - angle = _TWOPI - angle - - return angle - - -class QActionPieItem(object): - - def __init__(self, action, weight = 1): - self._action = action - self._weight = weight - - def action(self): - return self._action - - def setWeight(self, weight): - self._weight = weight - - def weight(self): - return self._weight - - def setEnabled(self, enabled = True): - self._action.setEnabled(enabled) - - def isEnabled(self): - return self._action.isEnabled() - - -class PieFiling(object): - - INNER_RADIUS_DEFAULT = 64 - OUTER_RADIUS_DEFAULT = 192 - - SELECTION_CENTER = -1 - SELECTION_NONE = -2 - - NULL_CENTER = QActionPieItem(QtGui.QAction(None)) - - def __init__(self): - self._innerRadius = self.INNER_RADIUS_DEFAULT - self._outerRadius = self.OUTER_RADIUS_DEFAULT - self._children = [] - self._center = self.NULL_CENTER - - self._cacheIndexToAngle = {} - self._cacheTotalWeight = 0 - - def insertItem(self, item, index = -1): - self._children.insert(index, item) - self._invalidate_cache() - - def removeItemAt(self, index): - item = self._children.pop(index) - self._invalidate_cache() - - def set_center(self, item): - if item is None: - item = self.NULL_CENTER - self._center = item - - def center(self): - return self._center - - def clear(self): - del self._children[:] - self._center = self.NULL_CENTER - self._invalidate_cache() - - def itemAt(self, index): - return self._children[index] - - def indexAt(self, center, point): - return self._angle_to_index(_angle_at(center, point)) - - def innerRadius(self): - return self._innerRadius - - def setInnerRadius(self, radius): - self._innerRadius = radius - - def outerRadius(self): - return self._outerRadius - - def setOuterRadius(self, radius): - self._outerRadius = radius - - def __iter__(self): - return iter(self._children) - - def __len__(self): - return len(self._children) - - def __getitem__(self, index): - return self._children[index] - - def _invalidate_cache(self): - self._cacheIndexToAngle.clear() - self._cacheTotalWeight = sum(child.weight() for child in self._children) - if self._cacheTotalWeight == 0: - self._cacheTotalWeight = 1 - - def _index_to_angle(self, index, isShifted): - key = index, isShifted - if key in self._cacheIndexToAngle: - return self._cacheIndexToAngle[key] - index = index % len(self._children) - - baseAngle = _TWOPI / self._cacheTotalWeight - - angle = math.pi / 2 - if isShifted: - if self._children: - angle -= (self._children[0].weight() * baseAngle) / 2 - else: - angle -= baseAngle / 2 - while angle < 0: - angle += _TWOPI - - for i, child in enumerate(self._children): - if index < i: - break - angle += child.weight() * baseAngle - while _TWOPI < angle: - angle -= _TWOPI - - self._cacheIndexToAngle[key] = angle - return angle - - def _angle_to_index(self, angle): - numChildren = len(self._children) - if numChildren == 0: - return self.SELECTION_CENTER - - baseAngle = _TWOPI / self._cacheTotalWeight - - iterAngle = math.pi / 2 - (self.itemAt(0).weight() * baseAngle) / 2 - while iterAngle < 0: - iterAngle += _TWOPI - - oldIterAngle = iterAngle - for index, child in enumerate(self._children): - iterAngle += child.weight() * baseAngle - if oldIterAngle < angle and angle <= iterAngle: - return index - 1 if index != 0 else numChildren - 1 - elif oldIterAngle < (angle + _TWOPI) and (angle + _TWOPI <= iterAngle): - return index - 1 if index != 0 else numChildren - 1 - oldIterAngle = iterAngle - - -class PieArtist(object): - - ICON_SIZE_DEFAULT = 48 - - SHAPE_CIRCLE = "circle" - SHAPE_SQUARE = "square" - DEFAULT_SHAPE = SHAPE_SQUARE - - BACKGROUND_FILL = "fill" - BACKGROUND_NOFILL = "no fill" - - def __init__(self, filing, background = BACKGROUND_FILL): - self._filing = filing - - self._cachedOuterRadius = self._filing.outerRadius() - self._cachedInnerRadius = self._filing.innerRadius() - canvasSize = self._cachedOuterRadius * 2 + 1 - self._canvas = QtGui.QPixmap(canvasSize, canvasSize) - self._mask = None - self._backgroundState = background - self.palette = None - - def pieSize(self): - diameter = self._filing.outerRadius() * 2 + 1 - return QtCore.QSize(diameter, diameter) - - def centerSize(self): - painter = QtGui.QPainter(self._canvas) - text = self._filing.center().action().text() - fontMetrics = painter.fontMetrics() - if text: - textBoundingRect = fontMetrics.boundingRect(text) - else: - textBoundingRect = QtCore.QRect() - textWidth = textBoundingRect.width() - textHeight = textBoundingRect.height() - - return QtCore.QSize( - textWidth + self.ICON_SIZE_DEFAULT, - max(textHeight, self.ICON_SIZE_DEFAULT), - ) - - def show(self, palette): - self.palette = palette - - if ( - self._cachedOuterRadius != self._filing.outerRadius() or - self._cachedInnerRadius != self._filing.innerRadius() - ): - self._cachedOuterRadius = self._filing.outerRadius() - self._cachedInnerRadius = self._filing.innerRadius() - self._canvas = self._canvas.scaled(self.pieSize()) - - if self._mask is None: - self._mask = QtGui.QBitmap(self._canvas.size()) - self._mask.fill(QtCore.Qt.color0) - self._generate_mask(self._mask) - self._canvas.setMask(self._mask) - return self._mask - - def hide(self): - self.palette = None - - def paint(self, selectionIndex): - painter = QtGui.QPainter(self._canvas) - painter.setRenderHint(QtGui.QPainter.Antialiasing, True) - - self.paintPainter(selectionIndex, painter) - - return self._canvas - - def paintPainter(self, selectionIndex, painter): - adjustmentRect = painter.viewport().adjusted(0, 0, -1, -1) - - numChildren = len(self._filing) - if numChildren == 0: - self._paint_center_background(painter, adjustmentRect, selectionIndex) - self._paint_center_foreground(painter, adjustmentRect, selectionIndex) - return self._canvas - else: - for i in xrange(len(self._filing)): - self._paint_slice_background(painter, adjustmentRect, i, selectionIndex) - - self._paint_center_background(painter, adjustmentRect, selectionIndex) - self._paint_center_foreground(painter, adjustmentRect, selectionIndex) - - for i in xrange(len(self._filing)): - self._paint_slice_foreground(painter, adjustmentRect, i, selectionIndex) - - def _generate_mask(self, mask): - """ - Specifies on the mask the shape of the pie menu - """ - painter = QtGui.QPainter(mask) - painter.setPen(QtCore.Qt.color1) - painter.setBrush(QtCore.Qt.color1) - if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: - painter.drawRect(mask.rect()) - elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: - painter.drawEllipse(mask.rect().adjusted(0, 0, -1, -1)) - else: - raise NotImplementedError(self.DEFAULT_SHAPE) - - def _paint_slice_background(self, painter, adjustmentRect, i, selectionIndex): - if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: - currentWidth = adjustmentRect.width() - newWidth = math.sqrt(2) * currentWidth - dx = (newWidth - currentWidth) / 2 - adjustmentRect = adjustmentRect.adjusted(-dx, -dx, dx, dx) - elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: - pass - else: - raise NotImplementedError(self.DEFAULT_SHAPE) - - if self._backgroundState == self.BACKGROUND_NOFILL: - painter.setBrush(QtGui.QBrush(QtCore.Qt.transparent)) - painter.setPen(self.palette.highlight().color()) - else: - if i == selectionIndex and self._filing[i].isEnabled(): - painter.setBrush(self.palette.highlight()) - painter.setPen(self.palette.highlight().color()) - else: - painter.setBrush(self.palette.window()) - painter.setPen(self.palette.window().color()) - - a = self._filing._index_to_angle(i, True) - b = self._filing._index_to_angle(i + 1, True) - if b < a: - b += _TWOPI - size = b - a - if size < 0: - size += _TWOPI - - startAngleInDeg = (a * 360 * 16) / _TWOPI - sizeInDeg = (size * 360 * 16) / _TWOPI - painter.drawPie(adjustmentRect, int(startAngleInDeg), int(sizeInDeg)) - - def _paint_slice_foreground(self, painter, adjustmentRect, i, selectionIndex): - child = self._filing[i] - - a = self._filing._index_to_angle(i, True) - b = self._filing._index_to_angle(i + 1, True) - if b < a: - b += _TWOPI - middleAngle = (a + b) / 2 - averageRadius = (self._cachedInnerRadius + self._cachedOuterRadius) / 2 - - sliceX = averageRadius * math.cos(middleAngle) - sliceY = - averageRadius * math.sin(middleAngle) - - piePos = adjustmentRect.center() - pieX = piePos.x() - pieY = piePos.y() - self._paint_label( - painter, child.action(), i == selectionIndex, pieX+sliceX, pieY+sliceY - ) - - def _paint_label(self, painter, action, isSelected, x, y): - text = action.text() - fontMetrics = painter.fontMetrics() - if text: - textBoundingRect = fontMetrics.boundingRect(text) - else: - textBoundingRect = QtCore.QRect() - textWidth = textBoundingRect.width() - textHeight = textBoundingRect.height() - - icon = action.icon().pixmap( - QtCore.QSize(self.ICON_SIZE_DEFAULT, self.ICON_SIZE_DEFAULT), - QtGui.QIcon.Normal, - QtGui.QIcon.On, - ) - iconWidth = icon.width() - iconHeight = icon.width() - averageWidth = (iconWidth + textWidth)/2 - if not icon.isNull(): - iconRect = QtCore.QRect( - x - averageWidth, - y - iconHeight/2, - iconWidth, - iconHeight, - ) - - painter.drawPixmap(iconRect, icon) - - if text: - if isSelected: - if action.isEnabled(): - pen = self.palette.highlightedText() - brush = self.palette.highlight() - else: - pen = self.palette.mid() - brush = self.palette.window() - else: - if action.isEnabled(): - pen = self.palette.windowText() - else: - pen = self.palette.mid() - brush = self.palette.window() - - leftX = x - averageWidth + iconWidth - topY = y + textHeight/2 - painter.setPen(pen.color()) - painter.setBrush(brush) - painter.drawText(leftX, topY, text) - - def _paint_center_background(self, painter, adjustmentRect, selectionIndex): - if self._backgroundState == self.BACKGROUND_NOFILL: - return - if len(self._filing) == 0: - if self._backgroundState == self.BACKGROUND_NOFILL: - painter.setBrush(QtGui.QBrush(QtCore.Qt.transparent)) - else: - if selectionIndex == PieFiling.SELECTION_CENTER and self._filing.center().isEnabled(): - painter.setBrush(self.palette.highlight()) - else: - painter.setBrush(self.palette.window()) - painter.setPen(self.palette.mid().color()) - - painter.drawRect(adjustmentRect) - else: - dark = self.palette.mid().color() - light = self.palette.light().color() - if self._backgroundState == self.BACKGROUND_NOFILL: - background = QtGui.QBrush(QtCore.Qt.transparent) - else: - if selectionIndex == PieFiling.SELECTION_CENTER and self._filing.center().isEnabled(): - background = self.palette.highlight().color() - else: - background = self.palette.window().color() - - innerRadius = self._cachedInnerRadius - adjustmentCenterPos = adjustmentRect.center() - innerRect = QtCore.QRect( - adjustmentCenterPos.x() - innerRadius, - adjustmentCenterPos.y() - innerRadius, - innerRadius * 2 + 1, - innerRadius * 2 + 1, - ) - - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(background) - painter.drawPie(innerRect, 0, 360 * 16) - - if self.DEFAULT_SHAPE == self.SHAPE_SQUARE: - pass - elif self.DEFAULT_SHAPE == self.SHAPE_CIRCLE: - painter.setPen(QtGui.QPen(dark, 1)) - painter.setBrush(QtCore.Qt.NoBrush) - painter.drawEllipse(adjustmentRect) - else: - raise NotImplementedError(self.DEFAULT_SHAPE) - - def _paint_center_foreground(self, painter, adjustmentRect, selectionIndex): - centerPos = adjustmentRect.center() - pieX = centerPos.x() - pieY = centerPos.y() - - x = pieX - y = pieY - - self._paint_label( - painter, - self._filing.center().action(), - selectionIndex == PieFiling.SELECTION_CENTER, - x, y - ) - - -class QPieDisplay(QtGui.QWidget): - - def __init__(self, filing, parent = None, flags = QtCore.Qt.Window): - QtGui.QWidget.__init__(self, parent, flags) - self._filing = filing - self._artist = PieArtist(self._filing) - self._selectionIndex = PieFiling.SELECTION_NONE - - def popup(self, pos): - self._update_selection(pos) - self.show() - - def sizeHint(self): - return self._artist.pieSize() - - @misc_utils.log_exception(_moduleLogger) - def showEvent(self, showEvent): - mask = self._artist.show(self.palette()) - self.setMask(mask) - - QtGui.QWidget.showEvent(self, showEvent) - - @misc_utils.log_exception(_moduleLogger) - def hideEvent(self, hideEvent): - self._artist.hide() - self._selectionIndex = PieFiling.SELECTION_NONE - QtGui.QWidget.hideEvent(self, hideEvent) - - @misc_utils.log_exception(_moduleLogger) - def paintEvent(self, paintEvent): - canvas = self._artist.paint(self._selectionIndex) - offset = (self.size() - canvas.size()) / 2 - - screen = QtGui.QPainter(self) - screen.drawPixmap(QtCore.QPoint(offset.width(), offset.height()), canvas) - - QtGui.QWidget.paintEvent(self, paintEvent) - - def selectAt(self, index): - oldIndex = self._selectionIndex - self._selectionIndex = index - if self.isVisible(): - self.update() - - -class QPieButton(QtGui.QWidget): - - activated = qt_compat.Signal(int) - highlighted = qt_compat.Signal(int) - canceled = qt_compat.Signal() - aboutToShow = qt_compat.Signal() - aboutToHide = qt_compat.Signal() - - BUTTON_RADIUS = 24 - DELAY = 250 - - def __init__(self, buttonSlice, parent = None, buttonSlices = None): - # @bug Artifacts on Maemo 5 due to window 3D effects, find way to disable them for just these? - # @bug The pie's are being pushed back on screen on Maemo, leading to coordinate issues - QtGui.QWidget.__init__(self, parent) - self._cachedCenterPosition = self.rect().center() - - self._filing = PieFiling() - self._display = QPieDisplay(self._filing, None, QtCore.Qt.SplashScreen) - self._selectionIndex = PieFiling.SELECTION_NONE - - self._buttonFiling = PieFiling() - self._buttonFiling.set_center(buttonSlice) - if buttonSlices is not None: - for slice in buttonSlices: - self._buttonFiling.insertItem(slice) - self._buttonFiling.setOuterRadius(self.BUTTON_RADIUS) - self._buttonArtist = PieArtist(self._buttonFiling, PieArtist.BACKGROUND_NOFILL) - self._poppedUp = False - self._pressed = False - - self._delayPopupTimer = QtCore.QTimer() - self._delayPopupTimer.setInterval(self.DELAY) - self._delayPopupTimer.setSingleShot(True) - self._delayPopupTimer.timeout.connect(self._on_delayed_popup) - self._popupLocation = None - - self._mousePosition = None - self.setFocusPolicy(QtCore.Qt.StrongFocus) - self.setSizePolicy( - QtGui.QSizePolicy( - QtGui.QSizePolicy.MinimumExpanding, - QtGui.QSizePolicy.MinimumExpanding, - ) - ) - - def insertItem(self, item, index = -1): - self._filing.insertItem(item, index) - - def removeItemAt(self, index): - self._filing.removeItemAt(index) - - def set_center(self, item): - self._filing.set_center(item) - - def set_button(self, item): - self.update() - - def clear(self): - self._filing.clear() - - def itemAt(self, index): - return self._filing.itemAt(index) - - def indexAt(self, point): - return self._filing.indexAt(self._cachedCenterPosition, point) - - def innerRadius(self): - return self._filing.innerRadius() - - def setInnerRadius(self, radius): - self._filing.setInnerRadius(radius) - - def outerRadius(self): - return self._filing.outerRadius() - - def setOuterRadius(self, radius): - self._filing.setOuterRadius(radius) - - def buttonRadius(self): - return self._buttonFiling.outerRadius() - - def setButtonRadius(self, radius): - self._buttonFiling.setOuterRadius(radius) - self._buttonFiling.setInnerRadius(radius / 2) - self._buttonArtist.show(self.palette()) - - def minimumSizeHint(self): - return self._buttonArtist.centerSize() - - @misc_utils.log_exception(_moduleLogger) - def mousePressEvent(self, mouseEvent): - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - self._mousePosition = lastMousePos - self._update_selection(self._cachedCenterPosition) - - self.highlighted.emit(self._selectionIndex) - - self._display.selectAt(self._selectionIndex) - self._pressed = True - self.update() - self._popupLocation = mouseEvent.globalPos() - self._delayPopupTimer.start() - - @misc_utils.log_exception(_moduleLogger) - def _on_delayed_popup(self): - assert self._popupLocation is not None, "Widget location abuse" - self._popup_child(self._popupLocation) - - @misc_utils.log_exception(_moduleLogger) - def mouseMoveEvent(self, mouseEvent): - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - if self._mousePosition is None: - # Absolute - self._update_selection(lastMousePos) - else: - # Relative - self._update_selection( - self._cachedCenterPosition + (lastMousePos - self._mousePosition), - ignoreOuter = True, - ) - - if lastSelection != self._selectionIndex: - self.highlighted.emit(self._selectionIndex) - self._display.selectAt(self._selectionIndex) - - if self._selectionIndex != PieFiling.SELECTION_CENTER and self._delayPopupTimer.isActive(): - self._on_delayed_popup() - - @misc_utils.log_exception(_moduleLogger) - def mouseReleaseEvent(self, mouseEvent): - self._delayPopupTimer.stop() - self._popupLocation = None - - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - if self._mousePosition is None: - # Absolute - self._update_selection(lastMousePos) - else: - # Relative - self._update_selection( - self._cachedCenterPosition + (lastMousePos - self._mousePosition), - ignoreOuter = True, - ) - self._mousePosition = None - - self._activate_at(self._selectionIndex) - self._pressed = False - self.update() - self._hide_child() - - @misc_utils.log_exception(_moduleLogger) - def keyPressEvent(self, keyEvent): - if keyEvent.key() in [QtCore.Qt.Key_Right, QtCore.Qt.Key_Down, QtCore.Qt.Key_Tab]: - self._popup_child(QtGui.QCursor.pos()) - if self._selectionIndex != len(self._filing) - 1: - nextSelection = self._selectionIndex + 1 - else: - nextSelection = 0 - self._select_at(nextSelection) - self._display.selectAt(self._selectionIndex) - elif keyEvent.key() in [QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Backtab]: - self._popup_child(QtGui.QCursor.pos()) - if 0 < self._selectionIndex: - nextSelection = self._selectionIndex - 1 - else: - nextSelection = len(self._filing) - 1 - self._select_at(nextSelection) - self._display.selectAt(self._selectionIndex) - elif keyEvent.key() in [QtCore.Qt.Key_Space]: - self._popup_child(QtGui.QCursor.pos()) - self._select_at(PieFiling.SELECTION_CENTER) - self._display.selectAt(self._selectionIndex) - elif keyEvent.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]: - self._delayPopupTimer.stop() - self._popupLocation = None - self._activate_at(self._selectionIndex) - self._hide_child() - elif keyEvent.key() in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Backspace]: - self._delayPopupTimer.stop() - self._popupLocation = None - self._activate_at(PieFiling.SELECTION_NONE) - self._hide_child() - else: - QtGui.QWidget.keyPressEvent(self, keyEvent) - - @misc_utils.log_exception(_moduleLogger) - def resizeEvent(self, resizeEvent): - self.setButtonRadius(min(resizeEvent.size().width(), resizeEvent.size().height()) / 2 - 1) - QtGui.QWidget.resizeEvent(self, resizeEvent) - - @misc_utils.log_exception(_moduleLogger) - def showEvent(self, showEvent): - self._buttonArtist.show(self.palette()) - self._cachedCenterPosition = self.rect().center() - - QtGui.QWidget.showEvent(self, showEvent) - - @misc_utils.log_exception(_moduleLogger) - def hideEvent(self, hideEvent): - self._display.hide() - self._select_at(PieFiling.SELECTION_NONE) - QtGui.QWidget.hideEvent(self, hideEvent) - - @misc_utils.log_exception(_moduleLogger) - def paintEvent(self, paintEvent): - self.setButtonRadius(min(self.rect().width(), self.rect().height()) / 2 - 1) - if self._poppedUp: - selectionIndex = PieFiling.SELECTION_CENTER - else: - selectionIndex = PieFiling.SELECTION_NONE - - screen = QtGui.QStylePainter(self) - screen.setRenderHint(QtGui.QPainter.Antialiasing, True) - option = QtGui.QStyleOptionButton() - option.initFrom(self) - option.state = QtGui.QStyle.State_Sunken if self._pressed else QtGui.QStyle.State_Raised - - screen.drawControl(QtGui.QStyle.CE_PushButton, option) - self._buttonArtist.paintPainter(selectionIndex, screen) - - QtGui.QWidget.paintEvent(self, paintEvent) - - def __iter__(self): - return iter(self._filing) - - def __len__(self): - return len(self._filing) - - def _popup_child(self, position): - self._poppedUp = True - self.aboutToShow.emit() - - self._delayPopupTimer.stop() - self._popupLocation = None - - position = position - QtCore.QPoint(self._filing.outerRadius(), self._filing.outerRadius()) - self._display.move(position) - self._display.show() - - self.update() - - def _hide_child(self): - self._poppedUp = False - self.aboutToHide.emit() - self._display.hide() - self.update() - - def _select_at(self, index): - self._selectionIndex = index - - def _update_selection(self, lastMousePos, ignoreOuter = False): - radius = _radius_at(self._cachedCenterPosition, lastMousePos) - if radius < self._filing.innerRadius(): - self._select_at(PieFiling.SELECTION_CENTER) - elif radius <= self._filing.outerRadius() or ignoreOuter: - self._select_at(self.indexAt(lastMousePos)) - else: - self._select_at(PieFiling.SELECTION_NONE) - - def _activate_at(self, index): - if index == PieFiling.SELECTION_NONE: - self.canceled.emit() - return - elif index == PieFiling.SELECTION_CENTER: - child = self._filing.center() - else: - child = self.itemAt(index) - - if child.action().isEnabled(): - child.action().trigger() - self.activated.emit(index) - else: - self.canceled.emit() - - -class QPieMenu(QtGui.QWidget): - - activated = qt_compat.Signal(int) - highlighted = qt_compat.Signal(int) - canceled = qt_compat.Signal() - aboutToShow = qt_compat.Signal() - aboutToHide = qt_compat.Signal() - - def __init__(self, parent = None): - QtGui.QWidget.__init__(self, parent) - self._cachedCenterPosition = self.rect().center() - - self._filing = PieFiling() - self._artist = PieArtist(self._filing) - self._selectionIndex = PieFiling.SELECTION_NONE - - self._mousePosition = () - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - def popup(self, pos): - self._update_selection(pos) - self.show() - - def insertItem(self, item, index = -1): - self._filing.insertItem(item, index) - self.update() - - def removeItemAt(self, index): - self._filing.removeItemAt(index) - self.update() - - def set_center(self, item): - self._filing.set_center(item) - self.update() - - def clear(self): - self._filing.clear() - self.update() - - def itemAt(self, index): - return self._filing.itemAt(index) - - def indexAt(self, point): - return self._filing.indexAt(self._cachedCenterPosition, point) - - def innerRadius(self): - return self._filing.innerRadius() - - def setInnerRadius(self, radius): - self._filing.setInnerRadius(radius) - self.update() - - def outerRadius(self): - return self._filing.outerRadius() - - def setOuterRadius(self, radius): - self._filing.setOuterRadius(radius) - self.update() - - def sizeHint(self): - return self._artist.pieSize() - - @misc_utils.log_exception(_moduleLogger) - def mousePressEvent(self, mouseEvent): - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - self._update_selection(lastMousePos) - self._mousePosition = lastMousePos - - if lastSelection != self._selectionIndex: - self.highlighted.emit(self._selectionIndex) - self.update() - - @misc_utils.log_exception(_moduleLogger) - def mouseMoveEvent(self, mouseEvent): - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - self._update_selection(lastMousePos) - - if lastSelection != self._selectionIndex: - self.highlighted.emit(self._selectionIndex) - self.update() - - @misc_utils.log_exception(_moduleLogger) - def mouseReleaseEvent(self, mouseEvent): - lastSelection = self._selectionIndex - - lastMousePos = mouseEvent.pos() - self._update_selection(lastMousePos) - self._mousePosition = () - - self._activate_at(self._selectionIndex) - self.update() - - @misc_utils.log_exception(_moduleLogger) - def keyPressEvent(self, keyEvent): - if keyEvent.key() in [QtCore.Qt.Key_Right, QtCore.Qt.Key_Down, QtCore.Qt.Key_Tab]: - if self._selectionIndex != len(self._filing) - 1: - nextSelection = self._selectionIndex + 1 - else: - nextSelection = 0 - self._select_at(nextSelection) - self.update() - elif keyEvent.key() in [QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Backtab]: - if 0 < self._selectionIndex: - nextSelection = self._selectionIndex - 1 - else: - nextSelection = len(self._filing) - 1 - self._select_at(nextSelection) - self.update() - elif keyEvent.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]: - self._activate_at(self._selectionIndex) - elif keyEvent.key() in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Backspace]: - self._activate_at(PieFiling.SELECTION_NONE) - else: - QtGui.QWidget.keyPressEvent(self, keyEvent) - - @misc_utils.log_exception(_moduleLogger) - def showEvent(self, showEvent): - self.aboutToShow.emit() - self._cachedCenterPosition = self.rect().center() - - mask = self._artist.show(self.palette()) - self.setMask(mask) - - lastMousePos = self.mapFromGlobal(QtGui.QCursor.pos()) - self._update_selection(lastMousePos) - - QtGui.QWidget.showEvent(self, showEvent) - - @misc_utils.log_exception(_moduleLogger) - def hideEvent(self, hideEvent): - self._artist.hide() - self._selectionIndex = PieFiling.SELECTION_NONE - QtGui.QWidget.hideEvent(self, hideEvent) - - @misc_utils.log_exception(_moduleLogger) - def paintEvent(self, paintEvent): - canvas = self._artist.paint(self._selectionIndex) - - screen = QtGui.QPainter(self) - screen.drawPixmap(QtCore.QPoint(0, 0), canvas) - - QtGui.QWidget.paintEvent(self, paintEvent) - - def __iter__(self): - return iter(self._filing) - - def __len__(self): - return len(self._filing) - - def _select_at(self, index): - self._selectionIndex = index - - def _update_selection(self, lastMousePos): - radius = _radius_at(self._cachedCenterPosition, lastMousePos) - if radius < self._filing.innerRadius(): - self._selectionIndex = PieFiling.SELECTION_CENTER - elif radius <= self._filing.outerRadius(): - self._select_at(self.indexAt(lastMousePos)) - else: - self._selectionIndex = PieFiling.SELECTION_NONE - - def _activate_at(self, index): - if index == PieFiling.SELECTION_NONE: - self.canceled.emit() - self.aboutToHide.emit() - self.hide() - return - elif index == PieFiling.SELECTION_CENTER: - child = self._filing.center() - else: - child = self.itemAt(index) - - if child.isEnabled(): - child.action().trigger() - self.activated.emit(index) - else: - self.canceled.emit() - self.aboutToHide.emit() - self.hide() - - -def init_pies(): - PieFiling.NULL_CENTER.setEnabled(False) - - -def _print(msg): - print msg - - -def _on_about_to_hide(app): - app.exit() - - -if __name__ == "__main__": - app = QtGui.QApplication([]) - init_pies() - - if False: - pie = QPieMenu() - pie.show() - - if False: - singleAction = QtGui.QAction(None) - singleAction.setText("Boo") - singleItem = QActionPieItem(singleAction) - spie = QPieMenu() - spie.insertItem(singleItem) - spie.show() - - if False: - oneAction = QtGui.QAction(None) - oneAction.setText("Chew") - oneItem = QActionPieItem(oneAction) - twoAction = QtGui.QAction(None) - twoAction.setText("Foo") - twoItem = QActionPieItem(twoAction) - iconTextAction = QtGui.QAction(None) - iconTextAction.setText("Icon") - iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) - iconTextItem = QActionPieItem(iconTextAction) - mpie = QPieMenu() - mpie.insertItem(oneItem) - mpie.insertItem(twoItem) - mpie.insertItem(oneItem) - mpie.insertItem(iconTextItem) - mpie.show() - - if True: - oneAction = QtGui.QAction(None) - oneAction.setText("Chew") - oneAction.triggered.connect(lambda: _print("Chew")) - oneItem = QActionPieItem(oneAction) - twoAction = QtGui.QAction(None) - twoAction.setText("Foo") - twoAction.triggered.connect(lambda: _print("Foo")) - twoItem = QActionPieItem(twoAction) - iconAction = QtGui.QAction(None) - iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) - iconAction.triggered.connect(lambda: _print("Icon")) - iconItem = QActionPieItem(iconAction) - iconTextAction = QtGui.QAction(None) - iconTextAction.setText("Icon") - iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) - iconTextAction.triggered.connect(lambda: _print("Icon and text")) - iconTextItem = QActionPieItem(iconTextAction) - mpie = QPieMenu() - mpie.set_center(iconItem) - mpie.insertItem(oneItem) - mpie.insertItem(twoItem) - mpie.insertItem(oneItem) - mpie.insertItem(iconTextItem) - mpie.show() - mpie.aboutToHide.connect(lambda: _on_about_to_hide(app)) - mpie.canceled.connect(lambda: _print("Canceled")) - - if False: - oneAction = QtGui.QAction(None) - oneAction.setText("Chew") - oneAction.triggered.connect(lambda: _print("Chew")) - oneItem = QActionPieItem(oneAction) - twoAction = QtGui.QAction(None) - twoAction.setText("Foo") - twoAction.triggered.connect(lambda: _print("Foo")) - twoItem = QActionPieItem(twoAction) - iconAction = QtGui.QAction(None) - iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) - iconAction.triggered.connect(lambda: _print("Icon")) - iconItem = QActionPieItem(iconAction) - iconTextAction = QtGui.QAction(None) - iconTextAction.setText("Icon") - iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) - iconTextAction.triggered.connect(lambda: _print("Icon and text")) - iconTextItem = QActionPieItem(iconTextAction) - pieFiling = PieFiling() - pieFiling.set_center(iconItem) - pieFiling.insertItem(oneItem) - pieFiling.insertItem(twoItem) - pieFiling.insertItem(oneItem) - pieFiling.insertItem(iconTextItem) - mpie = QPieDisplay(pieFiling) - mpie.show() - - if False: - oneAction = QtGui.QAction(None) - oneAction.setText("Chew") - oneAction.triggered.connect(lambda: _print("Chew")) - oneItem = QActionPieItem(oneAction) - twoAction = QtGui.QAction(None) - twoAction.setText("Foo") - twoAction.triggered.connect(lambda: _print("Foo")) - twoItem = QActionPieItem(twoAction) - iconAction = QtGui.QAction(None) - iconAction.setIcon(QtGui.QIcon.fromTheme("gtk-open")) - iconAction.triggered.connect(lambda: _print("Icon")) - iconItem = QActionPieItem(iconAction) - iconTextAction = QtGui.QAction(None) - iconTextAction.setText("Icon") - iconTextAction.setIcon(QtGui.QIcon.fromTheme("gtk-close")) - iconTextAction.triggered.connect(lambda: _print("Icon and text")) - iconTextItem = QActionPieItem(iconTextAction) - mpie = QPieButton(iconItem) - mpie.set_center(iconItem) - mpie.insertItem(oneItem) - mpie.insertItem(twoItem) - mpie.insertItem(oneItem) - mpie.insertItem(iconTextItem) - mpie.show() - mpie.aboutToHide.connect(lambda: _on_about_to_hide(app)) - mpie.canceled.connect(lambda: _print("Canceled")) - - app.exec_() diff --git a/src/util/qtpieboard.py b/src/util/qtpieboard.py deleted file mode 100755 index 50ae9ae..0000000 --- a/src/util/qtpieboard.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python - - -from __future__ import division - -import os -import warnings - -import qt_compat -QtGui = qt_compat.import_module("QtGui") - -import qtpie - - -class PieKeyboard(object): - - SLICE_CENTER = -1 - SLICE_NORTH = 0 - SLICE_NORTH_WEST = 1 - SLICE_WEST = 2 - SLICE_SOUTH_WEST = 3 - SLICE_SOUTH = 4 - SLICE_SOUTH_EAST = 5 - SLICE_EAST = 6 - SLICE_NORTH_EAST = 7 - - MAX_ANGULAR_SLICES = 8 - - SLICE_DIRECTIONS = [ - SLICE_CENTER, - SLICE_NORTH, - SLICE_NORTH_WEST, - SLICE_WEST, - SLICE_SOUTH_WEST, - SLICE_SOUTH, - SLICE_SOUTH_EAST, - SLICE_EAST, - SLICE_NORTH_EAST, - ] - - SLICE_DIRECTION_NAMES = [ - "CENTER", - "NORTH", - "NORTH_WEST", - "WEST", - "SOUTH_WEST", - "SOUTH", - "SOUTH_EAST", - "EAST", - "NORTH_EAST", - ] - - def __init__(self): - self._layout = QtGui.QGridLayout() - self._widget = QtGui.QWidget() - self._widget.setLayout(self._layout) - - self.__cells = {} - - @property - def toplevel(self): - return self._widget - - def add_pie(self, row, column, pieButton): - assert len(pieButton) == 8 - self._layout.addWidget(pieButton, row, column) - self.__cells[(row, column)] = pieButton - - def get_pie(self, row, column): - return self.__cells[(row, column)] - - -class KeyboardModifier(object): - - def __init__(self, name): - self.name = name - self.lock = False - self.once = False - - @property - def isActive(self): - return self.lock or self.once - - def on_toggle_lock(self, *args, **kwds): - self.lock = not self.lock - - def on_toggle_once(self, *args, **kwds): - self.once = not self.once - - def reset_once(self): - self.once = False - - -def parse_keyboard_data(text): - return eval(text) - - -def _enumerate_pie_slices(pieData, iconPaths): - for direction, directionName in zip( - PieKeyboard.SLICE_DIRECTIONS, PieKeyboard.SLICE_DIRECTION_NAMES - ): - if directionName in pieData: - sliceData = pieData[directionName] - - action = QtGui.QAction(None) - try: - action.setText(sliceData["text"]) - except KeyError: - pass - try: - relativeIconPath = sliceData["path"] - except KeyError: - pass - else: - for iconPath in iconPaths: - absIconPath = os.path.join(iconPath, relativeIconPath) - if os.path.exists(absIconPath): - action.setIcon(QtGui.QIcon(absIconPath)) - break - pieItem = qtpie.QActionPieItem(action) - actionToken = sliceData["action"] - else: - pieItem = qtpie.PieFiling.NULL_CENTER - actionToken = "" - yield direction, pieItem, actionToken - - -def load_keyboard(keyboardName, dataTree, keyboard, keyboardHandler, iconPaths): - for (row, column), pieData in dataTree.iteritems(): - pieItems = list(_enumerate_pie_slices(pieData, iconPaths)) - assert pieItems[0][0] == PieKeyboard.SLICE_CENTER, pieItems[0] - _, center, centerAction = pieItems.pop(0) - - pieButton = qtpie.QPieButton(center) - pieButton.set_center(center) - keyboardHandler.map_slice_action(center, centerAction) - for direction, pieItem, action in pieItems: - pieButton.insertItem(pieItem) - keyboardHandler.map_slice_action(pieItem, action) - keyboard.add_pie(row, column, pieButton) - - -class KeyboardHandler(object): - - def __init__(self, keyhandler): - self.__keyhandler = keyhandler - self.__commandHandlers = {} - self.__modifiers = {} - self.__sliceActions = {} - - self.register_modifier("Shift") - self.register_modifier("Super") - self.register_modifier("Control") - self.register_modifier("Alt") - - def register_command_handler(self, command, handler): - # @todo Look into hooking these up directly to the pie actions - self.__commandHandlers["[%s]" % command] = handler - - def unregister_command_handler(self, command): - # @todo Look into hooking these up directly to the pie actions - del self.__commandHandlers["[%s]" % command] - - def register_modifier(self, modifierName): - mod = KeyboardModifier(modifierName) - self.register_command_handler(modifierName, mod.on_toggle_lock) - self.__modifiers["<%s>" % modifierName] = mod - - def unregister_modifier(self, modifierName): - self.unregister_command_handler(modifierName) - del self.__modifiers["<%s>" % modifierName] - - def map_slice_action(self, slice, action): - callback = lambda direction: self(direction, action) - slice.action().triggered.connect(callback) - self.__sliceActions[slice] = (action, callback) - - def __call__(self, direction, action): - activeModifiers = [ - mod.name - for mod in self.__modifiers.itervalues() - if mod.isActive - ] - - needResetOnce = False - if action.startswith("[") and action.endswith("]"): - commandName = action[1:-1] - if action in self.__commandHandlers: - self.__commandHandlers[action](commandName, activeModifiers) - needResetOnce = True - else: - warnings.warn("Unknown command: [%s]" % commandName) - elif action.startswith("<") and action.endswith(">"): - modName = action[1:-1] - for mod in self.__modifiers.itervalues(): - if mod.name == modName: - mod.on_toggle_once() - break - else: - warnings.warn("Unknown modifier: <%s>" % modName) - else: - self.__keyhandler(action, activeModifiers) - needResetOnce = True - - if needResetOnce: - for mod in self.__modifiers.itervalues(): - mod.reset_once() diff --git a/src/util/qui_utils.py b/src/util/qui_utils.py deleted file mode 100644 index 11b3453..0000000 --- a/src/util/qui_utils.py +++ /dev/null @@ -1,419 +0,0 @@ -import sys -import contextlib -import datetime -import logging - -import qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -import misc - - -_moduleLogger = logging.getLogger(__name__) - - -@contextlib.contextmanager -def notify_error(log): - try: - yield - except: - log.push_exception() - - -@contextlib.contextmanager -def notify_busy(log, message): - log.push_busy(message) - try: - yield - finally: - log.pop(message) - - -class ErrorMessage(object): - - LEVEL_ERROR = 0 - LEVEL_BUSY = 1 - LEVEL_INFO = 2 - - def __init__(self, message, level): - self._message = message - self._level = level - self._time = datetime.datetime.now() - - @property - def level(self): - return self._level - - @property - def message(self): - return self._message - - def __repr__(self): - return "%s.%s(%r, %r)" % (__name__, self.__class__.__name__, self._message, self._level) - - -class QErrorLog(QtCore.QObject): - - messagePushed = qt_compat.Signal() - messagePopped = qt_compat.Signal() - - def __init__(self): - QtCore.QObject.__init__(self) - self._messages = [] - - def push_busy(self, message): - _moduleLogger.info("Entering state: %s" % message) - self._push_message(message, ErrorMessage.LEVEL_BUSY) - - def push_message(self, message): - self._push_message(message, ErrorMessage.LEVEL_INFO) - - def push_error(self, message): - self._push_message(message, ErrorMessage.LEVEL_ERROR) - - def push_exception(self): - userMessage = str(sys.exc_info()[1]) - _moduleLogger.exception(userMessage) - self.push_error(userMessage) - - def pop(self, message = None): - if message is None: - del self._messages[0] - else: - _moduleLogger.info("Exiting state: %s" % message) - messageIndex = [ - i - for (i, error) in enumerate(self._messages) - if error.message == message - ] - # Might be removed out of order - if messageIndex: - del self._messages[messageIndex[0]] - self.messagePopped.emit() - - def peek_message(self): - return self._messages[0] - - def _push_message(self, message, level): - self._messages.append(ErrorMessage(message, level)) - # Sort is defined as stable, so this should be fine - self._messages.sort(key=lambda x: x.level) - self.messagePushed.emit() - - def __len__(self): - return len(self._messages) - - -class ErrorDisplay(object): - - _SENTINEL_ICON = QtGui.QIcon() - - def __init__(self, errorLog): - self._errorLog = errorLog - self._errorLog.messagePushed.connect(self._on_message_pushed) - self._errorLog.messagePopped.connect(self._on_message_popped) - - self._icons = None - self._severityLabel = QtGui.QLabel() - self._severityLabel.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) - - self._message = QtGui.QLabel() - self._message.setText("Boo") - self._message.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) - self._message.setWordWrap(True) - - self._closeLabel = None - - self._controlLayout = QtGui.QHBoxLayout() - self._controlLayout.addWidget(self._severityLabel, 1, QtCore.Qt.AlignCenter) - self._controlLayout.addWidget(self._message, 1000) - - self._widget = QtGui.QWidget() - self._widget.setLayout(self._controlLayout) - self._widget.hide() - - @property - def toplevel(self): - return self._widget - - def _show_error(self): - if self._icons is None: - self._icons = { - ErrorMessage.LEVEL_BUSY: - get_theme_icon( - #("process-working", "view-refresh", "general_refresh", "gtk-refresh") - ("view-refresh", "general_refresh", "gtk-refresh", ) - ).pixmap(32, 32), - ErrorMessage.LEVEL_INFO: - get_theme_icon( - ("dialog-information", "general_notes", "gtk-info") - ).pixmap(32, 32), - ErrorMessage.LEVEL_ERROR: - get_theme_icon( - ("dialog-error", "app_install_error", "gtk-dialog-error") - ).pixmap(32, 32), - } - if self._closeLabel is None: - closeIcon = get_theme_icon(("window-close", "general_close", "gtk-close"), self._SENTINEL_ICON) - if closeIcon is not self._SENTINEL_ICON: - self._closeLabel = QtGui.QPushButton(closeIcon, "") - else: - self._closeLabel = QtGui.QPushButton("X") - self._closeLabel.clicked.connect(self._on_close) - self._controlLayout.addWidget(self._closeLabel, 1, QtCore.Qt.AlignCenter) - error = self._errorLog.peek_message() - self._message.setText(error.message) - self._severityLabel.setPixmap(self._icons[error.level]) - self._widget.show() - - @qt_compat.Slot() - @qt_compat.Slot(bool) - @misc.log_exception(_moduleLogger) - def _on_close(self, checked = False): - self._errorLog.pop() - - @qt_compat.Slot() - @misc.log_exception(_moduleLogger) - def _on_message_pushed(self): - self._show_error() - - @qt_compat.Slot() - @misc.log_exception(_moduleLogger) - def _on_message_popped(self): - if len(self._errorLog) == 0: - self._message.setText("") - self._widget.hide() - else: - self._show_error() - - -class QHtmlDelegate(QtGui.QStyledItemDelegate): - - UNDEFINED_SIZE = -1 - - def __init__(self, *args, **kwd): - QtGui.QStyledItemDelegate.__init__(*((self, ) + args), **kwd) - self._width = self.UNDEFINED_SIZE - - def paint(self, painter, option, index): - newOption = QtGui.QStyleOptionViewItemV4(option) - self.initStyleOption(newOption, index) - if newOption.widget is not None: - style = newOption.widget.style() - else: - style = QtGui.QApplication.style() - - doc = QtGui.QTextDocument() - doc.setHtml(newOption.text) - doc.setTextWidth(newOption.rect.width()) - - newOption.text = "" - style.drawControl(QtGui.QStyle.CE_ItemViewItem, newOption, painter) - - ctx = QtGui.QAbstractTextDocumentLayout.PaintContext() - if newOption.state & QtGui.QStyle.State_Selected: - ctx.palette.setColor( - QtGui.QPalette.Text, - newOption.palette.color( - QtGui.QPalette.Active, - QtGui.QPalette.HighlightedText - ) - ) - else: - ctx.palette.setColor( - QtGui.QPalette.Text, - newOption.palette.color( - QtGui.QPalette.Active, - QtGui.QPalette.Text - ) - ) - - textRect = style.subElementRect(QtGui.QStyle.SE_ItemViewItemText, newOption) - painter.save() - painter.translate(textRect.topLeft()) - painter.setClipRect(textRect.translated(-textRect.topLeft())) - doc.documentLayout().draw(painter, ctx) - painter.restore() - - def setWidth(self, width, model): - if self._width == width: - return - self._width = width - for c in xrange(model.rowCount()): - cItem = model.item(c, 0) - for r in xrange(model.rowCount()): - rItem = cItem.child(r, 0) - rIndex = model.indexFromItem(rItem) - self.sizeHintChanged.emit(rIndex) - return - - def sizeHint(self, option, index): - newOption = QtGui.QStyleOptionViewItemV4(option) - self.initStyleOption(newOption, index) - - doc = QtGui.QTextDocument() - doc.setHtml(newOption.text) - if self._width != self.UNDEFINED_SIZE: - width = self._width - else: - width = newOption.rect.width() - doc.setTextWidth(width) - size = QtCore.QSize(doc.idealWidth(), doc.size().height()) - return size - - -class QSignalingMainWindow(QtGui.QMainWindow): - - closed = qt_compat.Signal() - hidden = qt_compat.Signal() - shown = qt_compat.Signal() - resized = qt_compat.Signal() - - def __init__(self, *args, **kwd): - QtGui.QMainWindow.__init__(*((self, )+args), **kwd) - - def closeEvent(self, event): - val = QtGui.QMainWindow.closeEvent(self, event) - self.closed.emit() - return val - - def hideEvent(self, event): - val = QtGui.QMainWindow.hideEvent(self, event) - self.hidden.emit() - return val - - def showEvent(self, event): - val = QtGui.QMainWindow.showEvent(self, event) - self.shown.emit() - return val - - def resizeEvent(self, event): - val = QtGui.QMainWindow.resizeEvent(self, event) - self.resized.emit() - return val - -def set_current_index(selector, itemText, default = 0): - for i in xrange(selector.count()): - if selector.itemText(i) == itemText: - selector.setCurrentIndex(i) - break - else: - itemText.setCurrentIndex(default) - - -def _null_set_stackable(window, isStackable): - pass - - -def _maemo_set_stackable(window, isStackable): - window.setAttribute(QtCore.Qt.WA_Maemo5StackedWindow, isStackable) - - -try: - QtCore.Qt.WA_Maemo5StackedWindow - set_stackable = _maemo_set_stackable -except AttributeError: - set_stackable = _null_set_stackable - - -def _null_set_autorient(window, doAutoOrient): - pass - - -def _maemo_set_autorient(window, doAutoOrient): - window.setAttribute(QtCore.Qt.WA_Maemo5AutoOrientation, doAutoOrient) - - -try: - QtCore.Qt.WA_Maemo5AutoOrientation - set_autorient = _maemo_set_autorient -except AttributeError: - set_autorient = _null_set_autorient - - -def screen_orientation(): - geom = QtGui.QApplication.desktop().screenGeometry() - if geom.width() <= geom.height(): - return QtCore.Qt.Vertical - else: - return QtCore.Qt.Horizontal - - -def _null_set_window_orientation(window, orientation): - pass - - -def _maemo_set_window_orientation(window, orientation): - if orientation == QtCore.Qt.Vertical: - window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, False) - window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, True) - elif orientation == QtCore.Qt.Horizontal: - window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, True) - window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, False) - elif orientation is None: - window.setAttribute(QtCore.Qt.WA_Maemo5LandscapeOrientation, False) - window.setAttribute(QtCore.Qt.WA_Maemo5PortraitOrientation, False) - else: - raise RuntimeError("Unknown orientation: %r" % orientation) - - -try: - QtCore.Qt.WA_Maemo5LandscapeOrientation - QtCore.Qt.WA_Maemo5PortraitOrientation - set_window_orientation = _maemo_set_window_orientation -except AttributeError: - set_window_orientation = _null_set_window_orientation - - -def _null_show_progress_indicator(window, isStackable): - pass - - -def _maemo_show_progress_indicator(window, isStackable): - window.setAttribute(QtCore.Qt.WA_Maemo5ShowProgressIndicator, isStackable) - - -try: - QtCore.Qt.WA_Maemo5ShowProgressIndicator - show_progress_indicator = _maemo_show_progress_indicator -except AttributeError: - show_progress_indicator = _null_show_progress_indicator - - -def _null_mark_numbers_preferred(widget): - pass - - -def _newqt_mark_numbers_preferred(widget): - widget.setInputMethodHints(QtCore.Qt.ImhPreferNumbers) - - -try: - QtCore.Qt.ImhPreferNumbers - mark_numbers_preferred = _newqt_mark_numbers_preferred -except AttributeError: - mark_numbers_preferred = _null_mark_numbers_preferred - - -def _null_get_theme_icon(iconNames, fallback = None): - icon = fallback if fallback is not None else QtGui.QIcon() - return icon - - -def _newqt_get_theme_icon(iconNames, fallback = None): - for iconName in iconNames: - if QtGui.QIcon.hasThemeIcon(iconName): - icon = QtGui.QIcon.fromTheme(iconName) - break - else: - icon = fallback if fallback is not None else QtGui.QIcon() - return icon - - -try: - QtGui.QIcon.fromTheme - get_theme_icon = _newqt_get_theme_icon -except AttributeError: - get_theme_icon = _null_get_theme_icon - diff --git a/src/util/qwrappers.py b/src/util/qwrappers.py deleted file mode 100644 index 2c50c8a..0000000 --- a/src/util/qwrappers.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python - -from __future__ import with_statement -from __future__ import division - -import logging - -import qt_compat -QtCore = qt_compat.QtCore -QtGui = qt_compat.import_module("QtGui") - -from util import qui_utils -from util import misc as misc_utils - - -_moduleLogger = logging.getLogger(__name__) - - -class ApplicationWrapper(object): - - DEFAULT_ORIENTATION = "Default" - AUTO_ORIENTATION = "Auto" - LANDSCAPE_ORIENTATION = "Landscape" - PORTRAIT_ORIENTATION = "Portrait" - - def __init__(self, qapp, constants): - self._constants = constants - self._qapp = qapp - self._clipboard = QtGui.QApplication.clipboard() - - self._errorLog = qui_utils.QErrorLog() - self._mainWindow = None - - self._fullscreenAction = QtGui.QAction(None) - self._fullscreenAction.setText("Fullscreen") - self._fullscreenAction.setCheckable(True) - self._fullscreenAction.setShortcut(QtGui.QKeySequence("CTRL+Enter")) - self._fullscreenAction.toggled.connect(self._on_toggle_fullscreen) - - self._orientation = self.DEFAULT_ORIENTATION - self._orientationAction = QtGui.QAction(None) - self._orientationAction.setText("Next Orientation") - self._orientationAction.setCheckable(True) - self._orientationAction.setShortcut(QtGui.QKeySequence("CTRL+o")) - self._orientationAction.triggered.connect(self._on_next_orientation) - - self._logAction = QtGui.QAction(None) - self._logAction.setText("Log") - self._logAction.setShortcut(QtGui.QKeySequence("CTRL+l")) - self._logAction.triggered.connect(self._on_log) - - self._quitAction = QtGui.QAction(None) - self._quitAction.setText("Quit") - self._quitAction.setShortcut(QtGui.QKeySequence("CTRL+q")) - self._quitAction.triggered.connect(self._on_quit) - - self._aboutAction = QtGui.QAction(None) - self._aboutAction.setText("About") - self._aboutAction.triggered.connect(self._on_about) - - self._qapp.lastWindowClosed.connect(self._on_app_quit) - self._mainWindow = self._new_main_window() - self._mainWindow.window.destroyed.connect(self._on_child_close) - - self.load_settings() - - self._mainWindow.show() - self._idleDelay = QtCore.QTimer() - self._idleDelay.setSingleShot(True) - self._idleDelay.setInterval(0) - self._idleDelay.timeout.connect(self._on_delayed_start) - self._idleDelay.start() - - def load_settings(self): - raise NotImplementedError("Booh") - - def save_settings(self): - raise NotImplementedError("Booh") - - def _new_main_window(self): - raise NotImplementedError("Booh") - - @property - def qapp(self): - return self._qapp - - @property - def constants(self): - return self._constants - - @property - def errorLog(self): - return self._errorLog - - @property - def fullscreenAction(self): - return self._fullscreenAction - - @property - def orientationAction(self): - return self._orientationAction - - @property - def orientation(self): - return self._orientation - - @property - def logAction(self): - return self._logAction - - @property - def aboutAction(self): - return self._aboutAction - - @property - def quitAction(self): - return self._quitAction - - def set_orientation(self, orientation): - self._orientation = orientation - self._mainWindow.update_orientation(self._orientation) - - @classmethod - def _next_orientation(cls, current): - return { - cls.DEFAULT_ORIENTATION: cls.AUTO_ORIENTATION, - cls.AUTO_ORIENTATION: cls.LANDSCAPE_ORIENTATION, - cls.LANDSCAPE_ORIENTATION: cls.PORTRAIT_ORIENTATION, - cls.PORTRAIT_ORIENTATION: cls.DEFAULT_ORIENTATION, - }[current] - - def _close_windows(self): - if self._mainWindow is not None: - self.save_settings() - self._mainWindow.window.destroyed.disconnect(self._on_child_close) - self._mainWindow.close() - self._mainWindow = None - - @misc_utils.log_exception(_moduleLogger) - def _on_delayed_start(self): - self._mainWindow.start() - - @misc_utils.log_exception(_moduleLogger) - def _on_app_quit(self, checked = False): - if self._mainWindow is not None: - self.save_settings() - self._mainWindow.destroy() - - @misc_utils.log_exception(_moduleLogger) - def _on_child_close(self, obj = None): - if self._mainWindow is not None: - self.save_settings() - self._mainWindow = None - - @misc_utils.log_exception(_moduleLogger) - def _on_toggle_fullscreen(self, checked = False): - with qui_utils.notify_error(self._errorLog): - self._mainWindow.set_fullscreen(checked) - - @misc_utils.log_exception(_moduleLogger) - def _on_next_orientation(self, checked = False): - with qui_utils.notify_error(self._errorLog): - self.set_orientation(self._next_orientation(self._orientation)) - - @misc_utils.log_exception(_moduleLogger) - def _on_about(self, checked = True): - raise NotImplementedError("Booh") - - @misc_utils.log_exception(_moduleLogger) - def _on_log(self, checked = False): - with qui_utils.notify_error(self._errorLog): - with open(self._constants._user_logpath_, "r") as f: - logLines = f.xreadlines() - log = "".join(logLines) - self._clipboard.setText(log) - - @misc_utils.log_exception(_moduleLogger) - def _on_quit(self, checked = False): - with qui_utils.notify_error(self._errorLog): - self._close_windows() - - -class WindowWrapper(object): - - def __init__(self, parent, app): - self._app = app - - self._errorDisplay = qui_utils.ErrorDisplay(self._app.errorLog) - - self._layout = QtGui.QBoxLayout(QtGui.QBoxLayout.LeftToRight) - self._layout.setContentsMargins(0, 0, 0, 0) - - self._superLayout = QtGui.QVBoxLayout() - self._superLayout.addWidget(self._errorDisplay.toplevel) - self._superLayout.setContentsMargins(0, 0, 0, 0) - self._superLayout.addLayout(self._layout) - - centralWidget = QtGui.QWidget() - centralWidget.setLayout(self._superLayout) - centralWidget.setContentsMargins(0, 0, 0, 0) - - self._window = qui_utils.QSignalingMainWindow(parent) - self._window.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - qui_utils.set_stackable(self._window, True) - self._window.setCentralWidget(centralWidget) - - self._closeWindowAction = QtGui.QAction(None) - self._closeWindowAction.setText("Close") - self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w")) - self._closeWindowAction.triggered.connect(self._on_close_window) - - self._window.addAction(self._closeWindowAction) - self._window.addAction(self._app.quitAction) - self._window.addAction(self._app.fullscreenAction) - self._window.addAction(self._app.orientationAction) - self._window.addAction(self._app.logAction) - - @property - def window(self): - return self._window - - @property - def windowOrientation(self): - geom = self._window.size() - if geom.width() <= geom.height(): - return QtCore.Qt.Vertical - else: - return QtCore.Qt.Horizontal - - @property - def idealWindowOrientation(self): - if self._app.orientation == self._app.AUTO_ORIENTATION: - windowOrientation = self.windowOrientation - elif self._app.orientation == self._app.DEFAULT_ORIENTATION: - windowOrientation = qui_utils.screen_orientation() - elif self._app.orientation == self._app.LANDSCAPE_ORIENTATION: - windowOrientation = QtCore.Qt.Horizontal - elif self._app.orientation == self._app.PORTRAIT_ORIENTATION: - windowOrientation = QtCore.Qt.Vertical - else: - raise RuntimeError("Bad! No %r for you" % self._app.orientation) - return windowOrientation - - def walk_children(self): - return () - - def start(self): - pass - - def close(self): - for child in self.walk_children(): - child.window.destroyed.disconnect(self._on_child_close) - child.close() - self._window.close() - - def destroy(self): - pass - - def show(self): - self._window.show() - for child in self.walk_children(): - child.show() - self.set_fullscreen(self._app.fullscreenAction.isChecked()) - - def hide(self): - for child in self.walk_children(): - child.hide() - self._window.hide() - - def set_fullscreen(self, isFullscreen): - if self._window.isVisible(): - if isFullscreen: - self._window.showFullScreen() - else: - self._window.showNormal() - for child in self.walk_children(): - child.set_fullscreen(isFullscreen) - - def update_orientation(self, orientation): - if orientation == self._app.DEFAULT_ORIENTATION: - qui_utils.set_autorient(self.window, False) - qui_utils.set_window_orientation(self.window, None) - elif orientation == self._app.AUTO_ORIENTATION: - qui_utils.set_autorient(self.window, True) - qui_utils.set_window_orientation(self.window, None) - elif orientation == self._app.LANDSCAPE_ORIENTATION: - qui_utils.set_autorient(self.window, False) - qui_utils.set_window_orientation(self.window, QtCore.Qt.Horizontal) - elif orientation == self._app.PORTRAIT_ORIENTATION: - qui_utils.set_autorient(self.window, False) - qui_utils.set_window_orientation(self.window, QtCore.Qt.Vertical) - else: - raise RuntimeError("Unknown orientation: %r" % orientation) - for child in self.walk_children(): - child.update_orientation(orientation) - - @misc_utils.log_exception(_moduleLogger) - def _on_child_close(self, obj = None): - raise NotImplementedError("Booh") - - @misc_utils.log_exception(_moduleLogger) - def _on_close_window(self, checked = True): - with qui_utils.notify_error(self._errorLog): - self.close() - - -class AutoFreezeWindowFeature(object): - - def __init__(self, app, window): - self._app = app - self._window = window - self._app.qapp.focusChanged.connect(self._on_focus_changed) - if self._app.qapp.focusWidget() is not None: - self._window.setUpdatesEnabled(True) - else: - self._window.setUpdatesEnabled(False) - - def close(self): - self._app.qapp.focusChanged.disconnect(self._on_focus_changed) - self._window.setUpdatesEnabled(True) - - @misc_utils.log_exception(_moduleLogger) - def _on_focus_changed(self, oldWindow, newWindow): - with qui_utils.notify_error(self._app.errorLog): - if oldWindow is None and newWindow is not None: - self._window.setUpdatesEnabled(True) - elif oldWindow is not None and newWindow is None: - self._window.setUpdatesEnabled(False) diff --git a/src/util/time_utils.py b/src/util/time_utils.py deleted file mode 100644 index 90ec84d..0000000 --- a/src/util/time_utils.py +++ /dev/null @@ -1,94 +0,0 @@ -from datetime import tzinfo, timedelta, datetime - -ZERO = timedelta(0) -HOUR = timedelta(hours=1) - - -def first_sunday_on_or_after(dt): - days_to_go = 6 - dt.weekday() - if days_to_go: - dt += timedelta(days_to_go) - return dt - - -# US DST Rules -# -# This is a simplified (i.e., wrong for a few cases) set of rules for US -# DST start and end times. For a complete and up-to-date set of DST rules -# and timezone definitions, visit the Olson Database (or try pytz): -# http://www.twinsun.com/tz/tz-link.htm -# http://sourceforge.net/projects/pytz/ (might not be up-to-date) -# -# In the US, since 2007, DST starts at 2am (standard time) on the second -# Sunday in March, which is the first Sunday on or after Mar 8. -DSTSTART_2007 = datetime(1, 3, 8, 2) -# and ends at 2am (DST time; 1am standard time) on the first Sunday of Nov. -DSTEND_2007 = datetime(1, 11, 1, 1) -# From 1987 to 2006, DST used to start at 2am (standard time) on the first -# Sunday in April and to end at 2am (DST time; 1am standard time) on the last -# Sunday of October, which is the first Sunday on or after Oct 25. -DSTSTART_1987_2006 = datetime(1, 4, 1, 2) -DSTEND_1987_2006 = datetime(1, 10, 25, 1) -# From 1967 to 1986, DST used to start at 2am (standard time) on the last -# Sunday in April (the one on or after April 24) and to end at 2am (DST time; -# 1am standard time) on the last Sunday of October, which is the first Sunday -# on or after Oct 25. -DSTSTART_1967_1986 = datetime(1, 4, 24, 2) -DSTEND_1967_1986 = DSTEND_1987_2006 - - -class USTimeZone(tzinfo): - - def __init__(self, hours, reprname, stdname, dstname): - self.stdoffset = timedelta(hours=hours) - self.reprname = reprname - self.stdname = stdname - self.dstname = dstname - - def __repr__(self): - return self.reprname - - def tzname(self, dt): - if self.dst(dt): - return self.dstname - else: - return self.stdname - - def utcoffset(self, dt): - return self.stdoffset + self.dst(dt) - - def dst(self, dt): - if dt is None or dt.tzinfo is None: - # An exception may be sensible here, in one or both cases. - # It depends on how you want to treat them. The default - # fromutc() implementation (called by the default astimezone() - # implementation) passes a datetime with dt.tzinfo is self. - return ZERO - assert dt.tzinfo is self - - # Find start and end times for US DST. For years before 1967, return - # ZERO for no DST. - if 2006 < dt.year: - dststart, dstend = DSTSTART_2007, DSTEND_2007 - elif 1986 < dt.year < 2007: - dststart, dstend = DSTSTART_1987_2006, DSTEND_1987_2006 - elif 1966 < dt.year < 1987: - dststart, dstend = DSTSTART_1967_1986, DSTEND_1967_1986 - else: - return ZERO - - start = first_sunday_on_or_after(dststart.replace(year=dt.year)) - end = first_sunday_on_or_after(dstend.replace(year=dt.year)) - - # Can't compare naive to aware objects, so strip the timezone from - # dt first. - if start <= dt.replace(tzinfo=None) < end: - return HOUR - else: - return ZERO - - -Eastern = USTimeZone(-5, "Eastern", "EST", "EDT") -Central = USTimeZone(-6, "Central", "CST", "CDT") -Mountain = USTimeZone(-7, "Mountain", "MST", "MDT") -Pacific = USTimeZone(-8, "Pacific", "PST", "PDT") diff --git a/src/util/tp_utils.py b/src/util/tp_utils.py deleted file mode 100644 index 7c55c42..0000000 --- a/src/util/tp_utils.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python - -import logging - -import dbus -import telepathy - -import util.go_utils as gobject_utils -import misc - - -_moduleLogger = logging.getLogger(__name__) -DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties' - - -class WasMissedCall(object): - - def __init__(self, bus, conn, chan, on_success, on_error): - self.__on_success = on_success - self.__on_error = on_error - - self._requested = None - self._didMembersChange = False - self._didClose = False - self._didReport = False - - self._onTimeout = gobject_utils.Timeout(self._on_timeout) - self._onTimeout.start(seconds=60) - - chan[telepathy.interfaces.CHANNEL_INTERFACE_GROUP].connect_to_signal( - "MembersChanged", - self._on_members_changed, - ) - - chan[telepathy.interfaces.CHANNEL].connect_to_signal( - "Closed", - self._on_closed, - ) - - chan[DBUS_PROPERTIES].GetAll( - telepathy.interfaces.CHANNEL_INTERFACE, - reply_handler = self._on_got_all, - error_handler = self._on_error, - ) - - def cancel(self): - self._report_error("by request") - - def _report_missed_if_ready(self): - if self._didReport: - pass - elif self._requested is not None and (self._didMembersChange or self._didClose): - if self._requested: - self._report_error("wrong direction") - elif self._didClose: - self._report_success() - else: - self._report_error("members added") - else: - if self._didClose: - self._report_error("closed too early") - - def _report_success(self): - assert not self._didReport, "Double reporting a missed call" - self._didReport = True - self._onTimeout.cancel() - self.__on_success(self) - - def _report_error(self, reason): - assert not self._didReport, "Double reporting a missed call" - self._didReport = True - self._onTimeout.cancel() - self.__on_error(self, reason) - - @misc.log_exception(_moduleLogger) - def _on_got_all(self, properties): - self._requested = properties["Requested"] - self._report_missed_if_ready() - - @misc.log_exception(_moduleLogger) - def _on_members_changed(self, message, added, removed, lp, rp, actor, reason): - if added: - self._didMembersChange = True - self._report_missed_if_ready() - - @misc.log_exception(_moduleLogger) - def _on_closed(self): - self._didClose = True - self._report_missed_if_ready() - - @misc.log_exception(_moduleLogger) - def _on_error(self, *args): - self._report_error(args) - - @misc.log_exception(_moduleLogger) - def _on_timeout(self): - self._report_error("timeout") - return False - - -class NewChannelSignaller(object): - - def __init__(self, on_new_channel): - self._sessionBus = dbus.SessionBus() - self._on_user_new_channel = on_new_channel - - def start(self): - self._sessionBus.add_signal_receiver( - self._on_new_channel, - "NewChannel", - "org.freedesktop.Telepathy.Connection", - None, - None - ) - - def stop(self): - self._sessionBus.remove_signal_receiver( - self._on_new_channel, - "NewChannel", - "org.freedesktop.Telepathy.Connection", - None, - None - ) - - @misc.log_exception(_moduleLogger) - def _on_new_channel( - self, channelObjectPath, channelType, handleType, handle, supressHandler - ): - connObjectPath = channel_path_to_conn_path(channelObjectPath) - serviceName = path_to_service_name(channelObjectPath) - try: - self._on_user_new_channel( - self._sessionBus, serviceName, connObjectPath, channelObjectPath, channelType - ) - except Exception: - _moduleLogger.exception("Blocking exception from being passed up") - - -class EnableSystemContactIntegration(object): - - ACCOUNT_MGR_NAME = "org.freedesktop.Telepathy.AccountManager" - ACCOUNT_MGR_PATH = "/org/freedesktop/Telepathy/AccountManager" - ACCOUNT_MGR_IFACE_QUERY = "com.nokia.AccountManager.Interface.Query" - ACCOUNT_IFACE_COMPAT = "com.nokia.Account.Interface.Compat" - ACCOUNT_IFACE_COMPAT_PROFILE = "com.nokia.Account.Interface.Compat.Profile" - DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties' - - def __init__(self, profileName): - self._bus = dbus.SessionBus() - self._profileName = profileName - - def start(self): - self._accountManager = self._bus.get_object( - self.ACCOUNT_MGR_NAME, - self.ACCOUNT_MGR_PATH, - ) - self._accountManagerQuery = dbus.Interface( - self._accountManager, - dbus_interface=self.ACCOUNT_MGR_IFACE_QUERY, - ) - - self._accountManagerQuery.FindAccounts( - { - self.ACCOUNT_IFACE_COMPAT_PROFILE: self._profileName, - }, - reply_handler = self._on_found_accounts_reply, - error_handler = self._on_error, - ) - - @misc.log_exception(_moduleLogger) - def _on_found_accounts_reply(self, accountObjectPaths): - for accountObjectPath in accountObjectPaths: - print accountObjectPath - account = self._bus.get_object( - self.ACCOUNT_MGR_NAME, - accountObjectPath, - ) - accountProperties = dbus.Interface( - account, - self.DBUS_PROPERTIES, - ) - accountProperties.Set( - self.ACCOUNT_IFACE_COMPAT, - "SecondaryVCardFields", - ["TEL"], - reply_handler = self._on_field_set, - error_handler = self._on_error, - ) - - @misc.log_exception(_moduleLogger) - def _on_field_set(self): - _moduleLogger.info("SecondaryVCardFields Set") - - @misc.log_exception(_moduleLogger) - def _on_error(self, error): - _moduleLogger.error("%r" % (error, )) - - -def channel_path_to_conn_path(channelObjectPath): - """ - >>> channel_path_to_conn_path("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") - '/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME' - """ - return channelObjectPath.rsplit("/", 1)[0] - - -def path_to_service_name(path): - """ - >>> path_to_service_name("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") - 'org.freedesktop.Telepathy.ConnectionManager.theonering.gv.USERNAME' - """ - return ".".join(path[1:].split("/")[0:7]) - - -def cm_from_path(path): - """ - >>> cm_from_path("/org/freedesktop/Telepathy/ConnectionManager/theonering/gv/USERNAME/Channel1") - 'theonering' - """ - return path[1:].split("/")[4] diff --git a/support/dialcentral.desktop b/support/dialcentral.desktop deleted file mode 100644 index 3b446d7..0000000 --- a/support/dialcentral.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Encoding=UTF-8 -Version=1.0 -Type=Application -Name=DialCentral -Exec=/usr/bin/run-standalone.sh /opt/dialcentral/bin/dialcentral.py -Icon=dialcentral -Categories=Network;InstantMessaging;Qt; diff --git a/support/icons/hicolor/26x26/hildon/dialcentral.png b/support/icons/hicolor/26x26/hildon/dialcentral.png deleted file mode 100644 index df50c66807a4ac5a11657771901db51b56e2d2c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1671 zcmV;226*|2P)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01ejw01ejxLMWSf00007bV*G`2iOK2 z4=f7;iQIkw00sw1L_t(Y$EB5hOk39(#(($v+P*dcyI^C8Nj%6$p;`i>sX$$pc7bjJ zG?0L-iNX?ziH=B1rfHfckt{@!l0UXcQ=(`}qNtsS@}aV}sEUXpmu|2qDM^{MFJ(<# zzy=x^5DZ+dudlD~-5)Df)}~9RJ^!2|o%25LqxXHzdjvki<;$0?{{DVdlB7TT{r=v~ zn>R0On&wzNR}=-)G|_b(!!R&S6Gc(bb)7^afv)Qaf>0a?1YSLT`t*7L0bp)!?s#5a z-Y-^(PM$nTS63HLgQ6%zA`v2y2on<%BoYZKDk^AbXb>{wmStIw2X%FIQBqPuZ*MQ@ zbQ(bru-okjf`H9t%d9q=4M~!?diCmmBt3roxZd5}?UbIRXlZE)S(d4*s{_aot?nHT z2a+UVv)Q=)!3WI6<2?H3BD$_qy1tmBM~^zjJ{hZeB6%W_VB^M(w6(Q8wKQ(OpS0IY zfy0Yzfb?0Ho-KH`K?2Zq9f!k#VHoJTPBNLKy1JUFsVQb>XOSccQ4|qH5lz!F&2?sF z;r9E9#Z6u)ucEjMewBNIx4duQ`PdT?0mjG2Gv&}Ujg^%Z0D{3FB_$;k6%}EcCfBZA zBQGxxpU;QO<-%n*S-&=mg4Uf78(}>8A-m?k!}FyTq>Pj+qZ}ZaOrokPsZDcGQ8IhxXHL;3SN_66Qxy48HT~~@-l{DuxcJr z6p4ESXfvfnM|T-8iF7&FfhP{3m2H4o@Q`xkexesQdn3>b8|C0cI@Eb!5>jt zD6#Jc2Pu8|+idt+A?42nxO?UX+iSkX>zChNCzY3%+m@D=SYBR6QIyQ1si~>q)~#EZ zrU^hG5TL24iTn5OqiGs#-`|Z#8|T)9os(}%WO+?m>)~fxHj|a@!Z3`aB#L6tvMf|p zC7n)Z5&%RZ5ze1K55Tcw$B0IwbaZt5d#=pK2*26SyHPK5??jms99(+PK;5nfeEPsd z)3mjs)9JLSs+v)rOeWEFo&NrQ_Uze1XJ;p>s&eAQ2?`1dICA6&_wL=})0s!SQu`t$ z*^dx%mXZ8An{vm=T9b{IR8bVgA^Cj1(CFwWs;aWGvO+SM#4rr%>+2aA8DaPC-2m+0 zzaO{TO?P)U#Vw88&R#Kwn=UilT7p)G0Jg2!LqtgMV^G>WEa#N%;NsT79}9pcKBE9B(lke{DVRaF%;GczA^B|8XM}7<$T;OH#r^;Nm1Zx?qto{6GjmP77yFate%>h`bjhz-FoNFoxV5HlA@AtupscLyE8qV@SuD%?HSi4ZF)$Cb0pIwN z_@4qM;0MZq9}9x;$(Lrco`A*0MQd?!(Tc@l)>H7t<6K|(ps!!Qe)sIzv)))N_QzBz z^>Ev^ZKV!}qg9q=(&;p^ER#y5n4h1Igu~&!O`A4h7)B@*3bnSkx1V~P>t9a#+JnYl RY6buR002ovPDHLkV1gVhDY*au diff --git a/support/icons/hicolor/64x64/hildon/dialcentral.png b/support/icons/hicolor/64x64/hildon/dialcentral.png deleted file mode 100644 index 8d98390d0478e4bbef85641af67982cdd29ab00c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6411 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 LUFGt}R$>1G8dR*- diff --git a/support/icons/hicolor/scalable/hildon/dialcentral.png b/support/icons/hicolor/scalable/hildon/dialcentral.png deleted file mode 100644 index a8753506901fcff1ea8d827774b21ac938bf3026..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32182 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 OilMHV&O>ddsQ&|#ca-%2 -- 1.7.9.5