diff options
115 files changed, 6409 insertions, 4193 deletions
@@ -2,35 +2,75 @@ This file describes the new features in each poezio release. For more detailed changelog, see the roadmap: http://dev.louiz.org/projects/poezio/roadmap -* Poezio 0.8.3 - dev -- make the data forms tab better -- Implement ad-hoc commands -- a change_title plugin changes the title of the terminal depending - on the current tab. Also, poezio is now "poezio" and not "python3". -- the OTR plugin now tries to interpret html by default -- The interface will adapt if the terminal is too small -- Implement XEP-0184 (message delivery receipts) -- better setup scripts (use setuptools) -- Better timezone handling -- Better alias plugin, with permanent alias storage -- poezio_gpg_export script to generate a config for the gpg plugin -- improvements to dynamic tabs, information about jid lock/unlock -- irc plugin to use with biboumi -- implementation of XEP-0012 (last activity) -- errors.log contains a lot less stuff +* Poezio 0.9 - dev + +- Use slixmpp instead of sleekxmpp, remove threads and locks +- Require python 3.4 for the input handling, event loop, xml parser, + and ssl API + +- Due to the slixmpp move, /reconnect now works (ecf22cb) +- Add a check for broken python ncurses (1c5589b) +- Add a /reload command to reload the config (71f3848) +- Add a /color command to permanently set the color a nick (f7e7836) +- Add a /closeall command with a plugin, to cleanup the open tabs (ed7fe69) +- Add a /reorder command with a plugin to order the tabs based on a static + layout (8afbb44) +- Add a /set_default command which sets the value of an option back to its + default (9caa992) +- Use a deterministic algorithm to set the nickname colors by default (2452706) +- The user’s JID is now shown in the roster (a0a00d5) +- The screen_detach plugin now also detects tmux (21d8a3e) +- The autocorrect plugin now supports regular expressions (067ad58) +- Improve the XML tab with coloration, better filters, and filter chaining + (1cd0b4d) +- Allow the use of client X.509 certificates for authentication, and add + /certs, /cert_{add,disable,revoke,fetch} commands (00396c1) +- Stream errors are now displayed, which is more helpful to the user than + 'connection failed' (5cd854f) +- The (system-wide) launch script is now a setuptools entry point that checks + dependencies (22f9b7a) +- XHTML-IM base64 embedded images are now extracted by default (a9f642f) +- New open_all_bookmarks option to let the user choose what to do with + bookmarks without autojoin (5a5d581) +- Store the server certificate fingerprint with SHA-512 (8d2408c) +- pipe_cmd plugin that allows simple remote control of poezio (4b0d9a2, + dc46286) +- /link now uses xdg-open (ba32d74) +- Display information about the XMPP features the contact supports (a1f86720, + 2dafd6d5) +- make the data forms tab better (3858273, 7378ea9) +- Implement ad-hoc commands (bbc55fa) +- A change_title plugin changes the title of the terminal depending on the + current tab. Also, poezio is now "poezio" and not "python3" (c1d19fa, + 48e59d3) +- The OTR plugin now implements SMP (96442e9) +- The OTR plugin now tries to interpret html by default (4e4ab56) +- The OTR plugin also has nicer and more informative messages (71ae29df, + f782516e, d7bbf339c, a0c5f95, 25e91b0) +- The interface will adapt if the terminal is too small (0caf941) +- Implement XEP-0184 (message delivery receipts) (5999b71) +- Better timezone handling (02d9fd9) +- Better alias plugin, with permanent alias storage (e80a90) +- poezio_gpg_export script to generate a config for the gpg plugin (8ec5671) +- improvements to dynamic tabs, information about jid lock/unlock (f7294b2, + 5c9dd44, fba511e) +- irc plugin to use with biboumi (cf752c2) +- implementation of XEP-0012 - last activity (65c2473) +- errors.log contains a lot less stuff (695877b) - tabs where the input is not empty will not be considered as "normal" -- /bookmark * now preserves the order of the tabs -- /list on big servers should be much faster -- muc logs containing xhtml are colored properly -- /load and /unload can handle several plugins at once -- nicks are no longer highlighted if they are inside another word + (52e6334, fe072a8) +- /bookmark * now preserves the order of the tabs (38061a6) +- /list on big servers should be much faster (7b446d9) +- /load and /unload can handle several plugins at once (0847643) +- nicks are no longer highlighted if they are inside another word (f58dfe2) - /invite will now use the “direct” invitations of XEP-0249 when supported + (3221534) - tabs where the other party is currently composing will have a particular - state -- the OTR plugin will not stay in the composing state after sending a message + state (a784216) +- Implementation of room destruction (1f0ff4f) - running theming.py and keyboard.py will be nicer and not pollute the output - afterwards -- bugfixes + afterwards (5012611, 1bd9f78) +- Bugfixes * Poezio 0.8.1 - fix compilation with python 3.4 @@ -1,4 +1,4 @@ -Copyright (c) 2010-2014 Florent Le Coz and Mathieu Pasquet +Copyright (c) 2010-2015 Florent Le Coz and Mathieu Pasquet This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index c52d5166..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include src/default_config.cfg -include data/default_config.cfg -include data/poezio.1 -recursive-include data/ * @@ -1,10 +1,3 @@ -prefix=/usr/local/ -LIBDIR=$(prefix)/lib -BINDIR=$(prefix)/bin -DATADIR=$(prefix)/share -DOCDIR=$(DATADIR)/doc -LOCALEDIR=$(DATADIR)/locale -MANDIR=$(DATADIR)/man TMPDIR=/tmp/ all: Makefile @@ -14,21 +7,16 @@ clean: find ./ -name \*.pyc -delete find ./ -name \*.pyo -delete find ./ -name \*~ -delete + find ./ -type d -name __pycache__ -delete find ./ -name "#*#" -delete rm -rf doc/build/ + rm -rf poezio.egg-info + rm -rf dist rm -rf build rm -f src/*.so install: all python3 setup.py install --root=$(DESTDIR) --optimize=1 - mkdir -p $(DESTDIR)$(prefix) $(DESTDIR)$(DOCDIR)/poezio/ $(DESTDIR)$(LOCALEDIR) $(DESTDIR)$(BINDIR) - cp -R doc/* $(DESTDIR)$(DOCDIR)/poezio/ - cp README CHANGELOG COPYING $(DESTDIR)$(DOCDIR)/poezio/ - -uninstall: - rm -f $(DESTDIR)$(BINDIR)/poezio - rm -rf $(DESTDIR)$(DOCDIR)/poezio/ - rm -rf $(DESTDIR)$(MANDIR)/man1/poezio.1 doc: make -C doc/ html @@ -36,9 +24,6 @@ doc: test: py.test -v test/ -pot: - xgettext src/*.py --from-code=utf-8 --keyword=_ -o locale/poezio.pot - release: rm -fr $(TMPDIR)/poezio-$(version) git clone $(PWD) $(TMPDIR)/poezio-$(version) @@ -1,3 +1,4 @@ +:: _ (_) @@ -8,8 +9,9 @@ | | |_| -Homepage: http://poez.io -Forge Page: http://dev.poez.io +Homepage: http://poez.io + +Forge Page: http://dev.poez.io Poezio is a console Jabber/XMPP client. Its goal is to use anonymous connections to simply let the user join MultiUserChats. This way, the user @@ -27,9 +29,9 @@ MUCs, especially XEP 0045. Install ======================= -You need python 3.3 or higher (preferably the latest) and the associated devel -package, to build C modules, and the SleekXMPP python library (develop branch). -You can optionally add dnspython if you want SRV record support. +You need python 3.4 or higher (preferably the latest) and the associated devel +package, to build C modules, and the slixmpp python library. +You also need aiodns if you want SRV record support. Additionally, you’ll need sphinx to build the documentation pages. To read the documentation without these dependancies just read the rst @@ -37,34 +39,26 @@ files in the doc/source/ directory or the generated documentation on the website. The simplest way to have up-to-date dependencies and to be able to test -this developement version is to use the update.sh script that downloads -and places them in the right directory. - -You also need to compile some external C modules, to do this, just enter - -$ make +this developement version is to use the ``update.sh`` script that downloads +them, places them in the right directory, and builds the C module. -you can then launch poezio with +You can then launch poezio with -$ ./launch.sh +:: -or you can install it with (as root or with sudo) + $ ./launch.sh -$ make install - -(`make uninstall' works, don't worry ;)) -you can now simply launch `poezio' +you can now simply launch ``poezio`` You can edit the configuration file which is located in -~/.config/poezio/poezio.cfg by default, and you will have to edit -data/default_config.cfg if you want to edit the config before the -first launch. The default config file is fully commented, but you can +``~/.config/poezio/poezio.cfg`` by default, and you will have to copy +and edit ``data/default_config.cfg`` if you want to edit the config before +the first launch. The default config file is fully commented, but you can also read the “Configuration” documentation page which has links between options and longer descriptions. Please see the online documentation for more information on installing, -configuring or using poezio: -http://doc.poez.io/ +configuring or using poezio: http://doc.poez.io/ If you still have questions, or if you're lost, don't hesitate to come talk to us directly on our Jabber chat room (see Contact section). @@ -75,19 +69,22 @@ Please DO report any bug you encounter and ask for any feature you want ======================= Authors ======================= -Florent Le Coz (louiz’) <louiz@louiz.org> (developer) -Mathieu Pasquet (mathieui) <mathieui@mathieui.net> (developer) +- Florent Le Coz (louiz’) <louiz@louiz.org> (developer) +- Mathieu Pasquet (mathieui) <mathieui@mathieui.net> (developer) ======================= Contact/support ======================= -Jabber ChatRoom: poezio@muc.poezio.eu + +Jabber ChatRoom: `poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ + Report a bug: http://dev.poez.io/new ======================= License ======================= + Poezio is Free Software. (learn more: http://www.gnu.org/philosophy/free-sw.html) @@ -102,9 +99,11 @@ the Creative Commons BY license (http://creativecommons.org/licenses/by/2.0/) Hacking ======================= -If you want to contribute, you are invited on poezio@muc.poezio.eu to -announce your ideas, what you are going to do, or to seek help if you -have trouble understanding some of the code. +If you want to contribute, you will be welcome on +`poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ to announce your +ideas, what you are going to do, or to seek help if you have trouble +understanding some of the code. + The preferred way to submit changes is through a public git repository. But mercurial repositories or simple patches are also welcome. @@ -121,6 +120,7 @@ branches (for example the “plugins” or the “opt” branches). If it’s a really long feature, merge the “master” branch in that feature branch from time to time, to avoid huge merges (and merge issues) when you’ll have to merge your feature back in “master”. + Merge your work in master once it works and is usable, not necessarily when it’s 100% finished. Polishing and last bug fixes can take place in “master”. @@ -148,18 +148,19 @@ we merge it to “master” as well, of course). Thanks ======================= -= People = - - Todd Eisenberger (todd@teisen.be) - Plugin system and OTR support - - Jérôme Parment (Manfraid) - Code, testing - - Link Mauve - Code, testing - - Eijebong - Code - - Gaëtan Ribémont (http://www.bonbref.com) - Logo design - - Ovart - Testing - - Koshie - Donation - - Gapan - Makefile - - FlashCode (weechat dev) - Useful advices on how to use ncurses efficiently - - And all the people using and testing poezio, and especially the ones present - on the jabber chatroom doing bug reports and/or feature requests. -= Project = - Gajim - send_vcard method, common.py, and PEP listings +- People: + - Todd Eisenberger - Plugin system and OTR support + - Jérôme Parment (Manfraid) - Code, testing + - Link Mauve - Code, testing + - Perdu - Code + - Eijebong - Code + - Gaëtan Ribémont (http://www.bonbref.com) - Logo design + - Ovart - Testing + - Koshie - Donation + - Gapan - Makefile + - FlashCode (weechat dev) - Useful advices on how to use ncurses efficiently + - And all the people using and testing poezio, and especially the ones present + on the jabber chatroom doing bug reports and/or feature requests. +- Project + - Gajim - send_vcard method, common.py, and PEP listings diff --git a/data/default_config.cfg b/data/default_config.cfg index 35bc498b..5aa3fdcf 100644 --- a/data/default_config.cfg +++ b/data/default_config.cfg @@ -15,6 +15,16 @@ jid = # If you leave this empty, the password will be asked at each startup password = +# Path to a PEM certificate file to use for certificate authentication +# through SASL External. If set, keyfile MUST be provided as well in +# order to login. +certfile = + +# Path to a PEM private key file to use for certificate authentication +# through SASL External. If set, certfile MUST be provided as well in +# order to login. +keyfile = + # the nick you will use when joining a room with no associated nick # If this is empty, the $USER environnement variable will be used default_nick = @@ -72,8 +82,7 @@ whitespace_interval = 300 ca_cert_path = # Auto-reconnects you when you get disconnected from the server -# defaults to false because it should not be necessary -auto_reconnect = false +auto_reconnect = true # The time between the ping sent to the server to check if the connection is alive connection_check_interval = 60 @@ -118,6 +127,10 @@ use_bookmarks_method = # possible values are: anything/false use_remote_bookmarks = true +# Force the retrieval of the remote bookmarks even when the server +# doesn't advertise support for your method +force_remote_bookmarks = false + # Whether you want all bookmarks, even those without # autojoin, to be open on startup open_all_bookmarks = false @@ -137,6 +150,10 @@ max_nick_length = 25 # Show the timestamp of each messages, or not show_timestamps = true +# Allow a message to "correct" another message in the display if +# the sender wants it. +group_corrections = true + # Words that you want to complete on recent words completion, # separated by a colon (:). # e.g. words = "anticonstitutionnellement:I protest:I like bananas:" @@ -263,6 +280,9 @@ show_inactive_tabs = true # - false or anything else: no highlighting show_composing_tabs = direct +# Ignore private messages received in chatrooms +ignore_private = false + # If you want to show the tab names in the bottom tab bar, set this to true show_tab_names = false @@ -340,13 +360,17 @@ themes_dir = # in the theme_dir directory. # If the file is not found (or no filename is specified) the default # theme will be used instead -theme = +theme = default # Whether to create gaps when moving or closing a tab # (a gap means that the number of your tabs does not depend of the previous tabs # but only of the creation order) create_gaps = false +# If Alt-x is pressed and you are already on the tab numbered "x", +# you will go on the previous tab if that option is true +go_to_previous_tab_on_alt_number = false + # If true, a vertical list of tabs, with their name, is displayed on the left of # the screen. enable_vertical_tab_list = false @@ -362,6 +386,12 @@ vertical_tab_list_sort = desc # possible values: desc, asc user_list_sort = desc +# If the MUC nicks should receive a fixed color based on their text or not +deterministic_nick_colors = true + +# If _nick, nick_, _nick_, nick__ etc. should have the same color as nick +nick_color_aliases = true + # The nick of people who join, part, change their status, etc. in a MUC will # be displayed using their nick color if true. display_user_color_in_join_part = true @@ -369,6 +399,12 @@ display_user_color_in_join_part = true # Display user tune notifications as information messages or not display_tune_notifications = false +# Change the tab state when receiving MUC messages. +# useful if you are connected to a high-volume chatroom and do not +# want it to appear in your tab bar as active. Highlights are still +# shown. +notify_messages = true + # Enable Message Carbons (XEP-0280) to deliver message copies from and to # other resources with carbons enabled. enable_carbons = false @@ -475,7 +511,7 @@ exec_remote = false # The "poezio.fifo" file will be created in this directory # Used with exec_remote set to true, see the documentation of /link for details # Defaults to ./ -remote_fifo_path = +remote_fifo_path = ./ # Defines if all tabs are resized at the same time (if set to false) # or if they are really resized only when needed (if set to true). @@ -497,3 +533,7 @@ M-i = ^I # to save various data across restarts folded_roster_groups = info_win_height = 2 + +[muc_colors] +# Set color for a nick, under the form +# nick = color diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 449d4095..f1713a86 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -146,6 +146,12 @@ These commands work in *any* tab. just set the option to true if it’s currently false, and to false if it’s currently true. + /set_default + **Usage:** ``/set_default [section] <option>`` + + Set the value of an option back to the default. For example, + ``/set_default resource`` will reset the ``resource`` option. + /toggle **Usage:** ``/toggle <option>`` @@ -259,6 +265,8 @@ These commands work in *any* tab. /self Reminds you of who you are and what your status is. + /reload + Reload the config. You can achieve the same by sending SIGUSR1 to poezio. /close Close the tab. @@ -306,10 +314,25 @@ MultiUserChat tab commands .. glossary:: :sorted: - /clear [RosterTab version] + /color + **Usage:** ``/color <nick> <color>`` + + Assign a color to the given nick. The nick and all its alias (nicks + are considered identical if they only differ by the presence of one + ore more **_** character at the begining or the end. For example + _Foo and Foo___ are considered aliases of the nick Foo) will then + always have the specified color, in all MultiUserChat tabs. This is + true whatever the value of **deterministic_nick_colors** is. + + Use the completion to get a list of all the available color values. + Use the special color **unset** to remove the attributed color on + this nick. + You can also use **random** to attribute a random color. + + /clear [MUCTab version] **Usage:** ``/clear`` - Clear the information buffer. (was /clear_infos) + Clear the messages buffer. /ignore **Usage:** ``/ignore <nickname>`` @@ -496,8 +519,8 @@ Roster tab commands Disconnect from the remote server (if connected) and then connect to it again. -.. note:: The following commands only exist if your server supports them. If it - does not, you will be notified when you start poezio. +.. note:: The following commands only exist if your server announces it + supports them. .. glossary:: :sorted: @@ -517,6 +540,41 @@ Roster tab commands /list_blocks List the blocked JIDs. + /certs + + List the remotely stored X.509 certificated allowed to connect + to your accounts. + + /cert_add + **Usage:** ``/cert_add <name> <certificate file> [management]`` + + Add a client X.509 certificate to the list of the certificates + which grand access to your account. It must have an unique name + the file must be in PEM format. ``[management]`` is true by + default and specifies if the clients connecting with this + particular certificate will be able to manage the list of + authorized certificates. + + /cert_disable + **Usage:** ``/cert_disable <name>`` + + Remove a certificate from the authorized list. Clients currently + connected with the certificate identified by ``<name>`` will + however **not** be disconnected. + + /cert_revoke + **Usage:** ``/cert_revoke <name>`` + + Remove a certificate from the authorized list. Clients currently + connected with the certificate identified by ``<name>`` **will** + be disconnected. + + /cert_fetch + **Usage:** ``/cert_fetch <name> <path>`` + + Download the public key of the authorized certificate identified by + ``name`` from the XMPP server, and store it in ``<path>``. + .. note:: The following commands do not comply with any XEP or whatever, but they can still prove useful when you are migrating to an other JID. @@ -552,7 +610,7 @@ XML tab commands Write the content of the XML buffer into a file. /reset - Reset the stanza filter. + Reset the stanza filters. /filter_id **Usage:** ``/filter_id <id>`` @@ -568,3 +626,18 @@ XML tab commands **Usage:** ``/filter_xmlmask <xml mask>`` Filter using an XML mask + + /filter_jid + **Usage:** ``/filter_jid <jid>`` + + Filter by JID, both ``to`` and ``from``. + + /filter_to + **Usage:** ``/filter_to <jid>`` + + Filter by JID for the ``to`` attribute. + + /filter_from + **Usage:** ``/filter_from <jid>`` + + Filter by JID for ``from`` attribute. diff --git a/doc/source/conf.py b/doc/source/conf.py index 69fbbae3..a7a100be 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -33,7 +33,7 @@ sys.path.append(os.path.abspath('../../plugins/')) extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] # Add any paths that contain templates here, relative to this directory. -templates_path = ['theme/templates'] +#templates_path = ['theme/templates'] # The suffix of source filenames. source_suffix = '.rst' @@ -53,9 +53,9 @@ copyright = '%s, Mathieu Pasquet - Florent Le Coz' % time.strftime('%Y') # built documents. # # The short X.Y version. -version = '0.8.3' +version = '0.9' # The full version, including alpha/beta/rc tags. -release = '0.8.3-dev' +release = '0.9-dev' add_function_parentheses = True @@ -101,9 +101,9 @@ pygments_style = 'sphinx' # -- Options for HTML output --------------------------------------------------- -html_theme = 'pydoctheme' +html_theme = "sphinx_rtd_theme" + html_theme_path = ['theme/'] -html_theme_options = {'collapsiblesidebar': False} html_short_title = '%s Documentation' % release @@ -126,7 +126,7 @@ html_last_updated_fmt = '%b %d, %Y' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +html_favicon = "poezio.png" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index d0ff5bbe..8d39a7de 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -156,6 +156,22 @@ Options related to account configuration, nickname… your alternative nickname will be "john\_". + keyfile + + **Default value:** ``[empty]`` + + Path to a PEM private key file to use for certificate authentication + through SASL External. If set, :term:`certfile` **MUST** be set as well + in order to login. + + certfile + + **Default value:** ``[empty]`` + + Path to a PEM certificate file to use for certificate authentication + through SASL External. If set, :term:`keyfile` **MUST** be set as well + in order to login. + resource **Default value:** ``[empty]`` @@ -220,10 +236,10 @@ well, but you will at least want to know when you get disconnected. auto_reconnect - **Default value:** ``false`` + **Default value:** ``true`` - Auto-reconnects you when you get disconnected. Should not be necessary, so - the default is false. + Auto-reconnects you when you get disconnected from the + server. Poezio will try to reconnect forever, until it succeeds. connection_check_interval @@ -316,6 +332,14 @@ to understand what is :ref:`carbons <carbons-details>` or If this is set to ``false``, you will no longer be subscribed to tune events, and the :term:`display_tune_notifications` option will be ignored. + go_to_previous_tab_on_alt_number + + **Default value:** ``false`` + + If this is set to ``true``, when Alt+x is pressed, where x is a + number, if you are already on the tab number x, you will jump to the + previously selected tab. Otherwise you’ll stay on the same tab. + group_corrections **Default value:** ``true`` @@ -324,6 +348,14 @@ to understand what is :ref:`carbons <carbons-details>` or sender intended it as such. See :ref:`Message Correction <correct-feature>` for more information. + + force_remote_bookmarks + + **Default value:** ``false`` + + Try to retrieve your remote bookmarks, even when your server doesn’t advertise + support. + use_bookmark_method **Default value:** ``[empty]`` @@ -495,6 +527,24 @@ or the way messages are displayed. If set to ``desc``, the MUC users will be displayed from top to bottom in the list, if set to ``asc``, they will be displayed from bottom to top. + deterministic_nick_colors + + **Default value:** ``true`` + + Use a deterministic algorithm to choose the user colors in chatrooms if + set to ``true``. Otherwise the colors will be picked randomly. + + The value of this option affects the behavior of :term:`/recolor`. + + nick_color_aliases + + **Default value:** ``true`` + + Automatically search for color of nick aliases. For example, if nick is + set to red, _nick, nick\_, _nick_, nick\__ etc. will have the same color. + Aliases colors are checked first, so that it is still possible to have + different colors for nick\_ and nick. + vertical_tab_list_size **Default value:** ``20`` @@ -714,6 +764,14 @@ or the way messages are displayed. If the message takes more than one line, the popup will stay visible two more second per additional lines. + muc_colors (section) + + **Default:** ``[empty]`` + + Fix a color for a nick. Whenever such a nick appears in a MUC, it will + be displayed in that color. This color won't be changed by the recolor + command. + User Interaction ~~~~~~~~~~~~~~~~ @@ -793,7 +851,7 @@ Options related to logging. **Default value:** ``true`` - Logs all the tracebacks and erors of poezio/sleekxmpp in + Logs all the tracebacks and erors of poezio/slixmpp in :term:`log_dir`/errors.log by default. ``false`` disables this option. use_log diff --git a/doc/source/dev/events.rst b/doc/source/dev/events.rst index 770445a0..80397a6e 100644 --- a/doc/source/dev/events.rst +++ b/doc/source/dev/events.rst @@ -1,8 +1,8 @@ Event Index =========== -The following events are poezio-only events, for SleekXMPP events, check out -`their index <http://sleekxmpp.com/event_index.html>`_. +The following events are poezio-only events, for Slixmpp events, check out +`their index <http://slixmpp.com/event_index.html>`_. .. glossary:: :sorted: @@ -14,13 +14,13 @@ The following events are poezio-only events, for SleekXMPP events, check out Triggered whenever the user switches between tabs. muc_say - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.MucTab` source Triggered whenever the user sends a message to a :py:class:`~tabs.MucTab`. muc_say_after - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.MucTab` source Same thing than :term:`muc_say`, but after XHTML generation of the body, if needed. @@ -29,13 +29,13 @@ The following events are poezio-only events, for SleekXMPP events, check out you should probably not need it. private_say - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.PrivateTab` source Triggered whenever the user sends a message to a :py:class:`~tabs.PrivateTab`. private_say_after - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.PrivateTab` source Same thing than :term:`private_say`, but after XHTML generation of the body, if needed. @@ -44,13 +44,13 @@ The following events are poezio-only events, for SleekXMPP events, check out you should probably not need it. conversation_say - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.ConversationTab` source Triggered whenever the user sends a message to a :py:class:`~tabs.ConversationTab`. conversation_say_after: - - **message:** :py:class:`~sleekxmpp.Message` that will be sent + - **message:** :py:class:`~slixmpp.Message` that will be sent - **tab:** :py:class:`~tabs.ConversationTab` source Same thing than :term:`conversation_say`, but after XHTML generation @@ -60,101 +60,101 @@ The following events are poezio-only events, for SleekXMPP events, check out and you should probably not need it. muc_msg - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when a message is received in a :py:class:`~tabs.MucTab`. private_msg - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.PrivateTab` source Triggered when a message is received in a :py:class:`~tabs.PrivateTab`. conversation_msg - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.ConversationTab` source Triggered when a message is received in a :py:class:`~tabs.ConversationTab`. conversation_chatstate - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.ConversationTab` source Triggered when a chatstate is received in a :py:class:`~tabs.ConversationTab`. muc_chatstate - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when a chatstate is received in a :py:class:`~tabs.MucTab`. private_chatstate - - **message:** :py:class:`~sleekxmpp.Message` received + - **message:** :py:class:`~slixmpp.Message` received - **tab:** :py:class:`PrivateTab <tabs.PrivateTab>` source Triggered when a chatstate is received in a :py:class:`~tabs.PrivateTab`. normal_presence - - **presence:** :py:class:`~sleekxmpp.Presence` received - - **resource:** :py:class:`Resource <str>` that emitted the :py:class:`~sleekxmpp.Presence` + - **presence:** :py:class:`~slixmpp.Presence` received + - **resource:** :py:class:`Resource <str>` that emitted the :py:class:`~slixmpp.Presence` Triggered when a presence is received from a contact. muc_presence - - **presence:** :py:class:`~sleekxmpp.Presence` received + - **presence:** :py:class:`~slixmpp.Presence` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when a presence is received from someone in a :py:class:`~tabs.MucTab`. joining_muc - - **presence:** :py:class:`~~sleekxmpp.Presence` to be sent + - **presence:** :py:class:`~~slixmpp.Presence` to be sent Triggered when joining a MUC. The presence can thus be modified before being sent. changing_nick - - **presence:** :py:class:`~~sleekxmpp.Presence` to be sent + - **presence:** :py:class:`~~slixmpp.Presence` to be sent Triggered when the user changes his/her nickname on a MUC. The presence can thus be modified before being sent. send_normal_presence - - **presence:** :py:class:`~sleekxmpp.Presence` sent + - **presence:** :py:class:`~slixmpp.Presence` sent - Triggered when poezio sends a new :py:class:`~sleekxmpp.Presence` + Triggered when poezio sends a new :py:class:`~slixmpp.Presence` stanza. The presence can thus be modified before being sent. muc_join - - **presence:** :py:class:`~sleekxmpp.Presence` received + - **presence:** :py:class:`~slixmpp.Presence` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when an user joins a :py:class:`~tabs.MucTab` muc_ban - - **presence:** :py:class:`~sleekxmpp.Presence` received + - **presence:** :py:class:`~slixmpp.Presence` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when an user from a :py:class:`~tabs.MucTab` gets banned. muc_kicked - - **presence:** :py:class:`~sleekxmpp.Presence` received + - **presence:** :py:class:`~slixmpp.Presence` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when an user from a :py:class:`~tabs.MucTab` gets kicked. muc_nickchange - - **presence:** :py:class:`~sleekxmpp.Presence` received + - **presence:** :py:class:`~slixmpp.Presence` received - **tab:** :py:class:`~tabs.MucTab` source Triggered when an user in a :py:class:`~tabs.MucTab` changes his nickname. ignored_private - - **message**:py:class:`~sleekxmpp.Message` received + - **message**:py:class:`~slixmpp.Message` received - **tab:** :py:class:`~tabs.PrivateTab` source Triggered when a private message (that goes in a diff --git a/doc/source/dev/index.rst b/doc/source/dev/index.rst index aae0a6d0..21ea6253 100644 --- a/doc/source/dev/index.rst +++ b/doc/source/dev/index.rst @@ -15,7 +15,7 @@ About plugins plugin events - sleek + slix xep About Poezio diff --git a/doc/source/dev/overview.rst b/doc/source/dev/overview.rst index f0eef18a..3b27fe9e 100644 --- a/doc/source/dev/overview.rst +++ b/doc/source/dev/overview.rst @@ -26,8 +26,7 @@ dispatchs the I/O events (keypress) to the appropriate methods. But the main loop is not the most important thing in poezio; because it is an IM client, it is essentially event-driven. The event part is handled by -SleekXMPP, which is the library we chose after moving away from xmpppy. - +slixmpp, which is our fork of sleekxmpp to use asyncio instead of threads. **Tabs** are the second layer of poezio, but the first dealing with the UI: each **Tab** is a layout of several **windows**, it contains tab-specific commands, @@ -49,9 +48,9 @@ Event handlers -------------- The events handlers are registered right at the start of poezio, and then -when a matching stanza is received, the handler is called in a separate thread -from the main loop. The handlers are in **Core**, and then they call the -appropriate methods in the corresponding **tabs**. +when a matching stanza is received, the handler is called. The handlers are +in **Core**, and then they call the appropriate methods in the corresponding +**tabs**. Example scenario: if a message is received from a MUC, then the **Core** handler will identify the **Tab**, and call the relevant handler from this **Tab**, this tab @@ -86,14 +85,12 @@ There are utilities to deal with it (common.shell_split), but it is not always necessary. Commands are registered in the **commands** dictionnary of a tab structured as key (command name) -> tuple(command function, help string, completion). - Completions are a bit tricky, but it’s easy once you get used to it: They take an **Input** (a _windows_ class) as a parameter, named the_input everywhere in the sources. To effectively have a completion, you have to call -**the_input.auto_completion()** or **the_input.new_completion()** at the end -of the function. - +**the_input.auto_completion()** or **the_input.new_completion()** with the relevant +parameters before returning from the function. .. code-block:: python @@ -105,13 +102,48 @@ of the function. def new_completion(completion_list, argument_position, after='', quotify=True): # … -Set the input to iterate over _completion_list_ when the user hits tab, insert +Set the input to iterate over **completion_list** when the user hits tab, to insert **after** after the completed item, and surround the item with double quotes or not. To find the current completed argument, use the **input.get_argument_position()** -method. You can then use new_completion() to select the argument to be completed. +method. You can then use **new_completion()** to select the argument to be completed. You can look for examples in the sources, all the possible cases are covered (single-argument, complex arguments with spaces, several arguments, etc…). + +.. note:: + Only **new_completion()** used together with **get_argument_position()** allow + completing arguments that are not at the end of the command line, therefore it + is preferable to use that and not **auto_completion()**. + + +Dealing with the command line +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For convenience’s sake, poezio includes a **decorators** module containing a +**command_args_parser**, which can be used to filter the input easily. + +Examples: + +.. code-block:: python + + from decorators import command_args_parser + class MyClass(object): + + @command_args_parser.raw + def command_raw(self, raw): + # the "raw" parameter will be the raw input string + + @command_args_parser.ignored + def command_ignored(self): + # no argument is given to that function + + @command_args_parser.quoted(mandatory=1, optional=0) + def command_quoted_1(self, args): + # the "args" parameter will be a list containing one argument + +See the source of the CommandArgParser for more information. + +.. autoclass:: decorators.CommandArgParser diff --git a/doc/source/dev/sleek.rst b/doc/source/dev/slix.rst index 7baf5b29..3c06e349 100644 --- a/doc/source/dev/sleek.rst +++ b/doc/source/dev/slix.rst @@ -1,7 +1,7 @@ SleekXMPP classes ================= -.. module:: sleekxmpp +.. module:: slixmpp .. autoclass:: Message :members: diff --git a/doc/source/dev/timed_events.rst b/doc/source/dev/timed_events.rst index a2c96912..82f49232 100644 --- a/doc/source/dev/timed_events.rst +++ b/doc/source/dev/timed_events.rst @@ -6,10 +6,6 @@ Timed events documentation .. autoclass:: TimedEvent .. automethod:: __init__ - .. automethod:: has_timed_out - .. automethod:: change_date - .. automethod:: add_delay - .. autoclass:: DelayedEvent diff --git a/doc/source/install.rst b/doc/source/install.rst index 0ffa9883..3243e932 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -1,7 +1,8 @@ Installing poezio ================= -.. important:: Python 3.4 or above is **required** +.. warning:: Python 3.4 or above is **required**. + To install it on a distribution that doesn't provide it, see :ref:`pyenv <pyenv-install>`. poezio in the GNU/Linux distributions ------------------------------------- @@ -10,13 +11,16 @@ As far as I know, Poezio is available in the following distributions, you just have to install it by using the package manager of the distribution, if you're using one of these. -- *Archlinux*: A poezio_ and poezio-git_ packages are in AUR (use your favourite - AUR wrapper to install them) -- *Gentoo*: `Sekh’s overlay`_ contains everything required to build poezio - (sleekxmpp, dnspython, and poezio) -- *Fedora*: The poezio package was out of date for a long time in Fedora, but - now thanks to Casper, there is an `up-to-date package`_ in the repos since F19. -- *Debian*: Use an other distro. (or make a package, we can provide help :) ) +- **Archlinux**: A poezio_ and poezio-git_ packages are in AUR (use your favourite + AUR wrapper to install them) +- **Gentoo**: `Sekh’s overlay`_ contains everything required to build stable + versions of poezio (sleekxmpp, dnspython, and poezio) +- **Fedora**: The stable poezio package was out of date for a long time in + Fedora, but now thanks to Casper, there is an `up-to-date package`_ in + the repos since F19. +- **Debian**: A set of `development builds`_ are available for poezio and its + dependencies; those are automatically generated (and most likely untested) + packages. We welcome anyone willing to make proper ones. (If another distribution provides a poezio package, please tell us and we will add it to the list) @@ -69,14 +73,14 @@ Poezio depends on two libraries: Additionally, it needs *python3-setuptools*, which is required for proper python packaging management. -.. note:: We provide a script ``update.sh`` that creates a virtualenv and +.. note:: We provide an ``update.sh`` script that creates a virtualenv and downloads all the required and optional dependencies inside it. we recommend using it with the git version of poezio, in order to keep everything up-to-date. If you don’t want to use the update script for whatever reason, install the following dependencies by hand; otherwise, skip to the -`installation part <poezio-install-label>`. +:ref:`installation part <poezio-install-label>`. """""""" @@ -87,7 +91,7 @@ Poezio depends on slixmpp, a non-thread fork of the SleekXMPP library. .. code-block:: bash - git clone git://git.louiz.org/slixmpp + git clone git://git.poez.io/slixmpp python3 setup.py install --user @@ -105,6 +109,18 @@ The aiodns is required in order to properly resolve XMPP domains (with SRV recor This will also install pycares, which aiodns uses. +"""""""" +Building +"""""""" + +If you don’t run the ``update.sh`` script, you need to manually build the C +module used by poezio: + +.. code-block:: bash + + make + + .. _poezio-install-label: Installation @@ -125,6 +141,18 @@ poezio without a system-wide install, do, in the :file:`poezio` directory: ./update.sh +.. note:: + + If you want to use a custom directory for the virtualenv used by poezio, + you can use the ``$POEZIO_VENV`` environment variable to set use + another path (the default is :file:`poezio-venv`). + + If your distribution does not have a ``pyvenv`` symlink to the latest + pyvenv available, you will need to set ``$POEZIO_VENV_COMMAND`` to it. + For example, if you have python 3.4, you will need to set it to + ``pyvenv-3.4`` instead. + + If you really want to install it, run as root (or sudo in ubuntu or whatever): .. code-block:: bash @@ -145,7 +173,7 @@ with: ./launch.sh -If you did, it should be in he ``$PATH`` as ``poezio``, so run: +If you did, it should be in the ``$PATH`` as ``poezio``, so run: .. code-block:: bash @@ -159,3 +187,5 @@ If you did, it should be in he ``$PATH`` as ``poezio``, so run: .. _poezio-git: https://aur.archlinux.org/packages/poezio-git/ .. _up-to-date package: https://apps.fedoraproject.org/packages/poezio .. _pypi: https://pypi.python.org/pypi/poezio +.. _development builds: http://dédié.jeproteste.info/poezio_debian/ + diff --git a/doc/source/keys.rst b/doc/source/keys.rst index c590fffb..ac4428e5 100644 --- a/doc/source/keys.rst +++ b/doc/source/keys.rst @@ -182,21 +182,31 @@ These keys work only in the Roster tab (the tab number 0). **Ctrl-c**: Cancel the input (search or command) -.. note:: The following will not work if you can still write things in the - input (meaning you previously typed ``s`` or ``/``) +**Enter** on a contact/resource: open a chat tab with this contact/resource -**Space**: Fold/Unfold the current item. +**Enter** on a group: fold/unfold that group **Up**: Move the cursor down one contact. **Down**: Move the cursor up one contact. -**o**: Show the offline contacts. - **PageUp**: Scroll a page of contacts up. **PageDown**: Scroll a page of contacts down. +.. note:: The following will not work if you can still write things in the + input (meaning you previously typed ``s`` or ``/``) + +**Space**: Fold/Unfold the current item. + +**o**: Show the offline contacts. + +During a search +""""""""""""""" + +**Enter**: end the search while keeping the selected contact under the cursor +(tip: press **Enter** a second time to open a chat window) + .. _forms-keys: Data Forms tab keys diff --git a/doc/source/misc/client_certs.rst b/doc/source/misc/client_certs.rst new file mode 100644 index 00000000..df09ea3c --- /dev/null +++ b/doc/source/misc/client_certs.rst @@ -0,0 +1,43 @@ +Using client certificates to login +================================== + +Passwordless authentication is possible in XMPP through the use of mecanisms +such as `SASL External`_. This mechanism has to be supported by both the client +and the server. This page does not cover the server setup, but prosody has a +`mod_client_certs`_ module which can perform this kind of authentication, and +also helps you create a self-signed certificate. + +Poezio configuration +-------------------- + +If you created a certificate using the above link, you should have at least +two files, a ``.crt`` (public key in PEM format) and a ``.key`` (private key +in PEM format). + +You only have to store the files wherever you want and set :term:`keyfile` +with the path to the private key (``.key``), and :term:`certfile` with the +path to the public key (``.crt``). + +Authorizing your keys +--------------------- + +Now your poezio is setup to try to use client certificates at each connection. +However, you still need to inform your XMPP server that you want to allow +those keys to access your account. + +This is done through :term:`/cert_add`. Once you have added your certificate, +you can try to connect without a password by commenting the option. + +.. note:: The :term:`/cert_add` command and the others are only available if + your server supports them. + +Next +---- +Now that this is setup, you might want to use :term:`/certs` to list the +keys currently known by your XMPP server, :term:`/cert_revoke` or +:term:`/cert_disable` to remove them, and :term:`/cert_fetch` to retrieve +a public key. + + +.. _SASL External: http://xmpp.org/extensions/xep-0178.html +.. _mod_client_certs: https://code.google.com/p/prosody-modules/wiki/mod_client_certs diff --git a/doc/source/misc/index.rst b/doc/source/misc/index.rst index 08349437..2603298e 100644 --- a/doc/source/misc/index.rst +++ b/doc/source/misc/index.rst @@ -7,8 +7,10 @@ Contents: :maxdepth: 2 carbons + client_certs correct personal_events + pyenv separate ssl troubleshooting diff --git a/doc/source/misc/pyenv.rst b/doc/source/misc/pyenv.rst new file mode 100644 index 00000000..e46f1bec --- /dev/null +++ b/doc/source/misc/pyenv.rst @@ -0,0 +1,47 @@ +.. _pyenv-install: + +Installing python 3.4 as a user +------------------------------- + +Building your own python 3 +========================== + +- Go to the `python download page`_ +- Select the “Latest Python 3 Release” +- Download a tarball and extract it +- Run ``./configure && make`` (takes only a few minutes even on old CPUs) +- Edit the poezio launch.sh script to make it call your user-compiled python binary + +Pyenv (x86/x86_64 only) +======================= + +Pyenv_ is a useful script that allows you to install several python versions +in your user directory, and lets you manage which one you want depending on +the directory you are in. It is therefore useful for people who are on +distributions not providing the latest stable version, such as Debian or +CentOS. + +You can follow the step-by-step `installation tutorial`_ on github that will +help you install it to your home directory (on step 5, you should use 3.4.2 +which is the latest python 3.4 release at the time of writing this page); or +you can use the `automated installer`_ and use ``pyenv install 3.4.2`` +thereafter. + +Then you only need to add a ``.python-version`` file containing ``3.4.2`` in +your poezio directory to make the python version in that directory default to +the python 3.4.2 installed with pyenv. + + +Other +===== + +pythonz_ allows the same kind of version management as pyenv, but builds +from source instead of fetching precompiled binaries, so it allows more +control over what is going on. + + +.. _Pyenv: https://github.com/yyuu/pyenv +.. _installation tutorial: https://github.com/yyuu/pyenv#installation +.. _automated installer: https://github.com/yyuu/pyenv-installer +.. _python download page: https://www.python.org/downloads/source/ +.. _pythonz: https://github.com/saghul/pythonz diff --git a/doc/source/misc/troubleshooting.rst b/doc/source/misc/troubleshooting.rst index f3684dce..6e625d09 100644 --- a/doc/source/misc/troubleshooting.rst +++ b/doc/source/misc/troubleshooting.rst @@ -23,14 +23,6 @@ Poezio tracebacks with weird encoding errors -------------------------------------------- Please check your locale for utf-8 compatibility. -Reconnecting sucks ------------------- -We know. - -Some weird graphical glitches appear once in a blue moon and go away after a refresh ------------------------------------------------------------------------------------- -We know. - Python is too heavy ------------------- We know. It’s too late to change that. If you are running your XMPP client on a toaster, diff --git a/doc/source/plugins/close_all.rst b/doc/source/plugins/close_all.rst new file mode 100644 index 00000000..853a6e0d --- /dev/null +++ b/doc/source/plugins/close_all.rst @@ -0,0 +1,6 @@ +.. _closeall-plugin: + +Close all +========= + +.. automodule:: close_all diff --git a/doc/source/plugins/cyber.rst b/doc/source/plugins/cyber.rst new file mode 100644 index 00000000..40bc5eb8 --- /dev/null +++ b/doc/source/plugins/cyber.rst @@ -0,0 +1,6 @@ +.. _cyber-plugin: + +Cyber +===== + +.. automodule:: cyber diff --git a/doc/source/plugins/index.rst b/doc/source/plugins/index.rst index 452c2e8a..b5969fd9 100644 --- a/doc/source/plugins/index.rst +++ b/doc/source/plugins/index.rst @@ -82,6 +82,16 @@ Plugin index Allows a message to be broadcasted on all the rooms your are in. Caution: do not overuse. + Close all + :ref:`Documentation <closeall-plugin>` + + Close all tabs except MUCs and the roster. + + Cyber + :ref:`Documentation <cyber-plugin>` + + Add a cybertouch to your messages. + Day Change :ref:`Documentation <daychange-plugin>` @@ -158,7 +168,7 @@ Plugin index Screen Detach :ref:`Documentation <screendetach-plugin>` - Changes your status to **away** if the screen poezio is in is detached. + Changes your status to **away** if the screen (or tmux) poezio is in gets detached. Send Delayed :ref:`Documentation <senddelayed-plugin>` @@ -205,11 +215,21 @@ Plugin index Display the time between two messages. + Reorder + :ref:`Documentation <reorder-plugin>` + + Reorder the tabs according to a static layout. + Revstr :ref:`Documentation <revstr-plugin>` Reverse everything you say. + Pipe Command + :ref:`Documentation <pipecmd-plugin>` + + Send commands to poezio through a named pipe. + Shuffle :ref:`Documentation <shuffle-plugin>` @@ -279,3 +299,7 @@ Plugin index autocorrect irc change_title + pipe_cmd + close_all + reorder + cyber diff --git a/doc/source/plugins/pipe_cmd.rst b/doc/source/plugins/pipe_cmd.rst new file mode 100644 index 00000000..13509f75 --- /dev/null +++ b/doc/source/plugins/pipe_cmd.rst @@ -0,0 +1,6 @@ +.. _pipecmd-plugin: + +Pipe Command +============ + +.. automodule:: pipe_cmd diff --git a/doc/source/plugins/reorder.rst b/doc/source/plugins/reorder.rst new file mode 100644 index 00000000..a6a44244 --- /dev/null +++ b/doc/source/plugins/reorder.rst @@ -0,0 +1,6 @@ +.. _reorder-plugin: + +Reorder +======= + +.. automodule:: reorder diff --git a/doc/source/theme/pydoctheme/static/pydoctheme.css b/doc/source/theme/pydoctheme/static/pydoctheme.css deleted file mode 100644 index 9942ca63..00000000 --- a/doc/source/theme/pydoctheme/static/pydoctheme.css +++ /dev/null @@ -1,170 +0,0 @@ -@import url("default.css"); - -body { - background-color: white; - margin-left: 1em; - margin-right: 1em; -} - -div.related { - margin-bottom: 1.2em; - padding: 0.5em 0; - border-top: 1px solid #ccc; - margin-top: 0.5em; -} - -div.related a:hover { - color: #0095C4; -} - -div.related:first-child { - border-top: 0; - border-bottom: 1px solid #ccc; -} - -div.sphinxsidebar { - background-color: #eeeeee; - border-radius: 5px; - line-height: 130%; - font-size: smaller; -} - -div.sphinxsidebar h3, div.sphinxsidebar h4 { - margin-top: 1.5em; -} - -div.sphinxsidebarwrapper > h3:first-child { - margin-top: 0.2em; -} - -div.sphinxsidebarwrapper > ul > li > ul > li { - margin-bottom: 0.4em; -} - -div.sphinxsidebar a:hover { - color: #0095C4; -} - -div.sphinxsidebar input { - font-family: 'Lucida Grande',Arial,sans-serif; - border: 1px solid #999999; - font-size: smaller; - border-radius: 3px; -} - -div.sphinxsidebar input[type=text] { - max-width: 150px; -} - -div.body { - padding: 0 0 0 1.2em; -} - -div.body p { - line-height: 140%; -} - -div.body h1, div.body h2, div.body h3, div.body h4, div.body h5, div.body h6 { - margin: 0; - border: 0; - padding: 0.3em 0; -} - -div.body hr { - border: 0; - background-color: #ccc; - height: 1px; -} - -div.body pre { - border-radius: 3px; - border: 1px solid #ac9; -} - -div.body div.admonition, div.body div.impl-detail { - border-radius: 3px; -} - -div.body div.impl-detail > p { - margin: 0; -} - -div.body div.seealso { - border: 1px solid #dddd66; -} - -div.body a { - color: #00608f; -} - -div.body a:visited { - color: #30306f; -} - -div.body a:hover { - color: #00B0E4; -} - -tt, pre { - font-family: monospace, sans-serif; - font-size: 96.5%; -} - -div.body tt { - border-radius: 3px; -} - -div.body tt.descname { - font-size: 120%; -} - -div.body tt.xref, div.body a tt { - font-weight: normal; -} - -p.deprecated { - border-radius: 3px; -} - -table.docutils { - border: 1px solid #ddd; - min-width: 20%; - border-radius: 3px; - margin-top: 10px; - margin-bottom: 10px; -} - -table.docutils td, table.docutils th { - border: 1px solid #ddd !important; - border-radius: 3px; -} - -table p, table li { - text-align: left !important; -} - -table.docutils th { - background-color: #eee; - padding: 0.3em 0.5em; -} - -table.docutils td { - background-color: white; - padding: 0.3em 0.5em; -} - -table.footnote, table.footnote td { - border: 0 !important; -} - -div.footer { - line-height: 150%; - margin-top: -2em; - text-align: right; - width: auto; - margin-right: 10px; -} - -div.footer a:hover { - color: #0095C4; -} diff --git a/doc/source/theme/pydoctheme/theme.conf b/doc/source/theme/pydoctheme/theme.conf deleted file mode 100644 index 0c438816..00000000 --- a/doc/source/theme/pydoctheme/theme.conf +++ /dev/null @@ -1,23 +0,0 @@ -[theme] -inherit = default -stylesheet = pydoctheme.css -pygments_style = sphinx - -[options] -bodyfont = 'Lucida Grande', Arial, sans-serif -headfont = 'Lucida Grande', Arial, sans-serif -footerbgcolor = white -footertextcolor = #555555 -relbarbgcolor = white -relbartextcolor = #666666 -relbarlinkcolor = #444444 -sidebarbgcolor = white -sidebartextcolor = #444444 -sidebarlinkcolor = #444444 -bgcolor = white -textcolor = #222222 -linkcolor = #0090c0 -visitedlinkcolor = #00608f -headtextcolor = #1a1a1a -headbgcolor = white -headlinkcolor = #aaaaaa diff --git a/doc/source/theme/sphinx_rtd_theme/breadcrumbs.html b/doc/source/theme/sphinx_rtd_theme/breadcrumbs.html new file mode 100644 index 00000000..57f27a38 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/breadcrumbs.html @@ -0,0 +1,19 @@ +<div role="navigation" aria-label="breadcrumbs navigation"> + <ul class="wy-breadcrumbs"> + <li><a href="{{ pathto(master_doc) }}">Docs</a> »</li> + {% for doc in parents %} + <li><a href="{{ doc.link|e }}">{{ doc.title }}</a> »</li> + {% endfor %} + <li>{{ title }}</li> + <li class="wy-breadcrumbs-aside"> + {% if display_github %} + <a href="https://github.com/{{ github_user }}/{{ github_repo }}/blob/{{ github_version }}{{ conf_py_path }}{{ pagename }}{{ source_suffix }}"> Edit on GitHub</a> + {% elif display_bitbucket %} + <a href="https://bitbucket.org/{{ bitbucket_user }}/{{ bitbucket_repo }}/src/{{ bitbucket_version}}{{ conf_py_path }}{{ pagename }}{{ source_suffix }}">Edit on Bitbucket</a> + {% elif show_source and has_source and sourcename %} + <a href="{{ pathto('_sources/' + sourcename, true)|e }}" rel="nofollow"> View page source</a> + {% endif %} + </li> + </ul> + <hr/> +</div> diff --git a/doc/source/theme/sphinx_rtd_theme/footer.html b/doc/source/theme/sphinx_rtd_theme/footer.html new file mode 100644 index 00000000..c510a978 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/footer.html @@ -0,0 +1,33 @@ +<footer> + {% if next or prev %} + <div class="rst-footer-buttons" role="navigation" aria-label="footer navigation"> + {% if next %} + <a href="{{ next.link|e }}" class="btn btn-neutral float-right" title="{{ next.title|striptags|e }}">Next</a> + {% endif %} + {% if prev %} + <a href="{{ prev.link|e }}" class="btn btn-neutral" title="{{ prev.title|striptags|e }}">Previous</a> + {% endif %} + </div> + {% endif %} + + <hr/> + + <div role="contentinfo"> + <p> + {%- if show_copyright %} + {%- if hasdoc('copyright') %} + {% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %} + {%- else %} + {% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %} + {%- endif %} + {%- endif %} + + {%- if last_updated %} + {% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %} + {%- endif %} + </p> + </div> + + {% trans %}Built with <a href="http://sphinx-doc.org/">Sphinx</a> using a <a href="https://github.com/snide/sphinx_rtd_theme">theme</a> provided by <a href="https://readthedocs.org">Read the Docs</a>{% endtrans %}. + +</footer> diff --git a/doc/source/theme/sphinx_rtd_theme/layout.html b/doc/source/theme/sphinx_rtd_theme/layout.html new file mode 100644 index 00000000..98ac622c --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/layout.html @@ -0,0 +1,141 @@ +{# TEMPLATE VAR SETTINGS #} +{%- set url_root = pathto('', 1) %} +{%- if url_root == '#' %}{% set url_root = '' %}{% endif %} +{%- if not embedded and docstitle %} + {%- set titlesuffix = " — "|safe + docstitle|e %} +{%- else %} + {%- set titlesuffix = "" %} +{%- endif %} + +<!DOCTYPE html> +<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]--> +<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]--> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + {% block htmltitle %} + <title>{{ title|striptags|e }}{{ titlesuffix }}</title> + {% endblock %} + + {# FAVICON #} + {% if favicon %} + <link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/> + {% endif %} + + {# OPENSEARCH #} + {% if not embedded %} + {% if use_opensearch %} + <link rel="search" type="application/opensearchdescription+xml" title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}" href="{{ pathto('_static/opensearch.xml', 1) }}"/> + {% endif %} + + {% endif %} + + {# RTD hosts this file, so just load on non RTD builds #} + {% if not READTHEDOCS %} + <link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" /> + {% endif %} + + {% for cssfile in css_files %} + <link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" /> + {% endfor %} + + {%- block linktags %} + {%- if hasdoc('about') %} + <link rel="author" title="{{ _('About these documents') }}" + href="{{ pathto('about') }}"/> + {%- endif %} + {%- if hasdoc('genindex') %} + <link rel="index" title="{{ _('Index') }}" + href="{{ pathto('genindex') }}"/> + {%- endif %} + {%- if hasdoc('search') %} + <link rel="search" title="{{ _('Search') }}" href="{{ pathto('search') }}"/> + {%- endif %} + {%- if hasdoc('copyright') %} + <link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}"/> + {%- endif %} + <link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}"/> + {%- if parents %} + <link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}"/> + {%- endif %} + {%- if next %} + <link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}"/> + {%- endif %} + {%- if prev %} + <link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}"/> + {%- endif %} + {%- endblock %} + {%- block extrahead %} {% endblock %} +</head> + +<body class="wy-body-for-nav" role="document"> + + <div class="wy-grid-for-nav"> + + {# SIDE NAV, TOGGLES ON MOBILE #} + <nav data-toggle="wy-nav-shift" class="wy-nav-side"> + <div class="wy-side-nav-search"> + {% block sidebartitle %} + <a href="{{ pathto(master_doc) }}">{{ project }}</a> + {% endblock %} + {% include "searchbox.html" %} + </div> + + <div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation"> + {% block menu %} + {% set toctree = toctree(maxdepth=2, collapse=False, includehidden=True) %} + {% if toctree %} + {{ toctree }} + {% else %} + <!-- Local TOC --> + <div class="local-toc">{{ toc }}</div> + {% endif %} + {% endblock %} + </div> + + </nav> + + <section data-toggle="wy-nav-shift" class="wy-nav-content-wrap"> + + {# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #} + <nav class="wy-nav-top" role="navigation" aria-label="top navigation"> + <i data-toggle="wy-nav-top">…</i> + <a href="{{ pathto(master_doc) }}">{{ project }}</a> + </nav> + + + {# PAGE CONTENT #} + <div class="wy-nav-content"> + <div class="rst-content"> + {% include "breadcrumbs.html" %} + <div role="main" class="document"> + {% block body %}{% endblock %} + </div> + {% include "footer.html" %} + </div> + </div> + + </section> + + </div> + {% include "versions.html" %} + + {% if not embedded %} + + <script type="text/javascript"> + var DOCUMENTATION_OPTIONS = { + URL_ROOT:'{{ url_root }}', + VERSION:'{{ release|e }}', + COLLAPSE_INDEX:false, + FILE_SUFFIX:'{{ '' if no_search_suffix else file_suffix }}', + HAS_SOURCE: {{ has_source|lower }} + }; + </script> + {%- for scriptfile in script_files %} + <script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script> + {%- endfor %} + + {% endif %} + {%- block footer %} {% endblock %} +</body> +</html> diff --git a/doc/source/theme/sphinx_rtd_theme/layout_old.html b/doc/source/theme/sphinx_rtd_theme/layout_old.html new file mode 100644 index 00000000..deb8df2a --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/layout_old.html @@ -0,0 +1,205 @@ +{# + basic/layout.html + ~~~~~~~~~~~~~~~~~ + + Master layout template for Sphinx themes. + + :copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +#} +{%- block doctype -%} +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +{%- endblock %} +{%- set reldelim1 = reldelim1 is not defined and ' »' or reldelim1 %} +{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %} +{%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and + (sidebars != []) %} +{%- set url_root = pathto('', 1) %} +{# XXX necessary? #} +{%- if url_root == '#' %}{% set url_root = '' %}{% endif %} +{%- if not embedded and docstitle %} + {%- set titlesuffix = " — "|safe + docstitle|e %} +{%- else %} + {%- set titlesuffix = "" %} +{%- endif %} + +{%- macro relbar() %} + <div class="related"> + <h3>{{ _('Navigation') }}</h3> + <ul> + {%- for rellink in rellinks %} + <li class="right" {% if loop.first %}style="margin-right: 10px"{% endif %}> + <a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags|e }}" + {{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a> + {%- if not loop.first %}{{ reldelim2 }}{% endif %}</li> + {%- endfor %} + {%- block rootrellink %} + <li><a href="{{ pathto(master_doc) }}">{{ shorttitle|e }}</a>{{ reldelim1 }}</li> + {%- endblock %} + {%- for parent in parents %} + <li><a href="{{ parent.link|e }}" {% if loop.last %}{{ accesskey("U") }}{% endif %}>{{ parent.title }}</a>{{ reldelim1 }}</li> + {%- endfor %} + {%- block relbaritems %} {% endblock %} + </ul> + </div> +{%- endmacro %} + +{%- macro sidebar() %} + {%- if render_sidebar %} + <div class="sphinxsidebar"> + <div class="sphinxsidebarwrapper"> + {%- block sidebarlogo %} + {%- if logo %} + <p class="logo"><a href="{{ pathto(master_doc) }}"> + <img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/> + </a></p> + {%- endif %} + {%- endblock %} + {%- if sidebars != None %} + {#- new style sidebar: explicitly include/exclude templates #} + {%- for sidebartemplate in sidebars %} + {%- include sidebartemplate %} + {%- endfor %} + {%- else %} + {#- old style sidebars: using blocks -- should be deprecated #} + {%- block sidebartoc %} + {%- include "localtoc.html" %} + {%- endblock %} + {%- block sidebarrel %} + {%- include "relations.html" %} + {%- endblock %} + {%- block sidebarsourcelink %} + {%- include "sourcelink.html" %} + {%- endblock %} + {%- if customsidebar %} + {%- include customsidebar %} + {%- endif %} + {%- block sidebarsearch %} + {%- include "searchbox.html" %} + {%- endblock %} + {%- endif %} + </div> + </div> + {%- endif %} +{%- endmacro %} + +{%- macro script() %} + <script type="text/javascript"> + var DOCUMENTATION_OPTIONS = { + URL_ROOT: '{{ url_root }}', + VERSION: '{{ release|e }}', + COLLAPSE_INDEX: false, + FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}', + HAS_SOURCE: {{ has_source|lower }} + }; + </script> + {%- for scriptfile in script_files %} + <script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script> + {%- endfor %} +{%- endmacro %} + +{%- macro css() %} + <link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" /> + <link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" /> + {%- for cssfile in css_files %} + <link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" /> + {%- endfor %} +{%- endmacro %} + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset={{ encoding }}" /> + {{ metatags }} + {%- block htmltitle %} + <title>{{ title|striptags|e }}{{ titlesuffix }}</title> + {%- endblock %} + {{ css() }} + {%- if not embedded %} + {{ script() }} + {%- if use_opensearch %} + <link rel="search" type="application/opensearchdescription+xml" + title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}" + href="{{ pathto('_static/opensearch.xml', 1) }}"/> + {%- endif %} + {%- if favicon %} + <link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/> + {%- endif %} + {%- endif %} +{%- block linktags %} + {%- if hasdoc('about') %} + <link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" /> + {%- endif %} + {%- if hasdoc('genindex') %} + <link rel="index" title="{{ _('Index') }}" href="{{ pathto('genindex') }}" /> + {%- endif %} + {%- if hasdoc('search') %} + <link rel="search" title="{{ _('Search') }}" href="{{ pathto('search') }}" /> + {%- endif %} + {%- if hasdoc('copyright') %} + <link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" /> + {%- endif %} + <link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" /> + {%- if parents %} + <link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}" /> + {%- endif %} + {%- if next %} + <link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" /> + {%- endif %} + {%- if prev %} + <link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" /> + {%- endif %} +{%- endblock %} +{%- block extrahead %} {% endblock %} + </head> + <body> +{%- block header %}{% endblock %} + +{%- block relbar1 %}{{ relbar() }}{% endblock %} + +{%- block content %} + {%- block sidebar1 %} {# possible location for sidebar #} {% endblock %} + + <div class="document"> + {%- block document %} + <div class="documentwrapper"> + {%- if render_sidebar %} + <div class="bodywrapper"> + {%- endif %} + <div class="body"> + {% block body %} {% endblock %} + </div> + {%- if render_sidebar %} + </div> + {%- endif %} + </div> + {%- endblock %} + + {%- block sidebar2 %}{{ sidebar() }}{% endblock %} + <div class="clearer"></div> + </div> +{%- endblock %} + +{%- block relbar2 %}{{ relbar() }}{% endblock %} + +{%- block footer %} + <div class="footer"> + {%- if show_copyright %} + {%- if hasdoc('copyright') %} + {% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %} + {%- else %} + {% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %} + {%- endif %} + {%- endif %} + {%- if last_updated %} + {% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %} + {%- endif %} + {%- if show_sphinx %} + {% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %} + {%- endif %} + </div> + <p>asdf asdf asdf asdf 22</p> +{%- endblock %} + </body> +</html> + diff --git a/doc/source/theme/sphinx_rtd_theme/search.html b/doc/source/theme/sphinx_rtd_theme/search.html new file mode 100644 index 00000000..e3aa9b5c --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/search.html @@ -0,0 +1,50 @@ +{# + basic/search.html + ~~~~~~~~~~~~~~~~~ + + Template for the search page. + + :copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +#} +{%- extends "layout.html" %} +{% set title = _('Search') %} +{% set script_files = script_files + ['_static/searchtools.js'] %} +{% block footer %} + <script type="text/javascript"> + jQuery(function() { Search.loadIndex("{{ pathto('searchindex.js', 1) }}"); }); + </script> + {# this is used when loading the search index using $.ajax fails, + such as on Chrome for documents on localhost #} + <script type="text/javascript" id="searchindexloader"></script> + {{ super() }} +{% endblock %} +{% block body %} + <noscript> + <div id="fallback" class="admonition warning"> + <p class="last"> + {% trans %}Please activate JavaScript to enable the search + functionality.{% endtrans %} + </p> + </div> + </noscript> + + {% if search_performed %} + <h2>{{ _('Search Results') }}</h2> + {% if not search_results %} + <p>{{ _('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.') }}</p> + {% endif %} + {% endif %} + <div id="search-results"> + {% if search_results %} + <ul> + {% for href, caption, context in search_results %} + <li> + <a href="{{ pathto(item.href) }}">{{ caption }}</a> + <p class="context">{{ context|e }}</p> + </li> + {% endfor %} + </ul> + {% endif %} + </div> +{% endblock %} diff --git a/doc/source/theme/sphinx_rtd_theme/searchbox.html b/doc/source/theme/sphinx_rtd_theme/searchbox.html new file mode 100644 index 00000000..35ad52c5 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/searchbox.html @@ -0,0 +1,9 @@ +{%- if builder != 'singlehtml' %} +<div role="search"> + <form id="rtd-search-form" class="wy-form" action="{{ pathto('search') }}" method="get"> + <input type="text" name="q" placeholder="Search docs" /> + <input type="hidden" name="check_keywords" value="yes" /> + <input type="hidden" name="area" value="default" /> + </form> +</div> +{%- endif %} diff --git a/doc/source/theme/sphinx_rtd_theme/static/css/badge_only.css b/doc/source/theme/sphinx_rtd_theme/static/css/badge_only.css new file mode 100644 index 00000000..096f4285 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/static/css/badge_only.css @@ -0,0 +1,2 @@ +.fa:before{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:before,.clearfix:after{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-weight:normal;font-style:normal;src:url("../font/fontawesome_webfont.eot");src:url("../font/fontawesome_webfont.eot?#iefix") format("embedded-opentype"),url("../font/fontawesome_webfont.woff") format("woff"),url("../font/fontawesome_webfont.ttf") format("truetype"),url("../font/fontawesome_webfont.svg#FontAwesome") format("svg")}.fa:before{display:inline-block;font-family:FontAwesome;font-style:normal;font-weight:normal;line-height:1;text-decoration:inherit}a .fa{display:inline-block;text-decoration:inherit}li .fa{display:inline-block}li .fa-large:before,li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-0.8em}ul.fas li .fa{width:0.8em}ul.fas li .fa-large:before,ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before{content:"📚"}.icon-book:before{content:"📚"}.fa-caret-down:before{content:"⏷"}.icon-caret-down:before{content:"⏷"}.fa-caret-up:before{content:"⏶"}.icon-caret-up:before{content:"⏶"}.fa-caret-left:before{content:"⯇"}.icon-caret-left:before{content:"⯇"}.fa-caret-right:before{content:"⏵"}.icon-caret-right:before{content:"⏵"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;border-top:solid 10px #343131;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;z-index:400}.rst-versions a{color:#2980B9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27AE60;*zoom:1}.rst-versions .rst-current-version:before,.rst-versions .rst-current-version:after{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book{float:left}.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#E74C3C;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#F1C40F;color:#000}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:gray;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:solid 1px #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px}.rst-versions.rst-badge .icon-book{float:none}.rst-versions.rst-badge .fa-book{float:none}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book{float:left}.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge .rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width: 768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}img{width:100%;height:auto}} +/*# sourceMappingURL=badge_only.css.map */ diff --git a/doc/source/theme/sphinx_rtd_theme/static/css/theme.css b/doc/source/theme/sphinx_rtd_theme/static/css/theme.css new file mode 100644 index 00000000..ea29cf42 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/static/css/theme.css @@ -0,0 +1,5 @@ +*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}audio:not([controls]){display:none}[hidden]{display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:hover,a:active{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;color:#000;text-decoration:none}mark{background:#ff0;color:#000;font-style:italic;font-weight:bold}pre,code,.rst-content tt,kbd,samp{font-family:monospace,serif;_font-family:"courier new",monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:before,q:after{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}ul,ol,dl{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure{margin:0}form{margin:0}fieldset{border:0;margin:0;padding:0}label{cursor:pointer}legend{border:0;*margin-left:-7px;padding:0;white-space:normal}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0;*width:13px;*height:13px}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top;resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:0.2em 0;background:#ccc;color:#000;padding:0.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none !important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{html,body,section{background:none !important}*{box-shadow:none !important;text-shadow:none !important;filter:none !important;-ms-filter:none !important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}}.fa:before,.rst-content .admonition-title:before,.icon:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-alert,.rst-content .note,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .warning,.rst-content .seealso,.rst-content .admonition-todo,.btn,input[type="text"],input[type="password"],input[type="email"],input[type="url"],input[type="date"],input[type="month"],input[type="time"],input[type="datetime"],input[type="datetime-local"],input[type="week"],input[type="number"],input[type="search"],input[type="tel"],input[type="color"],select,textarea,.wy-menu-vertical li.on a,.wy-menu-vertical li.current>a,.wy-side-nav-search>a,.wy-side-nav-search .wy-dropdown>a,.wy-nav-top a{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:before,.clearfix:after{display:table;content:""}.clearfix:after{clear:both}/*! + * Font Awesome 4.2.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url("../fonts/fontawesome-webfont.eot?v=4.2.0");src:url("../fonts/fontawesome-webfont.eot?#iefix&v=4.2.0") format("embedded-opentype"),url("../fonts/fontawesome-webfont.woff?v=4.2.0") format("woff"),url("../fonts/fontawesome-webfont.ttf?v=4.2.0") format("truetype"),url("../fonts/fontawesome-webfont.svg?v=4.2.0#fontawesomeregular") format("svg");font-weight:normal;font-style:normal}.fa,.rst-content .admonition-title,.icon{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:0.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:0.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:solid 0.08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.rst-content .pull-left.admonition-title,.pull-left.icon{margin-right:.3em}.fa.pull-right,.rst-content .pull-right.admonition-title,.pull-right.icon{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-remove:before,.fa-close:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-gear:before,.fa-cog:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-rotate-right:before,.fa-repeat:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.rst-content .admonition-title:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-warning:before,.fa-exclamation-triangle:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-gears:before,.fa-cogs:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-save:before,.fa-floppy-o:before{content:""}.fa-square:before{content:""}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.wy-dropdown .caret:before,.icon-caret-down:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-unsorted:before,.fa-sort:before{content:""}.fa-sort-down:before,.fa-sort-desc:before{content:""}.fa-sort-up:before,.fa-sort-asc:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-legal:before,.fa-gavel:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-flash:before,.fa-bolt:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-paste:before,.fa-clipboard:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-unlink:before,.fa-chain-broken:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:""}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:""}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:""}.fa-euro:before,.fa-eur:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-rupee:before,.fa-inr:before{content:""}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:""}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:""}.fa-won:before,.fa-krw:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-turkish-lira:before,.fa-try:before{content:""}.fa-plus-square-o:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-institution:before,.fa-bank:before,.fa-university:before{content:""}.fa-mortar-board:before,.fa-graduation-cap:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:""}.fa-file-zip-o:before,.fa-file-archive-o:before{content:""}.fa-file-sound-o:before,.fa-file-audio-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before{content:""}.fa-ge:before,.fa-empire:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-send:before,.fa-paper-plane:before{content:""}.fa-send-o:before,.fa-paper-plane-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:""}.fa-meanpath:before{content:""}.fa,.rst-content .admonition-title,.icon,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context{font-family:inherit}.fa:before,.rst-content .admonition-title:before,.icon:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before{font-family:"FontAwesome";display:inline-block;font-style:normal;font-weight:normal;line-height:1;text-decoration:inherit}a .fa,a .rst-content .admonition-title,.rst-content a .admonition-title,a .icon{display:inline-block;text-decoration:inherit}.btn .fa,.btn .rst-content .admonition-title,.rst-content .btn .admonition-title,.btn .icon,.nav .fa,.nav .rst-content .admonition-title,.rst-content .nav .admonition-title,.nav .icon{display:inline}.btn .fa.fa-large,.btn .rst-content .fa-large.admonition-title,.rst-content .btn .fa-large.admonition-title,.btn .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .fa-large.admonition-title,.rst-content .nav .fa-large.admonition-title,.nav .fa-large.icon{line-height:0.9em}.btn .fa.fa-spin,.btn .rst-content .fa-spin.admonition-title,.rst-content .btn .fa-spin.admonition-title,.btn .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .fa-spin.admonition-title,.rst-content .nav .fa-spin.admonition-title,.nav .fa-spin.icon{display:inline-block}.btn.fa:before,.rst-content .btn.admonition-title:before,.btn.icon:before{opacity:0.5;-webkit-transition:opacity 0.05s ease-in;-moz-transition:opacity 0.05s ease-in;transition:opacity 0.05s ease-in}.btn.fa:hover:before,.rst-content .btn.admonition-title:hover:before,.btn.icon:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .rst-content .admonition-title:before,.rst-content .btn-mini .admonition-title:before,.btn-mini .icon:before{font-size:14px;vertical-align:-15%}.wy-alert,.rst-content .note,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .warning,.rst-content .seealso,.rst-content .admonition-todo{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.wy-alert-title,.rst-content .admonition-title{color:#fff;font-weight:bold;display:block;color:#fff;background:#6ab0de;margin:-12px;padding:6px 12px;margin-bottom:12px}.wy-alert.wy-alert-danger,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.admonition-todo{background:#fdf3f2}.wy-alert.wy-alert-danger .wy-alert-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .danger .wy-alert-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .danger .admonition-title,.rst-content .error .admonition-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title{background:#f29f97}.wy-alert.wy-alert-warning,.rst-content .wy-alert-warning.note,.rst-content .attention,.rst-content .caution,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.tip,.rst-content .warning,.rst-content .wy-alert-warning.seealso,.rst-content .admonition-todo{background:#ffedcc}.wy-alert.wy-alert-warning .wy-alert-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .attention .wy-alert-title,.rst-content .caution .wy-alert-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .admonition-todo .wy-alert-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .attention .admonition-title,.rst-content .caution .admonition-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .warning .admonition-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .admonition-todo .admonition-title{background:#f0b37e}.wy-alert.wy-alert-info,.rst-content .note,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.rst-content .seealso,.rst-content .wy-alert-info.admonition-todo{background:#e7f2fa}.wy-alert.wy-alert-info .wy-alert-title,.rst-content .note .wy-alert-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.rst-content .note .admonition-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .seealso .admonition-title,.rst-content .wy-alert-info.admonition-todo .admonition-title{background:#6ab0de}.wy-alert.wy-alert-success,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.warning,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.admonition-todo{background:#dbfaf4}.wy-alert.wy-alert-success .wy-alert-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .hint .wy-alert-title,.rst-content .important .wy-alert-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .hint .admonition-title,.rst-content .important .admonition-title,.rst-content .tip .admonition-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.admonition-todo .admonition-title{background:#1abc9c}.wy-alert.wy-alert-neutral,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.admonition-todo{background:#f3f6f6}.wy-alert.wy-alert-neutral .wy-alert-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .admonition-title{color:#404040;background:#e1e4e5}.wy-alert.wy-alert-neutral a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.admonition-todo a{color:#2980B9}.wy-alert p:last-child,.rst-content .note p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.rst-content .seealso p:last-child,.rst-content .admonition-todo p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0px;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,0.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all 0.3s ease-in;-moz-transition:all 0.3s ease-in;transition:all 0.3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27AE60}.wy-tray-container li.wy-tray-item-info{background:#2980B9}.wy-tray-container li.wy-tray-item-warning{background:#E67E22}.wy-tray-container li.wy-tray-item-danger{background:#E74C3C}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width: 768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px 12px;color:#fff;border:1px solid rgba(0,0,0,0.1);background-color:#27AE60;text-decoration:none;font-weight:normal;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;box-shadow:0px 1px 2px -1px rgba(255,255,255,0.5) inset,0px -2px 0px 0px rgba(0,0,0,0.1) inset;outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all 0.1s linear;-moz-transition:all 0.1s linear;transition:all 0.1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:0px -1px 0px 0px rgba(0,0,0,0.05) inset,0px 2px 0px 0px rgba(0,0,0,0.1) inset;padding:8px 12px 6px 12px}.btn:visited{color:#fff}.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:0.4;cursor:not-allowed;box-shadow:none}.btn-disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:0.4;cursor:not-allowed;box-shadow:none}.btn-disabled:hover,.btn-disabled:focus,.btn-disabled:active{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:0.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980B9 !important}.btn-info:hover{background-color:#2e8ece !important}.btn-neutral{background-color:#f3f6f6 !important;color:#404040 !important}.btn-neutral:hover{background-color:#e5ebeb !important;color:#404040}.btn-neutral:visited{color:#404040 !important}.btn-success{background-color:#27AE60 !important}.btn-success:hover{background-color:#295 !important}.btn-danger{background-color:#E74C3C !important}.btn-danger:hover{background-color:#ea6153 !important}.btn-warning{background-color:#E67E22 !important}.btn-warning:hover{background-color:#e98b39 !important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f !important}.btn-link{background-color:transparent !important;color:#2980B9;box-shadow:none;border-color:transparent !important}.btn-link:hover{background-color:transparent !important;color:#409ad5 !important;box-shadow:none}.btn-link:active{background-color:transparent !important;color:#409ad5 !important;box-shadow:none}.btn-link:visited{color:#9B59B6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:before,.wy-btn-group:after{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:solid 1px #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,0.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980B9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:solid 1px #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type="search"]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980B9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned input,.wy-form-aligned textarea,.wy-form-aligned select,.wy-form-aligned .wy-help-inline,.wy-form-aligned label{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{border:0;margin:0;padding:0}legend{display:block;width:100%;border:0;padding:0;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label{display:block;margin:0 0 0.3125em 0;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;*zoom:1;max-width:68em;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:before,.wy-control-group:after{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group:before,.wy-control-group:after{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#E74C3C}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full input[type="text"],.wy-control-group .wy-form-full input[type="password"],.wy-control-group .wy-form-full input[type="email"],.wy-control-group .wy-form-full input[type="url"],.wy-control-group .wy-form-full input[type="date"],.wy-control-group .wy-form-full input[type="month"],.wy-control-group .wy-form-full input[type="time"],.wy-control-group .wy-form-full input[type="datetime"],.wy-control-group .wy-form-full input[type="datetime-local"],.wy-control-group .wy-form-full input[type="week"],.wy-control-group .wy-form-full input[type="number"],.wy-control-group .wy-form-full input[type="search"],.wy-control-group .wy-form-full input[type="tel"],.wy-control-group .wy-form-full input[type="color"],.wy-control-group .wy-form-halves input[type="text"],.wy-control-group .wy-form-halves input[type="password"],.wy-control-group .wy-form-halves input[type="email"],.wy-control-group .wy-form-halves input[type="url"],.wy-control-group .wy-form-halves input[type="date"],.wy-control-group .wy-form-halves input[type="month"],.wy-control-group .wy-form-halves input[type="time"],.wy-control-group .wy-form-halves input[type="datetime"],.wy-control-group .wy-form-halves input[type="datetime-local"],.wy-control-group .wy-form-halves input[type="week"],.wy-control-group .wy-form-halves input[type="number"],.wy-control-group .wy-form-halves input[type="search"],.wy-control-group .wy-form-halves input[type="tel"],.wy-control-group .wy-form-halves input[type="color"],.wy-control-group .wy-form-thirds input[type="text"],.wy-control-group .wy-form-thirds input[type="password"],.wy-control-group .wy-form-thirds input[type="email"],.wy-control-group .wy-form-thirds input[type="url"],.wy-control-group .wy-form-thirds input[type="date"],.wy-control-group .wy-form-thirds input[type="month"],.wy-control-group .wy-form-thirds input[type="time"],.wy-control-group .wy-form-thirds input[type="datetime"],.wy-control-group .wy-form-thirds input[type="datetime-local"],.wy-control-group .wy-form-thirds input[type="week"],.wy-control-group .wy-form-thirds input[type="number"],.wy-control-group .wy-form-thirds input[type="search"],.wy-control-group .wy-form-thirds input[type="tel"],.wy-control-group .wy-form-thirds input[type="color"]{width:100%}.wy-control-group .wy-form-full{float:left;display:block;margin-right:2.35765%;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child{margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(2n+1){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child{margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control{margin:6px 0 0 0;font-size:90%}.wy-control-no-input{display:inline-block;margin:6px 0 0 0;font-size:90%}.wy-control-group.fluid-input input[type="text"],.wy-control-group.fluid-input input[type="password"],.wy-control-group.fluid-input input[type="email"],.wy-control-group.fluid-input input[type="url"],.wy-control-group.fluid-input input[type="date"],.wy-control-group.fluid-input input[type="month"],.wy-control-group.fluid-input input[type="time"],.wy-control-group.fluid-input input[type="datetime"],.wy-control-group.fluid-input input[type="datetime-local"],.wy-control-group.fluid-input input[type="week"],.wy-control-group.fluid-input input[type="number"],.wy-control-group.fluid-input input[type="search"],.wy-control-group.fluid-input input[type="tel"],.wy-control-group.fluid-input input[type="color"]{width:100%}.wy-form-message-inline{display:inline-block;padding-left:0.3em;color:#666;vertical-align:middle;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:0.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;*overflow:visible}input[type="text"],input[type="password"],input[type="email"],input[type="url"],input[type="date"],input[type="month"],input[type="time"],input[type="datetime"],input[type="datetime-local"],input[type="week"],input[type="number"],input[type="search"],input[type="tel"],input[type="color"]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border 0.3s linear;-moz-transition:border 0.3s linear;transition:border 0.3s linear}input[type="datetime-local"]{padding:0.34375em 0.625em}input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0;margin-right:0.3125em;*height:13px;*width:13px}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}input[type="text"]:focus,input[type="password"]:focus,input[type="email"]:focus,input[type="url"]:focus,input[type="date"]:focus,input[type="month"]:focus,input[type="time"]:focus,input[type="datetime"]:focus,input[type="datetime-local"]:focus,input[type="week"]:focus,input[type="number"]:focus,input[type="search"]:focus,input[type="tel"]:focus,input[type="color"]:focus{outline:0;outline:thin dotted \9;border-color:#333}input.no-focus:focus{border-color:#ccc !important}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted #333;outline:1px auto #129FEA}input[type="text"][disabled],input[type="password"][disabled],input[type="email"][disabled],input[type="url"][disabled],input[type="date"][disabled],input[type="month"][disabled],input[type="time"][disabled],input[type="datetime"][disabled],input[type="datetime-local"][disabled],input[type="week"][disabled],input[type="number"][disabled],input[type="search"][disabled],input[type="tel"][disabled],input[type="color"][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,textarea:focus:invalid,select:focus:invalid{color:#E74C3C;border:1px solid #E74C3C}input:focus:invalid:focus,textarea:focus:invalid:focus,select:focus:invalid:focus{border-color:#E74C3C}input[type="file"]:focus:invalid:focus,input[type="radio"]:focus:invalid:focus,input[type="checkbox"]:focus:invalid:focus{outline-color:#E74C3C}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif}select,textarea{padding:0.5em 0.625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border 0.3s linear;-moz-transition:border 0.3s linear;transition:border 0.3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}select[disabled],textarea[disabled],input[readonly],select[readonly],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type="radio"][disabled],input[type="checkbox"][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:solid 1px #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{width:36px;height:12px;margin:12px 0;position:relative;border-radius:4px;background:#ccc;cursor:pointer;-webkit-transition:all 0.2s ease-in-out;-moz-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out}.wy-switch:before{position:absolute;content:"";display:block;width:18px;height:18px;border-radius:4px;background:#999;left:-3px;top:-3px;-webkit-transition:all 0.2s ease-in-out;-moz-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out}.wy-switch:after{content:"false";position:absolute;left:48px;display:block;font-size:12px;color:#ccc}.wy-switch.active{background:#1e8449}.wy-switch.active:before{left:24px;background:#27AE60}.wy-switch.active:after{content:"true"}.wy-switch.disabled,.wy-switch.active.disabled{cursor:not-allowed}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#E74C3C}.wy-control-group.wy-control-group-error input[type="text"],.wy-control-group.wy-control-group-error input[type="password"],.wy-control-group.wy-control-group-error input[type="email"],.wy-control-group.wy-control-group-error input[type="url"],.wy-control-group.wy-control-group-error input[type="date"],.wy-control-group.wy-control-group-error input[type="month"],.wy-control-group.wy-control-group-error input[type="time"],.wy-control-group.wy-control-group-error input[type="datetime"],.wy-control-group.wy-control-group-error input[type="datetime-local"],.wy-control-group.wy-control-group-error input[type="week"],.wy-control-group.wy-control-group-error input[type="number"],.wy-control-group.wy-control-group-error input[type="search"],.wy-control-group.wy-control-group-error input[type="tel"],.wy-control-group.wy-control-group-error input[type="color"]{border:solid 1px #E74C3C}.wy-control-group.wy-control-group-error textarea{border:solid 1px #E74C3C}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:0.5em 0.625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27AE60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#E74C3C}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#E67E22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980B9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width: 480px){.wy-form button[type="submit"]{margin:0.7em 0 0}.wy-form input[type="text"],.wy-form input[type="password"],.wy-form input[type="email"],.wy-form input[type="url"],.wy-form input[type="date"],.wy-form input[type="month"],.wy-form input[type="time"],.wy-form input[type="datetime"],.wy-form input[type="datetime-local"],.wy-form input[type="week"],.wy-form input[type="number"],.wy-form input[type="search"],.wy-form input[type="tel"],.wy-form input[type="color"]{margin-bottom:0.3em;display:block}.wy-form label{margin-bottom:0.3em;display:block}.wy-form input[type="password"],.wy-form input[type="email"],.wy-form input[type="url"],.wy-form input[type="date"],.wy-form input[type="month"],.wy-form input[type="time"],.wy-form input[type="datetime"],.wy-form input[type="datetime-local"],.wy-form input[type="week"],.wy-form input[type="number"],.wy-form input[type="search"],.wy-form input[type="tel"],.wy-form input[type="color"]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:0.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0 0}.wy-form .wy-help-inline,.wy-form-message-inline,.wy-form-message{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width: 768px){.tablet-hide{display:none}}@media screen and (max-width: 480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.wy-table,.rst-content table.docutils,.rst-content table.field-list{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.wy-table caption,.rst-content table.docutils caption,.rst-content table.field-list caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.wy-table td,.rst-content table.docutils td,.rst-content table.field-list td,.wy-table th,.rst-content table.docutils th,.rst-content table.field-list th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.wy-table td:first-child,.rst-content table.docutils td:first-child,.rst-content table.field-list td:first-child,.wy-table th:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list th:first-child{border-left-width:0}.wy-table thead,.rst-content table.docutils thead,.rst-content table.field-list thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.wy-table thead th,.rst-content table.docutils thead th,.rst-content table.field-list thead th{font-weight:bold;border-bottom:solid 2px #e1e4e5}.wy-table td,.rst-content table.docutils td,.rst-content table.field-list td{background-color:transparent;vertical-align:middle}.wy-table td p,.rst-content table.docutils td p,.rst-content table.field-list td p{line-height:18px}.wy-table td p:last-child,.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child{margin-bottom:0}.wy-table .wy-table-cell-min,.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min{width:1%;padding-right:0}.wy-table .wy-table-cell-min input[type=checkbox],.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox],.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:gray;font-size:90%}.wy-table-tertiary{color:gray;font-size:80%}.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td,.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td{background-color:#f3f6f6}.wy-table-backed{background-color:#f3f6f6}.wy-table-bordered-all,.rst-content table.docutils{border:1px solid #e1e4e5}.wy-table-bordered-all td,.rst-content table.docutils td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.wy-table-bordered-all tbody>tr:last-child td,.rst-content table.docutils tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px 0;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0 !important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980B9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9B59B6}html{height:100%;overflow-x:hidden}body{font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;font-weight:normal;color:#404040;min-height:100%;overflow-x:hidden;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#E67E22 !important}a.wy-text-warning:hover{color:#eb9950 !important}.wy-text-info{color:#2980B9 !important}a.wy-text-info:hover{color:#409ad5 !important}.wy-text-success{color:#27AE60 !important}a.wy-text-success:hover{color:#36d278 !important}.wy-text-danger{color:#E74C3C !important}a.wy-text-danger:hover{color:#ed7669 !important}.wy-text-neutral{color:#404040 !important}a.wy-text-neutral:hover{color:#595959 !important}h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:"Roboto Slab","ff-tisa-web-pro","Georgia",Arial,sans-serif}p{line-height:24px;margin:0;font-size:16px;margin-bottom:24px}h1{font-size:175%}h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}code,.rst-content tt{white-space:nowrap;max-width:100%;background:#fff;border:solid 1px #e1e4e5;font-size:75%;padding:0 5px;font-family:Consolas,"Andale Mono WT","Andale Mono","Lucida Console","Lucida Sans Typewriter","DejaVu Sans Mono","Bitstream Vera Sans Mono","Liberation Mono","Nimbus Mono L",Monaco,"Courier New",Courier,monospace;color:#E74C3C;overflow-x:auto}code.code-large,.rst-content tt.code-large{font-size:90%}.wy-plain-list-disc,.rst-content .section ul,.rst-content .toctree-wrapper ul,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.wy-plain-list-disc li,.rst-content .section ul li,.rst-content .toctree-wrapper ul li,article ul li{list-style:disc;margin-left:24px}.wy-plain-list-disc li p:last-child,.rst-content .section ul li p:last-child,.rst-content .toctree-wrapper ul li p:last-child,article ul li p:last-child{margin-bottom:0}.wy-plain-list-disc li ul,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li ul,article ul li ul{margin-bottom:0}.wy-plain-list-disc li li,.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,article ul li li{list-style:circle}.wy-plain-list-disc li li li,.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,article ul li li li{list-style:square}.wy-plain-list-disc li ol li,.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,article ul li ol li{list-style:decimal}.wy-plain-list-decimal,.rst-content .section ol,.rst-content ol.arabic,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.wy-plain-list-decimal li,.rst-content .section ol li,.rst-content ol.arabic li,article ol li{list-style:decimal;margin-left:24px}.wy-plain-list-decimal li p:last-child,.rst-content .section ol li p:last-child,.rst-content ol.arabic li p:last-child,article ol li p:last-child{margin-bottom:0}.wy-plain-list-decimal li ul,.rst-content .section ol li ul,.rst-content ol.arabic li ul,article ol li ul{margin-bottom:0}.wy-plain-list-decimal li ul li,.rst-content .section ol li ul li,.rst-content ol.arabic li ul li,article ol li ul li{list-style:disc}.codeblock-example{border:1px solid #e1e4e5;border-bottom:none;padding:24px;padding-top:48px;font-weight:500;background:#fff;position:relative}.codeblock-example:after{content:"Example";position:absolute;top:0px;left:0px;background:#9B59B6;color:#fff;padding:6px 12px}.codeblock-example.prettyprint-example-only{border:1px solid #e1e4e5;margin-bottom:24px}.codeblock,pre.literal-block,.rst-content .literal-block,.rst-content pre.literal-block,div[class^='highlight']{border:1px solid #e1e4e5;padding:0px;overflow-x:auto;background:#fff;margin:1px 0 24px 0}.codeblock div[class^='highlight'],pre.literal-block div[class^='highlight'],.rst-content .literal-block div[class^='highlight'],div[class^='highlight'] div[class^='highlight']{border:none;background:none;margin:0}div[class^='highlight'] td.code{width:100%}.linenodiv pre{border-right:solid 1px #e6e9ea;margin:0;padding:12px 12px;font-family:Consolas,"Andale Mono WT","Andale Mono","Lucida Console","Lucida Sans Typewriter","DejaVu Sans Mono","Bitstream Vera Sans Mono","Liberation Mono","Nimbus Mono L",Monaco,"Courier New",Courier,monospace;font-size:12px;line-height:1.5;color:#d9d9d9}div[class^='highlight'] pre{white-space:pre;margin:0;padding:12px 12px;font-family:Consolas,"Andale Mono WT","Andale Mono","Lucida Console","Lucida Sans Typewriter","DejaVu Sans Mono","Bitstream Vera Sans Mono","Liberation Mono","Nimbus Mono L",Monaco,"Courier New",Courier,monospace;font-size:12px;line-height:1.5;display:block;overflow:auto;color:#404040}@media print{.codeblock,pre.literal-block,.rst-content .literal-block,.rst-content pre.literal-block,div[class^='highlight'],div[class^='highlight'] pre{white-space:pre-wrap}}.hll{background-color:#ffc;margin:0 -12px;padding:0 12px;display:block}.c{color:#998;font-style:italic}.err{color:#a61717;background-color:#e3d2d2}.k{font-weight:bold}.o{font-weight:bold}.cm{color:#998;font-style:italic}.cp{color:#999;font-weight:bold}.c1{color:#998;font-style:italic}.cs{color:#999;font-weight:bold;font-style:italic}.gd{color:#000;background-color:#fdd}.gd .x{color:#000;background-color:#faa}.ge{font-style:italic}.gr{color:#a00}.gh{color:#999}.gi{color:#000;background-color:#dfd}.gi .x{color:#000;background-color:#afa}.go{color:#888}.gp{color:#555}.gs{font-weight:bold}.gu{color:purple;font-weight:bold}.gt{color:#a00}.kc{font-weight:bold}.kd{font-weight:bold}.kn{font-weight:bold}.kp{font-weight:bold}.kr{font-weight:bold}.kt{color:#458;font-weight:bold}.m{color:#099}.s{color:#d14}.n{color:#333}.na{color:teal}.nb{color:#0086b3}.nc{color:#458;font-weight:bold}.no{color:teal}.ni{color:purple}.ne{color:#900;font-weight:bold}.nf{color:#900;font-weight:bold}.nn{color:#555}.nt{color:navy}.nv{color:teal}.ow{font-weight:bold}.w{color:#bbb}.mf{color:#099}.mh{color:#099}.mi{color:#099}.mo{color:#099}.sb{color:#d14}.sc{color:#d14}.sd{color:#d14}.s2{color:#d14}.se{color:#d14}.sh{color:#d14}.si{color:#d14}.sx{color:#d14}.sr{color:#009926}.s1{color:#d14}.ss{color:#990073}.bp{color:#999}.vc{color:teal}.vg{color:teal}.vi{color:teal}.il{color:#099}.gc{color:#999;background-color:#EAF2F5}.wy-breadcrumbs li{display:inline-block}.wy-breadcrumbs li.wy-breadcrumbs-aside{float:right}.wy-breadcrumbs li a{display:inline-block;padding:5px}.wy-breadcrumbs li a:first-child{padding-left:0}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width: 480px){.wy-breadcrumbs-extra{display:none}.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:before,.wy-menu-horiz:after{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz ul,.wy-menu-horiz li{display:inline-block}.wy-menu-horiz li:hover{background:rgba(255,255,255,0.1)}.wy-menu-horiz li.divide-left{border-left:solid 1px #404040}.wy-menu-horiz li.divide-right{border-right:solid 1px #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical header{height:32px;display:inline-block;line-height:32px;padding:0 1.618em;display:block;font-weight:bold;text-transform:uppercase;font-size:80%;color:#2980B9;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:solid 1px #404040}.wy-menu-vertical li.divide-bottom{border-bottom:solid 1px #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:gray;border-right:solid 1px #c9c9c9;padding:0.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.wy-menu-vertical li.on a,.wy-menu-vertical li.current>a{color:#404040;padding:0.4045em 1.618em;font-weight:bold;position:relative;background:#fcfcfc;border:none;border-bottom:solid 1px #c9c9c9;border-top:solid 1px #c9c9c9;padding-left:1.618em -4px}.wy-menu-vertical li.on a:hover,.wy-menu-vertical li.current>a:hover{background:#fcfcfc}.wy-menu-vertical li.toctree-l2.current>a{background:#c9c9c9;padding:0.4045em 2.427em}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical .local-toc li ul{display:block}.wy-menu-vertical li ul li a{margin-bottom:0;color:#b3b3b3;font-weight:normal}.wy-menu-vertical a{display:inline-block;line-height:18px;padding:0.4045em 1.618em;display:block;position:relative;font-size:90%;color:#b3b3b3}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:active{background-color:#2980B9;cursor:pointer;color:#fff}.wy-side-nav-search{z-index:200;background-color:#2980B9;text-align:center;padding:0.809em;display:block;color:#fcfcfc;margin-bottom:0.809em}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto 0.809em auto;height:45px;width:45px;background-color:#2980B9;padding:5px;border-radius:100%}.wy-side-nav-search>a,.wy-side-nav-search .wy-dropdown>a{color:#fcfcfc;font-size:100%;font-weight:bold;display:inline-block;padding:4px 6px;margin-bottom:0.809em}.wy-side-nav-search>a:hover,.wy-side-nav-search .wy-dropdown>a:hover{background:rgba(255,255,255,0.1)}.wy-nav .wy-menu-vertical header{color:#2980B9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980B9;color:#fff}[data-menu-wrap]{-webkit-transition:all 0.2s ease-in;-moz-transition:all 0.2s ease-in;transition:all 0.2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:left repeat-y #fcfcfc;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDoxOERBMTRGRDBFMUUxMUUzODUwMkJCOThDMEVFNURFMCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxOERBMTRGRTBFMUUxMUUzODUwMkJCOThDMEVFNURFMCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjE4REExNEZCMEUxRTExRTM4NTAyQkI5OEMwRUU1REUwIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjE4REExNEZDMEUxRTExRTM4NTAyQkI5OEMwRUU1REUwIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+EwrlwAAAAA5JREFUeNpiMDU0BAgwAAE2AJgB9BnaAAAAAElFTkSuQmCC);background-size:300px 1px}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:absolute;top:0;left:0;width:300px;overflow:hidden;min-height:100%;background:#343131;z-index:200}.wy-nav-top{display:none;background:#2980B9;color:#fff;padding:0.4045em 0.809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:before,.wy-nav-top:after{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:bold}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980B9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,0.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:#999}footer p{margin-bottom:12px}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:before,.rst-footer-buttons:after{display:table;content:""}.rst-footer-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:solid 1px #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:solid 1px #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:gray;font-size:90%}@media screen and (max-width: 768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width: 1400px){.wy-nav-content-wrap{background:rgba(0,0,0,0.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,footer,.wy-nav-side{display:none}.wy-nav-content-wrap{margin-left:0}}nav.stickynav{position:fixed;top:0}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;border-top:solid 10px #343131;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;z-index:400}.rst-versions a{color:#2980B9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27AE60;*zoom:1}.rst-versions .rst-current-version:before,.rst-versions .rst-current-version:after{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-versions .rst-current-version .icon{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#E74C3C;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#F1C40F;color:#000}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:gray;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:solid 1px #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px}.rst-versions.rst-badge .icon-book{float:none}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge .rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width: 768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}img{width:100%;height:auto}}.rst-content img{max-width:100%;height:auto !important}.rst-content div.figure{margin-bottom:24px}.rst-content div.figure.align-center{text-align:center}.rst-content .section>img,.rst-content .section>a>img{margin-bottom:24px}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content .note .last,.rst-content .attention .last,.rst-content .caution .last,.rst-content .danger .last,.rst-content .error .last,.rst-content .hint .last,.rst-content .important .last,.rst-content .tip .last,.rst-content .warning .last,.rst-content .seealso .last,.rst-content .admonition-todo .last{margin-bottom:0}.rst-content .admonition-title:before{content:""}.rst-content .note:before,.rst-content .seealso:before{content:""}.rst-content .hint:before,.rst-content .tip:before,.rst-content .important:before{content:""}.rst-content .error:before,.rst-content .danger:before{content:""}.rst-content .warning:before,.rst-content .caution:before,.rst-content .attention:before,.rst-content .admonition-todo:before{content:""}.rst-content .admonition table{border-color:rgba(0,0,0,0.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent !important;border-color:rgba(0,0,0,0.1) !important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha li{list-style:upper-alpha}.rst-content .section ol p,.rst-content .section ul p{margin-bottom:12px}.rst-content .line-block{margin-left:24px}.rst-content .topic-title{font-weight:bold;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0px 0px 24px 24px}.rst-content .align-left{float:left;margin:0px 24px 24px 0px}.rst-content .align-center{margin:auto;display:block}.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content dl dt .headerlink{display:none;visibility:hidden;font-size:14px}.rst-content h1 .headerlink:after,.rst-content h2 .headerlink:after,.rst-content h3 .headerlink:after,.rst-content h4 .headerlink:after,.rst-content h5 .headerlink:after,.rst-content h6 .headerlink:after,.rst-content dl dt .headerlink:after{visibility:visible;content:"¶";display:inline-block}.rst-content h1:hover .headerlink,.rst-content h2:hover .headerlink,.rst-content h3:hover .headerlink,.rst-content h4:hover .headerlink,.rst-content h5:hover .headerlink,.rst-content h6:hover .headerlink,.rst-content dl dt:hover .headerlink{display:inline-block}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:solid 1px #e1e4e5}.rst-content .sidebar p,.rst-content .sidebar ul,.rst-content .sidebar dl{font-size:90%}.rst-content .sidebar .last{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:"Roboto Slab","ff-tisa-web-pro","Georgia",Arial,sans-serif;font-weight:bold;background:#e1e4e5;padding:6px 12px;margin:-24px;margin-bottom:24px;font-size:100%}.rst-content .highlighted{background:#F1C40F;display:inline-block;font-weight:bold;padding:0 6px}.rst-content .footnote-reference,.rst-content .citation-reference{vertical-align:super;font-size:90%}.rst-content table.docutils.citation,.rst-content table.docutils.footnote{background:none;border:none;color:#999}.rst-content table.docutils.citation td,.rst-content table.docutils.citation tr,.rst-content table.docutils.footnote td,.rst-content table.docutils.footnote tr{border:none;background-color:transparent !important;white-space:normal}.rst-content table.docutils.citation td.label,.rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}.rst-content table.field-list{border:none}.rst-content table.field-list td{border:none;padding-top:5px}.rst-content table.field-list td>strong{display:inline-block;margin-top:3px}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left;padding-left:0}.rst-content tt{color:#000}.rst-content tt big,.rst-content tt em{font-size:100% !important;line-height:normal}.rst-content tt .xref,a .rst-content tt{font-weight:bold}.rst-content a tt{color:#2980B9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:bold}.rst-content dl p,.rst-content dl table,.rst-content dl ul,.rst-content dl ol{margin-bottom:12px !important}.rst-content dl dd{margin:0 0 12px 24px}.rst-content dl:not(.docutils){margin-bottom:24px}.rst-content dl:not(.docutils) dt{display:inline-block;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980B9;border-top:solid 3px #6ab0de;padding:6px;position:relative}.rst-content dl:not(.docutils) dt:before{color:#6ab0de}.rst-content dl:not(.docutils) dt .headerlink{color:#404040;font-size:100% !important}.rst-content dl:not(.docutils) dl dt{margin-bottom:6px;border:none;border-left:solid 3px #ccc;background:#f0f0f0;color:gray}.rst-content dl:not(.docutils) dl dt .headerlink{color:#404040;font-size:100% !important}.rst-content dl:not(.docutils) dt:first-child{margin-top:0}.rst-content dl:not(.docutils) tt{font-weight:bold}.rst-content dl:not(.docutils) tt.descname,.rst-content dl:not(.docutils) tt.descclassname{background-color:transparent;border:none;padding:0;font-size:100% !important}.rst-content dl:not(.docutils) tt.descname{font-weight:bold}.rst-content dl:not(.docutils) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:bold}.rst-content dl:not(.docutils) .property{display:inline-block;padding-right:8px}.rst-content .viewcode-link,.rst-content .viewcode-back{display:inline-block;color:#27AE60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:bold}@media screen and (max-width: 480px){.rst-content .sidebar{width:100%}}span[id*='MathJax-Span']{color:#404040}.math{text-align:center} +/*# sourceMappingURL=theme.css.map */ diff --git a/doc/source/theme/sphinx_rtd_theme/static/js/theme.js b/doc/source/theme/sphinx_rtd_theme/static/js/theme.js new file mode 100644 index 00000000..60520cc3 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/static/js/theme.js @@ -0,0 +1,47 @@ +$( document ).ready(function() { + // Shift nav in mobile when clicking the menu. + $(document).on('click', "[data-toggle='wy-nav-top']", function() { + $("[data-toggle='wy-nav-shift']").toggleClass("shift"); + $("[data-toggle='rst-versions']").toggleClass("shift"); + }); + // Close menu when you click a link. + $(document).on('click', ".wy-menu-vertical .current ul li a", function() { + $("[data-toggle='wy-nav-shift']").removeClass("shift"); + $("[data-toggle='rst-versions']").toggleClass("shift"); + }); + $(document).on('click', "[data-toggle='rst-current-version']", function() { + $("[data-toggle='rst-versions']").toggleClass("shift-up"); + }); + // Make tables responsive + $("table.docutils:not(.field-list)").wrap("<div class='wy-table-responsive'></div>"); +}); + +window.SphinxRtdTheme = (function (jquery) { + var stickyNav = (function () { + var navBar, + win, + stickyNavCssClass = 'stickynav', + applyStickNav = function () { + if (navBar.height() <= win.height()) { + navBar.addClass(stickyNavCssClass); + } else { + navBar.removeClass(stickyNavCssClass); + } + }, + enable = function () { + applyStickNav(); + win.on('resize', applyStickNav); + }, + init = function () { + navBar = jquery('nav.wy-nav-side:first'); + win = jquery(window); + }; + jquery(init); + return { + enable : enable + }; + }()); + return { + StickyNav : stickyNav + }; +}($)); diff --git a/doc/source/theme/sphinx_rtd_theme/theme.conf b/doc/source/theme/sphinx_rtd_theme/theme.conf new file mode 100644 index 00000000..3b7a2fc3 --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/theme.conf @@ -0,0 +1,6 @@ +[theme] +inherit = basic +stylesheet = css/theme.css + +[options] +sticky_navigation = False diff --git a/doc/source/theme/sphinx_rtd_theme/versions.html b/doc/source/theme/sphinx_rtd_theme/versions.html new file mode 100644 index 00000000..83fab64e --- /dev/null +++ b/doc/source/theme/sphinx_rtd_theme/versions.html @@ -0,0 +1,37 @@ +{% if READTHEDOCS %} +{# Add rst-badge after rst-versions for small badge style. #} + <div class="rst-versions" data-toggle="rst-versions" role="note" aria-label="versions"> + <span class="rst-current-version" data-toggle="rst-current-version"> + <span>📚 Read the Docs</span> + v: {{ current_version }} + <span>⏷</span> + </span> + <div class="rst-other-versions"> + <dl> + <dt>Versions</dt> + {% for slug, url in versions %} + <dd><a href="{{ url }}">{{ slug }}</a></dd> + {% endfor %} + </dl> + <dl> + <dt>Downloads</dt> + {% for type, url in downloads %} + <dd><a href="{{ url }}">{{ type }}</a></dd> + {% endfor %} + </dl> + <dl> + <dt>On Read the Docs</dt> + <dd> + <a href="//{{ PRODUCTION_DOMAIN }}/projects/{{ slug }}/?fromdocs={{ slug }}">Project Home</a> + </dd> + <dd> + <a href="//{{ PRODUCTION_DOMAIN }}/builds/{{ slug }}/?fromdocs={{ slug }}">Builds</a> + </dd> + </dl> + <hr/> + Free document hosting provided by <a href="http://www.readthedocs.org">Read the Docs</a>. + + </div> + </div> +{% endif %} + diff --git a/doc/source/theme/static/basic.css b/doc/source/theme/static/basic.css deleted file mode 100644 index 37dc5a99..00000000 --- a/doc/source/theme/static/basic.css +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Sphinx stylesheet -- basic theme - * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - */ - -/* -- main layout ----------------------------------------------------------- */ - -div.clearer { - clear: both; -} - -/* -- relbar ---------------------------------------------------------------- */ - -div.related { - width: 100%; - font-size: 90%; -} - -div.related h3 { - display: none; -} - -div.related ul { - margin: 0; - padding: 0 0 0 10px; - list-style: none; -} - -div.related li { - display: inline; -} - -div.related li.right { - float: right; - margin-right: 5px; -} - -/* -- sidebar --------------------------------------------------------------- */ - -div.sphinxsidebarwrapper { - padding: 10px 5px 0 10px; - word-wrap: break-word; -} - -div.sphinxsidebar { - float: left; - width: 230px; -/* margin-left: -100%; */ - font-size: 90%; - position: fixed; -} - -div.sphinxsidebar ul { - list-style: none; -} - -div.sphinxsidebar ul ul, -div.sphinxsidebar ul.want-points { - margin-left: 20px; - list-style: square; -} - -div.sphinxsidebar ul ul { - margin-top: 0; - margin-bottom: 0; -} - -div.sphinxsidebar form { - margin-top: 10px; -} - -div.sphinxsidebar input { - border: 1px solid #98dbcc; - font-family: sans-serif; - font-size: 1em; -} - -img { - border: 0; -} - -/* -- search page ----------------------------------------------------------- */ - -ul.search { - margin: 10px 0 0 20px; - padding: 0; -} - -ul.search li { - padding: 5px 0 5px 20px; - background-image: url(file.png); - background-repeat: no-repeat; - background-position: 0 7px; -} - -ul.search li a { - font-weight: bold; -} - -ul.search li div.context { - color: #888; - margin: 2px 0 0 30px; - text-align: left; -} - -ul.keywordmatches li.goodmatch a { - font-weight: bold; -} - -/* -- index page ------------------------------------------------------------ */ - -table.contentstable { - width: 90%; -} - -table.contentstable p.biglink { - line-height: 150%; -} - -a.biglink { - font-size: 1.3em; -} - -span.linkdescr { - font-style: italic; - padding-top: 5px; - font-size: 90%; -} - -/* -- general index --------------------------------------------------------- */ - -table.indextable td { - text-align: left; - vertical-align: top; -} - -table.indextable dl, table.indextable dd { - margin-top: 0; - margin-bottom: 0; -} - -table.indextable tr.pcap { - height: 10px; -} - -table.indextable tr.cap { - margin-top: 10px; - background-color: #f2f2f2; -} - -img.toggler { - margin-right: 3px; - margin-top: 3px; - cursor: pointer; -} - -/* -- general body styles --------------------------------------------------- */ - -a.headerlink { - visibility: hidden; -} - -h1:hover > a.headerlink, -h2:hover > a.headerlink, -h3:hover > a.headerlink, -h4:hover > a.headerlink, -h5:hover > a.headerlink, -h6:hover > a.headerlink, -dt:hover > a.headerlink { - visibility: visible; -} - -div.body p.caption { - text-align: inherit; -} - -div.body td { - text-align: left; -} - -.field-list ul { - padding-left: 1em; -} - -.first { - margin-top: 0 !important; -} - -p.rubric { - margin-top: 30px; - font-weight: bold; -} - -/* -- sidebars -------------------------------------------------------------- */ - -div.sidebar { - margin: 0 0 0.5em 1em; - border: 1px solid #ddb; - padding: 7px 7px 0 7px; - background-color: #ffe; - width: 40%; - float: right; -} - -p.sidebar-title { - font-weight: bold; -} - -/* -- topics ---------------------------------------------------------------- */ - -div.topic { - border: 1px solid #ccc; - padding: 7px 7px 0 7px; - margin: 10px 0 10px 0; -} - -p.topic-title { - font-size: 1.1em; - font-weight: bold; - margin-top: 10px; -} - -/* -- admonitions ----------------------------------------------------------- */ - -div.admonition { - margin-top: 10px; - margin-bottom: 10px; - padding: 7px; -} - -div.admonition dt { - font-weight: bold; -} - -div.admonition dl { - margin-bottom: 0; -} - -p.admonition-title { - margin: 0px 10px 5px 0px; - font-weight: bold; -} - -div.body p.centered { - text-align: center; - margin-top: 25px; -} - -/* -- tables ---------------------------------------------------------------- */ - -table.docutils { - border: 0 solid #dce; - border-collapse: collapse; -} - -table.docutils td, table.docutils th { - padding: 2px 5px 2px 5px; - border-left: 0; - background-color: #eef; -} - -table.docutils td p.last, table.docutils th p.last { - margin-bottom: 0; -} - -table.field-list td, table.field-list th { - border: 0 !important; -} - -table.footnote td, table.footnote th { - border: 0 !important; -} - -table.docutils th { - border-top: 1px solid #cac; - background-color: #ede; -} - -th { - text-align: left; - padding-right: 5px; -} - -th.head { - text-align: center; -} - -/* -- other body styles ----------------------------------------------------- */ - -dl { - margin-bottom: 15px; -} - -dd p { - margin-top: 0px; -} - -dd ul, dd table { - margin-bottom: 10px; -} - -dd { - margin-top: 3px; - margin-bottom: 10px; - margin-left: 30px; -} - -dt:target, .highlight { - background-color: #fbe54e; -} - -.highlighted { - background-color: #fbe54e; -} - -dl.glossary dt { - font-weight: bold; - font-size: 1.1em; -} - -.field-list ul { - margin: 0; - padding-left: 1em; -} - -.field-list p { - margin: 0; -} - -.refcount { - color: #060; -} - -.optional { - font-size: 1.3em; -} - -.versionmodified { - font-style: italic; -} - -p.deprecated, p.deprecated-removed { - background-color: #ffe4e4; - border: 1px solid #f66; - padding: 7px -} - -.system-message { - background-color: #fda; - padding: 5px; - border: 3px solid red; -} - -.footnote:target { - background-color: #ffa; -} - -.impl-detail { - margin-top: 10px; - margin-bottom: 10px; - padding: 7px; - border: 1px solid #ccc; -} - -.impl-detail .compound-first { - margin-top: 0; -} - -.impl-detail .compound-last { - margin-bottom: 0; -} - -/* -- code displays --------------------------------------------------------- */ - -pre { - overflow: auto; - overflow-y: hidden; -} - -td.linenos pre { - padding: 5px 0px; - border: 0; - background-color: transparent; - color: #aaa; -} - -table.highlighttable { - margin-left: 0.5em; -} - -table.highlighttable td { - padding: 0 0.5em 0 0.5em; -} - -tt.descname { - background-color: transparent; - font-weight: bold; - font-size: 1.2em; -} - -tt.descclassname { - background-color: transparent; -} - -tt.xref, a tt { - background-color: transparent; - font-weight: bold; -} - -h1 tt, h2 tt, h3 tt, h4 tt, h5 tt, h6 tt { - background-color: transparent; -} - -/* -- math display ---------------------------------------------------------- */ - -img.math { - vertical-align: middle; -} - -div.body div.math p { - text-align: center; -} - -span.eqno { - float: right; -} - -/* -- printout stylesheet --------------------------------------------------- */ - -@media print { - div.document, - div.documentwrapper, - div.bodywrapper { - margin: 0 !important; - width: 100%; - } - - div.sphinxsidebar, - div.related, - div.footer, - #top-link { - display: none; - } -} diff --git a/doc/source/theme/static/copybutton.js b/doc/source/theme/static/copybutton.js deleted file mode 100644 index 5d82c672..00000000 --- a/doc/source/theme/static/copybutton.js +++ /dev/null @@ -1,57 +0,0 @@ -$(document).ready(function() { - /* Add a [>>>] button on the top-right corner of code samples to hide - * the >>> and ... prompts and the output and thus make the code - * copyable. */ - var div = $('.highlight-python .highlight,' + - '.highlight-python3 .highlight') - var pre = div.find('pre'); - - // get the styles from the current theme - pre.parent().parent().css('position', 'relative'); - var hide_text = 'Hide the prompts and output'; - var show_text = 'Show the prompts and output'; - var border_width = pre.css('border-top-width'); - var border_style = pre.css('border-top-style'); - var border_color = pre.css('border-top-color'); - var button_styles = { - 'cursor':'pointer', 'position': 'absolute', 'top': '0', 'right': '0', - 'border-color': border_color, 'border-style': border_style, - 'border-width': border_width, 'color': border_color, 'text-size': '75%', - 'font-family': 'monospace', 'padding-left': '0.2em', 'padding-right': '0.2em', - 'border-radius': '0 3px 0 0' - } - - // create and add the button to all the code blocks that contain >>> - div.each(function(index) { - var jthis = $(this); - if (jthis.find('.gp').length > 0) { - var button = $('<span class="copybutton">>>></span>'); - button.css(button_styles) - button.attr('title', hide_text); - jthis.prepend(button); - } - // tracebacks (.gt) contain bare text elements that need to be - // wrapped in a span to work with .nextUntil() (see later) - jthis.find('pre:has(.gt)').contents().filter(function() { - return ((this.nodeType == 3) && (this.data.trim().length > 0)); - }).wrap('<span>'); - }); - - // define the behavior of the button when it's clicked - $('.copybutton').toggle( - function() { - var button = $(this); - button.parent().find('.go, .gp, .gt').hide(); - button.next('pre').find('.gt').nextUntil('.gp, .go').css('visibility', 'hidden'); - button.css('text-decoration', 'line-through'); - button.attr('title', show_text); - }, - function() { - var button = $(this); - button.parent().find('.go, .gp, .gt').show(); - button.next('pre').find('.gt').nextUntil('.gp, .go').css('visibility', 'visible'); - button.css('text-decoration', 'none'); - button.attr('title', hide_text); - }); -}); - diff --git a/doc/source/theme/static/py.png b/doc/source/theme/static/py.png Binary files differdeleted file mode 100644 index 93e4a02c..00000000 --- a/doc/source/theme/static/py.png +++ /dev/null diff --git a/doc/source/theme/static/sidebar.js b/doc/source/theme/static/sidebar.js deleted file mode 100644 index 0c410e6a..00000000 --- a/doc/source/theme/static/sidebar.js +++ /dev/null @@ -1,155 +0,0 @@ -/* - * sidebar.js - * ~~~~~~~~~~ - * - * This script makes the Sphinx sidebar collapsible. - * - * .sphinxsidebar contains .sphinxsidebarwrapper. This script adds in - * .sphixsidebar, after .sphinxsidebarwrapper, the #sidebarbutton used to - * collapse and expand the sidebar. - * - * When the sidebar is collapsed the .sphinxsidebarwrapper is hidden and the - * width of the sidebar and the margin-left of the document are decreased. - * When the sidebar is expanded the opposite happens. This script saves a - * per-browser/per-session cookie used to remember the position of the sidebar - * among the pages. Once the browser is closed the cookie is deleted and the - * position reset to the default (expanded). - * - * :copyright: Copyright 2007-2011 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -$(function() { - // global elements used by the functions. - // the 'sidebarbutton' element is defined as global after its - // creation, in the add_sidebar_button function - var bodywrapper = $('.bodywrapper'); - var sidebar = $('.sphinxsidebar'); - var sidebarwrapper = $('.sphinxsidebarwrapper'); - - // original margin-left of the bodywrapper and width of the sidebar - // with the sidebar expanded - var bw_margin_expanded = bodywrapper.css('margin-left'); - var ssb_width_expanded = sidebar.width(); - - // margin-left of the bodywrapper and width of the sidebar - // with the sidebar collapsed - var bw_margin_collapsed = '.8em'; - var ssb_width_collapsed = '.8em'; - - // colors used by the current theme - var dark_color = '#AAAAAA'; - var light_color = '#CCCCCC'; - - function sidebar_is_collapsed() { - return sidebarwrapper.is(':not(:visible)'); - } - - function toggle_sidebar() { - if (sidebar_is_collapsed()) - expand_sidebar(); - else - collapse_sidebar(); - } - - function collapse_sidebar() { - sidebarwrapper.hide(); - sidebar.css('width', ssb_width_collapsed); - bodywrapper.css('margin-left', bw_margin_collapsed); - sidebarbutton.css({ - 'margin-left': '0', - 'height': bodywrapper.height(), - 'border-radius': '5px' - }); - sidebarbutton.find('span').text('»'); - sidebarbutton.attr('title', _('Expand sidebar')); - document.cookie = 'sidebar=collapsed'; - } - - function expand_sidebar() { - bodywrapper.css('margin-left', bw_margin_expanded); - sidebar.css('width', ssb_width_expanded); - sidebarwrapper.show(); - sidebarbutton.css({ - 'margin-left': ssb_width_expanded-12, - 'height': bodywrapper.height(), - 'border-radius': '0 5px 5px 0' - }); - sidebarbutton.find('span').text('«'); - sidebarbutton.attr('title', _('Collapse sidebar')); - //sidebarwrapper.css({'padding-top': - // Math.max(window.pageYOffset - sidebarwrapper.offset().top, 10)}); - document.cookie = 'sidebar=expanded'; - } - - function add_sidebar_button() { - sidebarwrapper.css({ - 'float': 'left', - 'margin-right': '0', - 'width': ssb_width_expanded - 28 - }); - // create the button - sidebar.append( - '<div id="sidebarbutton"><span>«</span></div>' - ); - var sidebarbutton = $('#sidebarbutton'); - // find the height of the viewport to center the '<<' in the page - var viewport_height; - if (window.innerHeight) - viewport_height = window.innerHeight; - else - viewport_height = $(window).height(); - var sidebar_offset = sidebar.offset().top; - var sidebar_height = Math.max(bodywrapper.height(), sidebar.height()); - sidebarbutton.find('span').css({ - 'display': 'block', - 'position': 'fixed', - 'top': Math.min(viewport_height/2, sidebar_height/2 + sidebar_offset) - 10 - }); - - sidebarbutton.click(toggle_sidebar); - sidebarbutton.attr('title', _('Collapse sidebar')); - sidebarbutton.css({ - 'border-radius': '0 5px 5px 0', - 'color': '#444444', - 'background-color': '#CCCCCC', - 'font-size': '1.2em', - 'cursor': 'pointer', - 'height': sidebar_height, - 'padding-top': '1px', - 'padding-left': '1px', - 'margin-left': ssb_width_expanded - 12 - }); - - sidebarbutton.hover( - function () { - $(this).css('background-color', dark_color); - }, - function () { - $(this).css('background-color', light_color); - } - ); - } - - function set_position_from_cookie() { - if (!document.cookie) - return; - var items = document.cookie.split(';'); - for(var k=0; k<items.length; k++) { - var key_val = items[k].split('='); - var key = key_val[0]; - if (key == 'sidebar') { - var value = key_val[1]; - if ((value == 'collapsed') && (!sidebar_is_collapsed())) - collapse_sidebar(); - else if ((value == 'expanded') && (sidebar_is_collapsed())) - expand_sidebar(); - } - } - } - - add_sidebar_button(); - var sidebarbutton = $('#sidebarbutton'); - set_position_from_cookie(); -}); diff --git a/doc/source/theme/static/version_switch.js b/doc/source/theme/static/version_switch.js deleted file mode 100644 index cc7be1c5..00000000 --- a/doc/source/theme/static/version_switch.js +++ /dev/null @@ -1,66 +0,0 @@ -(function() { - 'use strict'; - - var all_versions = { - '3.4': 'dev (3.4)', - '3.3': '3.3', - '3.2': '3.2', - '2.7': '2.7', - '2.6': '2.6' - }; - - function build_select(current_version, current_release) { - var buf = ['<select>']; - - $.each(all_versions, function(version, title) { - buf.push('<option value="' + version + '"'); - if (version == current_version) - buf.push(' selected="selected">' + current_release + '</option>'); - else - buf.push('>' + title + '</option>'); - }); - - buf.push('</select>'); - return buf.join(''); - } - - function patch_url(url, new_version) { - var url_re = /\.org\/(\d|py3k|dev|((release\/)?\d\.\d[\w\d\.]*))\//, - new_url = url.replace(url_re, '.org/' + new_version + '/'); - - if (new_url == url && !new_url.match(url_re)) { - // python 2 url without version? - new_url = url.replace(/\.org\//, '.org/' + new_version + '/'); - } - return new_url; - } - - function on_switch() { - var selected = $(this).children('option:selected').attr('value'); - - var url = window.location.href, - new_url = patch_url(url, selected); - - if (new_url != url) { - // check beforehand if url exists, else redirect to version's start page - $.ajax({ - url: new_url, - success: function() { - window.location.href = new_url; - }, - error: function() { - window.location.href = 'http://docs.python.org/' + selected; - } - }); - } - } - - $(document).ready(function() { - var release = DOCUMENTATION_OPTIONS.VERSION; - var version = release.substr(0, 3); - var select = build_select(version, release); - - $('.version_switcher_placeholder').html(select); - $('.version_switcher_placeholder select').bind('change', on_switch); - }); -})(); diff --git a/doc/source/theme/templates/layout.html b/doc/source/theme/templates/layout.html deleted file mode 100644 index 01492ffc..00000000 --- a/doc/source/theme/templates/layout.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "!layout.html" %} -{% block rootrellink %} - <li><img src="{{ pathto('_static/poezio.png', 1) }}" alt="" - style="vertical-align: middle; margin-top: -1px"/></li> - <li><a href="http://poez.io/">Poezio</a>{{ reldelim1 }}</li> - <li> - <a href="{{ pathto('index') }}">{{ shorttitle }}</a>{{ reldelim1 }} - </li> -{% endblock %} -{% block extrahead %} - <link rel="shortcut icon" type="image/png" href="{{ pathto('_static/poezio.png', 1) }}" /> - {% if not embedded %}<script type="text/javascript" src="{{ pathto('_static/copybutton.js', 1) }}"></script>{% endif %} - {% if versionswitcher is defined and not embedded %}<script type="text/javascript" src="{{ pathto('_static/version_switch.js', 1) }}"></script>{% endif %} -{{ super() }} -{% endblock %} -{% block footer %} - <div class="footer"> - © <a href="{{ pathto('copyright') }}">Copyright</a> {{ copyright|e }}. - <br /> - Last updated on {{ last_updated|e }}. - <br /> - Created using <a href="http://sphinx.pocoo.org/">Sphinx</a> {{ sphinx_version|e }}. - </div> -{% endblock %} -{% block sidebarsourcelink %} -{%- if show_source and has_source and sourcename %} -<h3>{{ _('This Page') }}</h3> -<ul class="this-page-menu"> - <li><a href="{{ pathto('_sources/' + sourcename, true)|e }}" - rel="nofollow">Show Source</a></li> -</ul> -{%- endif %} -{% endblock %} diff --git a/doc/source/themes.rst b/doc/source/themes.rst index a674c915..0c2b143f 100644 --- a/doc/source/themes.rst +++ b/doc/source/themes.rst @@ -22,7 +22,7 @@ A theme file is a python file (with the .py extension) containing a class, inheriting the *theming.Theme* class defined into the *theming* poezio module. -To check how may colors your current terminal/$TERM supports, do: +To check how many colors your current terminal/$TERM supports, do: .. code-block:: bash diff --git a/doc/source/usage.rst b/doc/source/usage.rst index 726198e3..fa290bb6 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -207,7 +207,7 @@ status message of the contact. Plugins may add some elements to the status line. Dataforms tab ~~~~~~~~~~~~~ -:ref:`Specific shortcuts <formtab-keys>` +:ref:`Specific shortcuts <forms-keys>` This tab lets you view a form received from a remote entity, edit the values and send everything back. It is mostly used to configure MUCs with the diff --git a/doc/stub/pyinotify.py b/doc/stub/pyinotify.py index 48721264..1f8fc574 100644 --- a/doc/stub/pyinotify.py +++ b/doc/stub/pyinotify.py @@ -1,3 +1,5 @@ +class Notifier: + pass class WatchManager: pass class EventsCodes: diff --git a/doc/stub/tabs.py b/doc/stub/tabs.py index cd3dc073..b4d23c1c 100644 --- a/doc/stub/tabs.py +++ b/doc/stub/tabs.py @@ -6,4 +6,6 @@ class ConversationTab(ChatTab): pass class RosterInfoTab(Tab): pass class XMLTab(Tab): pass class DynamicConversationTab(Tab): pass +class StaticConversationTab(Tab): pass +class GapTab(Tab): pass @@ -1,17 +1,20 @@ #!/bin/sh poezio_dir=$(dirname "$0") -VENV="poezio-venv" +if [ -z "$POEZIO_VENV" ] +then + POEZIO_VENV="poezio-venv" +fi if [ -d "$poezio_dir/.git" ] then args=$(git --git-dir="$poezio_dir/.git" show --format='%h %ci' | head -n1) else - args="0.8.3-dev" + args="0.9-dev" fi -if [ -e "$poezio_dir/$VENV" ] +if [ -e "$poezio_dir/$POEZIO_VENV" ] then - PYTHON3="$poezio_dir/$VENV/bin/python3" + PYTHON3="$poezio_dir/$POEZIO_VENV/bin/python3" else echo "" echo "WARNING: Not using the up-to-date launch format" @@ -21,5 +24,6 @@ else PYTHON3=python3 fi +$PYTHON3 -c 'import sys;(print("Python 3.4 or newer is required") and exit(1)) if sys.version_info < (3, 4) else exit(0)' || exit 1 exec "$PYTHON3" "$poezio_dir/src/poezio.py" -v "$args" "$@" diff --git a/locale/poezio.pot b/locale/poezio.pot deleted file mode 100644 index fc66ac67..00000000 --- a/locale/poezio.pot +++ /dev/null @@ -1,625 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2011-05-28 23:53+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" -"Language-Team: LANGUAGE <LL@li.org>\n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" -"Content-Transfer-Encoding: 8bit\n" - -#: src/core.py:59 -msgid "A password is required" -msgstr "" - -#: src/core.py:60 -msgid "Permission denied" -msgstr "" - -#: src/core.py:61 -msgid "The room does'nt exist" -msgstr "" - -#: src/core.py:62 -msgid "Your are not allowed to create a new room" -msgstr "" - -#: src/core.py:63 -msgid "A reserved nick must be used" -msgstr "" - -#: src/core.py:64 -msgid "You are not in the member list" -msgstr "" - -#: src/core.py:65 -msgid "This nickname is already in use or has been reserved" -msgstr "" - -#: src/core.py:66 -msgid "The maximum number of users has been reached" -msgstr "" - -#: src/core.py:113 -msgid "" -"Usage: /join [room_name][@server][/nick] [password]\n" -"Join: Join the specified room. You can specify a nickname after a slash (/). " -"If no nickname is specified, you will use the default_nick in the " -"configuration file. You can omit the room name: you will then join the room " -"you're looking at (useful if you were kicked). You can also provide a " -"room_name without specifying a server, the server of the room you're " -"currently in will be used. You can also provide a password to join the " -"room.\n" -"Examples:\n" -"/join room@server.tld\n" -"/join room@server.tld/John\n" -"/join room2\n" -"/join /me_again\n" -"/join\n" -"/join room@server.tld/my_nick password\n" -"/join / password" -msgstr "" - -#: src/core.py:114 -msgid "" -"Usage: /exit\n" -"Exit: Just disconnect from the server and exit poezio." -msgstr "" - -#: src/core.py:115 -msgid "" -"Usage: /next\n" -"Next: Go to the next room." -msgstr "" - -#: src/core.py:116 -msgid "" -"Usage: /prev\n" -"Prev: Go to the previous room." -msgstr "" - -#: src/core.py:117 -msgid "" -"Usage: /win <number>\n" -"Win: Go to the specified room." -msgstr "" - -#: src/core.py:118 -msgid "" -"Usage: /w <number>\n" -"W: Go to the specified room." -msgstr "" - -#: src/core.py:119 -msgid "" -"Usage: /show <availability> [status message]\n" -"Show: Sets your availability and (optionaly) your status message. The " -"<availability> argument is one of \"available, chat, away, afk, dnd, busy, xa" -"\" and the optional [status] argument will be your status message." -msgstr "" - -#: src/core.py:120 -msgid "" -"Usage: /status <availability> [status message]\n" -"Status: Sets your availability and (optionaly) your status message. The " -"<availability> argument is one of \"available, chat, away, afk, dnd, busy, xa" -"\" and the optional [status] argument will be your status message." -msgstr "" - -#: src/core.py:121 -msgid "" -"Usage: /away [message]\n" -"Away: Sets your availability to away and (optionaly) your status message. " -"This is equivalent to '/status away [message]'" -msgstr "" - -#: src/core.py:122 -msgid "" -"Usage: /busy [message]\n" -"Busy: Sets your availability to busy and (optionaly) your status message. " -"This is equivalent to '/status busy [message]'" -msgstr "" - -#: src/core.py:123 -msgid "" -"Usage: /available [message]\n" -"Available: Sets your availability to available and (optionaly) your status " -"message. This is equivalent to '/status available [message]'" -msgstr "" - -#: src/core.py:124 -msgid "" -"Usage: /bookmark [roomname][/nick]\n" -"Bookmark: Bookmark the specified room (you will then auto-join it on each " -"poezio start). This commands uses the same syntaxe as /join. Type /help join " -"for syntaxe examples. Note that when typing \"/bookmark\" on its own, the " -"room will be bookmarked with the nickname you're currently using in this " -"room (instead of default_nick)" -msgstr "" - -#: src/core.py:125 -msgid "" -"Usage: /set <option> [value]\n" -"Set: Sets the value to the option in your configuration file. You can, for " -"example, change your default nickname by doing `/set default_nick toto` or " -"your resource with `/set resource blabla`. You can also set an empty value " -"(nothing) by providing no [value] after <option>." -msgstr "" - -#: src/core.py:126 -msgid "" -"Usage: /theme\n" -"Theme: Reload the theme defined in the config file." -msgstr "" - -#: src/core.py:127 -msgid "" -"Usage: /list\n" -"List: get the list of public chatrooms on the specified server" -msgstr "" - -#: src/core.py:128 -msgid "" -"Usage: /message <jid> [optional message]\n" -"Message: Open a conversation with the specified JID (even if it is not in " -"our roster), and send a message to it, if specified" -msgstr "" - -#: src/core.py:129 -msgid "" -"Usage: /version <jid>\n" -"Version: get the software version of the given JID (usually its XMPP client " -"and Operating System)" -msgstr "" - -#: src/core.py:130 -msgid "" -"Usage: /connect\n" -"Connect: disconnect from the remote server if you are currently connected " -"and then connect to it again" -msgstr "" - -#: src/core.py:131 -msgid "" -"Usage: /server_cycle [domain] [message]\n" -"Server Cycle: disconnect and reconnects in all the rooms in domain." -msgstr "" - -#: src/core.py:192 -msgid "Welcome to poezio!" -msgstr "" - -#: src/core.py:391 -msgid "Connection to remote server failed" -msgstr "" - -#: src/core.py:400 -msgid "Disconnected from server." -msgstr "" - -#: src/core.py:406 -msgid "Authentication failed." -msgstr "" - -#: src/core.py:412 -msgid "Connected to server." -msgstr "" - -#: src/core.py:419 -msgid "Authentication success." -msgstr "" - -#: src/core.py:420 -#, python-format -msgid "Your JID is %s" -msgstr "" - -#: src/core.py:860 -msgid "Unknown error" -msgstr "" - -#: src/core.py:862 src/tabs.py:1680 -#, python-format -msgid "Error: %(code)s - %(msg)s: %(body)s" -msgstr "" - -#: src/core.py:865 -#, python-format -msgid "Error: %(msg)s: %(body)s" -msgstr "" - -#: src/core.py:868 -msgid "" -"To provide a password in order to join the room, type \"/join / password" -"\" (replace \"password\" by the real password)" -msgstr "" - -#: src/core.py:874 -msgid "" -"You can join the room with an other nick, by typing \"/join /other_nick\"" -msgstr "" - -#: src/core.py:923 -#, python-format -msgid "%(nick)s set the subject to: %(subject)s" -msgstr "" - -#: src/core.py:925 -#, python-format -msgid "The subject is: %(subject)s" -msgstr "" - -#: src/core.py:960 -#, python-format -msgid "message received for a non-existing room: %s" -msgstr "" - -#: src/core.py:986 -msgid "Available commands are: " -msgstr "" - -#: src/core.py:991 -msgid "" -"\n" -"Type /help <command_name> to know what each command does" -msgstr "" - -#: src/core.py:998 -#, python-format -msgid "Unknown command: %s" -msgstr "" - -#: src/core.py:1060 src/tabs.py:551 -msgid "an unknown software" -msgstr "" - -#: src/core.py:1061 src/tabs.py:552 -msgid "unknown" -msgstr "" - -#: src/core.py:1062 src/tabs.py:553 -msgid "on an unknown platform" -msgstr "" - -#: src/core.py:1215 -msgid "You didn't specify a server for the room you want to join" -msgstr "" - -#: src/core.py:1276 -#, python-format -msgid "Your bookmarks are now: %s" -msgstr "" - -#: src/core.py:1347 -msgid "No server specified" -msgstr "" - -#: src/core.py:1474 src/tabs.py:144 -#, python-format -msgid "Unknown command (%s)" -msgstr "" - -#: src/core.py:1474 src/tabs.py:144 -msgid "Error" -msgstr "" - -#: src/tabs.py:59 -msgid "busy" -msgstr "" - -#: src/tabs.py:60 -msgid "away" -msgstr "" - -#: src/tabs.py:61 -msgid "not available" -msgstr "" - -#: src/tabs.py:62 -msgid "chatty" -msgstr "" - -#: src/tabs.py:63 -msgid "available" -msgstr "" - -#: src/tabs.py:263 -msgid "" -"Usage: /say <message>\n" -"Say: Just send the message.\n" -" Useful if you want your message to " -"begin with a '/'" -msgstr "" - -#: src/tabs.py:457 -msgid "" -"Usage: /ignore <nickname> \n" -"Ignore: Ignore a specified nickname." -msgstr "" - -#: src/tabs.py:458 -msgid "" -"Usage: /unignore <nickname>\n" -"Unignore: Remove the specified nickname from the ignore list." -msgstr "" - -#: src/tabs.py:459 -msgid "" -"Usage: /kick <nick> [reason]\n" -"Kick: Kick the user with the specified nickname. You also can give an " -"optional reason." -msgstr "" - -#: src/tabs.py:460 -msgid "" -"Usage: /topic <subject>\n" -"Topic: Change the subject of the room" -msgstr "" - -#: src/tabs.py:461 -msgid "" -"Usage: /query <nick> [message]\n" -"Query: Open a private conversation with <nick>. This nick has to be present " -"in the room you're currently in. If you specified a message after the " -"nickname, it will immediately be sent to this user" -msgstr "" - -#: src/tabs.py:462 -msgid "" -"Usage: /part [message]\n" -" Part: disconnect from a room. You can specify an optional message." -msgstr "" - -#: src/tabs.py:463 -msgid "" -"Usage: /nick <nickname>\n" -"Nick: Change your nickname in the current room" -msgstr "" - -#: src/tabs.py:464 -msgid "" -"Usage: /recolor\n" -"Recolor: Re-assign a color to all participants of the current room, based on " -"the last time they talked. Use this if the participants currently talking " -"have too many identical colors." -msgstr "" - -#: src/tabs.py:465 -msgid "" -"Usage: /cycle [message]\n" -"Cycle: Leaves the current room and rejoin it immediately" -msgstr "" - -#: src/tabs.py:466 -msgid "" -"Usage: /info <nickname>\n" -"Info: Display some information about the user in the MUC: his/here role, " -"affiliation, status and status message." -msgstr "" - -#: src/tabs.py:467 -msgid "" -"Usage: /configure\n" -"Configure: Configure the current room, through a form." -msgstr "" - -#: src/tabs.py:468 -msgid "" -"Usage: /version <jid or nick>\n" -"Version: get the software version of the given JID or nick in room (usually " -"its XMPP client and Operating System)" -msgstr "" - -#: src/tabs.py:602 -#, python-format -msgid "Cannot find user: %s" -msgstr "" - -#: src/tabs.py:610 -#, python-format -msgid "The subject of the room is: %s" -msgstr "" - -#: src/tabs.py:664 src/tabs.py:682 -#, python-format -msgid "%s is not in the room" -msgstr "" - -#: src/tabs.py:666 -#, python-format -msgid "%s is already ignored" -msgstr "" - -#: src/tabs.py:669 -#, python-format -msgid "%s is now ignored" -msgstr "" - -#: src/tabs.py:684 -#, python-format -msgid "%s is not ignored" -msgstr "" - -#: src/tabs.py:687 -#, python-format -msgid "%s is now unignored" -msgstr "" - -#: src/tabs.py:822 -#, python-format -msgid "5Your nickname is 3%s" -msgstr "" - -#: src/tabs.py:891 -#, python-format -msgid "1%(spec)s 3You5 have been banned by 4%(by)s" -msgstr "" - -#: src/tabs.py:893 -#, python-format -msgid "1%(spec)s 3You5 have been banned." -msgstr "" - -#: src/tabs.py:896 -#, python-format -msgid "1%(spec)s 3%(nick)s5 has been banned by 4%(by)s" -msgstr "" - -#: src/tabs.py:898 -#, python-format -msgid "1%(spec)s 3%(nick)s5 has been banned" -msgstr "" - -#: src/tabs.py:900 -#, python-format -msgid "5 Reason: 6%(reason)s5" -msgstr "" - -#: src/tabs.py:914 -#, python-format -msgid "1%(spec)s 3You5 have been kicked by 3%(by)s" -msgstr "" - -#: src/tabs.py:916 -#, python-format -msgid "1%(spec)s 3You5 have been kicked." -msgstr "" - -#: src/tabs.py:922 -#, python-format -msgid "1%(spec)s 3%(nick)s5 has been kicked by 3%(by)s" -msgstr "" - -#: src/tabs.py:924 -#, python-format -msgid "1%(spec)s 3%(nick)s5 has been kicked" -msgstr "" - -#: src/tabs.py:926 -#, python-format -msgid "5 Reason: 6%(reason)s" -msgstr "" - -#: src/tabs.py:940 -#, python-format -msgid "1%(spec)s 3%(nick)s5 has left the room" -msgstr "" - -#: src/tabs.py:942 -#, python-format -msgid "1%(spec)s 3%(nick)s5 (4%(jid)s5) has left the room" -msgstr "" - -#: src/tabs.py:955 -#, python-format -msgid "3%s5 changed: " -msgstr "" - -#: src/tabs.py:957 -#, python-format -msgid "affiliation: %s, " -msgstr "" - -#: src/tabs.py:960 -#, python-format -msgid "role: %s, " -msgstr "" - -#: src/tabs.py:963 -#, python-format -msgid "show: %s, " -msgstr "" - -#: src/tabs.py:966 -#, python-format -msgid "status: %s, " -msgstr "" - -#: src/tabs.py:1002 src/tabs.py:1496 -msgid "" -"Usage: /unquery\n" -"Unquery: close the tab" -msgstr "" - -#: src/tabs.py:1003 src/tabs.py:1497 -msgid "Usage: /part\\Part: close the tab" -msgstr "" - -#: src/tabs.py:1113 -#, python-format -msgid "\"[%(old_nick)s]\" is now known as \"[%(new_nick)s]\"" -msgstr "" - -#: src/tabs.py:1122 -#, python-format -msgid "%(spec)s \"[%(nick)s]\" has left the room" -msgstr "" - -#: src/tabs.py:1124 -#, python-format -msgid "%(spec)s \"[%(nick)s]\" has left the room \"(%(status)s)\"" -msgstr "" - -#: src/tabs.py:1155 -msgid "" -"Usage: /deny [jid]\n" -"Deny: Use this command to remove and deny your presence to the provided JID " -"(or the selected contact in your roster), who is asking you to be in his/" -"here roster" -msgstr "" - -#: src/tabs.py:1156 -msgid "" -"Usage: /accept [jid]\n" -"Accept: Use this command to authorize the provided JID (or the selected " -"contact in your roster), to see your presence, and to ask to subscribe to it " -"(mutual presence subscription)." -msgstr "" - -#: src/tabs.py:1157 -msgid "" -"Usage: /add <jid>\\Add: Use this command to add the specified JID to your " -"roster. The reverse authorization will automatically be accepted if the " -"remote JID accepts your subscription, leading to a mutual presence " -"subscription." -msgstr "" - -#: src/tabs.py:1158 -msgid "" -"Usage: /remove [jid]\\Remove: Use this command to remove the specified JID " -"from your roster. This wil unsubscribe you from its presence, cancel its " -"subscription to yours, and remove the item from your roster" -msgstr "" - -#: src/tabs.py:1159 -msgid "" -"Usage: /export [/path/to/file]\n" -"Export: Use this command to export your contacts into /path/to/file if " -"specified, or $HOME/poezio_contacts if not." -msgstr "" - -#: src/tabs.py:1160 -msgid "" -"Usage: /import [/path/to/file]\n" -"Import: Use this command to import your contacts from /path/to/file if " -"specified, or $HOME/poezio_contacts if not." -msgstr "" - -#: src/tabs.py:1206 -msgid "No JID specified" -msgstr "" - -#: src/tabs.py:1631 -msgid "" -"Usage: /close\n" -"Close: Just close this tab" -msgstr "" diff --git a/plugins/autocorrect.py b/plugins/autocorrect.py index dfd55e6c..a482d47f 100644 --- a/plugins/autocorrect.py +++ b/plugins/autocorrect.py @@ -4,15 +4,18 @@ This plugin lets you perform simple replacements on the last message. Usage ----- -.. note:: This plugin only performs *simple* replacements, not with - regular expressions, despite the syntax. Although it would be - possible, that would be even less useful. +.. note:: the ``/``, ``#``, ``!``, ``:`` and ``;`` chars can be used as separators, + even if the examples only use ``/`` + + +Regex replacement +~~~~~~~~~~~~~~~~~ Once the plugin is loaded, any message matching the following regex:: ^s/(.+?)/(.*?)(/|/g)?$ -will be interpreted as a replacement, and the substitution will be +will be interpreted as a regex replacement, and the substitution will be applied to the last sent message. For example, if you sent the message:: @@ -24,12 +27,29 @@ And you now want to replace “MUC” with “multi-user chat”, you input:: s/MUC/multi-user chat And poezio will correct the message for you. + + +Raw string replacement +~~~~~~~~~~~~~~~~~~~~~~ + +Once the plugin is loaded, any message matching the following regex:: + + ^r/(.+?)/(.*?)(/|/g)?$ + +will be interpreted as a replacement, and the substitution will be applied +to the last send message. + +This variant is useful if you don’t want to care about regular expressions +(and you do not want to have to escape stuff like space or backslashes). + + """ from plugin import BasePlugin import re -sed_re = re.compile('^s/(.+?)/(.*?)(/|/g)?$') +allowed_separators = '/#!:;' +sed_re = re.compile('^([sr])(?P<sep>[%s])(.+?)(?P=sep)(.*?)((?P=sep)|(?P=sep)g)?$' % allowed_separators) class Plugin(BasePlugin): def init(self): @@ -46,16 +66,29 @@ class Plugin(BasePlugin): match = sed_re.match(msg['body']) if not match: return - remove, put, matchall = match.groups() + typ, sep, remove, put, matchall = match.groups() replace_all = False - if matchall == '/g': + if matchall == sep + 'g': replace_all = True - if replace_all: - new_body = body.replace(remove, put) - else: - new_body = body.replace(remove, put, 1) + if typ == 's': + try: + regex = re.compile(remove) + + if replace_all: + new_body = re.sub(remove, put, body) + else: + new_body = re.sub(remove, put, body, count=1) + except Exception as e: + self.api.information('Invalid regex for the autocorrect ' + 'plugin: %s' % e, 'Error') + return + elif typ == 'r': + if replace_all: + new_body = body.replace(remove, put) + else: + new_body = body.replace(remove, put, 1) if body != new_body: msg['body'] = new_body diff --git a/plugins/close_all.py b/plugins/close_all.py new file mode 100644 index 00000000..1b98213e --- /dev/null +++ b/plugins/close_all.py @@ -0,0 +1,45 @@ +""" +``close_all`` plugin: close all tabs except MUCs and the roster. + +Commands +-------- + +.. glossary:: + + /closeall + **Usage:** ``/closeall`` + + Close all tabs except the roster and MUC tabs. +""" +from plugin import BasePlugin +import tabs +from decorators import command_args_parser + + +class Plugin(BasePlugin): + def init(self): + self.api.add_command('closeall', self.command_closeall, + help='Close all non-muc tabs.') + + @command_args_parser.ignored + def command_closeall(self): + """ + /closeall + """ + current = self.core.current_tab() + if not isinstance(current, (tabs.RosterInfoTab, tabs.MucTab)): + self.core.go_to_roster() + current = self.core.current_tab() + + def filter_func(x): + return not isinstance(x, (tabs.RosterInfoTab, tabs.MucTab)) + + matching_tabs = list(filter(filter_func, self.core.tabs)) + length = len(matching_tabs) + for tab in matching_tabs: + self.core.close_tab(tab) + self.core.current_tab_nb = current.nb + self.api.information('%s tabs closed.' % length, 'Info') + self.core.refresh_window() + + diff --git a/plugins/cyber.py b/plugins/cyber.py new file mode 100644 index 00000000..67d6cdc7 --- /dev/null +++ b/plugins/cyber.py @@ -0,0 +1,40 @@ +""" +This plugin adds a "cyber" prefix to a random word in your chatroom messages. + +Usage +----- + +Say something in a MUC tab. + +Configuration options +--------------------- + +.. glossary:: + + frequency + **Default:** ``10`` + + The percentage of the time the plugin will activate (randomly). 100 for every message, <= 0 for never. +""" + +from plugin import BasePlugin +from random import choice, randint +import re + + +DEFAULT_CONFIG = {'cyber': {'frequency': 10}} + +class Plugin(BasePlugin): + + default_config = DEFAULT_CONFIG + + def init(self): + self.api.add_event_handler('muc_say', self.cyberize) + + def cyberize(self, msg, tab): + if randint(1, 100) > self.config.get('frequency'): + return + words = [word for word in re.split('\W+', msg['body']) if len(word) > 3] + if words: + word = choice(words) + msg['body'] = msg['body'].replace(word, 'cyber' + word) diff --git a/plugins/gpg/__init__.py b/plugins/gpg/__init__.py index bc8a96c1..0f441653 100644 --- a/plugins/gpg/__init__.py +++ b/plugins/gpg/__init__.py @@ -117,6 +117,7 @@ log = logging.getLogger(__name__) from plugin import BasePlugin from tabs import ConversationTab +from theming import get_theme NS_SIGNED = "jabber:x:signed" NS_ENCRYPTED = "jabber:x:encrypted" @@ -127,7 +128,6 @@ Hash: %(hash)s %(clear)s -----BEGIN PGP SIGNATURE----- -Version: GnuPG %(data)s -----END PGP SIGNATURE----- @@ -135,7 +135,6 @@ Version: GnuPG ENCRYPTED_MESSAGE = """-----BEGIN PGP MESSAGE----- -Version: GnuPG %(data)s -----END PGP MESSAGE-----""" @@ -156,14 +155,14 @@ class Plugin(BasePlugin): self.keyid = self.config.get('keyid', '') or None self.passphrase = self.config.get('passphrase', '') or None if not self.keyid: - self.core.information('No GPG keyid provided in the configuration', 'Warning') + self.api.information('No GPG keyid provided in the configuration', 'Warning') - self.add_event_handler('send_normal_presence', self.sign_presence) - self.add_event_handler('normal_presence', self.on_normal_presence) - self.add_event_handler('conversation_say_after', self.on_conversation_say) - self.add_event_handler('conversation_msg', self.on_conversation_msg) + self.api.add_event_handler('send_normal_presence', self.sign_presence) + self.api.add_slix_event_handler('presence', self.on_normal_presence) + self.api.add_event_handler('conversation_say_after', self.on_conversation_say) + self.api.add_event_handler('conversation_msg', self.on_conversation_msg) - self.add_tab_command(ConversationTab, 'gpg', self.command_gpg, + self.api.add_tab_command(ConversationTab, 'gpg', self.command_gpg, usage='<force|disable|setkey> [jid] [keyid]', help='Force or disable gpg encryption with the fulljid of the current conversation. The setkey argument lets you associate a keyid with the given bare JID.', short='Manage the GPG status', @@ -197,7 +196,7 @@ class Plugin(BasePlugin): current_presence = self.core.get_status() self.core.command_status('%s %s' % (current_presence.show or 'available', current_presence.message or '',)) - def on_normal_presence(self, presence, resource): + def on_normal_presence(self, presence): """ Check if it’s signed, if it is and we can verify the signature, add 'valid' or 'invalid' into the dict. If it cannot be verified, just add @@ -212,7 +211,7 @@ class Plugin(BasePlugin): return if self.config.has_section('keys') and bare in self.config.options('keys'): self.contacts[full] = 'invalid' - for hash_ in ('SHA1', 'SHA256'): + for hash_ in ('SHA1', 'SHA256', 'SHA512'): to_verify = SIGNED_ATTACHED_MESSAGE % {'clear': presence['status'], 'data': signed.text, 'hash': hash_} @@ -225,7 +224,7 @@ class Plugin(BasePlugin): def on_conversation_say(self, message, tab): """ - Check if the contact has a signed AND veryfied signature. + Check if the contact has a signed AND verified signature. If yes, encrypt the message with her key. """ to = message['to'] @@ -234,12 +233,13 @@ class Plugin(BasePlugin): return signed = to.full in self.contacts.keys() if signed: - veryfied = self.contacts[to.full] in ('valid', 'forced') + verified = self.contacts[to.full] in ('valid', 'forced') else: - veryfied = False - if veryfied: + verified = False + if verified: # remove the xhtm_im body if present, because that # cannot be encrypted. + body = message['body'] del message['html'] encrypted_element = ET.Element('{%s}x' % (NS_ENCRYPTED,)) text = self.gpg.encrypt(message['body'], self.config.get(to.bare, '', section='keys'), always_trust=True) @@ -251,6 +251,13 @@ class Plugin(BasePlugin): encrypted_element.text = self.remove_gpg_headers(xml.sax.saxutils.escape(str(text))) message.append(encrypted_element) message['body'] = 'This message has been encrypted using the GPG key with id: %s' % self.keyid + message.send() + del message['body'] + tab.add_message(body, nickname=self.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=message['id'], + jid=self.core.xmpp.boundjid, + typ=0) def on_conversation_msg(self, message, tab): """ @@ -278,7 +285,7 @@ class Plugin(BasePlugin): if status in ('valid', 'invalid', 'signed'): return ' GPG Key: %s (%s)' % (status, 'encrypted' if status == 'valid' else 'NOT encrypted',) else: - return ' GPG: Encryption %s' % (status,) + return ' GPG: Encryption %s' % (status,) def command_gpg(self, args): """ @@ -314,7 +321,8 @@ class Plugin(BasePlugin): self.core.refresh_window() def gpg_completion(self, the_input): - return the_input.auto_completion(['force', 'disable', 'setkey'], ' ') + if the_input.get_argument_position() == 1: + return the_input.new_completion(['force', 'disable', 'setkey'], 1, quotify=False) def remove_gpg_headers(self, text): lines = text.splitlines() diff --git a/plugins/gpg/gnupg.py b/plugins/gpg/gnupg.py index 5cb11766..99bd7d25 100644 --- a/plugins/gpg/gnupg.py +++ b/plugins/gpg/gnupg.py @@ -27,15 +27,14 @@ Vinay Sajip to make use of the subprocess module (Steve's version uses os.fork() and so does not work on Windows). Renamed to gnupg.py to avoid confusion with the previous versions. -Modifications Copyright (C) 2008-2012 Vinay Sajip. All rights reserved. +Modifications Copyright (C) 2008-2014 Vinay Sajip. All rights reserved. A unittest harness (test_gnupg.py) has also been added. """ -import locale -__version__ = "0.3.1" +__version__ = "0.3.8.dev0" __author__ = "Vinay Sajip" -__date__ = "$01-Sep-2012 20:02:51$" +__date__ = "$07-Dec-2014 18:46:17$" try: from io import StringIO @@ -46,6 +45,7 @@ import codecs import locale import logging import os +import re import socket from subprocess import Popen from subprocess import PIPE @@ -61,13 +61,61 @@ except ImportError: try: unicode _py3k = False + string_types = basestring + text_type = unicode except NameError: _py3k = True + string_types = str + text_type = str logger = logging.getLogger(__name__) if not logger.handlers: logger.addHandler(NullHandler()) +# We use the test below because it works for Jython as well as CPython +if os.path.__name__ == 'ntpath': + # On Windows, we don't need shell quoting, other than worrying about + # paths with spaces in them. + def shell_quote(s): + return '"%s"' % s +else: + # Section copied from sarge + + # This regex determines which shell input needs quoting + # because it may be unsafe + UNSAFE = re.compile(r'[^\w%+,./:=@-]') + + def shell_quote(s): + """ + Quote text so that it is safe for Posix command shells. + + For example, "*.py" would be converted to "'*.py'". If the text is + considered safe it is returned unquoted. + + :param s: The value to quote + :type s: str (or unicode on 2.x) + :return: A safe version of the input, from the point of view of Posix + command shells + :rtype: The passed-in type + """ + if not isinstance(s, string_types): + raise TypeError('Expected string type, got %s' % type(s)) + if not s: + result = "''" + elif not UNSAFE.search(s): + result = s + else: + result = "'%s'" % s.replace("'", r"'\''") + return result + + # end of sarge code + +# Now that we use shell=False, we shouldn't need to quote arguments. +# Use no_quote instead of shell_quote to remind us of where quoting +# was needed. +def no_quote(s): + return s + def _copy_data(instream, outstream): # Copy one stream to another sent = 0 @@ -77,7 +125,7 @@ def _copy_data(instream, outstream): enc = 'ascii' while True: data = instream.read(1024) - if len(data) == 0: + if not data: break sent += len(data) logger.debug("sending chunk (%d): %r", sent, data[:256]) @@ -107,25 +155,28 @@ def _write_passphrase(stream, passphrase, encoding): passphrase = '%s\n' % passphrase passphrase = passphrase.encode(encoding) stream.write(passphrase) - logger.debug("Wrote passphrase: %r", passphrase) + logger.debug('Wrote passphrase') def _is_sequence(instance): - return isinstance(instance,list) or isinstance(instance,tuple) + return isinstance(instance, (list, tuple, set, frozenset)) -def _make_binary_stream(s, encoding): +def _make_memory_stream(s): try: - if _py3k: - if isinstance(s, str): - s = s.encode(encoding) - else: - if type(s) is not str: - s = s.encode(encoding) from io import BytesIO rv = BytesIO(s) except ImportError: rv = StringIO(s) return rv +def _make_binary_stream(s, encoding): + if _py3k: + if isinstance(s, str): + s = s.encode(encoding) + else: + if type(s) is not str: + s = s.encode(encoding) + return _make_memory_stream(s) + class Verify(object): "Handle status messages for --verify" @@ -149,6 +200,7 @@ class Verify(object): self.fingerprint = self.creation_date = self.timestamp = None self.signature_id = self.key_id = None self.username = None + self.key_status = None self.status = None self.pubkey_fingerprint = None self.expire_timestamp = None @@ -166,13 +218,27 @@ class Verify(object): self.trust_text = key self.trust_level = self.TRUST_LEVELS[key] elif key in ("RSA_OR_IDEA", "NODATA", "IMPORT_RES", "PLAINTEXT", - "PLAINTEXT_LENGTH", "POLICY_URL", "DECRYPTION_INFO", - "DECRYPTION_OKAY"): + "PLAINTEXT_LENGTH", "POLICY_URL", "DECRYPTION_INFO", + "DECRYPTION_OKAY", "INV_SGNR", "FILE_START", "FILE_ERROR", + "FILE_DONE", "PKA_TRUST_GOOD", "PKA_TRUST_BAD", "BADMDC", + "GOODMDC", "NO_SGNR", "NOTATION_NAME", "NOTATION_DATA", + "PROGRESS"): pass elif key == "BADSIG": self.valid = False self.status = 'signature bad' self.key_id, self.username = value.split(None, 1) + elif key == "ERRSIG": + self.valid = False + (self.key_id, + algo, hash_algo, + cls, + self.timestamp) = value.split()[:5] + self.status = 'signature error' + elif key == "EXPSIG": + self.valid = False + self.status = 'signature expired' + self.key_id, self.username = value.split(None, 1) elif key == "GOODSIG": self.valid = True self.status = 'signature good' @@ -188,13 +254,6 @@ class Verify(object): elif key == "SIG_ID": (self.signature_id, self.creation_date, self.timestamp) = value.split() - elif key == "ERRSIG": - self.valid = False - (self.key_id, - algo, hash_algo, - cls, - self.timestamp) = value.split()[:5] - self.status = 'signature error' elif key == "DECRYPTION_FAILED": self.valid = False self.key_id = value @@ -203,17 +262,25 @@ class Verify(object): self.valid = False self.key_id = value self.status = 'no public key' - elif key in ("KEYEXPIRED", "SIGEXPIRED"): + elif key in ("KEYEXPIRED", "SIGEXPIRED", "KEYREVOKED"): # these are useless in verify, since they are spit out for any # pub/subkeys on the key, not just the one doing the signing. # if we want to check for signatures with expired key, - # the relevant flag is EXPKEYSIG. + # the relevant flag is EXPKEYSIG or REVKEYSIG. pass elif key in ("EXPKEYSIG", "REVKEYSIG"): # signed with expired or revoked key self.valid = False self.key_id = value.split()[0] - self.status = (('%s %s') % (key[:3], key[3:])).lower() + if key == "EXPKEYSIG": + self.key_status = 'signing key has expired' + else: + self.key_status = 'signing key was revoked' + self.status = self.key_status + elif key == "UNEXPECTED": + self.valid = False + self.key_id = value + self.status = 'unexpected data' else: raise ValueError("Unknown status message: %r" % key) @@ -282,8 +349,8 @@ class ImportResult(object): 'problem': reason, 'text': self.problem_reason[reason]}) elif key == "IMPORT_RES": import_res = value.split() - for i in range(len(self.counts)): - setattr(self, self.counts[i], int(import_res[i])) + for i, count in enumerate(self.counts): + setattr(self, count, int(import_res[i])) elif key == "KEYEXPIRED": self.results.append({'fingerprint': None, 'problem': '0', 'text': 'Key expired'}) @@ -300,7 +367,63 @@ class ImportResult(object): l.append('%d not imported' % self.not_imported) return ', '.join(l) -class ListKeys(list): +ESCAPE_PATTERN = re.compile(r'\\x([0-9a-f][0-9a-f])', re.I) +BASIC_ESCAPES = { + r'\n': '\n', + r'\r': '\r', + r'\f': '\f', + r'\v': '\v', + r'\b': '\b', + r'\0': '\0', +} + +class SendResult(object): + def __init__(self, gpg): + self.gpg = gpg + + def handle_status(self, key, value): + logger.debug('SendResult: %s: %s', key, value) + +class SearchKeys(list): + ''' Handle status messages for --search-keys. + + Handle pub and uid (relating the latter to the former). + + Don't care about the rest + ''' + + UID_INDEX = 1 + FIELDS = 'type keyid algo length date expires'.split() + + def __init__(self, gpg): + self.gpg = gpg + self.curkey = None + self.fingerprints = [] + self.uids = [] + + def get_fields(self, args): + result = {} + for i, var in enumerate(self.FIELDS): + result[var] = args[i] + result['uids'] = [] + return result + + def pub(self, args): + self.curkey = curkey = self.get_fields(args) + self.append(curkey) + + def uid(self, args): + uid = args[self.UID_INDEX] + uid = ESCAPE_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), uid) + for k, v in BASIC_ESCAPES.items(): + uid = uid.replace(k, v) + self.curkey['uids'].append(uid) + self.uids.append(uid) + + def handle_status(self, key, value): + pass + +class ListKeys(SearchKeys): ''' Handle status messages for --list-keys. Handle pub and uid (relating the latter to the former). @@ -317,25 +440,17 @@ class ListKeys(list): grp = reserved for gpgsm rvk = revocation key ''' - def __init__(self, gpg): - self.gpg = gpg - self.curkey = None - self.fingerprints = [] - self.uids = [] + + UID_INDEX = 9 + FIELDS = 'type trust length algo keyid date expires dummy ownertrust uid'.split() def key(self, args): - vars = (""" - type trust length algo keyid date expires dummy ownertrust uid - """).split() - self.curkey = {} - for i in range(len(vars)): - self.curkey[vars[i]] = args[i] - self.curkey['uids'] = [] - if self.curkey['uid']: - self.curkey['uids'].append(self.curkey['uid']) - del self.curkey['uid'] - self.curkey['subkeys'] = [] - self.append(self.curkey) + self.curkey = curkey = self.get_fields(args) + if curkey['uid']: + curkey['uids'].append(curkey['uid']) + del curkey['uid'] + curkey['subkeys'] = [] + self.append(curkey) pub = sec = key @@ -343,18 +458,34 @@ class ListKeys(list): self.curkey['fingerprint'] = args[9] self.fingerprints.append(args[9]) - def uid(self, args): - self.curkey['uids'].append(args[9]) - self.uids.append(args[9]) - def sub(self, args): subkey = [args[4], args[11]] self.curkey['subkeys'].append(subkey) - def handle_status(self, key, value): - pass -class Crypt(Verify): +class ScanKeys(ListKeys): + ''' Handle status messages for --with-fingerprint.''' + + def sub(self, args): + # --with-fingerprint --with-colons somehow outputs fewer colons, + # use the last value args[-1] instead of args[11] + subkey = [args[4], args[-1]] + self.curkey['subkeys'].append(subkey) + +class TextHandler(object): + def _as_text(self): + return self.data.decode(self.gpg.encoding, self.gpg.decode_errors) + + if _py3k: + __str__ = _as_text + else: + __unicode__ = _as_text + + def __str__(self): + return self.data + + +class Crypt(Verify, TextHandler): "Handle status messages for --encrypt and --decrypt" def __init__(self, gpg): Verify.__init__(self, gpg) @@ -368,19 +499,16 @@ class Crypt(Verify): __bool__ = __nonzero__ - def __str__(self): - return self.data.decode(self.gpg.encoding, self.gpg.decode_errors) - def handle_status(self, key, value): if key in ("ENC_TO", "USERID_HINT", "GOODMDC", "END_DECRYPTION", - "BEGIN_SIGNING", "NO_SECKEY", "ERROR", "NODATA", - "CARDCTRL"): + "BEGIN_SIGNING", "NO_SECKEY", "ERROR", "NODATA", "PROGRESS", + "CARDCTRL", "BADMDC", "SC_OP_FAILURE", "SC_OP_SUCCESS"): # in the case of ERROR, this is because a more specific error # message will have come first pass elif key in ("NEED_PASSPHRASE", "BAD_PASSPHRASE", "GOOD_PASSPHRASE", "MISSING_PASSPHRASE", "DECRYPTION_FAILED", - "KEY_NOT_CREATED"): + "KEY_NOT_CREATED", "NEED_PASSPHRASE_PIN"): self.status = key.replace("_", " ").lower() elif key == "NEED_PASSPHRASE_SYM": self.status = 'need symmetric passphrase' @@ -441,7 +569,7 @@ class DeleteResult(object): problem_reason = { '1': 'No such key', '2': 'Must delete secret key first', - '3': 'Ambigious specification', + '3': 'Ambiguous specification', } def handle_status(self, key, value): @@ -451,11 +579,18 @@ class DeleteResult(object): else: raise ValueError("Unknown status message: %r" % key) -class Sign(object): + def __nonzero__(self): + return self.status == 'ok' + + __bool__ = __nonzero__ + + +class Sign(TextHandler): "Handle status messages for --sign" def __init__(self, gpg): self.gpg = gpg self.type = None + self.hash_algo = None self.fingerprint = None def __nonzero__(self): @@ -463,21 +598,26 @@ class Sign(object): __bool__ = __nonzero__ - def __str__(self): - return self.data.decode(self.gpg.encoding, self.gpg.decode_errors) - def handle_status(self, key, value): if key in ("USERID_HINT", "NEED_PASSPHRASE", "BAD_PASSPHRASE", - "GOOD_PASSPHRASE", "BEGIN_SIGNING", "CARDCTRL", "INV_SGNR"): + "GOOD_PASSPHRASE", "BEGIN_SIGNING", "CARDCTRL", "INV_SGNR", + "NO_SGNR", "MISSING_PASSPHRASE", "NEED_PASSPHRASE_PIN", + "SC_OP_FAILURE", "SC_OP_SUCCESS", "PROGRESS"): pass + elif key in ("KEYEXPIRED", "SIGEXPIRED"): + self.status = 'key expired' + elif key == "KEYREVOKED": + self.status = 'key revoked' elif key == "SIG_CREATED": (self.type, - algo, hashalgo, cls, + algo, self.hash_algo, cls, self.timestamp, self.fingerprint ) = value.split() else: raise ValueError("Unknown status message: %r" % key) +VERSION_RE = re.compile(r'gpg \(GnuPG\) (\d+(\.\d+)*)'.encode('ascii'), re.I) +HEX_DIGITS_RE = re.compile(r'[0-9a-f]+$', re.I) class GPG(object): @@ -488,35 +628,54 @@ class GPG(object): 'delete': DeleteResult, 'generate': GenKey, 'import': ImportResult, + 'send': SendResult, 'list': ListKeys, + 'scan': ScanKeys, + 'search': SearchKeys, 'sign': Sign, 'verify': Verify, } "Encapsulate access to the gpg executable" def __init__(self, gpgbinary='gpg', gnupghome=None, verbose=False, - use_agent=False, keyring=None, options=None): + use_agent=False, keyring=None, options=None, + secret_keyring=None): """Initialize a GPG process wrapper. Options are: gpgbinary -- full pathname for GPG binary. gnupghome -- full pathname to where we can find the public and private keyrings. Default is whatever gpg defaults to. - keyring -- name of alternative keyring file to use. If specified, - the default keyring is not used. + keyring -- name of alternative keyring file to use, or list of such + keyrings. If specified, the default keyring is not used. options =-- a list of additional options to pass to the GPG binary. + secret_keyring -- name of alternative secret keyring file to use, or + list of such keyrings. """ self.gpgbinary = gpgbinary self.gnupghome = gnupghome + if keyring: + # Allow passing a string or another iterable. Make it uniformly + # a list of keyring filenames + if isinstance(keyring, string_types): + keyring = [keyring] self.keyring = keyring + if secret_keyring: + # Allow passing a string or another iterable. Make it uniformly + # a list of keyring filenames + if isinstance(secret_keyring, string_types): + secret_keyring = [secret_keyring] + self.secret_keyring = secret_keyring self.verbose = verbose self.use_agent = use_agent if isinstance(options, str): options = [options] self.options = options - self.encoding = locale.getpreferredencoding() - if self.encoding is None: # This happens on Jython! - self.encoding = sys.stdin.encoding + # Changed in 0.3.7 to use Latin-1 encoding rather than + # locale.getpreferredencoding falling back to sys.stdin.encoding + # falling back to utf-8, because gpg itself uses latin-1 as the default + # encoding. + self.encoding = 'latin-1' if gnupghome and not os.path.isdir(self.gnupghome): os.makedirs(self.gnupghome,0x1C0) p = self._open_subprocess(["--version"]) @@ -525,6 +684,12 @@ class GPG(object): if p.returncode != 0: raise ValueError("Error invoking gpg: %s: %s" % (p.returncode, result.stderr)) + m = VERSION_RE.match(result.data) + if not m: + self.version = None + else: + dot = '.'.encode('ascii') + self.version = tuple([int(s) for s in m.groups()[0].split(dot)]) def make_args(self, args, passphrase): """ @@ -532,13 +697,18 @@ class GPG(object): will be appended. The ``passphrase`` argument needs to be True if a passphrase will be sent to GPG, else False. """ - cmd = [self.gpgbinary, '--status-fd 2 --no-tty'] + cmd = [self.gpgbinary, '--status-fd', '2', '--no-tty'] if self.gnupghome: - cmd.append('--homedir "%s" ' % self.gnupghome) + cmd.extend(['--homedir', no_quote(self.gnupghome)]) if self.keyring: - cmd.append('--no-default-keyring --keyring "%s" ' % self.keyring) + cmd.append('--no-default-keyring') + for fn in self.keyring: + cmd.extend(['--keyring', no_quote(fn)]) + if self.secret_keyring: + for fn in self.secret_keyring: + cmd.extend(['--secret-keyring', no_quote(fn)]) if passphrase: - cmd.append('--batch --passphrase-fd 0') + cmd.extend(['--batch', '--passphrase-fd', '0']) if self.use_agent: cmd.append('--use-agent') if self.options: @@ -549,11 +719,12 @@ class GPG(object): def _open_subprocess(self, args, passphrase=False): # Internal method: open a pipe to a GPG subprocess and return # the file objects for communicating with it. - cmd = ' '.join(self.make_args(args, passphrase)) + cmd = self.make_args(args, passphrase) if self.verbose: - print(cmd) + pcmd = ' '.join(cmd) + print(pcmd) logger.debug("%s", cmd) - return Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE) + return Popen(cmd, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE) def _read_response(self, stream, result): # Internal method: reads all the stderr output from GPG, taking notice @@ -561,31 +732,27 @@ class GPG(object): # # Calls methods on the response object for each valid token found, # with the arg being the remainder of the status line. - try: - lines = [] - while True: - line = stream.readline() - if len(line) == 0: - break - lines.append(line) - line = line.rstrip() - if self.verbose: - print(line) - logger.debug("%s", line) - if line[0:9] == '[GNUPG:] ': - # Chop off the prefix - line = line[9:] - L = line.split(None, 1) - keyword = L[0] - if len(L) > 1: - value = L[1] - else: - value = "" - result.handle_status(keyword, value) - result.stderr = ''.join(lines) - except: - import traceback - logger.error('Error in the GPG plugin:\n%s', traceback.format_exc()) + lines = [] + while True: + line = stream.readline() + if len(line) == 0: + break + lines.append(line) + line = line.rstrip() + if self.verbose: + print(line) + logger.debug("%s", line) + if line[0:9] == '[GNUPG:] ': + # Chop off the prefix + line = line[9:] + L = line.split(None, 1) + keyword = L[0] + if len(L) > 1: + value = L[1] + else: + value = "" + result.handle_status(keyword, value) + result.stderr = ''.join(lines) def _read_data(self, stream, result): # Read the contents of the file from GPG's stdout @@ -634,7 +801,7 @@ class GPG(object): stderr.close() stdout.close() - def _handle_io(self, args, file, result, passphrase=None, binary=False): + def _handle_io(self, args, fileobj, result, passphrase=None, binary=False): "Handle a call to GPG - pass input data, collect output data" # Handle a basic data call - pass data to GPG, handle the output # including status information. Garbage In, Garbage Out :) @@ -645,7 +812,7 @@ class GPG(object): stdin = p.stdin if passphrase: _write_passphrase(stdin, passphrase, self.encoding) - writer = _threaded_copy_data(file, stdin) + writer = _threaded_copy_data(fileobj, stdin) self._collect_output(p, result, writer, stdin) return result @@ -659,8 +826,15 @@ class GPG(object): f.close() return result + def set_output_without_confirmation(self, args, output): + "If writing to a file which exists, avoid a confirmation message." + if os.path.exists(output): + # We need to avoid an overwrite confirmation message + args.extend(['--batch', '--yes']) + args.extend(['--output', output]) + def sign_file(self, file, keyid=None, passphrase=None, clearsign=True, - detach=False, binary=False): + detach=False, binary=False, output=None): """sign file""" logger.debug("sign_file: %s", file) if binary: @@ -674,7 +848,10 @@ class GPG(object): elif clearsign: args.append("--clearsign") if keyid: - args.append('--default-key "%s"' % keyid) + args.extend(['--default-key', no_quote(keyid)]) + if output: # write the output to a file with the specified name + self.set_output_without_confirmation(args, output) + result = self.result_map['sign'](self) #We could use _handle_io here except for the fact that if the #passphrase is bad, gpg bails and you can't write the message. @@ -726,8 +903,8 @@ class GPG(object): logger.debug('Wrote to temp file: %r', s) os.write(fd, s) os.close(fd) - args.append(fn) - args.append('"%s"' % data_filename) + args.append(no_quote(fn)) + args.append(no_quote(data_filename)) try: p = self._open_subprocess(args) self._collect_output(p, result, stdin=p.stdin) @@ -735,6 +912,15 @@ class GPG(object): os.unlink(fn) return result + def verify_data(self, sig_filename, data): + "Verify the signature in sig_filename against data in memory" + logger.debug('verify_data: %r, %r ...', sig_filename, data[:16]) + result = self.result_map['verify'](self) + args = ['--verify', no_quote(sig_filename), '-'] + stream = _make_memory_stream(data) + self._handle_io(args, stream, result, binary=True) + return result + # # KEY MANAGEMENT # @@ -798,7 +984,8 @@ class GPG(object): >>> import shutil >>> shutil.rmtree("keys") >>> gpg = GPG(gnupghome="keys") - >>> result = gpg.recv_keys('pgp.mit.edu', '3FF0DB166A7476EA') + >>> os.chmod('keys', 0x1C0) + >>> result = gpg.recv_keys('keyserver.ubuntu.com', '92905378') >>> assert result """ @@ -806,33 +993,60 @@ class GPG(object): logger.debug('recv_keys: %r', keyids) data = _make_binary_stream("", self.encoding) #data = "" - args = ['--keyserver', keyserver, '--recv-keys'] - args.extend(keyids) + args = ['--keyserver', no_quote(keyserver), '--recv-keys'] + args.extend([no_quote(k) for k in keyids]) self._handle_io(args, data, result, binary=True) logger.debug('recv_keys result: %r', result.__dict__) data.close() return result + def send_keys(self, keyserver, *keyids): + """Send a key to a keyserver. + + Note: it's not practical to test this function without sending + arbitrary data to live keyservers. + """ + result = self.result_map['send'](self) + logger.debug('send_keys: %r', keyids) + data = _make_binary_stream('', self.encoding) + #data = "" + args = ['--keyserver', no_quote(keyserver), '--send-keys'] + args.extend([no_quote(k) for k in keyids]) + self._handle_io(args, data, result, binary=True) + logger.debug('send_keys result: %r', result.__dict__) + data.close() + return result + def delete_keys(self, fingerprints, secret=False): which='key' if secret: which='secret-key' if _is_sequence(fingerprints): - fingerprints = ' '.join(fingerprints) - args = ['--batch --delete-%s "%s"' % (which, fingerprints)] + fingerprints = [no_quote(s) for s in fingerprints] + else: + fingerprints = [no_quote(fingerprints)] + args = ['--batch', '--delete-%s' % which] + args.extend(fingerprints) result = self.result_map['delete'](self) p = self._open_subprocess(args) self._collect_output(p, result, stdin=p.stdin) return result - def export_keys(self, keyids, secret=False): + def export_keys(self, keyids, secret=False, armor=True, minimal=False): "export the indicated keys. 'keyid' is anything gpg accepts" which='' if secret: which='-secret-key' if _is_sequence(keyids): - keyids = ' '.join(['"%s"' % k for k in keyids]) - args = ["--armor --export%s %s" % (which, keyids)] + keyids = [no_quote(k) for k in keyids] + else: + keyids = [no_quote(keyids)] + args = ['--export%s' % which] + if armor: + args.append('--armor') + if minimal: + args.extend(['--export-options','export-minimal']) + args.extend(keyids) p = self._open_subprocess(args) # gpg --export produces no status-fd output; stdout will be # empty in case of failure @@ -842,6 +1056,27 @@ class GPG(object): logger.debug('export_keys result: %r', result.data) return result.data.decode(self.encoding, self.decode_errors) + def _get_list_output(self, p, kind): + # Get the response information + result = self.result_map[kind](self) + self._collect_output(p, result, stdin=p.stdin) + lines = result.data.decode(self.encoding, + self.decode_errors).splitlines() + valid_keywords = 'pub uid sec fpr sub'.split() + for line in lines: + if self.verbose: + print(line) + logger.debug("line: %r", line.rstrip()) + if not line: + break + L = line.strip().split(':') + if not L: + continue + keyword = L[0] + if keyword in valid_keywords: + getattr(result, keyword)(L) + return result + def list_keys(self, secret=False): """ list the keys currently in the keyring @@ -862,25 +1097,58 @@ class GPG(object): which='keys' if secret: which='secret-keys' - args = "--list-%s --fixed-list-mode --fingerprint --with-colons" % (which,) - args = [args] + args = ["--list-%s" % which, "--fixed-list-mode", "--fingerprint", + "--with-colons"] p = self._open_subprocess(args) + return self._get_list_output(p, 'list') - # there might be some status thingumy here I should handle... (amk) - # ...nope, unless you care about expired sigs or keys (stevegt) + def scan_keys(self, filename): + """ + List details of an ascii armored or binary key file + without first importing it to the local keyring. + + The function achieves this by running: + $ gpg --with-fingerprint --with-colons filename + """ + args = ['--with-fingerprint', '--with-colons'] + args.append(no_quote(filename)) + p = self._open_subprocess(args) + return self._get_list_output(p, 'scan') + + def search_keys(self, query, keyserver='pgp.mit.edu'): + """ search keyserver by query (using --search-keys option) + + >>> import shutil + >>> shutil.rmtree('keys') + >>> gpg = GPG(gnupghome='keys') + >>> os.chmod('keys', 0x1C0) + >>> result = gpg.search_keys('<vinay_sajip@hotmail.com>') + >>> assert result, 'Failed using default keyserver' + >>> keyserver = 'keyserver.ubuntu.com' + >>> result = gpg.search_keys('<vinay_sajip@hotmail.com>', keyserver) + >>> assert result, 'Failed using keyserver.ubuntu.com' + + """ + query = query.strip() + if HEX_DIGITS_RE.match(query): + query = '0x' + query + args = ['--fixed-list-mode', '--fingerprint', '--with-colons', + '--keyserver', no_quote(keyserver), '--search-keys', + no_quote(query)] + p = self._open_subprocess(args) # Get the response information - result = self.result_map['list'](self) + result = self.result_map['search'](self) self._collect_output(p, result, stdin=p.stdin) lines = result.data.decode(self.encoding, self.decode_errors).splitlines() - valid_keywords = 'pub uid sec fpr sub'.split() + valid_keywords = ['pub', 'uid'] for line in lines: if self.verbose: print(line) - logger.debug("line: %r", line.rstrip()) - if not line: - break + logger.debug('line: %r', line.rstrip()) + if not line: # sometimes get blank lines on Windows + continue L = line.strip().split(':') if not L: continue @@ -901,7 +1169,7 @@ class GPG(object): >>> assert not result """ - args = ["--gen-key --batch"] + args = ["--gen-key", "--batch"] result = self.result_map['generate'](self) f = _make_binary_stream(input, self.encoding) self._handle_io(args, f, result, binary=True) @@ -915,11 +1183,11 @@ class GPG(object): parms = {} for key, val in list(kwargs.items()): key = key.replace('_','-').title() - parms[key] = val + if str(val).strip(): # skip empty strings + parms[key] = val parms.setdefault('Key-Type','RSA') - parms.setdefault('Key-Length',1024) + parms.setdefault('Key-Length',2048) parms.setdefault('Name-Real', "Autogenerated Key") - parms.setdefault('Name-Comment', "Generated by gnupg.py") try: logname = os.environ['LOGNAME'] except KeyError: @@ -964,23 +1232,30 @@ class GPG(object): "Encrypt the message read from the file-like object 'file'" args = ['--encrypt'] if symmetric: + # can't be False or None - could be True or a cipher algo value + # such as AES256 args = ['--symmetric'] + if symmetric is not True: + args.extend(['--cipher-algo', no_quote(symmetric)]) + # else use the default, currently CAST5 else: - args = ['--encrypt'] + if not recipients: + raise ValueError('No recipients specified with asymmetric ' + 'encryption') if not _is_sequence(recipients): recipients = (recipients,) for recipient in recipients: - args.append('--recipient "%s"' % recipient) - if armor: # create ascii-armored output - set to False for binary output + args.extend(['--recipient', no_quote(recipient)]) + if armor: # create ascii-armored output - False for binary output args.append('--armor') if output: # write the output to a file with the specified name - if os.path.exists(output): - os.remove(output) # to avoid overwrite confirmation message - args.append('--output "%s"' % output) - if sign: - args.append('--sign --default-key "%s"' % sign) + self.set_output_without_confirmation(args, output) + if sign is True: + args.append('--sign') + elif sign: + args.extend(['--sign', '--default-key', no_quote(sign)]) if always_trust: - args.append("--always-trust") + args.append('--always-trust') result = self.result_map['crypt'](self) self._handle_io(args, file, result, passphrase=passphrase, binary=True) logger.debug('encrypt result: %r', result.data) @@ -1008,9 +1283,6 @@ class GPG(object): 'hello' >>> result = gpg.encrypt("hello again",print1) >>> message = str(result) - >>> result = gpg.decrypt(message) - >>> result.status == 'need passphrase' - True >>> result = gpg.decrypt(message,passphrase='bar') >>> result.status in ('decryption failed', 'bad passphrase') True @@ -1020,9 +1292,6 @@ class GPG(object): True >>> str(result) 'hello again' - >>> result = gpg.encrypt("signed hello",print2,sign=print1) - >>> result.status == 'need passphrase' - True >>> result = gpg.encrypt("signed hello",print2,sign=print1,passphrase='foo') >>> result.status == 'encryption ok' True @@ -1048,13 +1317,10 @@ class GPG(object): output=None): args = ["--decrypt"] if output: # write the output to a file with the specified name - if os.path.exists(output): - os.remove(output) # to avoid overwrite confirmation message - args.append('--output "%s"' % output) + self.set_output_without_confirmation(args, output) if always_trust: args.append("--always-trust") result = self.result_map['crypt'](self) self._handle_io(args, file, result, passphrase, binary=True) logger.debug('decrypt result: %r', result.data) return result - diff --git a/plugins/irc.py b/plugins/irc.py index 6341851e..065b1e62 100644 --- a/plugins/irc.py +++ b/plugins/irc.py @@ -4,8 +4,9 @@ Plugin destined to be used together with the Biboumi IRC gateway. For more information about Biboumi, please see the `official website`_. This plugin is here as a non-default extension of the poezio configuration -made to work with IRC rooms and logins. Therefore, it does not define any -commands or do anything useful except on load. +made to work with IRC rooms and logins. It also defines commands aimed at +reducing the amount of effort needed to navigate smoothly between IRC and +XMPP rooms. Configuration ------------- @@ -21,6 +22,14 @@ Global configuration The JID of the IRC gateway to use. If empty, irc.poez.io will be used. Please try to run your own, though, it’s painless to setup. + initial_connect + **Default:** ``true`` + + If you want to join all the rooms and try to authenticate with + nickserv when the plugin gets loaded. If ``false``, you will have + to use the :term:`/irc_login` command to authenticate, and the + :term:`/irc_join` command to join preconfigured rooms. + .. note:: There is no nickname option because the default from poezio will be used. Server-specific configuration @@ -49,14 +58,46 @@ section name, and the following options: Your nickname on this server. If empty, the default configuration will be used. - rooms + rooms [IRC plugin] **Default:** ``[empty]`` The list of rooms to join on this server (e.g. ``#room1:#room2``). .. note:: If no login_command or login_nick is set, the authentication phase - won’t take place and you will join the rooms after a small delay. + won’t take place and you will join the rooms without authentication + with nickserv or whatever. + +Commands +~~~~~~~~ + +.. glossary:: + :sorted: + + /irc_login + **Usage:** ``/irc_login [server1] [server2]…`` + Authenticate with the specified servers if they are correctly + configured. If no servers are provided, the plugin will try + them all. (You need to set :term:`login_nick` and + :term:`login_command` as well) + + /irc_join + **Usage:** ``/irc_join <room or server>`` + + Join the specified room on the same server as the current tab (can + be a private conversation or a chatroom). If a server that appears + in the conversation is specified instead of a room, the plugin + will try to join all the rooms configured with autojoin on that + server. + + /irc_query + **Usage:** ``/irc_query <nickname> [message]`` + + Open a private conversation with the given nickname, on the same IRC + server as the current tab (can be a private conversation or a + chatroom). Doing `/irc_query foo "hello there"` when the current + tab is #foo%irc.example.com@biboumi.example.com is equivalent to + `/message foo!irc.example.com@biboumi.example.com "hello there"` Example configuration ~~~~~~~~~~~~~~~~~~~~~ @@ -69,7 +110,7 @@ Example configuration [irc.freenode.net] nickname = mynick login_nick = nickserv - login_command = identify mynick mypassword + login_command = identify mypassword rooms = #testroom1:#testroom2 [irc.geeknode.org] @@ -81,41 +122,255 @@ Example configuration .. _official website: http://biboumi.louiz.org/ + """ from plugin import BasePlugin +from decorators import command_args_parser +import common +import tabs class Plugin(BasePlugin): - def init(self): - - def join(server): - "Join rooms after a small delay" - nick = self.config.get('nickname', '', server) or self.core.own_nick - rooms = self.config.get('rooms', '', server).split(':') - for room in rooms: - room = '{}%{}@{}/{}'.format(room, server, gateway, nick) - self.core.command_join(room) + def init(self): + if self.config.get('initial_connect', True): + self.initial_connect() + + self.api.add_command('irc_login', self.command_irc_login, + usage='[server] [server]…', + help=('Connect to the specified servers if they ' + 'exist in the configuration and the login ' + 'options are set. If not is given, the ' + 'plugin will try all the sections in the ' + 'configuration.'), + short='Login to irc servers with nickserv', + completion=self.completion_irc_login) + + self.api.add_command('irc_join', self.command_irc_join, + usage='<room or server>', + help=('Join <room> in the same server as the ' + 'current tab (if it is an IRC tab). Or ' + 'join all the preconfigured rooms in ' + '<server> '), + short='Join irc rooms more easily', + completion=self.completion_irc_join) + + self.api.add_command('irc_query', self.command_irc_query, + usage='<nickname> [message]', + help=('Open a private conversation with the ' + 'given <nickname>, on the current IRC ' + 'server. Optionally immediately send ' + 'the given message. For example, if the ' + 'current tab is #foo%irc.example.com@' + 'biboumi.example.com, doing `/irc_query ' + 'nick "hi there"` is equivalent to ' + '`/message nick!irc.example.com@biboumi.' + 'example.com "hi there"`'), + short='Open a private conversation with an IRC user') + + def join(self, gateway, server): + "Join irc rooms on a server" + nick = self.config.get_by_tabname('nickname', server, default='') or self.core.own_nick + rooms = self.config.get_by_tabname('rooms', server, default='').split(':') + for room in rooms: + room = '{}%{}@{}/{}'.format(room, server, gateway, nick) + self.core.command_join(room) + + def initial_connect(self): gateway = self.config.get('gateway', 'irc.poez.io') sections = self.config.sections() for section in (s for s in sections if s != 'irc'): - server_suffix = '%{}@{}'.format(section, gateway) + + room_suffix = '%{}@{}'.format(section, gateway) already_opened = False for tab in self.core.tabs: - if tab.name.endswith(server_suffix): + if tab.name.endswith(room_suffix) and tab.joined: already_opened = True + break - login_command = self.config.get('login_command', '', section) - login_nick = self.config.get('login_nick', '', section) - nick = self.config.get('nickname', '', section) or self.core.own_nick - + login_command = self.config.get_by_tabname('login_command', section, default='') + login_nick = self.config.get_by_tabname('login_nick', section, default='') + nick = self.config.get_by_tabname('nickname', section, default='') or self.core.own_nick if login_command and login_nick: - dest = '{}{}/{}'.format(login_nick, server_suffix, nick) + def login(gw, sect, log_nick, log_cmd, room_suff): + dest = '{}!{}'.format(log_nick, room_suff) + self.core.xmpp.send_message(mto=dest, mbody=log_cmd, mtype='chat') + delayed = self.api.create_delayed_event(5, self.join, gw, sect) + self.api.add_timed_event(delayed) + if not already_opened: + self.core.command_join(room_suffix + '/' + nick) + delayed = self.api.create_delayed_event(5, login, gateway, section, + login_nick, login_command, + room_suffix[1:]) + self.api.add_timed_event(delayed) + else: + login(gateway, section, login_nick, login_command, room_suffix[1:]) + elif not already_opened: + self.join(gateway, section) + + @command_args_parser.quoted(0, -1) + def command_irc_login(self, args): + """ + /irc_login [server] [server]… + """ + gateway = self.config.get('gateway', 'irc.poez.io') + if args: + not_present = [] + sections = self.config.sections() + for section in args: + if section not in sections: + not_present.append(section) + continue + login_command = self.config.get_by_tabname('login_command', section, default='') + login_nick = self.config.get_by_tabname('login_nick', section, default='') + if not login_command and not login_nick: + not_present.append(section) + continue + + room_suffix = '%{}@{}'.format(section, gateway) + dest = '{}!{}'.format(login_nick, room_suffix[1:]) + self.core.xmpp.send_message(mto=dest, mbody=login_command, mtype='chat') + if len(not_present) == 1: + self.api.information('Section %s does not exist or is not configured' % not_present[0], 'Warning') + elif len(not_present) > 1: + self.api.information('Sections %s do not exist or are not configured' % ', '.join(not_present), 'Warning') + else: + sections = self.config.sections() + + for section in (s for s in sections if s != 'irc'): + login_command = self.config.get_by_tabname('login_command', section, default='') + login_nick = self.config.get_by_tabname('login_nick', section, default='') + if not login_nick and not login_command: + continue + + room_suffix = '%{}@{}'.format(section, gateway) + dest = '{}!{}'.format(login_nick, room_suffix[1:]) self.core.xmpp.send_message(mto=dest, mbody=login_command, mtype='chat') - if not already_opened: - delayed = self.api.create_delayed_event(5, join, section) - self.api.add_timed_event(delayed) + + def completion_irc_login(self, the_input): + """ + completion for /irc_login + """ + args = the_input.text.split() + if '' in args: + args.remove('') + pos = the_input.get_argument_position() + sections = self.config.sections() + if 'irc' in sections: + sections.remove('irc') + for section in args: + try: + sections.remove(section) + except: + pass + return the_input.new_completion(sections, pos) + + @command_args_parser.quoted(1, 1) + def command_irc_join(self, args): + """ + /irc_join <room or server> + """ + if not args: + return self.core.command_help('irc_join') + sections = self.config.sections() + if 'irc' in sections: + sections.remove('irc') + if args[0] in sections and self.config.get_by_tabname('rooms', args[0]): + self.join_server_rooms(args[0]) + else: + self.join_room(args[0]) + + @command_args_parser.quoted(1, 1) + def command_irc_query(self, args): + """ + Open a private conversation with the given nickname, on the current IRC + server. + """ + if args is None: + return self.core.command_help('irc_query') + current_tab_info = self.get_current_tab_irc_info() + if not current_tab_info: + return + server, gateway = current_tab_info + nickname = args[0] + message = None + if len(args) == 2: + message = args[1] + jid = '{}!{}@{}'.format(nickname, server, gateway) + if message: + self.core.command_message('{} "{}"'.format(jid, message)) + else: + self.core.command_message('{}'.format(jid)) + + def join_server_rooms(self, section): + """ + Join all the rooms configured for a section + (section = irc server) + """ + gateway = self.config.get('gateway', 'irc.poez.io') + rooms = self.config.get_by_tabname('rooms', section).split(':') + nick = self.config.get_by_tabname('nickname', section) + if nick: + nick = '/' + nick + else: + nick = '' + suffix = '%{}@{}{}'.format(section, gateway, nick) + + for room in rooms: + self.core.command_join(room + suffix) + + def join_room(self, name): + """ + Join a room with only its name and the current tab + """ + current_tab_info = self.get_current_tab_irc_info() + if not current_tab_info: + return + server, gateway = current_tab_info + + room = '{}%{}@{}'.format(name, server, gateway) + if self.config.get_by_tabname('nickname', server): + room += '/' + self.config.get_by_tabname('nickname', server) + + self.core.command_join(room) + + def get_current_tab_irc_info(self): + """ + Return a tuple with the irc server and the gateway hostnames of the + current tab. If the current tab is not an IRC channel or private + conversation, a warning is displayed and None is returned + """ + gateway = self.config.get('gateway', 'irc.poez.io') + current = self.core.current_tab() + current_jid = common.safeJID(current.name) + if not current_jid.server == gateway: + self.api.information('The current tab does not appear to be an IRC one', 'Warning') + return None + if isinstance(current, tabs.OneToOneTab): + if not '!' in current_jid.node: + server = current_jid.node + else: + ignored, server = current_jid.node.rsplit('!', 1) + elif isinstance(current, tabs.MucTab): + if not '%' in current_jid.node: + server = current_jid.node + else: + ignored, server = current_jid.node.rsplit('%', 1) + else: + self.api.information('The current tab does not appear to be an IRC one', 'Warning') + return None + return server, gateway + + def completion_irc_join(self, the_input): + """ + completion for /irc_join + """ + sections = self.config.sections() + if 'irc' in sections: + sections.remove('irc') + return the_input.new_completion(sections, 1) + diff --git a/plugins/otr.py b/plugins/otr.py index 44fdb323..cceadb99 100644 --- a/plugins/otr.py +++ b/plugins/otr.py @@ -71,36 +71,28 @@ Command added to Conversation Tabs and Private Tabs: *NOT* with multiple rewrites in a secure manner, you should do that yourself if you want to be sure. + /otrsmp + **Usage:** ``/otrsmp <ask|answer|abort> [question] [secret]`` -To use OTR, make sure the plugin is loaded (if not, then do ``/load otr``). + Verify the identify of your contact by using a pre-defined secret. -A simple workflow looks like this: + - The ``abort`` command aborts an ongoing verification + - The ``ask`` command start a verification, with a question or not + - The ``answer`` command sends back the answer and finishes the verification -.. code-block:: none +Managing trust +-------------- - /otr start +An OTR conversation can be started with a simple ``/otr start`` and the +conversation will be encrypted. However it is very often useful to check +that your are talking to the right person. -The status of the OTR encryption should appear in the bar between the chat and -the input as ``OTR: encrypted``. +To this end, two actions are available, and a message explaining both +will be prompted each time an **untrusted** conversation is started: -Then you use ``fpr``/``ourfpr`` to check the fingerprints, and confirm your respective -identities out-of-band. - -You can then use - -.. code-block:: none - - /otr trust - -To set the key as trusted, which will be shown when you start or refresh a conversation -(the trust status will be in a bold font and if the key is untrusted, the remote fingerprint -will be shown). - -Once you’re done, end the OTR session with - -.. code-block:: none - - /otr end +- Checking the knowledge of a shared secret through the use of :term:`/otrsmp` +- Exchanging fingerprints (``/otr fpr`` and ``/otr ourfpr``) out of band (in a secure channel) to check that both match, + then use ``/otr trust`` to add then to the list of trusted fingerprints for this JID. Files ----- @@ -128,20 +120,31 @@ Configuration Decode embedded XHTML. - keys_dir - **Default:** ``$XDG_DATA_HOME/poezio/otr`` + decode_entities + **Default:** ``true`` - The directory in which you want keys and fpr to be stored. + Decode XML and HTML entities (like ``&``) even when the + document isn't valid (if it is valid, it will be decoded even + without this option). - allow_v2 + decode_newlines **Default:** ``true`` - Allow OTRv2 + Decode ``<br/>`` and ``<br>`` tags even when the document + isn't valid (if it is valid, it will be decoded even + without this option for ``<br/>``, and ``<br>`` will make + the document invalid anyway). - allow_v1 + keys_dir + **Default:** ``$XDG_DATA_HOME/poezio/otr`` + + The directory in which you want keys and fpr to be stored. + + require_encryption **Default:** ``false`` - Allow OTRv1 + If ``true``, prevents you from sending unencrypted messages, and tries + to establish OTR sessions when receiving unencrypted messages. timeout **Default:** ``3`` @@ -151,11 +154,11 @@ Configuration value will disable this notification. log - **Default:** false + **Default:** ``false`` Log conversations (OTR start/end marker, and messages). -The :term:`allow_v1`, :term:`allow_v2`, :term:`decode_xhtml` +The :term:`require_encryption`, :term:`decode_xhtml`, :term:`decode_entities` and :term:`log` configuration parameters are tab-specific. Important details @@ -177,34 +180,134 @@ import logging log = logging.getLogger(__name__) import os +import html import curses from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\ STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt +import common import xhtml from common import safeJID from config import config from plugin import BasePlugin from tabs import ConversationTab, DynamicConversationTab, PrivateTab from theming import get_theme, dump_tuple +from decorators import command_args_parser OTR_DIR = os.path.join(os.getenv('XDG_DATA_HOME') or - '~/.local/share', 'poezio', 'otr') + '~/.local/share', 'poezio', 'otr') POLICY_FLAGS = { - 'ALLOW_V1':False, - 'ALLOW_V2':True, - 'REQUIRE_ENCRYPTION': False, - 'SEND_TAG': True, - 'WHITESPACE_START_AKE': True, - 'ERROR_START_AKE': True + 'ALLOW_V1':False, + 'ALLOW_V2':True, + 'REQUIRE_ENCRYPTION': False, + 'SEND_TAG': True, + 'WHITESPACE_START_AKE': True, + 'ERROR_START_AKE': True } log = logging.getLogger(__name__) +OTR_TUTORIAL = _( +"""%(info)sThis contact has not yet been verified. +You have several methods of authentication available: + +1) Verify each other's fingerprints using a secure (and different) channel: +Your fingerprint: %(normal)s%(our_fpr)s%(info)s +%(jid_c)s%(jid)s%(info)s's fingerprint: %(normal)s%(remote_fpr)s%(info)s +Then use the command: /otr trust + +2) SMP pre-shared secret you both know: +/otrsmp ask <secret> + +3) SMP pre-shared secret you both know with a question: +/otrsmp ask <question> <secret> +""") + +OTR_NOT_ENABLED = _('%(jid_c)s%(jid)s%(info)s did not enable ' + 'OTR after %(secs)s seconds.') + +MESSAGE_NOT_SENT = _('%(info)sYour message to %(jid_c)s%(jid)s%(info)s was' + ' not sent because your configuration requires an ' + 'encrypted session.\nWait until it is established or ' + 'change your configuration.') + +OTR_REQUEST = _('%(info)sOTR request to %(jid_c)s%(jid)s%(info)s sent.') + +OTR_OWN_FPR = _('%(info)sYour OTR key fingerprint is ' + '%(normal)s%(fpr)s%(info)s.') + +OTR_REMOTE_FPR = _('%(info)sThe key fingerprint for %(jid_c)s' + '%(jid)s%(info)s is %(normal)s%(fpr)s%(info)s.') + +OTR_NO_FPR = _('%(jid_c)s%(jid)s%(info)s has no' + ' key currently in use.') + +OTR_START_TRUSTED = _('%(info)sStarted a \x19btrusted\x19o%(info)s ' + 'OTR conversation with %(jid_c)s%(jid)s') + +OTR_REFRESH_TRUSTED = _('%(info)sRefreshed \x19btrusted\x19o%(info)s' + ' OTR conversation with %(jid_c)s%(jid)s') + +OTR_START_UNTRUSTED = _('%(info)sStarted an \x19buntrusted\x19o%(info)s' + ' OTR conversation with %(jid_c)s%(jid)s') + +OTR_REFRESH_UNTRUSTED = _('%(info)sRefreshed \x19buntrusted\x19o%(info)s' + ' OTR conversation with %(jid_c)s%(jid)s') + +OTR_END = _('%(info)sEnded OTR conversation with %(jid_c)s%(jid)s') + +SMP_REQUESTED = _('%(jid_c)s%(jid)s%(info)s has requested SMP verification' + '%(q)s%(info)s.\nAnswer with: /otrsmp answer <secret>') + +SMP_INITIATED = _('%(info)sInitiated SMP request with ' + '%(jid_c)s%(jid)s%(info)s.') + +SMP_PROGRESS = _('%(info)sSMP progressing.') + +SMP_RECIPROCATE = _('%(info)sYou may want to authenticate your peer by asking' + ' your own question: /otrsmp ask [question] <secret>') + +SMP_SUCCESS = _('%(info)sSMP Verification \x19bsucceeded\x19o%(info)s.') + +SMP_FAIL = _('%(info)sSMP Verification \x19bfailed\x19o%(info)s.') + +SMP_ABORTED_PEER = _('%(info)sSMP aborted by peer.') + +SMP_ABORTED = _('%(info)sSMP aborted.') + +MESSAGE_UNENCRYPTED = _('%(info)sThe following message from %(jid_c)s%(jid)s' + '%(info)s was \x19bnot\x19o%(info)s encrypted:\x19o\n' + '%(msg)s') + +MESSAGE_UNREADABLE = _('%(info)sAn encrypted message from %(jid_c)s%(jid)s' + '%(info)s was received but is unreadable, as you are' + ' not currently communicating privately.') + +MESSAGE_INVALID = _('%(info)sThe message from %(jid_c)s%(jid)s%(info)s' + ' could not be decrypted.') + +OTR_ERROR = _('%(info)sReceived the following error from ' + '%(jid_c)s%(jid)s%(info)s:\x19o %(err)s') + +POTR_ERROR = _('%(info)sAn unspecified error in the OTR plugin occured:\n' + '%(exc)s') + +TRUST_ADDED = _('%(info)sYou added %(jid_c)s%(bare_jid)s%(info)s with key ' + '\x19o%(key)s%(info)s to your trusted list.') + + +TRUST_REMOVED = _('%(info)sYou removed %(jid_c)s%(bare_jid)s%(info)s with ' + 'key \x19o%(key)s%(info)s from your trusted list.') + +KEY_DROPPED = _('%(info)sPrivate key dropped.') + def hl(tab): + """ + Make a tab beep and change its status. + """ if tab.state != 'current': tab.state = 'private' @@ -214,12 +317,20 @@ def hl(tab): curses.beep() class PoezioContext(Context): + """ + OTR context, specific to a conversation with a contact + + Overrides methods from potr.context.Context + """ def __init__(self, account, peer, xmpp, core): super(PoezioContext, self).__init__(account, peer) self.xmpp = xmpp self.core = core self.flags = {} self.trustName = safeJID(peer).bare + self.in_smp = False + self.smp_own = False + self.log = 0 def getPolicy(self, key): if key in self.flags: @@ -227,6 +338,10 @@ class PoezioContext(Context): else: return False + def reset_smp(self): + self.in_smp = False + self.smp_own = False + def inject(self, msg, appdata=None): message = self.xmpp.make_message(mto=self.peer, mbody=msg.decode('ascii'), @@ -235,8 +350,13 @@ class PoezioContext(Context): message.send() def setState(self, newstate): - color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) - color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + format_dict = { + 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), + 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT), + 'jid': self.peer, + 'bare_jid': safeJID(self.peer).bare + } tab = self.core.get_tab_by_name(self.peer) if not tab: @@ -245,55 +365,29 @@ class PoezioContext(Context): if tab and not tab.locked_resource == safeJID(self.peer).resource: tab = None if self.state == STATE_ENCRYPTED: - if newstate == STATE_ENCRYPTED: + if newstate == STATE_ENCRYPTED and tab: log.debug('OTR conversation with %s refreshed', self.peer) - if tab: - if self.getCurrentTrust(): - msg = _('%(info)sRefreshed \x19btrusted\x19o%(info)s' - ' OTR conversation with %(jid_c)s%(jid)s') % { - 'info': color_info, - 'jid_c': color_jid, - 'jid': self.peer - } - tab.add_message(msg, typ=self.log) - else: - msg = _('%(info)sRefreshed \x19buntrusted\x19o%(info)s' - ' OTR conversation with %(jid_c)s%(jid)s' - '%(info)s, key: \x19o%(key)s') % { - 'jid': self.peer, - 'key': self.getCurrentKey(), - 'info': color_info, - 'jid_c': color_jid} - - tab.add_message(msg, typ=self.log) - hl(tab) + if self.getCurrentTrust(): + msg = OTR_REFRESH_TRUSTED % format_dict + tab.add_message(msg, typ=self.log) + else: + msg = OTR_REFRESH_UNTRUSTED % format_dict + tab.add_message(msg, typ=self.log) + hl(tab) elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT: log.debug('OTR conversation with %s finished', self.peer) if tab: - tab.add_message('%sEnded OTR conversation with %s%s' % ( - color_info, color_jid, self.peer), - typ=self.log) - hl(tab) - else: - if newstate == STATE_ENCRYPTED: - if tab: - if self.getCurrentTrust(): - msg = _('%(info)sStarted a \x19btrusted\x19o%(info)s ' - 'OTR conversation with %(jid_c)s%(jid)s') % { - 'jid': self.peer, - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(msg, typ=self.log) - else: - msg = _('%(info)sStarted an \x19buntrusted\x19o%(info)s' - ' OTR conversation with %(jid_c)s%(jid)s' - '%(info)s, key: \x19o%(key)s') % { - 'jid': self.peer, - 'key': self.getCurrentKey(), - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(msg, typ=self.log) + tab.add_message(OTR_END % format_dict, typ=self.log) hl(tab) + elif newstate == STATE_ENCRYPTED and tab: + if self.getCurrentTrust(): + tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log) + else: + format_dict['our_fpr'] = self.user.getPrivkey() + format_dict['remote_fpr'] = self.getCurrentKey() + tab.add_message(OTR_TUTORIAL % format_dict, typ=0) + tab.add_message(OTR_START_UNTRUSTED % format_dict, typ=self.log) + hl(tab) log.debug('Set encryption state of %s to %s', self.peer, states[newstate]) super(PoezioContext, self).setState(newstate) @@ -302,9 +396,14 @@ class PoezioContext(Context): self.core.doupdate() class PoezioAccount(Account): + """ + OTR Account, keeps track of a specific account (ours) + + Redefines the load/save methods from potr.context.Account + """ def __init__(self, jid, key_dir): - super(PoezioAccount, self).__init__(jid, 'xmpp', 1024) + super(PoezioAccount, self).__init__(jid, 'xmpp', 0) self.key_dir = os.path.join(key_dir, jid) def load_privkey(self): @@ -348,8 +447,7 @@ class PoezioAccount(Account): with open(self.key_dir + '.fpr', 'w') as fpr_fd: for uid, trusts in self.trusts.items(): for fpr, trustVal in trusts.items(): - fpr_fd.write('\t'.join( - (uid, self.name, 'xmpp', fpr, trustVal))) + fpr_fd.write('\t'.join((uid, self.name, 'xmpp', fpr, trustVal))) fpr_fd.write('\n') except: log.exception('Error in save_trusts', exc_info=True) @@ -360,32 +458,29 @@ class PoezioAccount(Account): savePrivkey = save_privkey states = { - STATE_PLAINTEXT: 'plaintext', - STATE_ENCRYPTED: 'encrypted', - STATE_FINISHED: 'finished', + STATE_PLAINTEXT: 'plaintext', + STATE_ENCRYPTED: 'encrypted', + STATE_FINISHED: 'finished', } class Plugin(BasePlugin): def init(self): # set the default values from the config - allow_v2 = self.config.get('allow_v2', True) - POLICY_FLAGS['ALLOW_V2'] = allow_v2 - allow_v1 = self.config.get('allow_v1', False) - POLICY_FLAGS['ALLOW_v1'] = allow_v1 - global OTR_DIR OTR_DIR = os.path.expanduser(self.config.get('keys_dir', '') or OTR_DIR) try: os.makedirs(OTR_DIR) except OSError as e: if e.errno != 17: - self.api.information('The OTR-specific folder could not be created' - ' poezio will be unable to save keys and trusts', 'OTR') + self.api.information('The OTR-specific folder could not ' + 'be created. Poezio will be unable ' + 'to save keys and trusts', 'OTR') except: - self.api.information('The OTR-specific folder could not be created' - ' poezio will be unable to save keys and trusts', 'OTR') + self.api.information('The OTR-specific folder could not ' + 'be created. Poezio will be unable ' + 'to save keys and trusts', 'OTR') self.api.add_event_handler('conversation_msg', self.on_conversation_msg) self.api.add_event_handler('private_msg', self.on_conversation_msg) @@ -398,7 +493,7 @@ class Plugin(BasePlugin): self.account = PoezioAccount(self.core.xmpp.boundjid.bare, OTR_DIR) self.account.load_trusts() self.contexts = {} - usage = '[start|refresh|end|fpr|ourfpr|drop|trust|untrust]' + usage = '<start|refresh|end|fpr|ourfpr|drop|trust|untrust>' shortdesc = 'Manage an OTR conversation' desc = ('Manage an OTR conversation.\n' 'start/refresh: Start or refresh a conversation\n' @@ -408,16 +503,26 @@ class Plugin(BasePlugin): 'drop: Remove the current key (FOREVER)\n' 'trust: Set this key for this contact as trusted\n' 'untrust: Remove the trust for the key of this contact\n') + smp_usage = '<abort|ask|answer> [question] [answer]' + smp_short = 'Identify a contact' + smp_desc = ('Verify the identify of your contact by using a pre-defined secret.\n' + 'abort: Abort an ongoing verification\n' + 'ask: Start a verification, with a question or not\n' + 'answer: Finish a verification\n') + + self.api.add_tab_command(ConversationTab, 'otrsmp', self.command_smp, + help=smp_desc, usage=smp_usage, short=smp_short, + completion=self.completion_smp) + self.api.add_tab_command(PrivateTab, 'otrsmp', self.command_smp, + help=smp_desc, usage=smp_usage, short=smp_short, + completion=self.completion_smp) + self.api.add_tab_command(ConversationTab, 'otr', self.command_otr, - help=desc, - usage=usage, - short=shortdesc, - completion=self.completion_otr) + help=desc, usage=usage, short=shortdesc, + completion=self.completion_otr) self.api.add_tab_command(PrivateTab, 'otr', self.command_otr, - help=desc, - usage=usage, - short=shortdesc, - completion=self.completion_otr) + help=desc, usage=usage, short=shortdesc, + completion=self.completion_otr) def cleanup(self): for context in self.contexts.values(): @@ -427,109 +532,159 @@ class Plugin(BasePlugin): PrivateTab.remove_information_element('otr') def get_context(self, jid): + """ + Retrieve or create an OTR context + """ jid = safeJID(jid).full if not jid in self.contexts: flags = POLICY_FLAGS.copy() - policy = self.config.get_by_tabname('encryption_policy', jid, default='ondemand').lower() + require = self.config.get_by_tabname('require_encryption', + jid, default=False) + flags['REQUIRE_ENCRYPTION'] = require logging_policy = self.config.get_by_tabname('log', jid, default='false').lower() - allow_v2 = self.config.get_by_tabname('allow_v2', jid, default='true').lower() - flags['ALLOW_V2'] = (allow_v2 != 'false') - allow_v1 = self.config.get_by_tabname('allow_v1', jid, default='false').lower() - flags['ALLOW_V1'] = (allow_v1 == 'true') self.contexts[jid] = PoezioContext(self.account, jid, self.core.xmpp, self.core) self.contexts[jid].log = 1 if logging_policy != 'false' else 0 self.contexts[jid].flags = flags return self.contexts[jid] def on_conversation_msg(self, msg, tab): - color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) - color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + """ + Message received + """ + format_dict = { + 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), + 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'jid': msg['from'] + } try: ctx = self.get_context(msg['from']) txt, tlvs = ctx.receiveMessage(msg["body"].encode('utf-8')) + + # SMP + if tlvs: + self.handle_tlvs(tlvs, ctx, tab, format_dict) except UnencryptedMessage as err: # received an unencrypted message inside an OTR session - text = _('%(info)sThe following message from %(jid_c)s%(jid)s' - '%(info)s was \x19bnot\x19o%(info)s encrypted:' - '\x19o\n%(msg)s') % { - 'info': color_info, - 'jid_c': color_jid, - 'jid': msg['from'], - 'msg': err.args[0].decode('utf-8')} - tab.add_message(text, jid=msg['from'], - typ=0) - del msg['body'] - del msg['html'] - hl(tab) - self.core.refresh_window() + self.unencrypted_message_received(err, ctx, msg, tab, format_dict) + self.otr_start(tab, tab.name, format_dict) + return + except NotOTRMessage as err: + # ignore non-otr messages + # if we expected an OTR message, we would have + # got an UnencryptedMesssage + # but do an additional check because of a bug with potr and py3k + if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'): + self.unencrypted_message_received(err, ctx, msg, tab, format_dict) + self.otr_start(tab, tab.name, format_dict) return except ErrorReceived as err: # Received an OTR error - text = _('%(info)sReceived the following error from ' - '%(jid_c)s%(jid)s%(info)s:\x19o %(err)s') % { - 'jid': msg['from'], - 'err': err.args[0], - 'info': color_info, - 'jid_c': color_jid} - - tab.add_message(text, typ=0) + format_dict['err'] = err.args[0].error.decode('utf-8', errors='replace') + tab.add_message(OTR_ERROR % format_dict, typ=0) del msg['body'] del msg['html'] hl(tab) self.core.refresh_window() return - except NotOTRMessage as err: - # ignore non-otr messages - # if we expected an OTR message, we would have - # got an UnencryptedMesssage - # but do an additional check because of a bug with py3k - if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'): - - text = _('%(info)sThe following message from ' - '%(jid_c)s%(jid)s%(info)s was \x19b' - 'not\x19o%(info)s encrypted:\x19o\n%(msg)s') % { - 'jid': msg['from'], - 'msg': err.args[0].decode('utf-8'), - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(text, jid=msg['from'], - typ=ctx.log) - del msg['body'] - del msg['html'] - hl(tab) - self.core.refresh_window() - return - return except NotEncryptedError as err: - text = _('%(info)sAn encrypted message from %(jid_c)s%(jid)s' - '%(info)s was received but is unreadable, as you are' - ' not currently communicating privately.') % { - 'info': color_info, - 'jid_c': color_jid, - 'jid': msg['from']} - tab.add_message(text, jid=msg['from'], - typ=0) + # Encrypted message received, but unreadable as we do not have + # an OTR session in place. + text = MESSAGE_UNREADABLE % format_dict + tab.add_message(text, jid=msg['from'], typ=0) hl(tab) del msg['body'] del msg['html'] self.core.refresh_window() return except crypt.InvalidParameterError: - tab.add_message('%sThe message from %s%s%s could not be decrypted.' - % (color_info, color_jid, msg['from'], color_info), - jid=msg['from'], typ=0) + # Malformed OTR payload and stuff + text = MESSAGE_INVALID % format_dict + tab.add_message(text, jid=msg['from'], typ=0) hl(tab) del msg['body'] del msg['html'] self.core.refresh_window() return - except: - tab.add_message('%sAn unspecified error in the OTR plugin occured' - % color_info, - typ=0) + except Exception: + # Unexpected error + import traceback + exc = traceback.format_exc() + format_dict['exc'] = exc + tab.add_message(POTR_ERROR % format_dict, typ=0) log.error('Unspecified error in the OTR plugin', exc_info=True) return + # No error, proceed with the message + self.encrypted_message_received(msg, ctx, tab, txt) + def handle_tlvs(self, tlvs, ctx, tab, format_dict): + """ + If the message had a TLV, it means we received part of an SMP + exchange. + """ + smp1q = get_tlv(tlvs, potr.proto.SMP1QTLV) + smp1 = get_tlv(tlvs, potr.proto.SMP1TLV) + smp2 = get_tlv(tlvs, potr.proto.SMP2TLV) + smp3 = get_tlv(tlvs, potr.proto.SMP3TLV) + smp4 = get_tlv(tlvs, potr.proto.SMP4TLV) + abort = get_tlv(tlvs, potr.proto.SMPABORTTLV) + if abort: + ctx.reset_smp() + tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0) + elif ctx.in_smp and not ctx.smpIsValid(): + ctx.reset_smp() + tab.add_message(SMP_ABORTED % format_dict, typ=0) + elif smp1 or smp1q: + # Received an SMP request (with a question or not) + if smp1q: + try: + question = ' with question: \x19o' + smp1q.msg.decode('utf-8') + except UnicodeDecodeError: + self.api.information('The peer sent a question but it had a wrong encoding', 'Error') + question = '' + else: + question = '' + ctx.in_smp = True + # we did not initiate it + ctx.smp_own = False + format_dict['q'] = question + tab.add_message(SMP_REQUESTED % format_dict, typ=0) + elif smp2: + # SMP reply received + if not ctx.in_smp: + ctx.reset_smp() + else: + tab.add_message(SMP_PROGRESS % format_dict, typ=0) + elif smp3 or smp4: + # Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received + # in both cases it is the final message of the SMP exchange + if ctx.smpIsSuccess(): + tab.add_message(SMP_SUCCESS % format_dict, typ=0) + if not ctx.getCurrentTrust(): + tab.add_message(SMP_RECIPROCATE % format_dict, typ=0) + else: + tab.add_message(SMP_FAIL % format_dict, typ=0) + ctx.reset_smp() + hl(tab) + self.core.refresh_window() + + def unencrypted_message_received(self, err, ctx, msg, tab, format_dict): + """ + An unencrypted message was received while we expected it to be + encrypted. Display it with a warning. + """ + format_dict['msg'] = err.args[0].decode('utf-8') + text = MESSAGE_UNENCRYPTED % format_dict + tab.add_message(text, jid=msg['from'], typ=ctx.log) + del msg['body'] + del msg['html'] + hl(tab) + self.core.refresh_window() + + def encrypted_message_received(self, msg, ctx, tab, txt): + """ + A properly encrypted message was received, so we add it to the + buffer, and try to format it according to the configuration. + """ # remove xhtml del msg['html'] del msg['body'] @@ -544,11 +699,25 @@ class Plugin(BasePlugin): nick_color = get_theme().COLOR_REMOTE_USER body = txt.decode() + decode_entities = self.config.get_by_tabname('decode_entities', + msg['from'].bare, + default=True) + decode_newlines = self.config.get_by_tabname('decode_newlines', + msg['from'].bare, + default=True) if self.config.get_by_tabname('decode_xhtml', msg['from'].bare, default=True): try: body = xhtml.xhtml_to_poezio_colors(body, force=True) - except: - pass + except Exception: + if decode_entities: + body = html.unescape(body) + if decode_newlines: + body = body.replace('<br/>', '\n').replace('<br>', '\n') + else: + if decode_entities: + body = html.unescape(body) + if decode_newlines: + body = body.replace('<br/>', '\n').replace('<br>', '\n') tab.add_message(body, nickname=tab.nick, jid=msg['from'], forced_user=user, typ=ctx.log, nick_color=nick_color) @@ -556,113 +725,127 @@ class Plugin(BasePlugin): self.core.refresh_window() del msg['body'] + def find_encrypted_context_with_matching(self, bare_jid): + """ + Find an OTR session from a bare JID. + + Useful when a dynamic tab unlocks, which would lead to sending + unencrypted messages until it locks again, if we didn’t fallback + with this. + """ + for ctx in self.contexts: + if safeJID(ctx).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED: + return self.contexts[ctx] + return None + def on_conversation_say(self, msg, tab): """ On message sent """ if isinstance(tab, DynamicConversationTab) and tab.locked_resource: - name = safeJID(tab.name) - name.resource = tab.locked_resource - name = name.full + jid = safeJID(tab.name) + jid.resource = tab.locked_resource + name = jid.full else: name = tab.name + jid = safeJID(tab.name) + + format_dict = { + 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), + 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'jid': name, + } + ctx = self.contexts.get(name) + if isinstance(tab, DynamicConversationTab) and not tab.locked_resource: + log.debug('Unlocked tab %s found, falling back to the first encrypted chat we find.', name) + ctx = self.find_encrypted_context_with_matching(jid.bare) + if ctx and ctx.state == STATE_ENCRYPTED: ctx.sendMessage(0, msg['body'].encode('utf-8')) if not tab.send_chat_state('active'): tab.send_chat_state('inactive', always_send=True) tab.add_message(msg['body'], - nickname=self.core.own_nick or tab.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=msg['id'], - jid=self.core.xmpp.boundjid, - typ=ctx.log) + nickname=self.core.own_nick or tab.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + typ=ctx.log) # remove everything from the message so that it doesn’t get sent del msg['body'] del msg['replace'] del msg['html'] + elif ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'): + tab.add_message(MESSAGE_NOT_SENT % format_dict, typ=0) + del msg['body'] + del msg['replace'] + del msg['html'] + self.otr_start(tab, name, format_dict) def display_encryption_status(self, jid): + """ + Returns the text to display in the infobar (the OTR status) + """ context = self.get_context(jid) + if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED: + ctx = self.find_encrypted_context_with_matching(jid) + if ctx: + context = ctx state = states[context.state] - return ' OTR: %s' % state + trust = 'trusted' if context.getCurrentTrust() else 'untrusted' + + return ' OTR: %s (%s)' % (state, trust) def command_otr(self, arg): """ /otr [start|refresh|end|fpr|ourfpr] """ - arg = arg.strip() + args = common.shell_split(arg) + if not args: + return self.core.command_help('otr') + action = args.pop(0) tab = self.api.current_tab() name = tab.name - color_jid = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) - color_info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - color_normal = '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT) if isinstance(tab, DynamicConversationTab) and tab.locked_resource: name = safeJID(tab.name) name.resource = tab.locked_resource name = name.full - if arg == 'end': # close the session + format_dict = { + 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), + 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT), + 'jid': name, + 'bare_jid': safeJID(name).bare + } + + if action == 'end': # close the session context = self.get_context(name) context.disconnect() - elif arg == 'start' or arg == 'refresh': - otr = self.get_context(name) - secs = self.config.get('timeout', 3) - def notify_otr_timeout(): - if otr.state != STATE_ENCRYPTED: - text = _('%(jid_c)s%(jid)s%(info)s did not enable' - ' OTR after %(sec)s seconds.') % { - 'jid': tab.name, - 'info': color_info, - 'jid_c': color_jid, - 'sec': secs} - tab.add_message(text, typ=0) - self.core.refresh_window() - if secs > 0: - event = self.api.create_delayed_event(secs, notify_otr_timeout) - self.api.add_timed_event(event) - self.core.xmpp.send_message(mto=name, mtype='chat', - mbody=self.contexts[name].sendMessage(0, b'?OTRv?').decode()) - text = _('%(info)sOTR request to %(jid_c)s%(jid)s%(info)s sent.') % { - 'jid': tab.name, - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(text, typ=0) - elif arg == 'ourfpr': - fpr = self.account.getPrivkey() - text = _('%(info)sYour OTR key fingerprint is %(norm)s%(fpr)s.') % { - 'jid': tab.name, - 'info': color_info, - 'norm': color_normal, - 'fpr': fpr} - tab.add_message(text, typ=0) - elif arg == 'fpr': + if isinstance(tab, DynamicConversationTab) and not tab.locked_resource: + ctx = self.find_encrypted_context_with_matching(safeJID(name).bare) + ctx.disconnect() + elif action == 'start' or action == 'refresh': + self.otr_start(tab, name, format_dict) + elif action == 'ourfpr': + format_dict['fpr'] = self.account.getPrivkey() + tab.add_message(OTR_OWN_FPR % format_dict, typ=0) + elif action == 'fpr': if name in self.contexts: ctx = self.contexts[name] if ctx.getCurrentKey() is not None: - text = _('%(info)sThe key fingerprint for %(jid_c)s' - '%(jid)s%(info)s is %(norm)s%(fpr)s%(info)s.') % { - 'jid': tab.name, - 'info': color_info, - 'norm': color_normal, - 'jid_c': color_jid, - 'fpr': ctx.getCurrentKey()} - tab.add_message(text, typ=0) + format_dict['fpr'] = ctx.getCurrentKey() + tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0) else: - text = _('%(jid_c)s%(jid)s%(info)s has no' - ' key currently in use.') % { - 'jid': tab.name, - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(text, typ=0) - elif arg == 'drop': + tab.add_message(OTR_NO_FPR % format_dict, typ=0) + elif action == 'drop': # drop the privkey (and obviously, end the current conversations before that) for context in self.contexts.values(): if context.state not in (STATE_FINISHED, STATE_PLAINTEXT): context.disconnect() self.account.drop_privkey() - tab.add_message('%sPrivate key dropped.' % color_info, typ=0) - elif arg == 'trust': + tab.add_message(KEY_DROPPED % format_dict, typ=0) + elif action == 'trust': ctx = self.get_context(name) key = ctx.getCurrentKey() if key: @@ -670,16 +853,11 @@ class Plugin(BasePlugin): else: return if not ctx.getCurrentTrust(): + format_dict['key'] = key ctx.setTrust(fpr, 'verified') self.account.saveTrusts() - text = _('%(info)sYou added %(jid_c)s%(jid)s%(info)s with key ' - '\x19o%(key)s%(info)s to your trusted list.') % { - 'jid': ctx.trustName, - 'key': key, - 'info': color_info, - 'jid_c': color_jid} - tab.add_message(text, typ=0) - elif arg == 'untrust': + tab.add_message(TRUST_ADDED % format_dict, typ=0) + elif action == 'untrust': ctx = self.get_context(name) key = ctx.getCurrentKey() if key: @@ -687,19 +865,108 @@ class Plugin(BasePlugin): else: return if ctx.getCurrentTrust(): + format_dict['key'] = key ctx.setTrust(fpr, '') self.account.saveTrusts() - text = _('%(info)sYou removed %(jid_c)s%(jid)s%(info)s with ' - 'key \x19o%(key)s%(info)s from your trusted list.') % { - 'jid': ctx.trustName, - 'key': key, - 'info': color_info, - 'jid_c': color_jid} - - tab.add_message(text, typ=0) + tab.add_message(TRUST_REMOVED % format_dict, typ=0) self.core.refresh_window() - def completion_otr(self, the_input): + def otr_start(self, tab, name, format_dict): + """ + Start an otr conversation with a contact + """ + secs = self.config.get('timeout', 3) + def notify_otr_timeout(): + tab_name = tab.name + otr = self.get_context(tab_name) + if isinstance(tab, DynamicConversationTab): + if tab.locked_resource: + tab_name = safeJID(tab.name) + tab_name.resource = tab.locked_resource + tab_name = tab_name.full + otr = self.get_context(tab_name) + if otr.state != STATE_ENCRYPTED: + format_dict['secs'] = secs + text = OTR_NOT_ENABLED % format_dict + tab.add_message(text, typ=0) + self.core.refresh_window() + if secs > 0: + event = self.api.create_delayed_event(secs, notify_otr_timeout) + self.api.add_timed_event(event) + body = self.get_context(name).sendMessage(0, b'?OTRv?').decode() + self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body) + tab.add_message(OTR_REQUEST % format_dict, typ=0) + + @staticmethod + def completion_otr(the_input): + """ + Completion for /otr + """ comp = ['start', 'fpr', 'ourfpr', 'refresh', 'end', 'trust', 'untrust'] return the_input.new_completion(comp, 1, quotify=False) + @command_args_parser.quoted(1, 2) + def command_smp(self, args): + """ + /otrsmp <ask|answer|abort> [question] [secret] + """ + if args is None or not args: + return self.core.command_help('otrsmp') + length = len(args) + action = args.pop(0) + if length == 2: + question = None + secret = args.pop(0).encode('utf-8') + elif length == 3: + question = args.pop(0).encode('utf-8') + secret = args.pop(0).encode('utf-8') + else: + question = secret = None + + tab = self.api.current_tab() + name = tab.name + if isinstance(tab, DynamicConversationTab) and tab.locked_resource: + name = safeJID(tab.name) + name.resource = tab.locked_resource + name = name.full + + format_dict = { + 'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID), + 'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'jid': name, + 'bare_jid': safeJID(name).bare + } + + ctx = self.get_context(name) + if ctx.state != STATE_ENCRYPTED: + self.api.information('The current conversation is not encrypted', + 'Error') + return + + if action == 'ask': + ctx.in_smp = True + ctx.smp_own = True + if question: + ctx.smpInit(secret, question) + else: + ctx.smpInit(secret) + tab.add_message(SMP_INITIATED % format_dict, typ=0) + elif action == 'answer': + ctx.smpGotSecret(secret) + elif action == 'abort': + if ctx.in_smp: + ctx.smpAbort() + tab.add_message(SMP_ABORTED % format_dict, typ=0) + self.core.refresh_window() + + @staticmethod + def completion_smp(the_input): + """Completion for /otrsmp""" + if the_input.get_argument_position() == 1: + return the_input.new_completion(['ask', 'answer', 'abort'], 1, quotify=False) + +def get_tlv(tlvs, cls): + """Find the instance of a class in a list""" + for tlv in tlvs: + if isinstance(tlv, cls): + return tlv diff --git a/plugins/pipe_cmd.py b/plugins/pipe_cmd.py index 762501ae..29404e0f 100644 --- a/plugins/pipe_cmd.py +++ b/plugins/pipe_cmd.py @@ -2,6 +2,20 @@ This plugins allows commands to be sent to poezio via a named pipe. +You can run the same commands that you would in the poezio input +(e.g. ``echo '/message toto@example.tld Hi' >> /tmp/poezio.fifo``). + +Configuration +------------- + +.. glossary:: + :sorted: + + pipename + **Default:** :file:`/tmp/poezio.fifo` + + The path to the fifo which will receive commands. + """ diff --git a/plugins/reorder.py b/plugins/reorder.py new file mode 100644 index 00000000..13d873e7 --- /dev/null +++ b/plugins/reorder.py @@ -0,0 +1,129 @@ +""" +``reorder`` plugin: Reorder the tabs according to a layout + +Commands +-------- + +.. glossary:: + + /reorder + **Usage:** ``/reorder`` + + Reorder the tabs according to the configuration. + + +Configuration +------------- + +The configuration file must contain a section ``[reorder]`` and each option +must be formatted like ``[tab number] = [tab type]:[tab name]``. + +For example: + +.. code-block:: ini + + [reorder] + 1 = muc:toto@conference.example.com + 2 = muc:example@muc.example.im + 3 = dynamic:robert@example.org + +The ``[tab number]`` must be at least ``1``; if the range is not entirely +covered, e.g.: + +.. code-block:: ini + + [reorder] + 1 = muc:toto@conference.example.com + 3 = dynamic:robert@example.org + +Poezio will insert gaps between the tabs in order to keep the specified +numbering (so in this case, there will be a tab 1, a tab 3, but no tab 2). + + +The ``[tab type]`` must be one of: + +- ``muc`` (for multi-user chats) +- ``private`` (for chats with a specific user inside a multi-user chat) +- ``dynamic`` (for normal, dynamic conversations tabs) +- ``static`` (for conversations with a specific resource) + +And finally, the ``[tab name]`` must be: + +- For a type ``muc``, the bare JID of the room +- For a type ``private``, the full JID of the user (room JID with the username as a resource) +- For a type ``dynamic``, the bare JID of the contact +- For a type ``static``, the full JID of the contact +""" +from plugin import BasePlugin +import tabs +from decorators import command_args_parser + +mapping = { + 'muc': tabs.MucTab, + 'private': tabs.PrivateTab, + 'dynamic': tabs.DynamicConversationTab, + 'static': tabs.StaticConversationTab, + 'empty': tabs.GapTab +} + +def parse_config(config): + result = {} + for option in config.options('reorder'): + if not option.isdecimal(): + continue + pos = int(option) + if pos in result or pos <= 0: + return + + typ, name = config.get(option, default=':').split(':', maxsplit=1) + if typ not in mapping: + return + result[pos] = (mapping[typ], name) + + return result + +class Plugin(BasePlugin): + def init(self): + self.api.add_command('reorder', self.command_reorder, + help='Reorder all tabs.') + + @command_args_parser.ignored + def command_reorder(self): + """ + /reorder + """ + self.core.go_to_roster() + self.core.current_tab_nb = 0 + + tabs_spec = parse_config(self.config) + if not tabs_spec: + return self.api.information('Invalid reorder config', 'Error') + + old_tabs = self.core.tabs[1:] + roster = self.core.tabs[0] + + new_tabs = [] + last = 0 + for pos in sorted(tabs_spec): + if pos > last + 1: + new_tabs += [tabs.GapTab() for i in range(pos - last)] + cls, name = tabs_spec[pos] + tab = self.core.get_tab_by_name(name, typ=cls) + if tab and tab in old_tabs: + new_tabs.append(tab) + old_tabs.remove(tab) + else: + self.api.information('Tab %s not found' % name, 'Warning') + new_tabs.append(tabs.GapTab()) + last = pos + + for tab in old_tabs: + if tab: + new_tabs.append(tab) + + self.core.tabs.clear() + self.core.tabs.append(roster) + self.core.tabs += new_tabs + + self.core.refresh_window() + diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py index 53827c11..2b42d01a 100644 --- a/plugins/screen_detach.py +++ b/plugins/screen_detach.py @@ -1,35 +1,91 @@ """ This plugin will set your status to **away** if you detach your screen. +The default behaviour is to check for both tmux and screen (in that order). + +Configuration options +--------------------- + +.. glossary:: + + use_screen + **Default:** ``true`` + + Try to find an attached screen. + + use_tmux + **Default:** ``true`` + + Try to find and attached tmux. + """ + from plugin import BasePlugin import os import stat import pyinotify +import asyncio + +DEFAULT_CONFIG = { + 'screen_detach': { + 'use_tmux': True, + 'use_screen': True + } +} + + +# overload if this is not how your stuff +# is configured +try: + LOGIN = os.getlogin() + LOGIN_TMUX = os.getuid() +except Exception: + LOGIN = os.getenv('USER') + LOGIN_TMUX = os.getuid() + +SCREEN_DIR = '/var/run/screens/S-%s' % LOGIN +TMUX_DIR = '/tmp/tmux-%s' % LOGIN_TMUX + +def find_screen(path): + if not os.path.isdir(path): + return + for f in os.listdir(path): + path = os.path.join(path, f) + if screen_attached(path): + return path + +def screen_attached(socket): + return (os.stat(socket).st_mode & stat.S_IXUSR) != 0 + +class Plugin(BasePlugin, pyinotify.Notifier): + + default_config = DEFAULT_CONFIG -class Plugin(BasePlugin): def init(self): - screen_dir = '/var/run/screen/S-%s' % (os.getlogin(),) - self.timed_event = None sock_path = None - self.thread = None - for f in os.listdir(screen_dir): - path = os.path.join(screen_dir, f) - if screen_attached(path): - sock_path = path - self.attached = True - break + if self.config.get('use_tmux'): + sock_path = find_screen(TMUX_DIR) + if sock_path is None and self.config.get('use_screen'): + sock_path = find_screen(SCREEN_DIR) # Only actually do something if we found an attached screen (assuming only one) if sock_path: + self.attached = True wm = pyinotify.WatchManager() wm.add_watch(sock_path, pyinotify.EventsCodes.ALL_FLAGS['IN_ATTRIB']) - self.thread = pyinotify.ThreadedNotifier(wm, default_proc_fun=HandleScreen(plugin=self)) - self.thread.start() + pyinotify.Notifier.__init__(self, wm, default_proc_fun=HandleScreen(plugin=self)) + asyncio.get_event_loop().add_reader(self._fd, self.process) + else: + self.api.information('screen_detach plugin: No tmux or screen found', + 'Warning') + self.attached = False + + def process(self): + self.read_events() + self.process_events() def cleanup(self): - if self.thread: - self.thread.stop() + asyncio.get_event_loop().remove_reader(self._fd) def update_screen_state(self, socket): attached = screen_attached(socket) @@ -38,9 +94,6 @@ class Plugin(BasePlugin): status = 'available' if self.attached else 'away' self.core.command_status(status) -def screen_attached(socket): - return (os.stat(socket).st_mode & stat.S_IXUSR) != 0 - class HandleScreen(pyinotify.ProcessEvent): def my_init(self, **kwargs): self.plugin = kwargs['plugin'] diff --git a/plugins/simple_notify.py b/plugins/simple_notify.py index f08fa259..9311efed 100644 --- a/plugins/simple_notify.py +++ b/plugins/simple_notify.py @@ -21,7 +21,7 @@ Second example: .. code-block:: ini [simple_notify] - command = echo \\<%{from}s\\> %{body}s >> some.fifo + command = echo \\<%(from)s\\> %(body)s >> some.fifo delay = 3 after_command = echo >> some.fifo diff --git a/plugins/uptime.py b/plugins/uptime.py index dbeb6a63..a36274e6 100644 --- a/plugins/uptime.py +++ b/plugins/uptime.py @@ -31,6 +31,6 @@ class Plugin(BasePlugin): jid = safeJID(arg) if not jid.server: return - iq = self.core.xmpp.makeIqGet(ito=jid.server) + iq = self.core.xmpp.make_iq_get(ito=jid.server) iq.append(ET.Element('{jabber:iq:last}query')) iq.send(callback=callback) diff --git a/requirements.txt b/requirements.txt index 6bd62d71..a8d93c94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -setuptools -e git+git://git.louiz.org/slixmpp#egg=slixmpp aiodns +pygments @@ -3,73 +3,111 @@ try: from setuptools import setup, Extension except ImportError: - print('Setuptools was not found.\n' - 'This script will use distutils instead, which will NOT' - ' be able to install a `poezio` executable.\nIf you are ' - 'using it to build a package or install poezio, please ' - 'install setuptools.\n\nYou will also see a few warnings.\n') - from distutils.core import setup, Extension + print('\nSetuptools was not found. Install setuptools for python 3.\n') + import sys + sys.exit(1) -import os +from os.path import basename, dirname, exists, join +from os import link, walk, unlink + +current_dir = dirname(__file__) + +def get_relative_dir(folder, stopper): + """ + Find the path from a directory to a pseudo-root in order to recreate + the filetree. + """ + acc = [] + last = basename(folder) + while last != stopper: + acc.append(last) + folder = dirname(folder) + last = basename(folder) + return join(*acc[::-1]) if acc else '' + +def find_doc(before, path): + _files = [] + stop = basename(path) + for root, dirs, files in walk(join(current_dir, 'doc', path)): + files_path = [] + relative_root = get_relative_dir(root, stop) + for name in files: + files_path.append(join(root, name)) + _files.append((join(before, relative_root), files_path)) + return _files module_poopt = Extension('poezio.poopt', extra_compile_args=['-Wno-declaration-after-statement'], - sources = ['src/pooptmodule.c']) + sources=['src/pooptmodule.c']) +# Create a link to the config file (for packaging purposes) +if not exists(join(current_dir, 'src', 'default_config.cfg')): + link(join(current_dir, 'data', 'default_config.cfg'), + join(current_dir, 'src', 'default_config.cfg')) -current_dir = os.path.dirname(__file__) +# identify the git version +git_dir = join(current_dir, '.git') +if exists(git_dir): + try: + import subprocess + result = subprocess.Popen(['git', '--git-dir', git_dir, 'describe'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + result.wait() + data = result.stdout.read().decode('utf-8', errors='ignore') + version = '.dev' + data.split('-')[1] + except: + version = '.dev1' +else: + version = '.dev1' -# Create a link to the config file (for packaging purposes) -if not os.path.exists(os.path.join(current_dir, 'src', 'default_config.cfg')): - os.link(os.path.join(current_dir, 'data', 'default_config.cfg'), - os.path.join(current_dir, 'src', 'default_config.cfg')) +with open('README.rst', encoding='utf-8') as readme_fd: + LONG_DESCRIPTION = readme_fd.read() setup(name="poezio", - version="0.8.3-dev", - description="A console XMPP client", - long_description= - "Poezio is a Free chat client aiming to reproduce the ease of use of most " - "IRC clients (e.g. weechat, irssi) while using the XMPP network." - "\n" - "Documentation is available at http://doc.poez.io/0.8.", - - - ext_modules = [module_poopt], - url = 'http://poez.io/', - license = 'zlib', - download_url = 'https://dev.louiz.org/projects/poezio/files', - - author = 'Florent Le Coz', - author_email = 'louiz@louiz.org', - - maintainer = 'Mathieu Pasquet', - maintainer_email = 'mathieui@mathieui.net', - - classifiers = ['Development Status :: 2 - Pre-Alpha', - 'Topic :: Communications :: Chat', - 'Environment :: Console :: Curses', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: zlib/libpng License', - 'Natural Language :: English', - 'Operating System :: Unix', - 'Programming Language :: Python :: 3' - ], - keywords = ['jabber', 'xmpp', 'client', 'chat', 'im', 'console'], - packages = ['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows', - 'poezio_plugins', 'poezio_plugins.gpg', 'poezio_themes'], - package_dir = {'poezio': 'src', 'poezio_plugins': 'plugins', 'poezio_themes': 'data/themes'}, - package_data = {'poezio': ['default_config.cfg']}, - scripts = ['scripts/poezio_gpg_export'], - entry_points={ 'console_scripts': [ 'poezio = poezio:main' ] }, - data_files = [('share/man/man1/', ['data/poezio.1'])], - - install_requires = ['slixmpp', - 'aiodns'], - extras_require = {'OTR plugin': 'python-potr>=1.0', - 'Screen autoaway plugin': 'pyinotify==0.9.4'} -) + version="0.9" + version, + description="A console XMPP client", + long_description=LONG_DESCRIPTION, + ext_modules=[module_poopt], + url='http://poez.io/', + license='zlib', + download_url='https://dev.louiz.org/projects/poezio/files', + + author='Florent Le Coz', + author_email='louiz@louiz.org', + + maintainer='Mathieu Pasquet', + maintainer_email='mathieui@mathieui.net', + + classifiers=['Development Status :: 2 - Pre-Alpha', + 'Topic :: Communications :: Chat', + 'Environment :: Console :: Curses', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: zlib/libpng License', + 'Natural Language :: English', + 'Operating System :: Unix', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3 :: Only'], + keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'], + packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows', + 'poezio_plugins', 'poezio_plugins.gpg', 'poezio_themes'], + package_dir={'poezio': 'src', + 'poezio_plugins': 'plugins', + 'poezio_themes': 'data/themes'}, + package_data={'poezio': ['default_config.cfg']}, + scripts=['scripts/poezio_gpg_export'], + entry_points={'console_scripts': ['poezio = poezio:main']}, + data_files=([('share/man/man1/', ['data/poezio.1']), + ('share/poezio/', ['README.rst', 'COPYING', 'CHANGELOG'])] + + find_doc('share/doc/poezio/source', 'source') + + find_doc('share/doc/poezio/html', 'build/html')), + install_requires=['slixmpp', 'aiodns'], + extras_require={'OTR plugin': 'python-potr>=1.0', + 'Screen autoaway plugin': 'pyinotify==0.9.4'}) # Remove the link afterwards -if os.path.exists(os.path.join(current_dir, 'src', 'default_config.cfg')): - os.unlink(os.path.join(current_dir, 'src', 'default_config.cfg')) +if (exists(join(current_dir, 'src', 'default_config.cfg')) and + exists(join(current_dir, 'data', 'default_config.cfg'))): + + unlink(join(current_dir, 'src', 'default_config.cfg')) diff --git a/src/args.py b/src/args.py index 6b0108f0..8b1ebbbd 100644 --- a/src/args.py +++ b/src/args.py @@ -3,41 +3,26 @@ Module related to the argument parsing There is a fallback to the deprecated optparse if argparse is not found """ -from gettext import gettext as _ from os import path +from argparse import ArgumentParser, SUPPRESS def parse_args(CONFIG_PATH=''): """ Parse the arguments from the command line """ - try: - from argparse import ArgumentParser, SUPPRESS - except ImportError: - from optparse import OptionParser - from optparse import SUPPRESS_HELP as SUPPRESS - parser = OptionParser() - parser.add_option("-f", "--file", dest="filename", - default=path.join(CONFIG_PATH, 'poezio.cfg'), - help=_("The config file you want to use"), - metavar="CONFIG_FILE") - parser.add_option("-d", "--debug", dest="debug", - help=_("The file where debug will be written"), - metavar="DEBUG_FILE") - parser.add_option("-v", "--version", dest="version", - help=SUPPRESS, metavar="VERSION", - default="0.8.3-dev") - (options, __) = parser.parse_args() - else: - parser = ArgumentParser() - parser.add_argument("-f", "--file", dest="filename", - default=path.join(CONFIG_PATH, 'poezio.cfg'), - help=_("The config file you want to use"), - metavar="CONFIG_FILE") - parser.add_argument("-d", "--debug", dest="debug", - help=_("The file where debug will be written"), - metavar="DEBUG_FILE") - parser.add_argument("-v", "--version", dest="version", - help=SUPPRESS, metavar="VERSION", - default="0.8.3-dev") - options = parser.parse_args() + parser = ArgumentParser('poezio') + parser.add_argument("-c", "--check-config", dest="check_config", + action='store_true', + help='Check the config file') + parser.add_argument("-d", "--debug", dest="debug", + help="The file where debug will be written", + metavar="DEBUG_FILE") + parser.add_argument("-f", "--file", dest="filename", + default=path.join(CONFIG_PATH, 'poezio.cfg'), + help="The config file you want to use", + metavar="CONFIG_FILE") + parser.add_argument("-v", "--version", dest="version", + help=SUPPRESS, metavar="VERSION", + default="0.9-dev") + options = parser.parse_args() return options diff --git a/src/bookmark.py b/src/bookmark.py deleted file mode 100644 index 15a28c9d..00000000 --- a/src/bookmark.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -Bookmarks module - -Therein the bookmark class is defined, representing one conference room. -This object is used to generate elements for both local and remote -bookmark storage. It can also parse xml Elements. - -This module also defines several functions for retrieving and updating -bookmarks, both local and remote. -""" - -import functools -import logging -from sys import version_info - -from slixmpp.plugins.xep_0048 import Bookmarks, Conference -from common import safeJID -from config import config - -log = logging.getLogger(__name__) - -def xml_iter(xml, tag=''): - if version_info[1] >= 2: - return xml.iter(tag) - else: - return xml.getiterator(tag) - -preferred = config.get('use_bookmarks_method').lower() -if preferred not in ('pep', 'privatexml'): - preferred = 'privatexml' -not_preferred = 'privatexml' if preferred == 'pep' else 'pep' -methods = ('local', preferred, not_preferred) - - -class Bookmark(object): - possible_methods = methods - - def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='privatexml'): - self.jid = jid - self.name = name or jid - self.autojoin = autojoin - self.nick = nick - self.password = password - self._method = method - - @property - def method(self): - return self._method - - @method.setter - def method(self, value): - if value not in self.possible_methods: - log.debug('Could not set bookmark storing method: %s', value) - return - self._method = value - - def __repr__(self): - return '<%s%s%s>' % (self.jid, ('/'+self.nick) if self.nick else '', '|autojoin' if self.autojoin else '') - - def stanza(self): - """ - Generate a <conference/> stanza from the instance - """ - el = Conference() - el['name'] = self.name - el['jid'] = self.jid - el['autojoin'] = 'true' if self.autojoin else 'false' - if self.nick: - el['nick'] = self.nick - if self.password: - el['password'] = self.password - return el - - def local(self): - """Generate a str for local storage""" - local = self.jid - if self.nick: - local += '/%s' % self.nick - local += ':' - if self.password: - config.set_and_save('password', self.password, section=self.jid) - return local - - @staticmethod - def parse_from_element(el, method=None): - """ - Generate a Bookmark object from a <conference/> element - """ - jid = el.get('jid') - name = el.get('name') - autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False - nick = None - for n in xml_iter(el, 'nick'): - nick = n.text - password = None - for p in xml_iter(el, 'password'): - password = p.text - - return Bookmark(jid, name, autojoin, nick, password, method) - -bookmarks = [] - -def get_by_jid(value): - """ - Get a bookmark by bare jid - """ - for item in bookmarks: - if item.jid == value: - return item - -def remove(value): - """ - Remove a bookmark (with its jid or directly the Bookmark object). - """ - if isinstance(value, str): - value = get_by_jid(value) - bookmarks.remove(value) - -def stanza_storage(method): - """Generate a <storage/> stanza with the conference elements.""" - storage = Bookmarks() - for b in (b for b in bookmarks if b.method == method): - storage.append(b.stanza()) - return storage - -def save_pep(xmpp): - """Save the remote bookmarks via PEP.""" - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('pep'), - method='xep_0223') - -def save_privatexml(xmpp): - """"Save the remote bookmarks with privatexml.""" - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('privatexml'), - method='xep_0049') - -def save_remote(xmpp, callback, method=preferred): - """Save the remote bookmarks.""" - method = 'privatexml' if method != 'pep' else 'pep' - - if method is 'privatexml': - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('privatexml'), - method='xep_0049', - callback=callback) - else: - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('pep'), - method='xep_0223', - callback=callback) - -def save_local(): - """Save the local bookmarks.""" - local = ''.join(bookmark.local() for bookmark in bookmarks if bookmark.method is 'local') - config.set_and_save('rooms', local) - -def save(xmpp, core=None): - """Save all the bookmarks.""" - save_local() - def _cb(core, iq): - if iq["type"] == "error": - core.information('Could not save bookmarks.', 'Error') - elif core: - core.information('Bookmarks saved', 'Info') - if config.get('use_remote_bookmarks'): - preferred = config.get('use_bookmarks_method') - cb = functools.partial(_cb, core) - save_remote(xmpp, cb, method=preferred) - -def get_pep(xmpp, available_methods, callback): - """Add the remotely stored bookmarks via pep to the list.""" - def _cb(iq): - if iq["type"] == "error": - available_methods["pep"] = False - else: - available_methods["pep"] = True - for conf in xml_iter(iq.xml, '{storage:bookmarks}conference'): - b = Bookmark.parse_from_element(conf, method='pep') - if not get_by_jid(b.jid): - bookmarks.append(b) - if callback: - callback() - - xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb) - -def get_privatexml(xmpp, available_methods, callback): - """Add the remotely stored bookmarks via privatexml to the list. - If both is True, we want to have the result of both methods (privatexml and pep) before calling pep""" - def _cb(iq): - if iq["type"] == "error": - available_methods["privatexml"] = False - else: - available_methods["privatexml"] = True - for conf in xml_iter(iq.xml, '{storage:bookmarks}conference'): - b = Bookmark.parse_from_element(conf, method='privatexml') - if not get_by_jid(b.jid): - bookmarks.append(b) - if callback: - callback() - - xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) - -def get_remote(xmpp, callback): - """Add the remotely stored bookmarks to the list.""" - if xmpp.anon: - return - method = config.get('use_bookmarks_method') - if not method: - available_methods = {} - def _save_and_call_callback(): - # If both methods returned a result, we can now call the given callback - if callback and "privatexml" in available_methods and "pep" in available_methods: - save_bookmarks_method(available_methods) - if callback: - callback() - for method in methods[1:]: - if method == 'pep': - get_pep(xmpp, available_methods, _save_and_call_callback) - else: - get_privatexml(xmpp, available_methods, _save_and_call_callback) - else: - if method == 'pep': - get_pep(xmpp, {}, callback) - else: - get_privatexml(xmpp, {}, callback) - -def save_bookmarks_method(available_methods): - pep, privatexml = available_methods["pep"], available_methods["privatexml"] - if pep and not privatexml: - config.set_and_save('use_bookmarks_method', 'pep') - elif privatexml and not pep: - config.set_and_save('use_bookmarks_method', 'privatexml') - elif not pep and not privatexml: - config.set_and_save('use_bookmarks_method', '') - -def get_local(): - """Add the locally stored bookmarks to the list.""" - rooms = config.get('rooms') - if not rooms: - return - rooms = rooms.split(':') - for room in rooms: - jid = safeJID(room) - if jid.bare == '': - continue - if jid.resource != '': - nick = jid.resource - else: - nick = None - passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None - b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local') - if not get_by_jid(b.jid): - bookmarks.append(b) diff --git a/src/bookmarks.py b/src/bookmarks.py new file mode 100644 index 00000000..c7d26a51 --- /dev/null +++ b/src/bookmarks.py @@ -0,0 +1,289 @@ +""" +Bookmarks module + +Therein the bookmark class is defined, representing one conference room. +This object is used to generate elements for both local and remote +bookmark storage. It can also parse xml Elements. + +This module also defines several functions for retrieving and updating +bookmarks, both local and remote. + +Poezio start scenario: + +- upon inital connection, poezio will disco#info the server +- the available storage methods will be stored in the available_storage dict + (either 'pep' or 'privatexml') +- if only one is available, poezio will set the use_bookmarks_method config option + to it. If both are, it will be set to 'privatexml' (or if it was previously set, the + value will be kept). +- it will then query the preferred storages for bookmarks and cache them locally + (Bookmark objects with a method='remote' attribute) + +Adding a remote bookmark: + +- New Bookmark object added to the list with storage='remote' +- All bookmarks are sent to the storage selected in use_bookmarks_method + if there was an error, the user is notified. + + +""" + +import functools +import logging + +from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL +from slixmpp import JID +from common import safeJID +from config import config + +log = logging.getLogger(__name__) + + +class Bookmark(object): + + def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='local'): + self.jid = jid + self.name = name or jid + self.autojoin = autojoin + self.nick = nick + self.password = password + self._method = method + + @property + def method(self): + return self._method + + @method.setter + def method(self, value): + if value not in ('local', 'remote'): + log.debug('Could not set bookmark storing method: %s', value) + return + self._method = value + + def __repr__(self): + return '<%s%s|%s>' % (self.jid, + ('/'+self.nick) if self.nick else '', + self.method) + + def stanza(self): + """ + Generate a <conference/> stanza from the instance + """ + el = Conference() + el['name'] = self.name + el['jid'] = self.jid + el['autojoin'] = 'true' if self.autojoin else 'false' + if self.nick: + el['nick'] = self.nick + if self.password: + el['password'] = self.password + return el + + def local(self): + """Generate a str for local storage""" + local = self.jid + if self.nick: + local += '/%s' % self.nick + local += ':' + if self.password: + config.set_and_save('password', self.password, section=self.jid) + return local + + @functools.singledispatch + @staticmethod + def parse(el): + """ + Generate a Bookmark object from a <conference/> element + (this is a fallback for raw XML Elements) + """ + jid = el.get('jid') + name = el.get('name') + autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False + nick = None + for n in el.iter('nick'): + nick = n.text + password = None + for p in el.iter('password'): + password = p.text + + return Bookmark(jid, name, autojoin, nick, password, method='remote') + + @staticmethod + @parse.register(Conference) + def parse_from_stanza(el): + """ + Parse a Conference element into a Bookmark object + """ + jid = el['jid'] + autojoin = el['autojoin'] + password = el['password'] + nick = el['nick'] + name = el['name'] + return Bookmark(jid, name, autojoin, nick, password, method='remote') + +class BookmarkList(object): + + def __init__(self): + self.bookmarks = [] + preferred = config.get('use_bookmarks_method').lower() + if preferred not in ('pep', 'privatexml'): + preferred = 'privatexml' + self.preferred = preferred + self.available_storage = { + 'privatexml': False, + 'pep': False, + } + + def __getitem__(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks: + if key == i.jid: + return i + else: + return self.bookmarks[key] + + def __in__(self, key): + if isinstance(key, (str, JID)): + for bookmark in self.bookmarks: + if bookmark.jid == key: + return True + else: + return key in self.bookmarks + return False + + def remove(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks[:]: + if i.jid == key: + self.bookmarks.remove(i) + else: + self.bookmarks.remove(key) + + def __iter__(self): + return iter(self.bookmarks) + + def local(self): + return [bm for bm in self.bookmarks if bm.method == 'local'] + + def remote(self): + return [bm for bm in self.bookmarks if bm.method == 'remote'] + + def set(self, new): + self.bookmarks = new + + def append(self, bookmark): + bookmark_exists = self[bookmark.jid] + if not bookmark_exists: + self.bookmarks.append(bookmark) + else: + self.bookmarks.remove(bookmark_exists) + self.bookmarks.append(bookmark) + + def set_bookmarks_method(self, value): + if self.available_storage.get(value): + self.preferred = value + config.set_and_save('use_bookmarks_method', value) + + def save_remote(self, xmpp, callback): + """Save the remote bookmarks.""" + if not any(self.available_storage.values()): + return + method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223' + + if method: + xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage(self.bookmarks), + method=method, + callback=callback) + def save_local(self): + """Save the local bookmarks.""" + local = ''.join(bookmark.local() for bookmark in self if bookmark.method == 'local') + config.set_and_save('rooms', local) + + def save(self, xmpp, core=None, callback=None): + """Save all the bookmarks.""" + self.save_local() + def _cb(iq): + if callback: + callback(iq) + if iq["type"] == "error" and core: + core.information('Could not save remote bookmarks.', 'Error') + elif core: + core.information('Bookmarks saved', 'Info') + if config.get('use_remote_bookmarks'): + self.save_remote(xmpp, _cb) + + def get_pep(self, xmpp, callback): + """Add the remotely stored bookmarks via pep to the list.""" + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['pubsub']['items']['item']['bookmarks']['conferences']: + if isinstance(conf, URL): + continue + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb) + + def get_privatexml(self, xmpp, callback): + """ + Fetch the remote bookmarks stored via privatexml. + """ + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['private']['bookmarks']['conferences']: + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) + + def get_remote(self, xmpp, information, callback): + """Add the remotely stored bookmarks to the list.""" + force = config.get('force_remote_bookmarks') + if xmpp.anon or not (any(self.available_storage.values()) or force): + information('No remote bookmark storage available', 'Warning') + return + + if force and not any(self.available_storage.values()): + old_callback = callback + method = 'pep' if self.preferred == 'pep' else 'privatexml' + def new_callback(result): + if result['type'] != 'error': + self.available_storage[method] = True + old_callback(result) + else: + information('No remote bookmark storage available', 'Warning') + callback = new_callback + + if self.preferred == 'pep': + self.get_pep(xmpp, callback=callback) + else: + self.get_privatexml(xmpp, callback=callback) + + def get_local(self): + """Add the locally stored bookmarks to the list.""" + rooms = config.get('rooms') + if not rooms: + return + rooms = rooms.split(':') + for room in rooms: + jid = safeJID(room) + if jid.bare == '': + continue + if jid.resource != '': + nick = jid.resource + else: + nick = None + passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None + b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local') + self.append(b) + +def stanza_storage(bookmarks): + """Generate a <storage/> stanza with the conference elements.""" + storage = Bookmarks() + for b in (b for b in bookmarks if b.method == 'remote'): + storage.append(b.stanza()) + return storage diff --git a/src/config.py b/src/config.py index 1f0771ca..3ca53dd2 100644 --- a/src/config.py +++ b/src/config.py @@ -15,7 +15,7 @@ DEFSECTION = "Poezio" import logging.config import os import sys -from gettext import gettext as _ +import pkg_resources from configparser import RawConfigParser, NoOptionError, NoSectionError from os import environ, makedirs, path, remove @@ -28,12 +28,13 @@ DEFAULT_CONFIG = { 'add_space_after_completion': True, 'after_completion': ',', 'alternative_nickname': '', - 'auto_reconnect': False, + 'auto_reconnect': True, 'autorejoin_delay': '5', 'autorejoin': False, 'beep_on': 'highlight private invite', 'ca_cert_path': '', 'certificate': '', + 'certfile': '', 'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL', 'connection_check_interval': 60, 'connection_timeout_delay': 10, @@ -41,6 +42,8 @@ DEFAULT_CONFIG = { 'custom_host': '', 'custom_port': '', 'default_nick': '', + 'deterministic_nick_colors': True, + 'nick_color_aliases': True, 'display_activity_notifications': False, 'display_gaming_notifications': False, 'display_mood_notifications': False, @@ -58,6 +61,8 @@ DEFAULT_CONFIG = { 'extract_inline_images': True, 'filter_info_messages': '', 'force_encryption': True, + 'force_remote_bookmarks': False, + 'go_to_previous_tab_on_alt_number': False, 'group_corrections': True, 'hide_exit_join': -1, 'hide_status_change': 120, @@ -67,11 +72,11 @@ DEFAULT_CONFIG = { 'ignore_private': False, 'information_buffer_popup_on': 'error roster warning help info', 'jid': '', + 'keyfile': '', 'lang': 'en', 'lazy_resize': True, 'load_log': 10, 'log_dir': '', - 'logfile': 'logs', 'log_errors': True, 'max_lines_in_memory': 2048, 'max_messages_in_memory': 2048, @@ -131,6 +136,8 @@ DEFAULT_CONFIG = { 'var': { 'folded_roster_groups': '', 'info_win_height': 2 + }, + 'muc_colors': { } } @@ -152,9 +159,11 @@ class Config(RawConfigParser): except TypeError: # python < 3.2 sucks RawConfigParser.read(self, self.file_name) # Check config integrity and fix it if it’s wrong - for section in ('bindings', 'var'): - if not self.has_section(section): - self.add_section(section) + # only when the object is the main config + if self.__class__ is Config: + for section in ('bindings', 'var'): + if not self.has_section(section): + self.add_section(section) def get(self, option, default=None, section=DEFSECTION): """ @@ -400,9 +409,9 @@ class Config(RawConfigParser): elif current.lower() == "true": value = "false" else: - return (_('Could not toggle option: %s.' - ' Current value is %s.') % - (option, current or _("empty")), + return ('Could not toggle option: %s.' + ' Current value is %s.' % + (option, current or "empty"), 'Warning') if self.has_section(section): RawConfigParser.set(self, section, option, value) @@ -410,7 +419,7 @@ class Config(RawConfigParser): self.add_section(section) RawConfigParser.set(self, section, option, value) if not self.write_in_file(section, option, value): - return (_('Unable to write in the config file'), 'Error') + return ('Unable to write in the config file', 'Error') return ("%s=%s" % (option, value), 'Info') def remove_and_save(self, option, section=DEFSECTION): @@ -420,8 +429,8 @@ class Config(RawConfigParser): if self.has_section(section): RawConfigParser.remove_option(self, section, option) if not self.remove_in_file(section, option): - return (_('Unable to save the config file'), 'Error') - return (_('Option %s deleted') % option, 'Info') + return ('Unable to save the config file', 'Error') + return ('Option %s deleted' % option, 'Info') def silent_set(self, option, value, section=DEFSECTION): """ @@ -511,6 +520,34 @@ def check_create_cache_dir(): except OSError: pass +def check_config(): + """ + Check the config file and print results + """ + result = {'missing': [], 'changed': []} + for option in DEFAULT_CONFIG['Poezio']: + value = config.get(option) + if value != DEFAULT_CONFIG['Poezio'][option]: + result['changed'].append((option, value, DEFAULT_CONFIG['Poezio'][option])) + else: + value = config.get(option, default='') + upper = value.upper() + default = str(DEFAULT_CONFIG['Poezio'][option]).upper() + if upper != default: + result['missing'].append(option) + + result['changed'].sort(key=lambda x: x[0]) + result['missing'].sort() + if result['changed']: + print('\033[1mOptions changed from the default configuration:\033[0m\n') + for option, new_value, default in result['changed']: + print(' \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)' % (option, new_value, default)) + + if result['missing']: + print('\n\033[1mMissing options:\033[0m (the defaults are used)\n') + for option in result['missing']: + print(' \033[31m%s\033[0m' % option) + def run_cmdline_args(CONFIG_PATH): "Parse the command line arguments" global options @@ -518,9 +555,8 @@ def run_cmdline_args(CONFIG_PATH): # Copy a default file if none exists if not path.isfile(options.filename): - default = path.join(path.dirname(__file__), - '../data/default_config.cfg') - other = path.join(path.dirname(__file__), 'default_config.cfg') + default = path.join(path.dirname(__file__), '../data/default_config.cfg') + other = pkg_resources.resource_filename('poezio', 'default_config.cfg') if path.isfile(default): copy2(default, options.filename) elif path.isfile(other): @@ -552,7 +588,7 @@ def check_create_log_dir(): home = environ.get('HOME') data_dir = path.join(home, '.local', 'share') - LOG_DIR = path.join(data_dir, 'poezio') + LOG_DIR = path.join(data_dir, 'poezio', 'logs') LOG_DIR = path.expanduser(LOG_DIR) diff --git a/src/connection.py b/src/connection.py index 1bbe632d..cd2ccedd 100644 --- a/src/connection.py +++ b/src/connection.py @@ -30,6 +30,10 @@ class Connection(slixmpp.ClientXMPP): __init = False def __init__(self): resource = config.get('resource') + + keyfile = config.get('keyfile') + certfile = config.get('certfile') + if config.get('jid'): # Field used to know if we are anonymous or not. # many features will be handled differently @@ -38,7 +42,9 @@ class Connection(slixmpp.ClientXMPP): jid = '%s' % config.get('jid') if resource: jid = '%s/%s'% (jid, resource) - password = config.get('password') or getpass.getpass() + password = config.get('password') + if not password and not (keyfile and certfile): + password = getpass.getpass() else: # anonymous auth self.anon = True jid = config.get('server') @@ -57,6 +63,13 @@ class Connection(slixmpp.ClientXMPP): self['feature_mechanisms'].unencrypted_cram = False self['feature_mechanisms'].unencrypted_scram = False + self.keyfile = config.get('keyfile') + self.certfile = config.get('certfile') + if keyfile and not certfile: + log.error('keyfile is present in configuration file without certfile') + elif certfile and not keyfile: + log.error('certfile is present in configuration file without keyfile') + self.core = None self.auto_reconnect = config.get('auto_reconnect') self.reconnect_max_attempts = 0 @@ -127,6 +140,7 @@ class Connection(slixmpp.ClientXMPP): self.register_plugin('xep_0202') self.register_plugin('xep_0224') self.register_plugin('xep_0249') + self.register_plugin('xep_0257') self.register_plugin('xep_0280') self.register_plugin('xep_0297') self.register_plugin('xep_0308') diff --git a/src/core/commands.py b/src/core/commands.py index 4a8f7f19..3830d72a 100644 --- a/src/core/commands.py +++ b/src/core/commands.py @@ -6,37 +6,35 @@ import logging log = logging.getLogger(__name__) -import functools import os -import sys from datetime import datetime -from gettext import gettext as _ from xml.etree import cElementTree as ET from slixmpp.xmlstream.stanzabase import StanzaBase from slixmpp.xmlstream.handler import Callback from slixmpp.xmlstream.matcher import StanzaPath -import bookmark import common import fixes import pep import tabs +from bookmarks import Bookmark from common import safeJID -from config import config, options as config_opts +from config import config, DEFAULT_CONFIG, options as config_opts import multiuserchat as muc from plugin import PluginConfig from roster import roster from theming import dump_tuple, get_theme +from decorators import command_args_parser from . structs import Command, possible_show -def command_help(self, arg): +@command_args_parser.quoted(0, 1) +def command_help(self, args): """ - /help <command_name> + /help [command_name] """ - args = arg.split() if not args: color = dump_tuple(get_theme().COLOR_HELP_COMMANDS) acc = [] @@ -66,8 +64,8 @@ def command_help(self, arg): buff.extend(acc) msg = '\n'.join(buff) - msg += _("\nType /help <command_name> to know what each command does") - if args: + msg += "\nType /help <command_name> to know what each command does" + else: command = args[0].lstrip('/').strip() if command in self.current_tab().commands: @@ -75,16 +73,17 @@ def command_help(self, arg): elif command in self.commands: tup = self.commands[command] else: - self.information(_('Unknown command: %s') % command, 'Error') + self.information('Unknown command: %s' % command, 'Error') return if isinstance(tup, Command): - msg = _('Usage: /%s %s\n' % (command, tup.usage)) + msg = 'Usage: /%s %s\n' % (command, tup.usage) msg += tup.desc else: msg = tup[1] self.information(msg, 'Help') -def command_runkey(self, arg): +@command_args_parser.quoted(1) +def command_runkey(self, args): """ /runkey <key> """ @@ -93,7 +92,9 @@ def command_runkey(self, arg): if key == '^J': return '\n' return key - char = arg.strip() + if args is None: + return self.command_help('runkey') + char = args[0] func = self.key_func.get(char, None) if func: func() @@ -102,21 +103,20 @@ def command_runkey(self, arg): if res: self.refresh_window() -def command_status(self, arg): +@command_args_parser.quoted(1, 1, [None]) +def command_status(self, args): """ /status <status> [msg] """ - args = common.shell_split(arg) - if len(args) < 1: - return + if args is None: + return self.command_help('status') + if not args[0] in possible_show.keys(): - self.command_help('status') - return + return self.command_help('status') + show = possible_show[args[0]] - if len(args) == 2: - msg = args[1] - else: - msg = None + msg = args[1] + pres = self.xmpp.make_presence() if msg: pres['status'] = msg @@ -136,19 +136,15 @@ def command_status(self, arg): if is_muctab and current.joined and show not in ('away', 'xa'): current.send_chat_state('active') -def command_presence(self, arg): +@command_args_parser.quoted(1, 2, [None, None]) +def command_presence(self, args): """ /presence <JID> [type] [status] """ - args = common.shell_split(arg) - if len(args) == 1: - jid, type, status = args[0], None, None - elif len(args) == 2: - jid, type, status = args[0], args[1], None - elif len(args) == 3: - jid, type, status = args[0], args[1], args[2] - else: - return + if args is None: + return self.command_help('presence') + + jid, type, status = args[0], args[1], args[2] if jid == '.' and isinstance(self.current_tab(), tabs.ChatTab): jid = self.current_tab().name if type == 'available': @@ -158,7 +154,7 @@ def command_presence(self, arg): self.events.trigger('send_normal_presence', pres) pres.send() except: - self.information(_('Could not send directed presence'), 'Error') + self.information('Could not send directed presence', 'Error') log.debug('Could not send directed presence to %s', jid, exc_info=True) return tab = self.get_tab_by_name(jid) @@ -177,24 +173,26 @@ def command_presence(self, arg): if self.current_tab() in tab.privates: self.current_tab().send_chat_state(chatstate, True) -def command_theme(self, arg=''): +@command_args_parser.quoted(1) +def command_theme(self, args=None): """/theme <theme name>""" - args = arg.split() - if args: - self.command_set('theme %s' % (args[0],)) + if args is None: + return self.command_help('theme') + self.command_set('theme %s' % (args[0],)) -def command_win(self, arg): +@command_args_parser.quoted(1) +def command_win(self, args): """ /win <number> """ - arg = arg.strip() - if not arg: - self.command_help('win') - return + if args is None: + return self.command_help('win') + + nb = args[0] try: - nb = int(arg.split()[0]) + nb = int(nb) except ValueError: - nb = arg + pass if self.current_tab_nb == nb: return self.previous_tab_nb = self.current_tab_nb @@ -219,15 +217,15 @@ def command_win(self, arg): self.current_tab().on_gain_focus() self.refresh_window() -def command_move_tab(self, arg): +@command_args_parser.quoted(2) +def command_move_tab(self, args): """ /move_tab old_pos new_pos """ - args = common.shell_split(arg) - current_tab = self.current_tab() - if len(args) != 2: + if args is None: return self.command_help('move_tab') + current_tab = self.current_tab() if args[0] == '.': args[0] = current_tab.nb if args[1] == '.': @@ -259,16 +257,16 @@ def command_move_tab(self, arg): self.current_tab_nb = self.tabs.index(current_tab) self.refresh_window() -def command_list(self, arg): +@command_args_parser.quoted(0, 1) +def command_list(self, args): """ - /list <server> + /list [server] Opens a MucListTab containing the list of the room in the specified server """ - arg = arg.split() - if len(arg) > 1: + if args is None: return self.command_help('list') - elif arg: - server = safeJID(arg[0]).server + elif args: + server = safeJID(args[0]) else: if not isinstance(self.current_tab(), tabs.MucTab): return self.information('Please provide a server', 'Error') @@ -279,26 +277,27 @@ def command_list(self, arg): self.xmpp.plugin['xep_0030'].get_items(jid=server, callback=cb) -def command_version(self, arg): +@command_args_parser.quoted(1) +def command_version(self, args): """ /version <jid> """ def callback(res): "Callback for /version" if not res: - return self.information(_('Could not get the software' - ' version from %s') % jid, - _('Warning')) - version = _('%s is running %s version %s on %s') % ( + return self.information('Could not get the software' + ' version from %s' % jid, + 'Warning') + version = '%s is running %s version %s on %s' % ( jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.information(version, 'Info') - args = common.shell_split(arg) - if len(args) < 1: + if args is None: return self.command_help('version') + jid = safeJID(args[0]) if jid.resource or jid not in roster: fixes.get_version(self.xmpp, jid, callback=callback) @@ -308,11 +307,11 @@ def command_version(self, arg): else: fixes.get_version(self.xmpp, jid, callback=callback) -def command_join(self, arg, histo_length=None): +@command_args_parser.quoted(0, 2) +def command_join(self, args, histo_length=None): """ /join [room][/nick] [password] """ - args = common.shell_split(arg) password = None if len(args) == 0: tab = self.current_tab() @@ -388,13 +387,15 @@ def command_join(self, arg, histo_length=None): seconds = int(seconds) else: seconds = 0 + if password: + tab.password = password muc.join_groupchat(self, room, nick, password, histo_length, current_status.message, current_status.show, seconds=seconds) if not tab: - self.open_new_room(room, nick) + self.open_new_room(room, nick, password=password) muc.join_groupchat(self, room, nick, password, histo_length, current_status.message, @@ -409,196 +410,162 @@ def command_join(self, arg, histo_length=None): tab.refresh() self.doupdate() -def command_bookmark_local(self, arg=''): +@command_args_parser.quoted(0, 2) +def command_bookmark_local(self, args): """ /bookmark_local [room][/nick] [password] """ - args = common.shell_split(arg) - nick = None - password = None if not args and not isinstance(self.current_tab(), tabs.MucTab): return - if not args: - tab = self.current_tab() - roomname = tab.name - if tab.joined and tab.own_nick != self.own_nick: - nick = tab.own_nick - elif args[0] == '*': - new_bookmarks = [] - for tab in self.get_tabs(tabs.MucTab): - b = bookmark.get_by_jid(tab.name) - if not b: - b = bookmark.Bookmark(tab.name, - autojoin=True, - method="local") - new_bookmarks.append(b) - else: - b.method = "local" - new_bookmarks.append(b) - bookmark.bookmarks.remove(b) - new_bookmarks.extend(bookmark.bookmarks) - bookmark.bookmarks = new_bookmarks - bookmark.save_local() - bookmark.save_remote(self.xmpp, None) - self.information('Bookmarks added and saved.', 'Info') - return - else: - info = safeJID(args[0]) - if info.resource != '': - nick = info.resource - roomname = info.bare - if not roomname: - if not isinstance(self.current_tab(), tabs.MucTab): - return - roomname = self.current_tab().name - if len(args) > 1: - password = args[1] - - bm = bookmark.get_by_jid(roomname) - if not bm: - bm = bookmark.Bookmark(jid=roomname) - bookmark.bookmarks.append(bm) - self.information('Bookmark added.', 'Info') - else: - self.information('Bookmark updated.', 'Info') - if nick: - bm.nick = nick - bm.autojoin = True - bm.password = password - bm.method = "local" - bookmark.save_local() - self.information(_('Your local bookmarks are now: %s') % - [b for b in bookmark.bookmarks if b.method == 'local'], 'Info') + password = args[1] if len(args) > 1 else None + jid = args[0] if args else None + + _add_bookmark(self, jid, True, password, 'local') -def command_bookmark(self, arg=''): +@command_args_parser.quoted(0, 3) +def command_bookmark(self, args): """ /bookmark [room][/nick] [autojoin] [password] """ + if not args and not isinstance(self.current_tab(), tabs.MucTab): + return + jid = args[0] if args else '' + password = args[2] if len(args) > 2 else None if not config.get('use_remote_bookmarks'): - self.command_bookmark_local(arg) - return - args = common.shell_split(arg) + return _add_bookmark(self, jid, True, password, 'local') + + if len(args) > 1: + autojoin = False if args[1].lower() != 'true' else True + else: + autojoin = True + + _add_bookmark(self, jid, autojoin, password, 'remote') + +def _add_bookmark(self, jid, autojoin, password, method): nick = None - if not args and not isinstance(self.current_tab(), tabs.MucTab): - return - if not args: + if not jid: tab = self.current_tab() roomname = tab.name - if tab.joined: + if tab.joined and tab.own_nick != self.own_nick: nick = tab.own_nick - autojoin = True - password = None - elif args[0] == '*': - if len(args) > 1: - autojoin = False if args[1].lower() != 'true' else True - else: - autojoin = True - new_bookmarks = [] - for tab in self.get_tabs(tabs.MucTab): - b = bookmark.get_by_jid(tab.name) - if not b: - b = bookmark.Bookmark(tab.name, autojoin=autojoin, - method=bookmark.preferred) - new_bookmarks.append(b) - else: - b.method = bookmark.preferred - bookmark.bookmarks.remove(b) - new_bookmarks.append(b) - new_bookmarks.extend(bookmark.bookmarks) - bookmark.bookmarks = new_bookmarks - def _cb(self, iq): - if iq["type"] != "error": - bookmark.save_local() - self.information("Bookmarks added.", "Info") - else: - self.information("Could not add the bookmarks.", "Info") - bookmark.save_remote(self.xmpp, functools.partial(_cb, self)) - return + if password is None and tab.password is not None: + password = tab.password + elif jid == '*': + return _add_wildcard_bookmarks(self, method) else: - info = safeJID(args[0]) - if info.resource != '': - nick = info.resource - roomname = info.bare + info = safeJID(jid) + roomname, nick = info.bare, info.resource if roomname == '': if not isinstance(self.current_tab(), tabs.MucTab): return roomname = self.current_tab().name - if len(args) > 1: - autojoin = False if args[1].lower() != 'true' else True - else: - autojoin = True - if len(args) > 2: - password = args[2] - else: - password = None - bm = bookmark.get_by_jid(roomname) - if not bm: - bm = bookmark.Bookmark(roomname) - bookmark.bookmarks.append(bm) - bm.method = config.get('use_bookmarks_method') + bookmark = self.bookmarks[roomname] + if bookmark is None: + bookmark = Bookmark(roomname) + self.bookmarks.append(bookmark) + bookmark.method = method + bookmark.autojoin = autojoin if nick: - bm.nick = nick + bookmark.nick = nick if password: - bm.password = password - bm.autojoin = autojoin - def _cb(self, iq): + bookmark.password = password + def callback(iq): if iq["type"] != "error": self.information('Bookmark added.', 'Info') else: self.information("Could not add the bookmarks.", "Info") - bookmark.save_remote(self.xmpp, functools.partial(_cb, self)) - remote = [] - for each in bookmark.bookmarks: - if each.method in ('pep', 'privatexml'): - remote.append(each) + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, callback) + +def _add_wildcard_bookmarks(self, method): + new_bookmarks = [] + for tab in self.get_tabs(tabs.MucTab): + bookmark = self.bookmarks[tab.name] + if not bookmark: + bookmark = Bookmark(tab.name, autojoin=True, + method=method) + new_bookmarks.append(bookmark) + else: + bookmark.method = method + new_bookmarks.append(bookmark) + self.bookmarks.remove(bookmark) + new_bookmarks.extend(self.bookmarks.bookmarks) + self.bookmarks.set(new_bookmarks) + def _cb(iq): + if iq["type"] != "error": + self.information("Bookmarks saved.", "Info") + else: + self.information("Could not save the remote bookmarks.", "Info") + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, _cb) -def command_bookmarks(self, arg=''): +@command_args_parser.ignored +def command_bookmarks(self): """/bookmarks""" - local = [] - remote = [] - for each in bookmark.bookmarks: - if each.method in ('pep', 'privatexml'): - remote.append(each) - elif each.method == 'local': - local.append(each) - - self.information(_('Your remote bookmarks are: %s') % remote, - _('Info')) - self.information(_('Your local bookmarks are: %s') % local, - _('Info')) - -def command_remove_bookmark(self, arg=''): + tab = self.get_tab_by_name('Bookmarks', tabs.BookmarksTab) + old_tab = self.current_tab() + if tab: + self.current_tab_nb = tab.nb + else: + tab = tabs.BookmarksTab(self.bookmarks) + self.tabs.append(tab) + self.current_tab_nb = tab.nb + old_tab.on_lose_focus() + tab.on_gain_focus() + self.refresh_window() + +@command_args_parser.quoted(0, 1) +def command_remove_bookmark(self, args): """/remove_bookmark [jid]""" - args = common.shell_split(arg) + + def cb(success): + if success: + self.information('Bookmark deleted', 'Info') + else: + self.information('Error while deleting the bookmark', 'Error') + if not args: tab = self.current_tab() - if isinstance(tab, tabs.MucTab) and bookmark.get_by_jid(tab.name): - bookmark.remove(tab.name) - bookmark.save(self.xmpp) - if bookmark.save(self.xmpp): - self.information('Bookmark deleted', 'Info') + if isinstance(tab, tabs.MucTab) and self.bookmarks[tab.name]: + self.bookmarks.remove(tab.name) + self.bookmarks.save(self.xmpp, callback=cb) else: self.information('No bookmark to remove', 'Info') else: - if bookmark.get_by_jid(args[0]): - bookmark.remove(args[0]) - if bookmark.save(self.xmpp): - self.information('Bookmark deleted', 'Info') - + if self.bookmarks[args[0]]: + self.bookmarks.remove(args[0]) + self.bookmarks.save(self.xmpp, callback=cb) else: self.information('No bookmark to remove', 'Info') -def command_set(self, arg): +@command_args_parser.quoted(0, 3) +def command_set(self, args): """ /set [module|][section] <option> [value] """ - args = common.shell_split(arg) - if len(args) == 1: + if args is None or len(args) == 0: + config_dict = config.to_dict() + lines = [] + theme = get_theme() + for section_name, section in config_dict.items(): + lines.append('\x19%(section_col)s}[%(section)s]\x19o' % + { + 'section': section_name, + 'section_col': dump_tuple(theme.COLOR_INFORMATION_TEXT), + }) + for option_name, option_value in section.items(): + lines.append('%s\x19%s}=\x19o%s' % (option_name, + dump_tuple(theme.COLOR_REVISIONS_MESSAGE), + option_value)) + info = ('Current options:\n%s' % '\n'.join(lines), 'Info') + elif len(args) == 1: option = args[0] value = config.get(option) + if value is None and '=' in option: + args = option.split('=', 1) info = ('%s=%s' % (option, value), 'Info') - elif len(args) == 2: + if len(args) == 2: if '|' in args[0]: plugin_name, section = args[0].split('|')[:2] if not section: @@ -639,44 +606,72 @@ def command_set(self, arg): plugin_config = self.plugin_manager.plugins[plugin_name].config info = plugin_config.set_and_save(option, value, section) else: - section = args[0] + if args[0] == '.': + name = safeJID(self.current_tab().name).bare + if not name: + self.information('Invalid tab to use the "." argument.', + 'Error') + return + section = name + else: + section = args[0] option = args[1] value = args[2] info = config.set_and_save(option, value, section) self.trigger_configuration_change(option, value) - else: - self.command_help('set') - return - self.call_for_resize() + elif len(args) > 3: + return self.command_help('set') self.information(*info) -def command_toggle(self, arg): +@command_args_parser.quoted(1, 2) +def command_set_default(self, args): + """ + /set_default [section] <option> + """ + if len(args) == 1: + option = args[0] + section = 'Poezio' + elif len(args) == 2: + section = args[0] + option = args[1] + else: + return self.command_help('set_default') + + default_config = DEFAULT_CONFIG.get(section, tuple()) + if option not in default_config: + info = ("Option %s has no default value" % (option), "Error") + return self.information(*info) + self.command_set('%s %s %s' % (section, option, default_config[option])) + +@command_args_parser.quoted(1) +def command_toggle(self, args): """ /toggle <option> shortcut for /set <option> toggle """ - arg = arg.split() - if arg and arg[0]: - self.command_set('%s toggle' % arg[0]) + if args is None: + return self.command_help('toggle') -def command_server_cycle(self, arg=''): + if args[0]: + self.command_set('%s toggle' % args[0]) + +@command_args_parser.quoted(1, 1) +def command_server_cycle(self, args): """ Do a /cycle on each room of the given server. If none, do it on the current tab """ - args = common.shell_split(arg) tab = self.current_tab() message = "" - if len(args): + if args: domain = args[0] - if len(args) > 1: + if len(args) == 2: message = args[1] else: if isinstance(tab, tabs.MucTab): domain = safeJID(tab.name).domain else: - self.information(_("No server specified"), "Error") - return + return self.information("No server specified", "Error") for tab in self.get_tabs(tabs.MucTab): if tab.name.endswith(domain): if tab.joined: @@ -690,7 +685,8 @@ def command_server_cycle(self, arg=''): else: self.command_join('"%s/%s"' %(tab.name, tab.own_nick)) -def command_last_activity(self, arg): +@command_args_parser.quoted(1) +def command_last_activity(self, args): """ /last_activity <jid> """ @@ -698,11 +694,11 @@ def command_last_activity(self, arg): "Callback for the last activity" if iq['type'] != 'result': if iq['error']['type'] == 'auth': - self.information(_('You are not allowed to see the ' - 'activity of this contact.'), - _('Error')) + self.information('You are not allowed to see the ' + 'activity of this contact.', + 'Error') else: - self.information(_('Error retrieving the activity'), 'Error') + self.information('Error retrieving the activity', 'Error') return seconds = iq['last_activity']['seconds'] status = iq['last_activity']['status'] @@ -717,46 +713,47 @@ def command_last_activity(self, arg): common.parse_secs_to_str(seconds), (' and his/her last status was %s' % status) if status else '') self.information(msg, 'Info') - jid = safeJID(arg) - if jid == '': + + if args is None: return self.command_help('last_activity') + jid = safeJID(args[0]) self.xmpp.plugin['xep_0012'].get_last_activity(jid, callback=callback) -def command_mood(self, arg): +@command_args_parser.quoted(0, 2) +def command_mood(self, args): """ /mood [<mood> [text]] """ - args = common.shell_split(arg) if not args: - self.xmpp.plugin['xep_0107'].stop() - return + return self.xmpp.plugin['xep_0107'].stop() + mood = args[0] if mood not in pep.MOODS: - return self.information(_('%s is not a correct value for a mood.') - % mood, - _('Error')) - if len(args) > 1: + return self.information('%s is not a correct value for a mood.' + % mood, + 'Error') + if len(args) == 2: text = args[1] else: text = None self.xmpp.plugin['xep_0107'].publish_mood(mood, text, callback=dumb_callback) -def command_activity(self, arg): +@command_args_parser.quoted(0, 3) +def command_activity(self, args): """ /activity [<general> [specific] [text]] """ - args = common.shell_split(arg) length = len(args) if not length: - self.xmpp.plugin['xep_0108'].stop() - return + return self.xmpp.plugin['xep_0108'].stop() + general = args[0] if general not in pep.ACTIVITIES: - return self.information(_('%s is not a correct value for an activity') + return self.information('%s is not a correct value for an activity' % general, - _('Error')) + 'Error') specific = None text = None if length == 2: @@ -768,20 +765,20 @@ def command_activity(self, arg): specific = args[1] text = args[2] if specific and specific not in pep.ACTIVITIES[general]: - return self.information(_('%s is not a correct value ' - 'for an activity') % specific, - _('Error')) + return self.information('%s is not a correct value ' + 'for an activity' % specific, + 'Error') self.xmpp.plugin['xep_0108'].publish_activity(general, specific, text, callback=dumb_callback) -def command_gaming(self, arg): +@command_args_parser.quoted(0, 2) +def command_gaming(self, args): """ /gaming [<game name> [server address]] """ - args = common.shell_split(arg) if not args: - self.xmpp.plugin['xep_0196'].stop() - return + return self.xmpp.plugin['xep_0196'].stop() + name = args[0] if len(args) > 1: address = args[1] @@ -791,25 +788,27 @@ def command_gaming(self, arg): server_address=address, callback=dumb_callback) -def command_invite(self, arg): +@command_args_parser.quoted(2, 1, [None]) +def command_invite(self, args): """/invite <to> <room> [reason]""" - args = common.shell_split(arg) - if len(args) < 2: - return - reason = args[2] if len(args) > 2 else None + + if args is None: + return self.command_help('invite') + + reason = args[2] to = safeJID(args[0]) room = safeJID(args[1]).bare self.invite(to.full, room, reason=reason) -def command_decline(self, arg): +@command_args_parser.quoted(1, 1, ['']) +def command_decline(self, args): """/decline <room@server.tld> [reason]""" - args = common.shell_split(arg) - if not len(args): - return + if args is None: + return self.command_help('decline') jid = safeJID(args[0]) if jid.bare not in self.pending_invites: return - reason = args[1] if len(args) > 1 else '' + reason = args[1] del self.pending_invites[jid.bare] self.xmpp.plugin['xep_0045'].decline_invite(jid.bare, self.pending_invites[jid.bare], @@ -817,7 +816,8 @@ def command_decline(self, arg): ### Commands without a completion in this class ### -def command_invitations(self, arg=''): +@command_args_parser.ignored +def command_invitations(self): """/invitations""" build = "" for invite in self.pending_invites: @@ -829,17 +829,16 @@ def command_invitations(self, arg=''): build = "You do not have any pending invitations." self.information(build, 'Info') -def command_quit(self, arg=''): +@command_args_parser.quoted(0, 1, [None]) +def command_quit(self, args): """ - /quit + /quit [message] """ if not self.xmpp.is_connected(): self.exit() return - if len(arg.strip()) != 0: - msg = arg - else: - msg = None + + msg = args[0] if config.get('enable_user_mood'): self.xmpp.plugin['xep_0107'].stop() if config.get('enable_user_activity'): @@ -851,44 +850,47 @@ def command_quit(self, arg=''): self.disconnect(msg) self.xmpp.add_event_handler("disconnected", self.exit, disposable=True) -def command_destroy_room(self, arg=''): +@command_args_parser.quoted(0, 1, ['']) +def command_destroy_room(self, args): """ /destroy_room [JID] """ - room = safeJID(arg).bare + room = safeJID(args[0]).bare if room: muc.destroy_room(self.xmpp, room) - elif isinstance(self.current_tab(), tabs.MucTab) and not arg: + elif isinstance(self.current_tab(), tabs.MucTab) and not args[0]: muc.destroy_room(self.xmpp, self.current_tab().general_jid) else: - self.information(_('Invalid JID: "%s"') % arg, _('Error')) + self.information('Invalid JID: "%s"' % args[0], 'Error') -def command_bind(self, arg): +@command_args_parser.quoted(1, 1, ['']) +def command_bind(self, args): """ Bind a key. """ - args = common.shell_split(arg) - if len(args) < 1: + if args is None: return self.command_help('bind') - elif len(args) < 2: - args.append("") + if not config.silent_set(args[0], args[1], section='bindings'): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') + if args[1]: self.information('%s is now bound to %s' % (args[0], args[1]), 'Info') else: self.information('%s is now unbound' % args[0], 'Info') -def command_rawxml(self, arg): +@command_args_parser.raw +def command_rawxml(self, args): """ /rawxml <xml stanza> """ - if not arg: - return + if not args: + return + stanza = args try: - stanza = StanzaBase(self.xmpp, xml=ET.fromstring(arg)) + stanza = StanzaBase(self.xmpp, xml=ET.fromstring(stanza)) if stanza.xml.tag == 'iq' and \ stanza.xml.attrib.get('type') in ('get', 'set') and \ stanza.xml.attrib.get('id'): @@ -910,78 +912,85 @@ def command_rawxml(self, arg): stanza.send() except: - self.information(_('Could not send custom stanza'), 'Error') + self.information('Could not send custom stanza', 'Error') log.debug('/rawxml: Could not send custom stanza (%s)', - repr(arg), + repr(stanza), exc_info=True) -def command_load(self, arg): +@command_args_parser.quoted(1, 256) +def command_load(self, args): """ /load <plugin> [<otherplugin> …] + # TODO: being able to load more than 256 plugins at once, hihi. """ - args = arg.split() for plugin in args: self.plugin_manager.load(plugin) -def command_unload(self, arg): +@command_args_parser.quoted(1, 256) +def command_unload(self, args): """ /unload <plugin> [<otherplugin> …] """ - args = arg.split() for plugin in args: self.plugin_manager.unload(plugin) -def command_plugins(self, arg=''): +@command_args_parser.ignored +def command_plugins(self): """ /plugins """ - self.information(_("Plugins currently in use: %s") % + self.information("Plugins currently in use: %s" % repr(list(self.plugin_manager.plugins.keys())), - _('Info')) + 'Info') -def command_message(self, arg): +@command_args_parser.quoted(1, 1) +def command_message(self, args): """ /message <jid> [message] """ - args = common.shell_split(arg) - if len(args) < 1: - self.command_help('message') - return + if args is None: + return self.command_help('message') jid = safeJID(args[0]) if not jid.user and not jid.domain and not jid.resource: return self.information('Invalid JID.', 'Error') tab = self.get_conversation_by_jid(jid.full, False, fallback_barejid=False) - if not tab: + muc = self.get_tab_by_name(jid.bare, typ=tabs.MucTab) + if not tab and not muc: tab = self.open_conversation_window(jid.full, focus=True) + elif muc: + tab = self.get_tab_by_name(jid.full, typ=tabs.PrivateTab) + if tab: + self.focus_tab_named(tab.name) + else: + tab = self.open_private_window(jid.bare, jid.resource) else: self.focus_tab_named(tab.name) - if len(args) > 1: + if len(args) == 2: tab.command_say(args[1]) -def command_xml_tab(self, arg=''): +@command_args_parser.ignored +def command_xml_tab(self): """/xml_tab""" - self.xml_tab = True xml_tab = self.focus_tab_named('XMLTab', tabs.XMLTab) if not xml_tab: tab = tabs.XMLTab() self.add_tab(tab, True) + self.xml_tab = tab -def command_adhoc(self, arg): - arg = arg.split() - if len(arg) > 1: +@command_args_parser.quoted(1) +def command_adhoc(self, args): + if not args: return self.command_help('ad-hoc') - elif arg: - jid = safeJID(arg[0]) - else: - return self.information('Please provide a jid', 'Error') + jid = safeJID(args[0]) list_tab = tabs.AdhocCommandsListTab(jid) self.add_tab(list_tab, True) cb = list_tab.on_list_received self.xmpp.plugin['xep_0050'].get_commands(jid=jid, local=False, callback=cb) -def command_self(self, arg=None): +@command_args_parser.ignored +def command_self(self): """ /self """ @@ -998,5 +1007,14 @@ def command_self(self, arg=None): config_opts.version)) self.information(info, 'Info') + +@command_args_parser.ignored +def command_reload(self): + """ + /reload + """ + self.reload_config() + def dumb_callback(*args, **kwargs): "mock callback" + diff --git a/src/core/completions.py b/src/core/completions.py index 7d95321b..f17e916c 100644 --- a/src/core/completions.py +++ b/src/core/completions.py @@ -8,7 +8,6 @@ log = logging.getLogger(__name__) import os from functools import reduce -import bookmark import common import pep import tabs @@ -57,7 +56,7 @@ def completion_theme(self, the_input): except OSError as e: log.error('Completion for /theme failed', exc_info=True) return - theme_files = [name[:-3] for name in names if name.endswith('.py')] + theme_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py'] if not 'default' in theme_files: theme_files.append('default') return the_input.new_completion(theme_files, 1, '', quotify=False) @@ -96,7 +95,7 @@ def completion_join(self, the_input): relevant_rooms = [] relevant_rooms.extend(sorted(self.pending_invites.keys())) - bookmarks = {str(elem.jid): False for elem in bookmark.bookmarks} + bookmarks = {str(elem.jid): False for elem in self.bookmarks} for tab in self.get_tabs(tabs.MucTab): name = tab.name if name in bookmarks and not tab.joined: @@ -119,7 +118,6 @@ def completion_join(self, the_input): return the_input.new_completion(['/%s' % self.own_nick], 1, quotify=True) else: return the_input.new_completion(relevant_rooms, 1, quotify=True) - return True def completion_version(self, the_input): @@ -192,7 +190,7 @@ def completion_bookmark(self, the_input): def completion_remove_bookmark(self, the_input): """Completion for /remove_bookmark""" - return the_input.new_completion([bm.jid for bm in bookmark.bookmarks], 1, quotify=False) + return the_input.new_completion([bm.jid for bm in self.bookmarks], 1, quotify=False) def completion_decline(self, the_input): @@ -214,9 +212,6 @@ def completion_bind(self, the_input): return the_input.new_completion(args, n, '', quotify=False) - return the_input - - def completion_message(self, the_input): """Completion for /message""" n = the_input.get_argument_position(quoted=True) @@ -304,14 +299,21 @@ def completion_set(self, the_input): plugin = self.plugin_manager.plugins[plugin_name] end_list = ['%s|%s' % (plugin_name, section) for section in plugin.config.sections()] else: - end_list = config.options('Poezio') + end_list = set(config.options('Poezio')) + end_list.update(config.default.get('Poezio', {})) + end_list = list(end_list) + end_list.sort() elif n == 2: if '|' in args[1]: plugin_name, section = args[1].split('|')[:2] if not plugin_name in self.plugin_manager.plugins: return the_input.new_completion([''], n, quotify=True) plugin = self.plugin_manager.plugins[plugin_name] - end_list = plugin.config.options(section or plugin_name) + end_list = set(plugin.config.options(section or plugin_name)) + if plugin.config.default: + end_list.update(plugin.config.default.get(section or plugin_name, {})) + end_list = list(end_list) + end_list.sort() elif not config.has_option('Poezio', args[1]): if config.has_section(args[1]): end_list = config.options(args[1]) @@ -336,6 +338,19 @@ def completion_set(self, the_input): return return the_input.new_completion(end_list, n, quotify=True) + +def completion_set_default(self, the_input): + """ Completion for /set_default + """ + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + if n >= len(args): + args.append('') + if n == 1 or (n == 2 and config.has_section(args[1])): + return self.completion_set(the_input) + return [] + + def completion_toggle(self, the_input): "Completion for /toggle" return the_input.new_completion(config.options('Poezio'), 1, quotify=False) diff --git a/src/core/core.py b/src/core/core.py index 4daeed6c..92c9f987 100644 --- a/src/core/core.py +++ b/src/core/core.py @@ -10,7 +10,6 @@ import logging log = logging.getLogger(__name__) import asyncio -import collections import shutil import curses import os @@ -18,22 +17,19 @@ import pipes import sys import time from threading import Event -from datetime import datetime -from gettext import gettext as _ from slixmpp.xmlstream.handler import Callback -import bookmark import connection import decorators import events -import fixes import singleton import tabs import theming import timed_events import windows +from bookmarks import BookmarkList from common import safeJID from config import config, firstrun from contact import Contact, Resource @@ -75,6 +71,7 @@ class Core(object): self.keyboard = keyboard.Keyboard() roster.set_node(self.xmpp.client_roster) decorators.refresh_wrapper.core = self + self.bookmarks = BookmarkList() self.paused = False self.event = Event() self.debug = False @@ -90,7 +87,7 @@ class Core(object): self.tab_win = windows.GlobalInfoBar() # Whether the XML tab is opened - self.xml_tab = False + self.xml_tab = None self.xml_buffer = TextBuffer() self.tabs = [] @@ -226,6 +223,7 @@ class Core(object): self.xmpp.add_event_handler("groupchat_subject", self.on_groupchat_subject) self.xmpp.add_event_handler("message", self.on_message) + self.xmpp.add_event_handler("message_error", self.on_error_message) self.xmpp.add_event_handler("receipt_received", self.on_receipt) self.xmpp.add_event_handler("got_online", self.on_got_online) self.xmpp.add_event_handler("got_offline", self.on_got_offline) @@ -253,6 +251,7 @@ class Core(object): self.on_chatstate_inactive) self.xmpp.add_event_handler("attention", self.on_attention) self.xmpp.add_event_handler("ssl_cert", self.validate_ssl) + self.xmpp.add_event_handler("ssl_invalid_chain", self.ssl_invalid_chain) self.all_stanzas = Callback('custom matcher', connection.MatchAll(None), self.incoming_stanza) @@ -310,8 +309,14 @@ class Core(object): theming.update_themes_dir) self.add_configuration_handler("theme", self.on_theme_config_change) + self.add_configuration_handler("use_bookmarks_method", + self.on_bookmarks_method_config_change) self.add_configuration_handler("password", self.on_password_change) + self.add_configuration_handler("enable_vertical_tab_list", + self.on_vertical_tab_list_config_change) + self.add_configuration_handler("deterministic_nick_colors", + self.on_nick_determinism_changed) self.add_configuration_handler("", self.on_any_config_change) @@ -346,6 +351,15 @@ class Core(object): for callback in self.configuration_change_handlers[option]: callback(option, value) + def on_bookmarks_method_config_change(self, option, value): + """ + Called when the use_bookmarks_method option changes + """ + if value not in ('pep', 'privatexml'): + return + self.bookmarks.preferred = value + self.bookmarks.save(self.xmpp, core=self) + def on_gaps_config_change(self, option, value): """ Called when the option create_gaps is changed. @@ -374,6 +388,12 @@ class Core(object): path = os.path.expanduser(value) self.plugin_manager.on_plugins_dir_change(path) + def on_vertical_tab_list_config_change(self, option, value): + """ + Called when the enable_vertical_tab_list option is changed + """ + self.call_for_resize() + def on_plugins_conf_dir_config_change(self, option, value): """ Called when the plugins_conf_dir option is changed @@ -396,19 +416,23 @@ class Core(object): """ self.xmpp.password = value - def sigusr_handler(self, num, stack): - """ - Handle SIGUSR1 (10) - When caught, reload all the possible files. + + def on_nick_determinism_changed(self, option, value): + """If we change the value to true, we call /recolor on all the MucTabs, to + make the current nick colors reflect their deterministic value. """ - log.debug("SIGUSR1 caught, reloading the files…") + if value.lower() == "true": + for tab in self.get_tabs(tabs.MucTab): + tab.command_recolor('') + + def reload_config(self): # reload all log files log.debug("Reloading the log files…") logger.reload_all() log.debug("Log files reloaded.") # reload the theme log.debug("Reloading the theme…") - self.command_theme("") + theming.reload_theme() log.debug("Theme reloaded.") # reload the config from the disk log.debug("Reloading the config…") @@ -428,6 +452,14 @@ class Core(object): # in case some roster options have changed roster.modified() + def sigusr_handler(self, num, stack): + """ + Handle SIGUSR1 (10) + When caught, reload all the possible files. + """ + log.debug("SIGUSR1 caught, reloading the files…") + self.reload_config() + def exit_from_signal(self, *args, **kwargs): """ Quit when receiving SIGHUP or SIGTERM or SIGPIPE @@ -476,15 +508,15 @@ class Core(object): default_tab = tabs.RosterInfoTab() default_tab.on_gain_focus() self.tabs.append(default_tab) - self.information(_('Welcome to poezio!'), _('Info')) + self.information('Welcome to poezio!', 'Info') if firstrun: - self.information(_( + self.information( 'It seems that it is the first time you start poezio.\n' 'The online help is here http://doc.poez.io/\n' 'No room is joined by default, but you can join poezio’s' 'chatroom (with /join poezio@muc.poez.io), where you can' - ' ask for help or tell us how great it is.'), - _('Help')) + ' ask for help or tell us how great it is.', + 'Help') self.refresh_window() self.xmpp.plugin['xep_0012'].begin_idle(jid=self.xmpp.boundjid) @@ -592,7 +624,7 @@ class Core(object): except ValueError: pass else: - if self.current_tab().nb == nb: + if self.current_tab().nb == nb and config.get('go_to_previous_tab_on_alt_number'): self.go_to_previous_tab() else: self.command_win('%d' % nb) @@ -617,9 +649,9 @@ class Core(object): self.information_win_size, 'var') if not ok: - self.information(_('Unable to save runtime preferences' - ' in the config file'), - _('Error')) + self.information('Unable to save runtime preferences' + ' in the config file', + 'Error') def on_roster_enter_key(self, roster_row): """ @@ -675,8 +707,8 @@ class Core(object): func(arg) return else: - self.information(_("Unknown command (%s)") % (command), - _('Error')) + self.information("Unknown command (%s)" % (command), + 'Error') def exec_command(self, command): """ @@ -809,15 +841,15 @@ class Core(object): msg = msg.replace('\n', '|') if msg else '' ok = ok and config.silent_set('status_message', msg) if not ok: - self.information(_('Unable to save the status in ' - 'the config file'), 'Error') + self.information('Unable to save the status in ' + 'the config file', 'Error') def get_bookmark_nickname(self, room_name): """ Returns the nickname associated with a bookmark or the default nickname """ - bm = bookmark.get_by_jid(room_name) + bm = self.bookmarks[room_name] if bm: return bm.nick return self.own_nick @@ -884,18 +916,18 @@ class Core(object): if code in DEPRECATED_ERRORS: body = DEPRECATED_ERRORS[code] else: - body = condition or _('Unknown error') + body = condition or 'Unknown error' else: if code in ERROR_AND_STATUS_CODES: body = ERROR_AND_STATUS_CODES[code] else: - body = condition or _('Unknown error') + body = condition or 'Unknown error' if code: - message = _('%(from)s: %(code)s - %(msg)s: %(body)s') % { - 'from': sender, 'msg': msg, 'body': body, 'code': code} + message = '%(from)s: %(code)s - %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body, 'code': code} else: - message = _('%(from)s: %(msg)s: %(body)s') % { - 'from': sender, 'msg': msg, 'body': body} + message = '%(from)s: %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body} return message @@ -1163,7 +1195,7 @@ class Core(object): self._current_tab_nb = len(self.tabs) - 1 else: self._current_tab_nb = value - if old != self._current_tab_nb: + if old != self._current_tab_nb and self.tabs[self._current_tab_nb]: self.events.trigger('tab_change', old, self._current_tab_nb) ### Opening actions ### @@ -1209,11 +1241,11 @@ class Core(object): tab.privates.append(new_tab) return new_tab - def open_new_room(self, room, nick, focus=True): + def open_new_room(self, room, nick, *, password=None, focus=True): """ Open a new tab.MucTab containing a muc Room, using the specified nick """ - new_tab = tabs.MucTab(room, nick) + new_tab = tabs.MucTab(room, nick, password=password) self.add_tab(new_tab, focus) self.refresh_window() @@ -1262,7 +1294,7 @@ class Core(object): Disable private tabs when leaving a room """ if reason is None: - reason = _('\x195}You left the chatroom\x193}') + reason = '\x195}You left the chatroom\x193}' for tab in self.get_tabs(tabs.PrivateTab): if tab.name.startswith(room_name): tab.deactivate(reason=reason) @@ -1272,7 +1304,7 @@ class Core(object): Enable private tabs when joining a room """ if reason is None: - reason = _('\x195}You joined the chatroom\x193}') + reason = '\x195}You joined the chatroom\x193}' for tab in self.get_tabs(tabs.PrivateTab): if tab.name.startswith(room_name): tab.activate(reason=reason) @@ -1286,6 +1318,7 @@ class Core(object): """ Close the given tab. If None, close the current one """ + was_current = tab is None tab = tab or self.current_tab() if isinstance(tab, tabs.RosterInfoTab): return # The tab 0 should NEVER be closed @@ -1293,9 +1326,10 @@ class Core(object): del tab.commands # and make the object collectable tab.on_close() nb = tab.nb - if self.previous_tab_nb != nb: - self.current_tab_nb = self.previous_tab_nb - self.previous_tab_nb = 0 + if was_current: + if self.previous_tab_nb != nb: + self.current_tab_nb = self.previous_tab_nb + self.previous_tab_nb = 0 if config.get('create_gaps'): if nb >= len(self.tabs) - 1: self.tabs.remove(tab) @@ -1315,7 +1349,8 @@ class Core(object): self.current_tab_nb = len(self.tabs) - 1 while not self.tabs[self.current_tab_nb]: self.current_tab_nb -= 1 - self.current_tab().on_gain_focus() + if was_current: + self.current_tab().on_gain_focus() self.refresh_window() import gc gc.collect() @@ -1403,9 +1438,11 @@ class Core(object): """ Refresh everything """ + nocursor = curses.curs_set(0) self.current_tab().state = 'current' self.current_tab().refresh() self.doupdate() + curses.curs_set(nocursor) def refresh_tab_win(self): """ @@ -1556,7 +1593,7 @@ class Core(object): """ enabled = config.get('enable_vertical_tab_list') if not config.silent_set('enable_vertical_tab_list', str(not enabled)): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') self.call_for_resize() def resize_global_information_win(self): @@ -1681,214 +1718,225 @@ class Core(object): Register the commands when poezio starts """ self.register_command('help', self.command_help, - usage=_('[command]'), + usage='[command]', shortdesc='\\_o< KOIN KOIN KOIN', completion=self.completion_help) self.register_command('join', self.command_join, - usage=_("[room_name][@server][/nick] [password]"), - desc=_("Join the specified room. You can specify a nickname " - "after a slash (/). If no nickname is specified, you will" - " use the default_nick in the configuration file. You can" - " omit the room name: you will then join the room you\'re" - " looking at (useful if you were kicked). You can also " - "provide a room_name without specifying a server, the " - "server of the room you're currently in will be used. You" - " can also provide a password to join the room.\nExamples" - ":\n/join room@server.tld\n/join room@server.tld/John\n" - "/join room2\n/join /me_again\n/join\n/join room@server" - ".tld/my_nick password\n/join / password"), - shortdesc=_('Join a room'), + usage="[room_name][@server][/nick] [password]", + desc="Join the specified room. You can specify a nickname " + "after a slash (/). If no nickname is specified, you will" + " use the default_nick in the configuration file. You can" + " omit the room name: you will then join the room you\'re" + " looking at (useful if you were kicked). You can also " + "provide a room_name without specifying a server, the " + "server of the room you're currently in will be used. You" + " can also provide a password to join the room.\nExamples" + ":\n/join room@server.tld\n/join room@server.tld/John\n" + "/join room2\n/join /me_again\n/join\n/join room@server" + ".tld/my_nick password\n/join / password", + shortdesc='Join a room', completion=self.completion_join) self.register_command('exit', self.command_quit, - desc=_('Just disconnect from the server and exit poezio.'), - shortdesc=_('Exit poezio.')) + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') self.register_command('quit', self.command_quit, - desc=_('Just disconnect from the server and exit poezio.'), - shortdesc=_('Exit poezio.')) + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') self.register_command('next', self.rotate_rooms_right, - shortdesc=_('Go to the next room.')) + shortdesc='Go to the next room.') self.register_command('prev', self.rotate_rooms_left, - shortdesc=_('Go to the previous room.')) + shortdesc='Go to the previous room.') self.register_command('win', self.command_win, - usage=_('<number or name>'), - shortdesc=_('Go to the specified room'), + usage='<number or name>', + shortdesc='Go to the specified room', completion=self.completion_win) self.commands['w'] = self.commands['win'] self.register_command('move_tab', self.command_move_tab, - usage=_('<source> <destination>'), - desc=_("Insert the <source> tab at the position of " - "<destination>. This will make the following tabs shift in" - " some cases (refer to the documentation). A tab can be " - "designated by its number or by the beginning of its " - "address. You can use \".\" as a shortcut for the current " - "tab."), - shortdesc=_('Move a tab.'), + usage='<source> <destination>', + desc="Insert the <source> tab at the position of " + "<destination>. This will make the following tabs shift in" + " some cases (refer to the documentation). A tab can be " + "designated by its number or by the beginning of its " + "address. You can use \".\" as a shortcut for the current " + "tab.", + shortdesc='Move a tab.', completion=self.completion_move_tab) self.register_command('destroy_room', self.command_destroy_room, - usage=_('[room JID]'), - desc=_('Try to destroy the room [room JID], or the current' - ' tab if it is a multi-user chat and [room JID] is ' - 'not given.'), - shortdesc=_('Destroy a room.'), + usage='[room JID]', + desc='Try to destroy the room [room JID], or the current' + ' tab if it is a multi-user chat and [room JID] is ' + 'not given.', + shortdesc='Destroy a room.', completion=None) self.register_command('show', self.command_status, - usage=_('<availability> [status message]'), - desc=_("Sets your availability and (optionally) your status " - "message. The <availability> argument is one of \"available" - ", chat, away, afk, dnd, busy, xa\" and the optional " - "[status message] argument will be your status message."), - shortdesc=_('Change your availability.'), + usage='<availability> [status message]', + desc="Sets your availability and (optionally) your status " + "message. The <availability> argument is one of \"available" + ", chat, away, afk, dnd, busy, xa\" and the optional " + "[status message] argument will be your status message.", + shortdesc='Change your availability.', completion=self.completion_status) self.commands['status'] = self.commands['show'] self.register_command('bookmark_local', self.command_bookmark_local, - usage=_("[roomname][/nick] [password]"), - desc=_("Bookmark Local: Bookmark locally the specified room " - "(you will then auto-join it on each poezio start). This" - " commands uses almost the same syntaxe as /join. Type " - "/help join for syntax examples. Note that when typing " - "\"/bookmark\" on its own, the room will be bookmarked " - "with the nickname you\'re currently using in this room " - "(instead of default_nick)"), - shortdesc=_('Bookmark a room locally.'), + usage="[roomname][/nick] [password]", + desc="Bookmark Local: Bookmark locally the specified room " + "(you will then auto-join it on each poezio start). This" + " commands uses almost the same syntaxe as /join. Type " + "/help join for syntax examples. Note that when typing " + "\"/bookmark\" on its own, the room will be bookmarked " + "with the nickname you\'re currently using in this room " + "(instead of default_nick)", + shortdesc='Bookmark a room locally.', completion=self.completion_bookmark_local) self.register_command('bookmark', self.command_bookmark, - usage=_("[roomname][/nick] [autojoin] [password]"), - desc=_("Bookmark: Bookmark online the specified room (you " - "will then auto-join it on each poezio start if autojoin" - " is specified and is 'true'). This commands uses almost" - " the same syntax as /join. Type /help join for syntax " - "examples. Note that when typing \"/bookmark\" alone, the" - " room will be bookmarked with the nickname you\'re " - "currently using in this room (instead of default_nick)."), - shortdesc=_("Bookmark a room online."), + usage="[roomname][/nick] [autojoin] [password]", + desc="Bookmark: Bookmark online the specified room (you " + "will then auto-join it on each poezio start if autojoin" + " is specified and is 'true'). This commands uses almost" + " the same syntax as /join. Type /help join for syntax " + "examples. Note that when typing \"/bookmark\" alone, the" + " room will be bookmarked with the nickname you\'re " + "currently using in this room (instead of default_nick).", + shortdesc="Bookmark a room online.", completion=self.completion_bookmark) self.register_command('set', self.command_set, - usage=_("[plugin|][section] <option> [value]"), - desc=_("Set the value of an option in your configuration file." - " You can, for example, change your default nickname by " - "doing `/set default_nick toto` or your resource with `/set" - "resource blabla`. You can also set options in specific " - "sections with `/set bindings M-i ^i` or in specific plugin" - " with `/set mpd_client| host 127.0.0.1`. `toggle` can be " - "used as a special value to toggle a boolean option."), - shortdesc=_("Set the value of an option"), + usage="[plugin|][section] <option> [value]", + desc="Set the value of an option in your configuration file." + " You can, for example, change your default nickname by " + "doing `/set default_nick toto` or your resource with `/set" + " resource blabla`. You can also set options in specific " + "sections with `/set bindings M-i ^i` or in specific plugin" + " with `/set mpd_client| host 127.0.0.1`. `toggle` can be " + "used as a special value to toggle a boolean option.", + shortdesc="Set the value of an option", completion=self.completion_set) + self.register_command('set_default', self.command_set_default, + usage="[section] <option>", + desc="Set the default value of an option. For example, " + "`/set_default resource` will reset the resource " + "option. You can also reset options in specific " + "sections by doing `/set_default section option`.", + shortdesc="Set the default value of an option", + completion=self.completion_set_default) self.register_command('toggle', self.command_toggle, - usage=_('<option>'), - desc=_('Shortcut for /set <option> toggle'), - shortdesc=_('Toggle an option'), + usage='<option>', + desc='Shortcut for /set <option> toggle', + shortdesc='Toggle an option', completion=self.completion_toggle) self.register_command('theme', self.command_theme, - usage=_('[theme name]'), - desc=_("Reload the theme defined in the config file. If theme" - "_name is provided, set that theme before reloading it."), - shortdesc=_('Load a theme'), + usage='[theme name]', + desc="Reload the theme defined in the config file. If theme" + "_name is provided, set that theme before reloading it.", + shortdesc='Load a theme', completion=self.completion_theme) self.register_command('list', self.command_list, - usage=_('[server]'), - desc=_("Get the list of public chatrooms" - " on the specified server."), - shortdesc=_('List the rooms.'), + usage='[server]', + desc="Get the list of public chatrooms" + " on the specified server.", + shortdesc='List the rooms.', completion=self.completion_list) self.register_command('message', self.command_message, - usage=_('<jid> [optional message]'), - desc=_("Open a conversation with the specified JID (even if it" - " is not in our roster), and send a message to it, if the " - "message is specified."), - shortdesc=_('Send a message'), + usage='<jid> [optional message]', + desc="Open a conversation with the specified JID (even if it" + " is not in our roster), and send a message to it, if the " + "message is specified.", + shortdesc='Send a message', completion=self.completion_message) self.register_command('version', self.command_version, usage='<jid>', - desc=_("Get the software version of the given JID (usually its" - " XMPP client and Operating System)."), - shortdesc=_('Get the software version of a JID.'), + desc="Get the software version of the given JID (usually its" + " XMPP client and Operating System).", + shortdesc='Get the software version of a JID.', completion=self.completion_version) self.register_command('server_cycle', self.command_server_cycle, - usage=_('[domain] [message]'), - desc=_('Disconnect and reconnect in all the rooms in domain.'), - shortdesc=_('Cycle a range of rooms'), + usage='[domain] [message]', + desc='Disconnect and reconnect in all the rooms in domain.', + shortdesc='Cycle a range of rooms', completion=self.completion_server_cycle) self.register_command('bind', self.command_bind, - usage=_('<key> <equ>'), - desc=_("Bind a key to another key or to a “command”. For " - "example \"/bind ^H KEY_UP\" makes Control + h do the" - " same same as the Up key."), + usage='<key> <equ>', + desc="Bind a key to another key or to a “command”. For " + "example \"/bind ^H KEY_UP\" makes Control + h do the" + " same same as the Up key.", completion=self.completion_bind, - shortdesc=_('Bind a key to another key.')) + shortdesc='Bind a key to another key.') self.register_command('load', self.command_load, - usage=_('<plugin> [<otherplugin> …]'), - shortdesc=_('Load the specified plugin(s)'), + usage='<plugin> [<otherplugin> …]', + shortdesc='Load the specified plugin(s)', completion=self.plugin_manager.completion_load) self.register_command('unload', self.command_unload, - usage=_('<plugin> [<otherplugin> …]'), - shortdesc=_('Unload the specified plugin(s)'), + usage='<plugin> [<otherplugin> …]', + shortdesc='Unload the specified plugin(s)', completion=self.plugin_manager.completion_unload) self.register_command('plugins', self.command_plugins, - shortdesc=_('Show the plugins in use.')) + shortdesc='Show the plugins in use.') self.register_command('presence', self.command_presence, - usage=_('<JID> [type] [status]'), - desc=_("Send a directed presence to <JID> and using" - " [type] and [status] if provided."), - shortdesc=_('Send a directed presence.'), + usage='<JID> [type] [status]', + desc="Send a directed presence to <JID> and using" + " [type] and [status] if provided.", + shortdesc='Send a directed presence.', completion=self.completion_presence) self.register_command('rawxml', self.command_rawxml, usage='<xml>', - shortdesc=_('Send a custom xml stanza.')) + shortdesc='Send a custom xml stanza.') self.register_command('invite', self.command_invite, - usage=_('<jid> <room> [reason]'), - desc=_('Invite jid in room with reason.'), - shortdesc=_('Invite someone in a room.'), + usage='<jid> <room> [reason]', + desc='Invite jid in room with reason.', + shortdesc='Invite someone in a room.', completion=self.completion_invite) self.register_command('invitations', self.command_invitations, - shortdesc=_('Show the pending invitations.')) + shortdesc='Show the pending invitations.') self.register_command('bookmarks', self.command_bookmarks, - shortdesc=_('Show the current bookmarks.')) + shortdesc='Show the current bookmarks.') self.register_command('remove_bookmark', self.command_remove_bookmark, usage='[jid]', - desc=_("Remove the specified bookmark, or the " - "bookmark on the current tab, if any."), - shortdesc=_('Remove a bookmark'), + desc="Remove the specified bookmark, or the " + "bookmark on the current tab, if any.", + shortdesc='Remove a bookmark', completion=self.completion_remove_bookmark) self.register_command('xml_tab', self.command_xml_tab, - shortdesc=_('Open an XML tab.')) + shortdesc='Open an XML tab.') self.register_command('runkey', self.command_runkey, - usage=_('<key>'), - shortdesc=_('Execute the action defined for <key>.'), + usage='<key>', + shortdesc='Execute the action defined for <key>.', completion=self.completion_runkey) self.register_command('self', self.command_self, - shortdesc=_('Remind you of who you are.')) + shortdesc='Remind you of who you are.') self.register_command('last_activity', self.command_last_activity, usage='<jid>', - desc=_('Informs you of the last activity of a JID.'), - shortdesc=_('Get the activity of someone.'), + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', completion=self.completion_last_activity) self.register_command('ad-hoc', self.command_adhoc, usage='<jid>', - shortdesc=_('List available ad-hoc commands on the given jid')) + shortdesc='List available ad-hoc commands on the given jid') + self.register_command('reload', self.command_reload, + shortdesc='Reload the config. You can achieve the same by ' + 'sending SIGUSR1 to poezio.') if config.get('enable_user_activity'): self.register_command('activity', self.command_activity, usage='[<general> [specific] [text]]', - desc=_('Send your current activity to your contacts ' - '(use the completion). Nothing means ' - '"stop broadcasting an activity".'), - shortdesc=_('Send your activity.'), + desc='Send your current activity to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting an activity".', + shortdesc='Send your activity.', completion=self.completion_activity) if config.get('enable_user_mood'): self.register_command('mood', self.command_mood, usage='[<mood> [text]]', - desc=_('Send your current mood to your contacts ' - '(use the completion). Nothing means ' - '"stop broadcasting a mood".'), - shortdesc=_('Send your mood.'), + desc='Send your current mood to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting a mood".', + shortdesc='Send your mood.', completion=self.completion_mood) if config.get('enable_user_gaming'): self.register_command('gaming', self.command_gaming, usage='[<game name> [server address]]', - desc=_('Send your current gaming activity to ' - 'your contacts. Nothing means "stop ' - 'broadcasting a gaming activity".'), - shortdesc=_('Send your gaming activity.'), + desc='Send your current gaming activity to ' + 'your contacts. Nothing means "stop ' + 'broadcasting a gaming activity".', + shortdesc='Send your gaming activity.', completion=None) ####################### XMPP Event Handlers ################################## @@ -1899,6 +1947,7 @@ class Core(object): on_groupchat_direct_invitation = handlers.on_groupchat_direct_invitation on_groupchat_decline = handlers.on_groupchat_decline on_message = handlers.on_message + on_error_message = handlers.on_error_message on_normal_message = handlers.on_normal_message on_nick_received = handlers.on_nick_received on_gaming_event = handlers.on_gaming_event @@ -1943,9 +1992,11 @@ class Core(object): on_receipt = handlers.on_receipt on_attention = handlers.on_attention room_error = handlers.room_error + check_bookmark_storage = handlers.check_bookmark_storage outgoing_stanza = handlers.outgoing_stanza incoming_stanza = handlers.incoming_stanza validate_ssl = handlers.validate_ssl + ssl_invalid_chain = handlers.ssl_invalid_chain on_next_adhoc_step = handlers.on_next_adhoc_step on_adhoc_error = handlers.on_adhoc_error cancel_adhoc_command = handlers.cancel_adhoc_command @@ -1967,6 +2018,7 @@ class Core(object): command_destroy_room = commands.command_destroy_room command_remove_bookmark = commands.command_remove_bookmark command_set = commands.command_set + command_set_default = commands.command_set_default command_toggle = commands.command_toggle command_server_cycle = commands.command_server_cycle command_last_activity = commands.command_last_activity @@ -1986,6 +2038,7 @@ class Core(object): command_xml_tab = commands.command_xml_tab command_adhoc = commands.command_adhoc command_self = commands.command_self + command_reload = commands.command_reload completion_help = completions.completion_help completion_status = completions.completion_status completion_presence = completions.completion_presence @@ -2007,6 +2060,7 @@ class Core(object): completion_last_activity = completions.completion_last_activity completion_server_cycle = completions.completion_server_cycle completion_set = completions.completion_set + completion_set_default = completions.completion_set_default completion_toggle = completions.completion_toggle completion_bookmark_local = completions.completion_bookmark_local diff --git a/src/core/handlers.py b/src/core/handlers.py index 50dca216..828c39d1 100644 --- a/src/core/handlers.py +++ b/src/core/handlers.py @@ -12,14 +12,12 @@ import ssl import sys import time from hashlib import sha1, sha512 -from gettext import gettext as _ from os import path from slixmpp import InvalidJID -from slixmpp.stanza import Message -from slixmpp.xmlstream.stanzabase import StanzaBase +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase +from xml.etree import ElementTree as ET -import bookmark import common import fixes import pep @@ -32,11 +30,64 @@ from config import config, CACHE_DIR from contact import Resource from logger import logger from roster import roster -from text_buffer import CorrectionError +from text_buffer import CorrectionError, AckError from theming import dump_tuple, get_theme from . commands import dumb_callback +try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import HtmlFormatter + LEXER = get_lexer_by_name('xml') + FORMATTER = HtmlFormatter(noclasses=True) + PYGMENTS = True +except ImportError: + PYGMENTS = False + +def _join_initial_rooms(self, bookmarks): + """Join all rooms given in the iterator `bookmarks`""" + for bm in bookmarks: + if not (bm.autojoin or config.get('open_all_bookmarks')): + continue + tab = self.get_tab_by_name(bm.jid, tabs.MucTab) + nick = bm.nick if bm.nick else self.own_nick + if not tab: + self.open_new_room(bm.jid, nick, focus=False) + self.initial_joins.append(bm.jid) + histo_length = config.get('muc_history_length') + if histo_length == -1: + histo_length = None + if histo_length is not None: + histo_length = str(histo_length) + # do not join rooms that do not have autojoin + # but display them anyway + if bm.autojoin: + muc.join_groupchat(self, bm.jid, nick, + passwd=bm.password, + maxhistory=histo_length, + status=self.status.message, + show=self.status.show) + +def check_bookmark_storage(self, features): + private = 'jabber:iq:private' in features + pep_ = 'http://jabber.org/protocol/pubsub#publish' in features + self.bookmarks.available_storage['private'] = private + self.bookmarks.available_storage['pep'] = pep_ + def _join_remote_only(iq): + if iq['type'] == 'error': + type_ = iq['error']['type'] + condition = iq['error']['condition'] + if not (type_ == 'cancel' and condition == 'item-not-found'): + self.information('Unable to fetch the remote' + ' bookmarks; %s: %s' % (type_, condition), + 'Error') + return + remote_bookmarks = self.bookmarks.remote() + _join_initial_rooms(self, remote_bookmarks) + if not self.xmpp.anon and config.get('use_remote_bookmarks'): + self.bookmarks.get_remote(self.xmpp, self.information, _join_remote_only) + def on_session_start_features(self, _): """ Enable carbons & blocking on session start if wanted and possible @@ -47,11 +98,13 @@ def on_session_start_features(self, _): features = iq['disco_info']['features'] rostertab = self.get_tab_by_name('Roster', tabs.RosterInfoTab) rostertab.check_blocking(features) + rostertab.check_saslexternal(features) if (config.get('enable_carbons') and 'urn:xmpp:carbons:2' in features): self.xmpp.plugin['xep_0280'].enable() self.xmpp.add_event_handler('carbon_received', self.on_carbon_received) self.xmpp.add_event_handler('carbon_sent', self.on_carbon_sent) + self.check_bookmark_storage(features) self.xmpp.plugin['xep_0030'].get_info(jid=self.xmpp.boundjid.domain, callback=callback) @@ -173,11 +226,31 @@ def on_message(self, message): jid_from = message['from'] for tab in self.get_tabs(tabs.MucTab): if tab.name == jid_from.bare: + if message['type'] == 'chat': + return self.on_groupchat_private_message(message) + return self.on_normal_message(message) + +def on_error_message(self, message): + """ + When receiving any message with type="error" + """ + jid_from = message['from'] + for tab in self.get_tabs(tabs.MucTab): + if tab.name == jid_from.bare: if message['type'] == 'error': - return self.room_error(message, jid_from) + return self.room_error(message, jid_from.bare) else: return self.on_groupchat_private_message(message) - return self.on_normal_message(message) + tab = self.get_conversation_by_jid(message['from'], create=False) + error_msg = self.get_error_message(message, deprecated=True) + if not tab: + return self.information(error_msg, 'Error') + error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK), + error_msg) + if not tab.nack_message('\n' + error, message['id'], message['to']): + tab.add_message(error, typ=0) + self.refresh_window() + def on_normal_message(self, message): """ @@ -185,7 +258,7 @@ def on_normal_message(self, message): muc participant) """ if message['type'] == 'error': - return self.information(self.get_error_message(message, deprecated=True), 'Error') + return elif message['type'] == 'headline' and message['body']: return self.information('%s says: %s' % (message['from'], message['body']), 'Headline') @@ -448,7 +521,7 @@ def on_groupchat_message(self, message): tab = self.get_tab_by_name(room_from, tabs.MucTab) if not tab: - self.information(_("message received for a non-existing room: %s") % (room_from)) + self.information("message received for a non-existing room: %s" % (room_from)) muc.leave_groupchat(self.xmpp, room_from, self.own_nick, msg='') return @@ -689,7 +762,10 @@ def on_subscription_request(self, presence): contact = roster.get_and_set(jid) roster.update_contact_groups(contact) contact.pending_in = True - self.information('%s wants to subscribe to your presence' % jid, 'Roster') + self.information('%s wants to subscribe to your presence, ' + 'use /accept <jid> or /deny <jid> to accept ' + 'or reject the query.' % jid, + 'Roster') self.get_tab_by_number(0).state = 'highlight' roster.modified() if isinstance(self.current_tab(), tabs.RosterInfoTab): @@ -782,7 +858,7 @@ def on_got_offline(self, presence): return jid = presence['from'] if not logger.log_roster_change(jid.bare, 'got offline'): - self.information(_('Unable to write in the log file'), 'Error') + self.information('Unable to write in the log file', 'Error') # If a resource got offline, display the message in the conversation with this # precise resource. if jid.resource: @@ -806,7 +882,7 @@ def on_got_online(self, presence): return roster.modified() if not logger.log_roster_change(jid.bare, 'got online'): - self.information(_('Unable to write in the log file'), 'Error') + self.information('Unable to write in the log file', 'Error') resource = Resource(jid.full, { 'priority': presence.get_priority() or 0, 'status': presence['status'], @@ -843,7 +919,7 @@ def on_failed_connection(self, error): """ We cannot contact the remote server """ - self.information(_("Connection to remote server failed: %s" % (error,)), _('Error')) + self.information("Connection to remote server failed: %s" % (error,), 'Error') def on_disconnected(self, event): """ @@ -854,9 +930,10 @@ def on_disconnected(self, event): roster.modified() for tab in self.get_tabs(tabs.MucTab): tab.disconnect() - self.information(_("Disconnected from server."), _('Error')) - if not self.legitimate_disconnect and config.get('auto_reconnect', False): - self.information(_("Auto-reconnecting."), _('Info')) + msg_typ = 'Error' if not self.legitimate_disconnect else 'Info' + self.information("Disconnected from server.", msg_typ) + if not self.legitimate_disconnect and config.get('auto_reconnect', True): + self.information("Auto-reconnecting.", 'Info') self.xmpp.connect() def on_stream_error(self, event): @@ -864,29 +941,29 @@ def on_stream_error(self, event): When we receive a stream error """ if event and event['text']: - self.information(_('Stream error: %s') % event['text'], _('Error')) + self.information('Stream error: %s' % event['text'], 'Error') def on_failed_all_auth(self, event): """ Authentication failed """ - self.information(_("Authentication failed (bad credentials?)."), - _('Error')) + self.information("Authentication failed (bad credentials?).", + 'Error') self.legitimate_disconnect = True def on_no_auth(self, event): """ Authentication failed (no mech) """ - self.information(_("Authentication failed, no login method available."), - _('Error')) + self.information("Authentication failed, no login method available.", + 'Error') self.legitimate_disconnect = True def on_connected(self, event): """ Remote host responded, but we are not yet authenticated """ - self.information(_("Connected to server."), 'Info') + self.information("Connected to server.", 'Info') def on_connecting(self, event): """ @@ -901,11 +978,12 @@ def on_session_start(self, event): self.connection_time = time.time() if not self.plugins_autoloaded: # Do not reload plugins on reconnection self.autoload_plugins() - self.information(_("Authentication success."), 'Info') - self.information(_("Your JID is %s") % self.xmpp.boundjid.full, 'Info') + self.information("Authentication success.", 'Info') + self.information("Your JID is %s" % self.xmpp.boundjid.full, 'Info') if not self.xmpp.anon: # request the roster self.xmpp.get_roster() + roster.update_contact_groups(self.xmpp.boundjid.bare) # send initial presence if config.get('send_initial_presence'): pres = self.xmpp.make_presence() @@ -913,37 +991,9 @@ def on_session_start(self, event): pres['status'] = self.status.message self.events.trigger('send_normal_presence', pres) pres.send() - bookmark.get_local() - def _join_initial_rooms(bookmarks): - """Join all rooms given in the iterator `bookmarks`""" - for bm in bookmarks: - if bm.autojoin or config.get('open_all_bookmarks'): - tab = self.get_tab_by_name(bm.jid, tabs.MucTab) - nick = bm.nick if bm.nick else self.own_nick - if not tab: - self.open_new_room(bm.jid, nick, False) - self.initial_joins.append(bm.jid) - histo_length = config.get('muc_history_length') - if histo_length == -1: - histo_length = None - if histo_length is not None: - histo_length = str(histo_length) - # do not join rooms that do not have autojoin - # but display them anyway - if bm.autojoin: - muc.join_groupchat(self, bm.jid, nick, - passwd=bm.password, - maxhistory=histo_length, - status=self.status.message, - show=self.status.show) - def _join_remote_only(): - remote_bookmarks = (bm for bm in bookmark.bookmarks if (bm.method in ("pep", "privatexml"))) - _join_initial_rooms(remote_bookmarks) - if not self.xmpp.anon and config.get('use_remote_bookmarks'): - bookmark.get_remote(self.xmpp, _join_remote_only) - # join all the available bookmarks. As of yet, this is just the local - # ones - _join_initial_rooms(bookmark.bookmarks) + self.bookmarks.get_local() + # join all the available bookmarks. As of yet, this is just the local ones + _join_initial_rooms(self, self.bookmarks) if config.get('enable_user_nick'): self.xmpp.plugin['xep_0172'].publish_nick(nick=self.own_nick, callback=dumb_callback) @@ -1024,12 +1074,12 @@ def on_groupchat_subject(self, message): # Do not display the message if the subject did not change or if we # receive an empty topic when joining the room. if nick_from: - tab.add_message(_("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s") % + tab.add_message("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'nick':nick_from, 'subject':subject}, time=None, typ=2) else: - tab.add_message(_("\x19%(info_col)s}The subject is: %(subject)s") % + tab.add_message("\x19%(info_col)s}The subject is: %(subject)s" % {'subject':subject, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, time=None, typ=2) @@ -1052,7 +1102,10 @@ def on_receipt(self, message): if not conversation: return - conversation.ack_message(msg_id) + try: + conversation.ack_message(msg_id, self.xmpp.boundjid) + except AckError: + log.debug('Error while receiving an ack', exc_info=True) def on_data_form(self, message): """ @@ -1083,19 +1136,21 @@ def room_error(self, error, room_name): Display the error in the tab """ tab = self.get_tab_by_name(room_name, tabs.MucTab) + if not tab: + return error_message = self.get_error_message(error) tab.add_message(error_message, highlight=True, nickname='Error', nick_color=get_theme().COLOR_ERROR_MSG, typ=2) code = error['error']['code'] if code == '401': - msg = _('To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)') + msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)' tab.add_message(msg, typ=2) if code == '409': if config.get('alternative_nickname') != '': self.command_join('%s/%s'% (tab.name, tab.own_nick+config.get('alternative_nickname'))) else: if not tab.joined: - tab.add_message(_('You can join the room with an other nick, by typing "/join /other_nick"'), typ=2) + tab.add_message('You can join the room with an other nick, by typing "/join /other_nick"', typ=2) self.refresh_window() def outgoing_stanza(self, stanza): @@ -1103,7 +1158,20 @@ def outgoing_stanza(self, stanza): We are sending a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x191}<--\x19o %s' % stanza) + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + try: + if self.xml_tab.match_stanza(ElementBase(ET.fromstring(stanza))): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + except: + log.debug('', exc_info=True) + if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() @@ -1113,11 +1181,27 @@ def incoming_stanza(self, stanza): We are receiving a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x192}-->\x19o %s' % stanza) + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + try: + if self.xml_tab.match_stanza(stanza): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + except: + log.debug('', exc_info=True) if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() +def ssl_invalid_chain(self, tb): + self.information('The certificate sent by the server is invalid.', 'Error') + self.disconnect() + def validate_ssl(self, pem): """ Check the server certificate using the slixmpp ssl_cert event @@ -1151,40 +1235,34 @@ def validate_ssl(self, pem): self.information('New certificate found (sha-2 hash:' ' %s)\nPlease validate or abort' % sha2_found_cert, 'Warning') - input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)") - self.current_tab().input = input - input.resize(1, self.current_tab().width, self.current_tab().height-1, 0) - input.refresh() - self.doupdate() - old_loop = asyncio.get_event_loop() - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - new_loop.add_reader(sys.stdin, self.on_input_readable) - future = asyncio.Future() - @asyncio.coroutine - def check_input(future): - while input.value is None: - yield from asyncio.sleep(0.01) + def check_input(): self.current_tab().input = saved_input self.paused = False if input.value: self.information('Setting new certificate: old: %s, new: %s' % (cert, sha2_found_cert), 'Info') log.debug('Setting certificate to %s', sha2_found_cert) if not config.silent_set('certificate', sha2_found_cert): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') else: self.information('You refused to validate the certificate. You are now disconnected', 'Info') - self.xmpp.disconnect() + self.disconnect() new_loop.stop() asyncio.set_event_loop(old_loop) - asyncio.async(check_input(future)) + input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)", callback=check_input) + self.current_tab().input = input + input.resize(1, self.current_tab().width, self.current_tab().height-1, 0) + input.refresh() + self.doupdate() + old_loop = asyncio.get_event_loop() + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + new_loop.add_reader(sys.stdin, self.on_input_readable) + curses.beep() new_loop.run_forever() - - else: log.debug('First time. Setting certificate to %s', sha2_found_cert) if not config.silent_set('certificate', sha2_found_cert): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') def _composing_tab_state(tab, state): """ diff --git a/src/core/structs.py b/src/core/structs.py index d97acd9f..4ce0ef43 100644 --- a/src/core/structs.py +++ b/src/core/structs.py @@ -2,39 +2,38 @@ Module defining structures useful to the core class and related methods """ import collections -from gettext import gettext as _ # http://xmpp.org/extensions/xep-0045.html#errorstatus ERROR_AND_STATUS_CODES = { - '401': _('A password is required'), - '403': _('Permission denied'), - '404': _('The room doesn’t exist'), - '405': _('Your are not allowed to create a new room'), - '406': _('A reserved nick must be used'), - '407': _('You are not in the member list'), - '409': _('This nickname is already in use or has been reserved'), - '503': _('The maximum number of users has been reached'), + '401': 'A password is required', + '403': 'Permission denied', + '404': 'The room doesn’t exist', + '405': 'Your are not allowed to create a new room', + '406': 'A reserved nick must be used', + '407': 'You are not in the member list', + '409': 'This nickname is already in use or has been reserved', + '503': 'The maximum number of users has been reached', } # http://xmpp.org/extensions/xep-0086.html DEPRECATED_ERRORS = { - '302': _('Redirect'), - '400': _('Bad request'), - '401': _('Not authorized'), - '402': _('Payment required'), - '403': _('Forbidden'), - '404': _('Not found'), - '405': _('Not allowed'), - '406': _('Not acceptable'), - '407': _('Registration required'), - '408': _('Request timeout'), - '409': _('Conflict'), - '500': _('Internal server error'), - '501': _('Feature not implemented'), - '502': _('Remote server error'), - '503': _('Service unavailable'), - '504': _('Remote server timeout'), - '510': _('Disconnected'), + '302': 'Redirect', + '400': 'Bad request', + '401': 'Not authorized', + '402': 'Payment required', + '403': 'Forbidden', + '404': 'Not found', + '405': 'Not allowed', + '406': 'Not acceptable', + '407': 'Registration required', + '408': 'Request timeout', + '409': 'Conflict', + '500': 'Internal server error', + '501': 'Feature not implemented', + '502': 'Remote server error', + '503': 'Service unavailable', + '504': 'Remote server timeout', + '510': 'Disconnected', } possible_show = {'available':None, diff --git a/src/daemon.py b/src/daemon.py index 395054a7..6325d8df 100755 --- a/src/daemon.py +++ b/src/daemon.py @@ -25,11 +25,7 @@ import subprocess import shlex import logging -try: - from subprocess import DEVNULL # Only in python >= 3.3 -except ImportError: - import os - DEVNULL = open(os.devnull, 'wb') +from subprocess import DEVNULL log = logging.getLogger(__name__) diff --git a/src/decorators.py b/src/decorators.py index 251d8749..c4ea6563 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -2,6 +2,8 @@ Module containing various decorators """ +import common + class RefreshWrapper(object): def __init__(self): self.core = None @@ -41,3 +43,97 @@ class RefreshWrapper(object): return wrap refresh_wrapper = RefreshWrapper() + +class CommandArgParser(object): + """Modify the string argument of the function into a list of strings + containing the right number of extracted arguments, or None if we don’t + have enough. + """ + @staticmethod + def raw(func): + """Just call the function with a single string, which is the original string + untouched + """ + def wrap(self, args, *a, **kw): + return func(self, args, *a, **kw) + return wrap + + @staticmethod + def ignored(func): + """ + Call the function without any argument + """ + def wrap(self, args, *a, **kw): + return func(self, *a, **kw) + return wrap + + @staticmethod + def quoted(mandatory, optional=0, defaults=[], + ignore_trailing_arguments=False): + + """The function receives a list with a number of arguments that is between + the numbers `mandatory` and `optional`. + + If the string doesn’t contain at least `mandatory` arguments, we return + None because the given arguments are invalid. + + If there are any remaining arguments after `mandatory` and `optional` + arguments have been found (and “ignore_trailing_arguments" is not True), + we happen them to the last argument of the list. + + An argument is a string (with or without whitespaces) between to quotes + ("), or a whitespace separated word (if not inside quotes). + + The argument `defaults` is a list of strings that are used when an + optional argument is missing. For example if we accept one optional + argument, zero is available but we have one value in the `defaults` + list, we use that string inplace. The `defaults` list can only + replace missing optional arguments, not mandatory ones. And it + should not contain more than `mandatory` values. Also you cannot + + Example: + This method needs at least one argument, and accepts up to 3 + arguments + + >> @command_args_parser.quoted(1, 2, ['default for first arg'], False) + >> def f(args): + >> print(args) + + >> f('coucou les amis') # We have one mandatory and two optional + ['coucou', 'les', 'amis'] + >> f('"coucou les amis" "PROUT PROUT"') # One mandator and only one optional, + # no default for the second + ['coucou les amis', 'PROUT PROUT'] + >> f('') # Not enough args for mandatory number + None + >> f('"coucou les potes"') # One mandatory, and use the default value + # for the first optional + ['coucou les potes, 'default for first arg'] + >> f('"un et demi" deux trois quatre cinq six') # We have three trailing arguments + ['un et demi', 'deux', 'trois quatre cinq six'] + + """ + def first(func): + def second(self, args, *a, **kw): + default_args = defaults + args = common.shell_split(args) + if len(args) < mandatory: + return func(self, None, *a, **kw) + res, args = args[:mandatory], args[mandatory:] + if optional == -1: + opt_args = args[:] + else: + opt_args = args[:optional] + + if opt_args: + res += opt_args + args = args[len(opt_args):] + default_args = default_args[len(opt_args):] + res += default_args + if args and res and not ignore_trailing_arguments: + res[-1] += " " + " ".join(args) + return func(self, res, *a, **kw) + return second + return first + +command_args_parser = CommandArgParser() diff --git a/src/events.py b/src/events.py index 50711022..15ef3e35 100644 --- a/src/events.py +++ b/src/events.py @@ -10,9 +10,6 @@ The list of available events is here: http://poezio.eu/doc/en/plugins.html#_poezio_events """ -import logging -log = logging.getLogger(__name__) - class EventHandler(object): """ A class keeping a list of possible events that are triggered @@ -71,9 +68,7 @@ class EventHandler(object): """ callbacks = self.events.get(name, None) if callbacks is None: - log.debug('%s: No such event.', name) return - log.debug('Event %s triggered, callbacks: %s', name, callbacks) for callback in callbacks: callback(*args, **kwargs) diff --git a/src/fixes.py b/src/fixes.py index 1c5da7c8..3840a093 100644 --- a/src/fixes.py +++ b/src/fixes.py @@ -41,12 +41,12 @@ def get_version(xmpp, jid, callback=None, **kwargs): def get_room_form(xmpp, room, callback): def _cb(result): if result["type"] == "error": - callback(None) + return callback(None) xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') if xform is None: - callback(None) + return callback(None) form = xmpp.plugin['xep_0004'].buildForm(xform) - callback(form) + return callback(form) iq = xmpp.make_iq_get(ito=room) query = ET.Element('{http://jabber.org/protocol/muc#owner}query') diff --git a/src/keyboard.py b/src/keyboard.py index ec1e7d0a..ccf9e752 100755 --- a/src/keyboard.py +++ b/src/keyboard.py @@ -66,6 +66,9 @@ def get_char_list(s): if key == '^[': try: part = s.get_wch() + if part == '[': + # CTRL+arrow and meta+arrow keys have a long format + part += s.get_wch() + s.get_wch() + s.get_wch() + s.get_wch() except curses.error: pass except ValueError: # invalid input diff --git a/src/logger.py b/src/logger.py index 85c7a746..7efa8f61 100644 --- a/src/logger.py +++ b/src/logger.py @@ -25,9 +25,7 @@ import logging log = logging.getLogger(__name__) -from config import LOG_DIR - -log_dir = os.path.join(LOG_DIR, 'logs') +from config import LOG_DIR as log_dir message_log_re = re.compile(r'MR (\d{4})(\d{2})(\d{2})T' r'(\d{2}):(\d{2}):(\d{2})Z ' @@ -119,10 +117,15 @@ class Logger(object): try: fd = open(os.path.join(log_dir, jid), 'rb') - except: + except FileNotFoundError: + log.info('Non-existing log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return + except OSError: log.error('Unable to open the log file (%s)', - os.path.join(log_dir, jid), - exc_info=True) + os.path.join(log_dir, jid), + exc_info=True) return if not fd: return diff --git a/src/multiuserchat.py b/src/multiuserchat.py index 92d09a60..80e2c706 100644 --- a/src/multiuserchat.py +++ b/src/multiuserchat.py @@ -11,7 +11,6 @@ Add some facilities that are not available on the XEP_0045 slix plugin """ -from gettext import gettext as _ from xml.etree import cElementTree as ET from common import safeJID @@ -43,10 +42,10 @@ def destroy_room(xmpp, room, reason='', altroom=''): iq.append(query) def callback(iq): if not iq or iq['type'] == 'error': - xmpp.core.information(_('Unable to destroy room %s') % room, - _('Info')) + xmpp.core.information('Unable to destroy room %s' % room, + 'Info') else: - xmpp.core.information(_('Room %s destroyed') % room, _('Info')) + xmpp.core.information('Room %s destroyed' % room, 'Info') iq.send(callback=callback) return True @@ -3,89 +3,87 @@ Collection of mappings for PEP moods/activities extracted directly from the XEP """ -from gettext import gettext as _ - MOODS = { - 'afraid': _('Afraid'), - 'amazed': _('Amazed'), - 'angry': _('Angry'), - 'amorous': _('Amorous'), - 'annoyed': _('Annoyed'), - 'anxious': _('Anxious'), - 'aroused': _('Aroused'), - 'ashamed': _('Ashamed'), - 'bored': _('Bored'), - 'brave': _('Brave'), - 'calm': _('Calm'), - 'cautious': _('Cautious'), - 'cold': _('Cold'), - 'confident': _('Confident'), - 'confused': _('Confused'), - 'contemplative': _('Contemplative'), - 'contented': _('Contented'), - 'cranky': _('Cranky'), - 'crazy': _('Crazy'), - 'creative': _('Creative'), - 'curious': _('Curious'), - 'dejected': _('Dejected'), - 'depressed': _('Depressed'), - 'disappointed': _('Disappointed'), - 'disgusted': _('Disgusted'), - 'dismayed': _('Dismayed'), - 'distracted': _('Distracted'), - 'embarrassed': _('Embarrassed'), - 'envious': _('Envious'), - 'excited': _('Excited'), - 'flirtatious': _('Flirtatious'), - 'frustrated': _('Frustrated'), - 'grumpy': _('Grumpy'), - 'guilty': _('Guilty'), - 'happy': _('Happy'), - 'hopeful': _('Hopeful'), - 'hot': _('Hot'), - 'humbled': _('Humbled'), - 'humiliated': _('Humiliated'), - 'hungry': _('Hungry'), - 'hurt': _('Hurt'), - 'impressed': _('Impressed'), - 'in_awe': _('In awe'), - 'in_love': _('In love'), - 'indignant': _('Indignant'), - 'interested': _('Interested'), - 'intoxicated': _('Intoxicated'), - 'invincible': _('Invincible'), - 'jealous': _('Jealous'), - 'lonely': _('Lonely'), - 'lucky': _('Lucky'), - 'mean': _('Mean'), - 'moody': _('Moody'), - 'nervous': _('Nervous'), - 'neutral': _('Neutral'), - 'offended': _('Offended'), - 'outraged': _('Outraged'), - 'playful': _('Playful'), - 'proud': _('Proud'), - 'relaxed': _('Relaxed'), - 'relieved': _('Relieved'), - 'remorseful': _('Remorseful'), - 'restless': _('Restless'), - 'sad': _('Sad'), - 'sarcastic': _('Sarcastic'), - 'serious': _('Serious'), - 'shocked': _('Shocked'), - 'shy': _('Shy'), - 'sick': _('Sick'), - 'sleepy': _('Sleepy'), - 'spontaneous': _('Spontaneous'), - 'stressed': _('Stressed'), - 'strong': _('Strong'), - 'surprised': _('Surprised'), - 'thankful': _('Thankful'), - 'thirsty': _('Thirsty'), - 'tired': _('Tired'), - 'undefined': _('Undefined'), - 'weak': _('Weak'), - 'worried': _('Worried') + 'afraid': 'Afraid', + 'amazed': 'Amazed', + 'angry': 'Angry', + 'amorous': 'Amorous', + 'annoyed': 'Annoyed', + 'anxious': 'Anxious', + 'aroused': 'Aroused', + 'ashamed': 'Ashamed', + 'bored': 'Bored', + 'brave': 'Brave', + 'calm': 'Calm', + 'cautious': 'Cautious', + 'cold': 'Cold', + 'confident': 'Confident', + 'confused': 'Confused', + 'contemplative': 'Contemplative', + 'contented': 'Contented', + 'cranky': 'Cranky', + 'crazy': 'Crazy', + 'creative': 'Creative', + 'curious': 'Curious', + 'dejected': 'Dejected', + 'depressed': 'Depressed', + 'disappointed': 'Disappointed', + 'disgusted': 'Disgusted', + 'dismayed': 'Dismayed', + 'distracted': 'Distracted', + 'embarrassed': 'Embarrassed', + 'envious': 'Envious', + 'excited': 'Excited', + 'flirtatious': 'Flirtatious', + 'frustrated': 'Frustrated', + 'grumpy': 'Grumpy', + 'guilty': 'Guilty', + 'happy': 'Happy', + 'hopeful': 'Hopeful', + 'hot': 'Hot', + 'humbled': 'Humbled', + 'humiliated': 'Humiliated', + 'hungry': 'Hungry', + 'hurt': 'Hurt', + 'impressed': 'Impressed', + 'in_awe': 'In awe', + 'in_love': 'In love', + 'indignant': 'Indignant', + 'interested': 'Interested', + 'intoxicated': 'Intoxicated', + 'invincible': 'Invincible', + 'jealous': 'Jealous', + 'lonely': 'Lonely', + 'lucky': 'Lucky', + 'mean': 'Mean', + 'moody': 'Moody', + 'nervous': 'Nervous', + 'neutral': 'Neutral', + 'offended': 'Offended', + 'outraged': 'Outraged', + 'playful': 'Playful', + 'proud': 'Proud', + 'relaxed': 'Relaxed', + 'relieved': 'Relieved', + 'remorseful': 'Remorseful', + 'restless': 'Restless', + 'sad': 'Sad', + 'sarcastic': 'Sarcastic', + 'serious': 'Serious', + 'shocked': 'Shocked', + 'shy': 'Shy', + 'sick': 'Sick', + 'sleepy': 'Sleepy', + 'spontaneous': 'Spontaneous', + 'stressed': 'Stressed', + 'strong': 'Strong', + 'surprised': 'Surprised', + 'thankful': 'Thankful', + 'thirsty': 'Thirsty', + 'tired': 'Tired', + 'undefined': 'Undefined', + 'weak': 'Weak', + 'worried': 'Worried' } @@ -93,131 +91,131 @@ MOODS = { ACTIVITIES = { 'doing_chores': { - 'category': _('Doing_chores'), - - 'buying_groceries': _('Buying groceries'), - 'cleaning': _('Cleaning'), - 'cooking': _('Cooking'), - 'doing_maintenance': _('Doing maintenance'), - 'doing_the_dishes': _('Doing the dishes'), - 'doing_the_laundry': _('Doing the laundry'), - 'gardening': _('Gardening'), - 'running_an_errand': _('Running an errand'), - 'walking_the_dog': _('Walking the dog'), - 'other': _('Other'), + 'category': 'Doing_chores', + + 'buying_groceries': 'Buying groceries', + 'cleaning': 'Cleaning', + 'cooking': 'Cooking', + 'doing_maintenance': 'Doing maintenance', + 'doing_the_dishes': 'Doing the dishes', + 'doing_the_laundry': 'Doing the laundry', + 'gardening': 'Gardening', + 'running_an_errand': 'Running an errand', + 'walking_the_dog': 'Walking the dog', + 'other': 'Other', }, 'drinking': { - 'category': _('Drinking'), + 'category': 'Drinking', - 'having_a_beer': _('Having a beer'), - 'having_coffee': _('Having coffee'), - 'having_tea': _('Having tea'), - 'other': _('Other'), + 'having_a_beer': 'Having a beer', + 'having_coffee': 'Having coffee', + 'having_tea': 'Having tea', + 'other': 'Other', }, 'eating': { - 'category':_('Eating'), + 'category':'Eating', - 'having_breakfast': _('Having breakfast'), - 'having_a_snack': _('Having a snack'), - 'having_dinner': _('Having dinner'), - 'having_lunch': _('Having lunch'), - 'other': _('Other'), + 'having_breakfast': 'Having breakfast', + 'having_a_snack': 'Having a snack', + 'having_dinner': 'Having dinner', + 'having_lunch': 'Having lunch', + 'other': 'Other', }, 'exercising': { - 'category': _('Exercising'), - - 'cycling': _('Cycling'), - 'dancing': _('Dancing'), - 'hiking': _('Hiking'), - 'jogging': _('Jogging'), - 'playing_sports': _('Playing sports'), - 'running': _('Running'), - 'skiing': _('Skiing'), - 'swimming': _('Swimming'), - 'working_out': _('Working out'), - 'other': _('Other'), + 'category': 'Exercising', + + 'cycling': 'Cycling', + 'dancing': 'Dancing', + 'hiking': 'Hiking', + 'jogging': 'Jogging', + 'playing_sports': 'Playing sports', + 'running': 'Running', + 'skiing': 'Skiing', + 'swimming': 'Swimming', + 'working_out': 'Working out', + 'other': 'Other', }, 'grooming': { - 'category': _('Grooming'), - - 'at_the_spa': _('At the spa'), - 'brushing_teeth': _('Brushing teeth'), - 'getting_a_haircut': _('Getting a haircut'), - 'shaving': _('Shaving'), - 'taking_a_bath': _('Taking a bath'), - 'taking_a_shower': _('Taking a shower'), - 'other': _('Other'), + 'category': 'Grooming', + + 'at_the_spa': 'At the spa', + 'brushing_teeth': 'Brushing teeth', + 'getting_a_haircut': 'Getting a haircut', + 'shaving': 'Shaving', + 'taking_a_bath': 'Taking a bath', + 'taking_a_shower': 'Taking a shower', + 'other': 'Other', }, 'having_appointment': { - 'category': _('Having appointment'), + 'category': 'Having appointment', - 'other': _('Other'), + 'other': 'Other', }, 'inactive': { - 'category': _('Inactive'), - - 'day_off': _('Day_off'), - 'hanging_out': _('Hanging out'), - 'hiding': _('Hiding'), - 'on_vacation': _('On vacation'), - 'praying': _('Praying'), - 'scheduled_holiday': _('Scheduled holiday'), - 'sleeping': _('Sleeping'), - 'thinking': _('Thinking'), - 'other': _('Other'), + 'category': 'Inactive', + + 'day_off': 'Day_off', + 'hanging_out': 'Hanging out', + 'hiding': 'Hiding', + 'on_vacation': 'On vacation', + 'praying': 'Praying', + 'scheduled_holiday': 'Scheduled holiday', + 'sleeping': 'Sleeping', + 'thinking': 'Thinking', + 'other': 'Other', }, 'relaxing': { - 'category': _('Relaxing'), - - 'fishing': _('Fishing'), - 'gaming': _('Gaming'), - 'going_out': _('Going out'), - 'partying': _('Partying'), - 'reading': _('Reading'), - 'rehearsing': _('Rehearsing'), - 'shopping': _('Shopping'), - 'smoking': _('Smoking'), - 'socializing': _('Socializing'), - 'sunbathing': _('Sunbathing'), - 'watching_a_movie': _('Watching a movie'), - 'watching_tv': _('Watching tv'), - 'other': _('Other'), + 'category': 'Relaxing', + + 'fishing': 'Fishing', + 'gaming': 'Gaming', + 'going_out': 'Going out', + 'partying': 'Partying', + 'reading': 'Reading', + 'rehearsing': 'Rehearsing', + 'shopping': 'Shopping', + 'smoking': 'Smoking', + 'socializing': 'Socializing', + 'sunbathing': 'Sunbathing', + 'watching_a_movie': 'Watching a movie', + 'watching_tv': 'Watching tv', + 'other': 'Other', }, 'talking': { - 'category': _('Talking'), + 'category': 'Talking', - 'in_real_life': _('In real life'), - 'on_the_phone': _('On the phone'), - 'on_video_phone': _('On video phone'), - 'other': _('Other'), + 'in_real_life': 'In real life', + 'on_the_phone': 'On the phone', + 'on_video_phone': 'On video phone', + 'other': 'Other', }, 'traveling': { - 'category': _('Traveling'), - - 'commuting': _('Commuting'), - 'driving': _('Driving'), - 'in_a_car': _('In a car'), - 'on_a_bus': _('On a bus'), - 'on_a_plane': _('On a plane'), - 'on_a_train': _('On a train'), - 'on_a_trip': _('On a trip'), - 'walking': _('Walking'), - 'cycling': _('Cycling'), - 'other': _('Other'), + 'category': 'Traveling', + + 'commuting': 'Commuting', + 'driving': 'Driving', + 'in_a_car': 'In a car', + 'on_a_bus': 'On a bus', + 'on_a_plane': 'On a plane', + 'on_a_train': 'On a train', + 'on_a_trip': 'On a trip', + 'walking': 'Walking', + 'cycling': 'Cycling', + 'other': 'Other', }, 'undefined': { - 'category': _('Undefined'), + 'category': 'Undefined', - 'other': _('Other'), + 'other': 'Other', }, 'working': { - 'category': _('Working'), + 'category': 'Working', - 'coding': _('Coding'), - 'in_a_meeting': _('In a meeting'), - 'writing': _('Writing'), - 'studying': _('Studying'), - 'other': _('Other'), + 'coding': 'Coding', + 'in_a_meeting': 'In a meeting', + 'writing': 'Writing', + 'studying': 'Studying', + 'other': 'Other', } } diff --git a/src/plugin.py b/src/plugin.py index eb2a89e3..eca6baf2 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -19,12 +19,12 @@ class PluginConfig(config.Config): They are accessible inside the plugin with self.config and behave like the core Config object. """ - def __init__(self, filename, module_name): - config.Config.__init__(self, filename) + def __init__(self, filename, module_name, default=None): + config.Config.__init__(self, filename, default=default) self.module_name = module_name self.read() - def get(self, option, default, section=None): + def get(self, option, default=None, section=None): if not section: section = self.module_name return config.Config.get(self, option, default, section) @@ -80,6 +80,7 @@ class SafetyMetaclass(type): if inspect.stack()[1][1] == inspect.getfile(f): raise elif SafetyMetaclass.core: + log.error('Error in a plugin', exc_info=True) SafetyMetaclass.core.information(traceback.format_exc()) return None return helper @@ -364,12 +365,19 @@ class BasePlugin(object, metaclass=SafetyMetaclass): Class that all plugins derive from. """ + default_config = None + def __init__(self, plugin_api, core, plugins_conf_dir): self.core = core # More hack; luckily we'll never have more than one core object SafetyMetaclass.core = core conf = os.path.join(plugins_conf_dir, self.__module__+'.cfg') - self.config = PluginConfig(conf, self.__module__) + try: + self.config = PluginConfig(conf, self.__module__, + default=self.default_config) + except Exception: + log.debug('Error while creating the plugin config', exc_info=True) + self.config = PluginConfig(conf, self.__module__) self._api = plugin_api[self.name] self.init() diff --git a/src/plugin_manager.py b/src/plugin_manager.py index d4cc7384..549753a9 100644 --- a/src/plugin_manager.py +++ b/src/plugin_manager.py @@ -5,12 +5,9 @@ the API together. Defines also a bunch of variables related to the plugin env. """ -import imp import os from os import path import logging -from gettext import gettext as _ -from sys import version_info import core import tabs @@ -44,9 +41,8 @@ class PluginManager(object): self.tab_keys = {} self.roster_elements = {} - if version_info[1] >= 3: # 3.3 & > - from importlib import machinery - self.finder = machinery.PathFinder() + from importlib import machinery + self.finder = machinery.PathFinder() self.initial_set_plugins_dir() self.initial_set_plugins_conf_dir() @@ -70,29 +66,16 @@ class PluginManager(object): try: module = None - if version_info[1] < 3: # < 3.3 - if name in self.modules: - imp.acquire_lock() - module = imp.reload(self.modules[name]) - else: - file, filename, info = imp.find_module(name, - self.load_path) - imp.acquire_lock() - module = imp.load_module(name, file, filename, info) - else: # 3.3 & > - loader = self.finder.find_module(name, self.load_path) - if not loader: - self.core.information('Could not find plugin: %s' % name) - return - module = loader.load_module() - + loader = self.finder.find_module(name, self.load_path) + if not loader: + self.core.information('Could not find plugin: %s' % name) + return + module = loader.load_module() except Exception as e: log.debug("Could not load plugin %s", name, exc_info=True) self.core.information("Could not load plugin %s: %s" % (name, e), 'Error') finally: - if version_info[1] < 3 and imp.lock_held(): - imp.release_lock() if not module: return @@ -109,8 +92,8 @@ class PluginManager(object): except Exception as e: log.error('Error while loading the plugin %s', name, exc_info=True) if notify: - self.core.information(_('Unable to load the plugin %s: %s') % - (name, e), + self.core.information('Unable to load the plugin %s: %s' % + (name, e), 'Error') self.unload(name, notify=False) else: @@ -147,8 +130,8 @@ class PluginManager(object): self.core.information('Plugin %s unloaded' % name, 'Info') except Exception as e: log.debug("Could not unload plugin %s", name, exc_info=True) - self.core.information(_("Could not unload plugin %s: %s") % - (name, e), + self.core.information("Could not unload plugin %s: %s" % + (name, e), 'Error') def add_command(self, module_name, name, handler, help, @@ -157,7 +140,7 @@ class PluginManager(object): Add a global command. """ if name in self.core.commands: - raise Exception(_("Command '%s' already exists") % (name,)) + raise Exception("Command '%s' already exists" % (name,)) commands = self.commands[module_name] commands[name] = core.Command(handler, help, completion, short, usage) @@ -244,7 +227,7 @@ class PluginManager(object): already exists. """ if key in self.core.key_func: - raise Exception(_("Key '%s' already exists") % (key,)) + raise Exception("Key '%s' already exists" % (key,)) keys = self.keys[module_name] keys[key] = handler self.core.key_func[key] = handler @@ -295,7 +278,7 @@ class PluginManager(object): except: pass except OSError as e: - self.core.information(_('Completion failed: %s' % e), 'Error') + self.core.information('Completion failed: %s' % e, 'Error') return plugins_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py' and not name.startswith('.')] diff --git a/src/poezio.py b/src/poezio.py index 9a26e135..7a83f510 100644 --- a/src/poezio.py +++ b/src/poezio.py @@ -19,6 +19,29 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) import singleton +def test_curses(): + """ + Check if the system ncurses linked with python has unicode capabilities. + """ + import curses + if hasattr(curses, 'unget_wch'): + return True + print("""\ +ERROR: The current python executable is linked with a ncurses version that \ +has no unicode capabilities. + +This could mean that: + - python was built on a system where readline is linked against \ +libncurses and not libncursesw + - python was built without ncursesw headers available + +Please file a bug for your distribution or fix that on your system and then \ +recompile python. +Poezio is currently unable to read your input or draw its interface properly,\ + so it will now exit.""") + return False + + def main(): """ Enter point @@ -36,6 +59,10 @@ def main(): from config import options + if options.check_config: + config.check_config() + sys.exit(0) + import theming theming.update_themes_dir() @@ -75,4 +102,7 @@ def main(): pass if __name__ == '__main__': - main() + if test_curses(): + main() + else: + sys.exit(1) diff --git a/src/tabs/__init__.py b/src/tabs/__init__.py index eaf41a2f..d0a881a6 100644 --- a/src/tabs/__init__.py +++ b/src/tabs/__init__.py @@ -10,3 +10,4 @@ from . listtab import ListTab from . muclisttab import MucListTab from . adhoc_commands_list import AdhocCommandsListTab from . data_forms import DataFormsTab +from . bookmarkstab import BookmarksTab diff --git a/src/tabs/adhoc_commands_list.py b/src/tabs/adhoc_commands_list.py index 7f5abf6a..10ebf22b 100644 --- a/src/tabs/adhoc_commands_list.py +++ b/src/tabs/adhoc_commands_list.py @@ -4,8 +4,6 @@ select one of them and start executing it, or just close the tab and do nothing. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -20,7 +18,7 @@ class AdhocCommandsListTab(ListTab): def __init__(self, jid): ListTab.__init__(self, jid.full, "“Enter”: execute selected command.", - _('Ad-hoc commands of JID %s (Loading)') % jid, + 'Ad-hoc commands of JID %s (Loading)' % jid, (('Node', 0), ('Description', 1))) self.key_func['^M'] = self.execute_selected_command @@ -50,7 +48,7 @@ class AdhocCommandsListTab(ListTab): yield item items = [(item['node'], item['name'] or '', item['jid']) for item in get_items()] self.listview.set_lines(items) - self.info_header.message = _('Ad-hoc commands of JID %s') % self.name + self.info_header.message = 'Ad-hoc commands of JID %s' % self.name if self.core.current_tab() is self: self.refresh() else: diff --git a/src/tabs/basetabs.py b/src/tabs/basetabs.py index 0a55640c..30ddf239 100644 --- a/src/tabs/basetabs.py +++ b/src/tabs/basetabs.py @@ -13,8 +13,6 @@ This module also defines ChatTabs, the parent class for all tabs revolving around chats. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -35,7 +33,7 @@ from decorators import refresh_wrapper from logger import logger from text_buffer import TextBuffer from theming import get_theme, dump_tuple - +from decorators import command_args_parser # getters for tab colors (lambdas, so that they are dynamic) STATE_COLORS = { @@ -254,7 +252,6 @@ class Tab(object): return False # There's no completion function else: return command[2](the_input) - return True return False def execute_command(self, provided_text): @@ -282,14 +279,15 @@ class Tab(object): if self.missing_command_callback is not None: error_handled = self.missing_command_callback(low) if not error_handled: - self.core.information(_("Unknown command (%s)") % - (command), - _('Error')) + self.core.information("Unknown command (%s)" % + (command), + 'Error') if command in ('correct', 'say'): # hack arg = xhtml.convert_simple_to_full_colors(arg) else: arg = xhtml.clean_text_simple(arg) if func: + self.input.reset_completion() func(arg) return True else: @@ -455,16 +453,16 @@ class ChatTab(Tab): self.key_func['M-/'] = self.last_words_completion self.key_func['^M'] = self.on_enter self.register_command('say', self.command_say, - usage=_('<message>'), - shortdesc=_('Send the message.')) + usage='<message>', + shortdesc='Send the message.') self.register_command('xhtml', self.command_xhtml, - usage=_('<custom xhtml>'), - shortdesc=_('Send custom XHTML.')) + usage='<custom xhtml>', + shortdesc='Send custom XHTML.') self.register_command('clear', self.command_clear, - shortdesc=_('Clear the current buffer.')) + shortdesc='Clear the current buffer.') self.register_command('correct', self.command_correct, - desc=_('Fix the last message with whatever you want.'), - shortdesc=_('Correct the last message.'), + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', completion=self.completion_correct) self.chat_state = None self.update_commands() @@ -492,7 +490,7 @@ class ChatTab(Tab): """ name = safeJID(self.name).bare if not logger.log_message(name, nickname, txt, date=time, typ=typ): - self.core.information(_('Unable to write in the log file'), 'Error') + self.core.information('Unable to write in the log file', 'Error') def add_message(self, txt, time=None, nickname=None, forced_user=None, nick_color=None, identifier=None, jid=None, history=None, @@ -544,11 +542,12 @@ class ChatTab(Tab): self.command_say(xhtml.convert_simple_to_full_colors(txt)) self.cancel_paused_delay() - def command_xhtml(self, arg): + @command_args_parser.raw + def command_xhtml(self, xhtml): """" /xhtml <custom xhtml> """ - message = self.generate_xhtml_message(arg) + message = self.generate_xhtml_message(xhtml) if message: message.send() @@ -573,7 +572,7 @@ class ChatTab(Tab): return self.name @refresh_wrapper.always - def command_clear(self, args): + def command_clear(self, ignored): """ /clear """ @@ -637,6 +636,7 @@ class ChatTab(Tab): self.core.remove_timed_event(self.timed_event_paused) self.timed_event_paused = None + @command_args_parser.raw def command_correct(self, line): """ /correct <fixed message> @@ -645,7 +645,7 @@ class ChatTab(Tab): self.core.command_help('correct') return if not self.last_sent_message: - self.core.information(_('There is no message to correct.')) + self.core.information('There is no message to correct.') return self.command_say(line, correct=True) @@ -672,6 +672,7 @@ class ChatTab(Tab): if self.text_win.pos != 0: self.state = 'scrolled' + @command_args_parser.raw def command_say(self, line, correct=False): pass @@ -707,20 +708,67 @@ class OneToOneTab(ChatTab): # change this to True or False when # we know that the remote user wants chatstates, or not. # None means we don’t know yet, and we send only "active" chatstates - self.remote_wants_chatstates = None + self._remote_wants_chatstates = None self.remote_supports_attention = True self.remote_supports_receipts = True self.check_features() - def ack_message(self, msg_id): + @property + def remote_wants_chatstates(self): + return self._remote_wants_chatstates + + @remote_wants_chatstates.setter + def remote_wants_chatstates(self, value): + old_value = self._remote_wants_chatstates + self._remote_wants_chatstates = value + if (old_value is None and value != None) or \ + (old_value != value and value != None): + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + support = ok if value else nope + if value: + msg = '\x19%s}Contact supports chat states [%s].' + else: + msg = '\x19%s}Contact does not support chat states [%s].' + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, support) + self.add_message(msg, typ=0) + self.core.refresh_window() + + def ack_message(self, msg_id, msg_jid): """ Ack a message """ - new_msg = self._text_buffer.ack_message(msg_id) + new_msg = self._text_buffer.ack_message(msg_id, msg_jid) if new_msg: self.text_win.modify_message(msg_id, new_msg) self.core.refresh_window() + def nack_message(self, error, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + return True + return False + + @command_args_parser.raw + def command_xhtml(self, xhtml_data): + message = self.generate_xhtml_message(xhtml_data) + if message: + if self.remote_supports_receipts: + message._add_receipt = True + if self.remote_wants_chatstates: + message['chat_sate'] = 'active' + message.send() + body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) + self._text_buffer.add_message(body, nickname=self.core.own_nick, + identifier=message['id'],) + self.refresh() + def check_features(self): "check the features supported by the other party" if safeJID(self.get_dest_jid()).resource: @@ -728,8 +776,9 @@ class OneToOneTab(ChatTab): jid=self.get_dest_jid(), timeout=5, callback=self.features_checked) - def command_attention(self, message=''): - "/attention [message]" + @command_args_parser.raw + def command_attention(self, message): + """/attention [message]""" if message is not '': self.command_say(message, attention=True) else: @@ -738,6 +787,7 @@ class OneToOneTab(ChatTab): msg['attention'] = True msg.send() + @command_args_parser.raw def command_say(self, line, correct=False, attention=False): pass @@ -746,11 +796,11 @@ class OneToOneTab(ChatTab): return False if command_name == 'correct': - feature = _('message correction') + feature = 'message correction' elif command_name == 'attention': - feature = _('attention requests') - msg = _('%s does not support %s, therefore the /%s ' - 'command is currently disabled in this tab.') + feature = 'attention requests' + msg = ('%s does not support %s, therefore the /%s ' + 'command is currently disabled in this tab.') msg = msg % (self.name, feature, command_name) self.core.information(msg, 'Info') return True @@ -760,11 +810,11 @@ class OneToOneTab(ChatTab): if 'urn:xmpp:attention:0' in features: self.remote_supports_attention = True self.register_command('attention', self.command_attention, - usage=_('[message]'), - shortdesc=_('Request the attention.'), - desc=_('Attention: Request the attention of ' - 'the contact. Can also send a message' - ' along with the attention.')) + usage='[message]', + shortdesc='Request the attention.', + desc='Attention: Request the attention of ' + 'the contact. Can also send a message' + ' along with the attention.') else: self.remote_supports_attention = False return self.remote_supports_attention @@ -776,8 +826,8 @@ class OneToOneTab(ChatTab): del self.commands['correct'] elif not 'correct' in self.commands: self.register_command('correct', self.command_correct, - desc=_('Fix the last message with whatever you want.'), - shortdesc=_('Correct the last message.'), + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', completion=self.completion_correct) return 'correct' in self.commands @@ -814,8 +864,8 @@ class OneToOneTab(ChatTab): attention = ok if attention else nope receipts = ok if receipts else nope - msg = _('\x19%s}Contact supports: correction [%s], ' - 'attention [%s], receipts [%s].') + msg = ('\x19%s}Contact supports: correction [%s], ' + 'attention [%s], receipts [%s].') color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) msg = msg % (color, correct, attention, receipts) self.add_message(msg, typ=0) diff --git a/src/tabs/bookmarkstab.py b/src/tabs/bookmarkstab.py new file mode 100644 index 00000000..7f5069ea --- /dev/null +++ b/src/tabs/bookmarkstab.py @@ -0,0 +1,145 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from bookmarks import Bookmark, BookmarkList, stanza_storage +from tabs import Tab +from common import safeJID + + +class BookmarksTab(Tab): + """ + A tab displaying lines of bookmarks, each bookmark having + a 4 widgets to set the jid/password/autojoin/storage method + """ + plugin_commands = {} + def __init__(self, bookmarks: BookmarkList): + Tab.__init__(self) + self.name = "Bookmarks" + self.bookmarks = bookmarks + self.new_bookmarks = [] + self.removed_bookmarks = [] + self.header_win = windows.ColumnHeaderWin(('room@server/nickname', + 'password', + 'autojoin', + 'storage')) + self.bookmarks_win = windows.BookmarksWin(self.bookmarks, + self.height-4, + self.width, 1, 0) + self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, ' + '↑↓: change lines, tab: change ' + 'column, M-a: add bookmark, C-k' + ': delete bookmark') + self.info_header = windows.BookmarksInfoWin() + self.key_func['KEY_UP'] = self.bookmarks_win.go_to_previous_line_input + self.key_func['KEY_DOWN'] = self.bookmarks_win.go_to_next_line_input + self.key_func['^I'] = self.bookmarks_win.go_to_next_horizontal_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_save + self.key_func['M-a'] = self.add_bookmark + self.key_func['^K'] = self.del_bookmark + self.resize() + self.update_commands() + + def add_bookmark(self): + new_bookmark = Bookmark(safeJID('room@example.tld/nick'), method='local') + self.new_bookmarks.append(new_bookmark) + self.bookmarks_win.add_bookmark(new_bookmark) + + def del_bookmark(self): + current = self.bookmarks_win.del_current_bookmark() + if current in self.new_bookmarks: + self.new_bookmarks.remove(current) + else: + self.removed_bookmarks.append(current) + + def on_cancel(self): + self.core.close_tab() + return True + + def on_save(self): + self.bookmarks_win.save() + if find_duplicates(self.new_bookmarks): + self.core.information('Duplicate bookmarks in list (saving aborted)', 'Error') + return + for bm in self.new_bookmarks: + if safeJID(bm.jid): + if not self.bookmarks[bm.jid]: + self.bookmarks.append(bm) + else: + self.core.information('Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick), 'Error') + return + + for bm in self.removed_bookmarks: + if bm in self.bookmarks: + self.bookmarks.remove(bm) + + def send_cb(success): + if success: + self.core.information('Bookmarks saved.', 'Info') + else: + self.core.information('Remote bookmarks not saved.', 'Error') + log.debug('alerte %s', str(stanza_storage(self.bookmarks.bookmarks))) + self.bookmarks.save(self.core.xmpp, callback=send_cb) + self.core.close_tab() + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.bookmarks_win.refresh_current_input() + else: + self.bookmarks_win.on_input(key) + + def resize(self): + self.need_resize = False + self.header_win.resize_columns({ + 'room@server/nickname': self.width//3, + 'password': self.width//3, + 'autojoin': self.width//6, + 'storage': self.width//6 + }) + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.header_win.resize(1, self.width, 0, 0) + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height - 3: + return + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def refresh(self): + if self.need_resize: + self.resize() + self.header_win.refresh() + self.refresh_tab_win() + self.help_win.refresh() + self.info_header.refresh(self.bookmarks.preferred) + self.info_win.refresh() + self.bookmarks_win.refresh() + + +def find_duplicates(bm_list): + jids = set() + for bookmark in bm_list: + if bookmark.jid in jids: + return True + jids.add(bookmark.jid) + return False + diff --git a/src/tabs/conversationtab.py b/src/tabs/conversationtab.py index 52c503d7..1d8c60a4 100644 --- a/src/tabs/conversationtab.py +++ b/src/tabs/conversationtab.py @@ -11,8 +11,6 @@ There are two different instances of a ConversationTab: the time. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -29,6 +27,7 @@ from config import config from decorators import refresh_wrapper from roster import roster from theming import get_theme, dump_tuple +from decorators import command_args_parser class ConversationTab(OneToOneTab): """ @@ -53,18 +52,18 @@ class ConversationTab(OneToOneTab): self.key_func['^I'] = self.completion # commands self.register_command('unquery', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('close', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('version', self.command_version, - desc=_('Get the software version of the current interlocutor (usually its XMPP client and Operating System).'), - shortdesc=_('Get the software version of the user.')) + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of the user.') self.register_command('info', self.command_info, - shortdesc=_('Get the status of the contact.')) + shortdesc='Get the status of the contact.') self.register_command('last_activity', self.command_last_activity, - usage=_('[jid]'), - desc=_('Get the last activity of the given or the current contact.'), - shortdesc=_('Get the activity.'), + usage='[jid]', + desc='Get the last activity of the given or the current contact.', + shortdesc='Get the activity.', completion=self.core.completion_last_activity) self.resize() self.update_commands() @@ -88,6 +87,7 @@ class ConversationTab(OneToOneTab): def completion(self): self.complete_commands(self.input) + @command_args_parser.raw def command_say(self, line, attention=False, correct=False): msg = self.core.xmpp.make_message(self.get_dest_jid()) msg['type'] = 'chat' @@ -149,19 +149,13 @@ class ConversationTab(OneToOneTab): self.text_win.refresh() self.input.refresh() - def command_xhtml(self, arg): - message = self.generate_xhtml_message(arg) - if message: - message.send() - self.core.add_message_to_text_buffer(self._text_buffer, message['body'], None, self.core.own_nick) - self.refresh() - - def command_last_activity(self, arg): + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): """ - /activity [jid] + /last_activity [jid] """ - if arg.strip(): - return self.core.command_last_activity(arg) + if args and args[0]: + return self.core.command_last_activity(args[0]) def callback(iq): if iq['type'] != 'result': @@ -188,10 +182,11 @@ class ConversationTab(OneToOneTab): self.add_message(msg) self.core.refresh_window() - self.core.xmpp.plugin['xep_0012'].get_last_activity(self.general_jid, callback=callback) + self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback) @refresh_wrapper.conditional - def command_info(self, arg): + @command_args_parser.ignored + def command_info(self): contact = roster[self.get_dest_jid()] jid = safeJID(self.get_dest_jid()) if contact: @@ -202,7 +197,7 @@ class ConversationTab(OneToOneTab): else: resource = None if resource: - status = (_('Status: %s') % resource.status) if resource.status else '' + status = ('Status: %s' % resource.status) if resource.status else '' self._text_buffer.add_message("\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % { 'show': resource.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) return True @@ -210,23 +205,25 @@ class ConversationTab(OneToOneTab): self._text_buffer.add_message("\x19%(info_col)s}No information available\x19o" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) return True - def command_unquery(self, arg): + @command_args_parser.ignored + def command_unquery(self): self.core.close_tab() - def command_version(self, arg): + @command_args_parser.quoted(0, 1) + def command_version(self, args): """ - /version + /version [jid] """ def callback(res): if not res: return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') version = '%s is running %s version %s on %s' % (jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if arg: - return self.core.command_version(arg) + if args: + return self.core.command_version(args[0]) jid = safeJID(self.name) if not jid.resource: if jid in roster: @@ -381,7 +378,7 @@ class DynamicConversationTab(ConversationTab): self.info_header = windows.DynamicConversationInfoWin() ConversationTab.__init__(self, jid) self.register_command('unlock', self.unlock_command, - shortdesc=_('Unlock the conversation from a particular resource.')) + shortdesc='Unlock the conversation from a particular resource.') def lock(self, resource): """ @@ -393,8 +390,8 @@ class DynamicConversationTab(ConversationTab): info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) - message = _('%(info)sConversation locked to ' - '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { + message = ('%(info)sConversation locked to ' + '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { 'info': info, 'jid_c': jid_c, 'jid': self.name, @@ -418,14 +415,14 @@ class DynamicConversationTab(ConversationTab): jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) if from_: - message = _('%(info)sConversation unlocked (received activity' - ' from %(jid_c)s%(jid)s%(info)s).') % { + message = ('%(info)sConversation unlocked (received activity' + ' from %(jid_c)s%(jid)s%(info)s).') % { 'info': info, 'jid_c': jid_c, 'jid': from_} self.add_message(message, typ=0) else: - message = _('%sConversation unlocked.') % info + message = '%sConversation unlocked.' % info self.add_message(message, typ=0) def get_dest_jid(self): diff --git a/src/tabs/listtab.py b/src/tabs/listtab.py index c5aab5eb..7021c8e3 100644 --- a/src/tabs/listtab.py +++ b/src/tabs/listtab.py @@ -4,8 +4,6 @@ sortable list. It should be inherited, to actually provide methods that insert items in the list, and that lets the user interact with them. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -52,7 +50,7 @@ class ListTab(Tab): self.key_func['KEY_RIGHT'] = self.list_header.sel_column_right self.key_func[' '] = self.sort_by self.register_command('close', self.close, - shortdesc=_('Close this tab.')) + shortdesc='Close this tab.') self.resize() self.update_keys() self.update_commands() @@ -121,7 +119,7 @@ class ListTab(Tab): """ If there's an error (retrieving the values etc) """ - self._error_message = _('Error: %(code)s - %(msg)s: %(body)s') % {'msg':msg, 'body':body, 'code':code} + self._error_message = 'Error: %(code)s - %(msg)s: %(body)s' % {'msg':msg, 'body':body, 'code':code} self.info_header.message = self._error_message self.info_header.refresh() curses.doupdate() diff --git a/src/tabs/muclisttab.py b/src/tabs/muclisttab.py index 55d5c2bd..c26fb268 100644 --- a/src/tabs/muclisttab.py +++ b/src/tabs/muclisttab.py @@ -4,8 +4,6 @@ A MucListTab is a tab listing the rooms on a conference server. It has no functionnality except scrolling the list, and allowing the user to join the rooms. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -22,9 +20,9 @@ class MucListTab(ListTab): plugin_keys = {} def __init__(self, server): - ListTab.__init__(self, server, + ListTab.__init__(self, server.full, "“j”: join room.", - _('Chatroom list on server %s (Loading)') % server, + 'Chatroom list on server %s (Loading)' % server, (('node-part', 0), ('name', 2), ('users', 3))) self.key_func['j'] = self.join_selected self.key_func['J'] = self.join_selected_no_focus @@ -56,7 +54,7 @@ class MucListTab(ListTab): item[0], item[2] or '', '') for item in get_items()] self.listview.set_lines(items) - self.info_header.message = _('Chatroom list on server %s') % self.name + self.info_header.message = 'Chatroom list on server %s' % self.name if self.core.current_tab() is self: self.refresh() else: diff --git a/src/tabs/muctab.py b/src/tabs/muctab.py index 8ac9b7e2..d4b13258 100644 --- a/src/tabs/muctab.py +++ b/src/tabs/muctab.py @@ -7,8 +7,6 @@ It keeps track of many things such as part/joins, maintains an user list, and updates private tabs when necessary. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -29,7 +27,7 @@ import windows import xhtml from common import safeJID from config import config -from decorators import refresh_wrapper +from decorators import refresh_wrapper, command_args_parser from logger import logger from roster import roster from theming import get_theme, dump_tuple @@ -37,11 +35,11 @@ from user import User SHOW_NAME = { - 'dnd': _('busy'), - 'away': _('away'), - 'xa': _('not available'), - 'chat': _('chatty'), - '': _('available') + 'dnd': 'busy', + 'away': 'away', + 'xa': 'not available', + 'chat': 'chatty', + '': 'available' } NS_MUC_USER = 'http://jabber.org/protocol/muc#user' @@ -55,13 +53,14 @@ class MucTab(ChatTab): message_type = 'groupchat' plugin_commands = {} plugin_keys = {} - def __init__(self, jid, nick): + def __init__(self, jid, nick, password=None): self.joined = False ChatTab.__init__(self, jid) if self.joined == False: self._state = 'disconnected' self.own_nick = nick self.name = jid + self.password = password self.users = [] self.privates = [] # private conversations self.topic = '' @@ -88,106 +87,112 @@ class MucTab(ChatTab): self.key_func['M-p'] = self.go_to_prev_hl # commands self.register_command('ignore', self.command_ignore, - usage=_('<nickname>'), - desc=_('Ignore a specified nickname.'), - shortdesc=_('Ignore someone'), + usage='<nickname>', + desc='Ignore a specified nickname.', + shortdesc='Ignore someone', completion=self.completion_ignore) self.register_command('unignore', self.command_unignore, - usage=_('<nickname>'), - desc=_('Remove the specified nickname from the ignore list.'), - shortdesc=_('Unignore someone.'), + usage='<nickname>', + desc='Remove the specified nickname from the ignore list.', + shortdesc='Unignore someone.', completion=self.completion_unignore) self.register_command('kick', self.command_kick, - usage=_('<nick> [reason]'), - desc=_('Kick the user with the specified nickname.' - ' You also can give an optional reason.'), - shortdesc=_('Kick someone.'), + usage='<nick> [reason]', + desc='Kick the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Kick someone.', completion=self.completion_quoted) self.register_command('ban', self.command_ban, - usage=_('<nick> [reason]'), - desc=_('Ban the user with the specified nickname.' - ' You also can give an optional reason.'), + usage='<nick> [reason]', + desc='Ban the user with the specified nickname.' + ' You also can give an optional reason.', shortdesc='Ban someone', completion=self.completion_quoted) self.register_command('role', self.command_role, - usage=_('<nick> <role> [reason]'), - desc=_('Set the role of an user. Roles can be:' - ' none, visitor, participant, moderator.' - ' You also can give an optional reason.'), - shortdesc=_('Set the role of an user.'), + usage='<nick> <role> [reason]', + desc='Set the role of an user. Roles can be:' + ' none, visitor, participant, moderator.' + ' You also can give an optional reason.', + shortdesc='Set the role of an user.', completion=self.completion_role) self.register_command('affiliation', self.command_affiliation, - usage=_('<nick or jid> <affiliation>'), - desc=_('Set the affiliation of an user. Affiliations can be:' - ' outcast, none, member, admin, owner.'), - shortdesc=_('Set the affiliation of an user.'), + usage='<nick or jid> <affiliation>', + desc='Set the affiliation of an user. Affiliations can be:' + ' outcast, none, member, admin, owner.', + shortdesc='Set the affiliation of an user.', completion=self.completion_affiliation) self.register_command('topic', self.command_topic, - usage=_('<subject>'), - desc=_('Change the subject of the room.'), - shortdesc=_('Change the subject.'), + usage='<subject>', + desc='Change the subject of the room.', + shortdesc='Change the subject.', completion=self.completion_topic) self.register_command('query', self.command_query, - usage=_('<nick> [message]'), - desc=_('Open a private conversation with <nick>. This nick' - ' has to be present in the room you\'re currently in.' - ' If you specified a message after the nickname, it ' - 'will immediately be sent to this user.'), - shortdesc=_('Query an user.'), + usage='<nick> [message]', + desc='Open a private conversation with <nick>. This nick' + ' has to be present in the room you\'re currently in.' + ' If you specified a message after the nickname, it ' + 'will immediately be sent to this user.', + shortdesc='Query an user.', completion=self.completion_quoted) self.register_command('part', self.command_part, - usage=_('[message]'), - desc=_('Disconnect from a room. You can' - ' specify an optional message.'), - shortdesc=_('Leave the room.')) + usage='[message]', + desc='Disconnect from a room. You can' + ' specify an optional message.', + shortdesc='Leave the room.') self.register_command('close', self.command_close, - usage=_('[message]'), - desc=_('Disconnect from a room and close the tab.' - ' You can specify an optional message if ' - 'you are still connected.'), - shortdesc=_('Close the tab.')) + usage='[message]', + desc='Disconnect from a room and close the tab.' + ' You can specify an optional message if ' + 'you are still connected.', + shortdesc='Close the tab.') self.register_command('nick', self.command_nick, - usage=_('<nickname>'), - desc=_('Change your nickname in the current room.'), - shortdesc=_('Change your nickname.'), + usage='<nickname>', + desc='Change your nickname in the current room.', + shortdesc='Change your nickname.', completion=self.completion_nick) self.register_command('recolor', self.command_recolor, - usage=_('[random]'), - desc=_('Re-assign a color to all participants of the' - ' current room, based on the last time they talked.' - ' Use this if the participants currently talking ' - 'have too many identical colors. Use /recolor random' - ' for a non-deterministic result.'), - shortdesc=_('Change the nicks colors.'), + usage='[random]', + desc='Re-assign a color to all participants of the' + ' current room, based on the last time they talked.' + ' Use this if the participants currently talking ' + 'have too many identical colors. Use /recolor random' + ' for a non-deterministic result.', + shortdesc='Change the nicks colors.', completion=self.completion_recolor) + self.register_command('color', self.command_color, + usage='<nick> <color>', + desc='Fix a color for a nick. Use "unset" instead of a color' + ' to remove the attribution', + shortdesc='Fix a color for a nick.', + completion=self.completion_color) self.register_command('cycle', self.command_cycle, - usage=_('[message]'), - desc=_('Leave the current room and rejoin it immediately.'), - shortdesc=_('Leave and re-join the room.')) + usage='[message]', + desc='Leave the current room and rejoin it immediately.', + shortdesc='Leave and re-join the room.') self.register_command('info', self.command_info, - usage=_('<nickname>'), - desc=_('Display some information about the user ' - 'in the MUC: its/his/her role, affiliation,' - ' status and status message.'), - shortdesc=_('Show an user\'s infos.'), + usage='<nickname>', + desc='Display some information about the user ' + 'in the MUC: its/his/her role, affiliation,' + ' status and status message.', + shortdesc='Show an user\'s infos.', completion=self.completion_info) self.register_command('configure', self.command_configure, - desc=_('Configure the current room, through a form.'), - shortdesc=_('Configure the room.')) + desc='Configure the current room, through a form.', + shortdesc='Configure the room.') self.register_command('version', self.command_version, - usage=_('<jid or nick>'), - desc=_('Get the software version of the given JID' - ' or nick in room (usually its XMPP client' - ' and Operating System).'), - shortdesc=_('Get the software version of a jid.'), + usage='<jid or nick>', + desc='Get the software version of the given JID' + ' or nick in room (usually its XMPP client' + ' and Operating System).', + shortdesc='Get the software version of a jid.', completion=self.completion_version) self.register_command('names', self.command_names, - desc=_('Get the users in the room with their roles.'), - shortdesc=_('List the users.')) + desc='Get the users in the room with their roles.', + shortdesc='List the users.') self.register_command('invite', self.command_invite, - desc=_('Invite a contact to this room'), - usage=_('<jid> [reason]'), - shortdesc=_('Invite a contact to this room'), + desc='Invite a contact to this room', + usage='<jid> [reason]', + shortdesc='Invite a contact to this room', completion=self.completion_invite) if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks @@ -263,6 +268,21 @@ class MucTab(ChatTab): return the_input.new_completion(['random'], 1, '', quotify=False) return True + def completion_color(self, the_input): + """Completion for /color""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + colors = [i for i in xhtml.colors if i] + colors.sort() + colors.append('unset') + colors.append('random') + return the_input.new_completion(colors, 2, '', quotify=False) + def completion_ignore(self, the_input): """Completion for /ignore""" userlist = [user.nick for user in self.users] @@ -302,15 +322,12 @@ class MucTab(ChatTab): return the_input.new_completion(possible_affiliations, 2, '', quotify=True) + @command_args_parser.quoted(1, 1, ['']) def command_invite(self, args): """/invite <jid> [reason]""" - args = common.shell_split(args) - if len(args) == 1: - jid, reason = args[0], '' - elif len(args) == 2: - jid, reason = args - else: + if args is None: return self.core.command_help('invite') + jid, reason = args self.core.command_invite('%s %s "%s"' % (jid, self.name, reason)) def completion_invite(self, the_input): @@ -329,15 +346,17 @@ class MucTab(ChatTab): self.user_win.refresh(self.users) self.input.refresh() - def command_info(self, arg): + @command_args_parser.quoted(1) + def command_info(self, args): """ /info <nick> """ - if not arg: + if args is None: return self.core.command_help('info') - user = self.get_user_by_name(arg) + nick = args[0] + user = self.get_user_by_name(nick) if not user: - return self.core.information(_("Unknown user: %s") % arg) + return self.core.information("Unknown user: %s" % nick) theme = get_theme() if user.jid: user_jid = ' (\x19%s}%s\x19o)' % ( @@ -345,10 +364,10 @@ class MucTab(ChatTab): user.jid) else: user_jid = '' - info = _('\x19%s}%s\x19o%s: show: \x19%s}%s\x19o, affiliation:' - ' \x19%s}%s\x19o, role: \x19%s}%s\x19o%s') % ( + info = ('\x19%s}%s\x19o%s: show: \x19%s}%s\x19o, affiliation:' + ' \x19%s}%s\x19o, role: \x19%s}%s\x19o%s') % ( dump_tuple(user.color), - arg, + nick, user_jid, dump_tuple(theme.color_show(user.show)), user.show or 'Available', @@ -360,19 +379,20 @@ class MucTab(ChatTab): self.add_message(info, typ=0) self.core.refresh_window() - def command_configure(self, arg): + @command_args_parser.quoted(0) + def command_configure(self, ignored): """ /configure """ def on_form_received(form): if not form: self.core.information( - _('Could not retrieve the configuration form'), - _('Error')) + 'Could not retrieve the configuration form', + 'Error') return self.core.open_new_form(form, self.cancel_config, self.send_config) - form = fixes.get_room_form(self.core.xmpp, self.name, on_form_received) + fixes.get_room_form(self.core.xmpp, self.name, on_form_received) def cancel_config(self, form): """ @@ -388,30 +408,53 @@ class MucTab(ChatTab): muc.configure_room(self.core.xmpp, self.name, form) self.core.close_tab() - def command_cycle(self, arg): + @command_args_parser.raw + def command_cycle(self, msg): """/cycle [reason]""" - self.command_part(arg) + self.command_part(msg) self.disconnect() self.user_win.pos = 0 self.core.disable_private_tabs(self.name) self.core.command_join('"/%s"' % self.own_nick) - def command_recolor(self, arg): + @command_args_parser.quoted(0, 1, ['']) + def command_recolor(self, args): """ /recolor [random] Re-assign color to the participants of the room """ - arg = arg.strip() + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + if deterministic: + for user in self.users: + if user.nick == self.own_nick: + continue + color = self.search_for_color(user.nick) + if color != '': + continue + user.set_deterministic_color() + if args[0] == 'random': + self.core.information('"random" was provided, but poezio is ' + 'configured to use deterministic colors', + 'Warning') + self.user_win.refresh(self.users) + self.input.refresh() + return compare_users = lambda x: x.last_talked users = list(self.users) sorted_users = sorted(users, key=compare_users, reverse=True) + full_sorted_users = sorted_users[:] # search our own user, to remove it from the list - for user in sorted_users: + # Also remove users whose color is fixed + for user in full_sorted_users: + color = self.search_for_color(user.nick) if user.nick == self.own_nick: sorted_users.remove(user) user.color = get_theme().COLOR_OWN_NICK + elif color != '': + sorted_users.remove(user) + user.change_color(color, deterministic) colors = list(get_theme().LIST_COLOR_NICKNAMES) - if arg and arg == 'random': + if args[0] == 'random': random.shuffle(colors) for i, user in enumerate(sorted_users): user.color = colors[i % len(colors)] @@ -420,41 +463,86 @@ class MucTab(ChatTab): self.text_win.refresh() self.input.refresh() - def command_version(self, arg): + @command_args_parser.quoted(2, 2, ['']) + def command_color(self, args): + """ + /color <nick> <color> + Fix a color for a nick. + Use "unset" instead of a color to remove the attribution. + User "random" to attribute a random color. + """ + if args is None: + return self.core.command_help('color') + nick = args[0] + color = args[1].lower() + user = self.get_user_by_name(nick) + if not color in xhtml.colors and color not in ('unset', 'random'): + return self.core.information("Unknown color: %s" % color, 'Error') + if user and user.nick == self.own_nick: + return self.core.information("You cannot change the color of your" + " own nick.", 'Error') + if color == 'unset': + if config.remove_and_save(nick, 'muc_colors'): + self.core.information('Color for nick %s unset' % (nick)) + else: + if color == 'random': + color = random.choice(list(xhtml.colors)) + if user: + user.change_color(color) + config.set_and_save(nick, color, 'muc_colors') + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + # if any user in the room has a nick which is an alias of the + # nick, update its color + for tab in self.core.get_tabs(MucTab): + for u in tab.users: + nick_alias = re.sub('^_*', '', u.nick) + nick_alias = re.sub('_*$', '', nick_alias) + if nick_alias == nick: + u.change_color(color) + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(1) + def command_version(self, args): """ /version <jid or nick> """ def callback(res): if not res: - return self.core.information(_('Could not get the software ' - 'version from %s') % (jid,), - _('Warning')) - version = _('%s is running %s version %s on %s') % ( + return self.core.information('Could not get the software ' + 'version from %s' % (jid,), + 'Warning') + version = '%s is running %s version %s on %s' % ( jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if not arg: + if args is None: return self.core.command_help('version') - if arg in [user.nick for user in self.users]: + nick = args[0] + if nick in [user.nick for user in self.users]: jid = safeJID(self.name).bare - jid = safeJID(jid + '/' + arg) + jid = safeJID(jid + '/' + nick) else: - jid = safeJID(arg) + jid = safeJID(nick) fixes.get_version(self.core.xmpp, jid, - callback=callback) + callback=callback) - def command_nick(self, arg): + @command_args_parser.quoted(1) + def command_nick(self, args): """ /nick <nickname> """ - if not arg: + if args is None: return self.core.command_help('nick') - nick = arg + nick = args[0] if not self.joined: - return self.core.information(_('/nick only works in joined rooms'), - _('Info')) + return self.core.information('/nick only works in joined rooms', + 'Info') current_status = self.core.get_status() if not safeJID(self.name + '/' + nick): return self.core.information('Invalid nick', 'Info') @@ -462,11 +550,12 @@ class MucTab(ChatTab): current_status.message, current_status.show) - def command_part(self, arg): + @command_args_parser.quoted(0, 1, ['']) + def command_part(self, args): """ /part [msg] """ - arg = arg.strip() + arg = args[0] msg = None if self.joined: info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) @@ -480,24 +569,24 @@ class MucTab(ChatTab): color = 3 if arg: - msg = _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' - 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' - ' left the chatroom' - ' (\x19o%(reason)s\x19%(info_col)s})') % { - 'info_col': info_col, 'reason': arg, - 'spec': char_quit, 'color': color, - 'color_spec': spec_col, - 'nick': self.own_nick, - } + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom' + ' (\x19o%(reason)s\x19%(info_col)s})') % { + 'info_col': info_col, 'reason': arg, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } else: - msg = _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' - 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' - ' left the chatroom') % { - 'info_col': info_col, - 'spec': char_quit, 'color': color, - 'color_spec': spec_col, - 'nick': self.own_nick, - } + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom') % { + 'info_col': info_col, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } self.add_message(msg, typ=2) self.disconnect() @@ -507,49 +596,52 @@ class MucTab(ChatTab): self.refresh() self.core.doupdate() - def command_close(self, arg): + @command_args_parser.raw + def command_close(self, msg): """ /close [msg] """ - self.command_part(arg) + self.command_part(msg) self.core.close_tab() - def command_query(self, arg): + @command_args_parser.quoted(1, 1) + def command_query(self, args): """ /query <nick> [message] """ - args = common.shell_split(arg) - if len(args) < 1: - return + if args is None: + return self.core.command_help('query') nick = args[0] r = None for user in self.users: if user.nick == nick: r = self.core.open_private_window(self.name, user.nick) - if r and len(args) > 1: + if r and len(args) == 2: msg = args[1] self.core.current_tab().command_say( xhtml.convert_simple_to_full_colors(msg)) if not r: - self.core.information(_("Cannot find user: %s" % nick), 'Error') + self.core.information("Cannot find user: %s" % nick, 'Error') - def command_topic(self, arg): + @command_args_parser.raw + def command_topic(self, subject): """ /topic [new topic] """ - if not arg.strip(): + if not subject: self._text_buffer.add_message( - _("\x19%s}The subject of the room is: %s %s") % + "\x19%s}The subject of the room is: %s %s" % (dump_tuple(get_theme().COLOR_INFORMATION_TEXT), self.topic, '(set by %s)' % self.topic_from if self.topic_from else '')) self.refresh() return - subject = arg + muc.change_subject(self.core.xmpp, self.name, subject) - def command_names(self, arg=None): + @command_args_parser.quoted(0) + def command_names(self, args): """ /names """ @@ -620,29 +712,28 @@ class MucTab(ChatTab): return the_input.new_completion(word_list, 1, quotify=True) - def command_kick(self, arg): + @command_args_parser.quoted(1, 1) + def command_kick(self, args): """ /kick <nick> [reason] """ - args = common.shell_split(arg) - if not args: - self.core.command_help('kick') + if args is None: + return self.core.command_help('kick') + if len(args) == 2: + msg = ' "%s"' % args[1] else: - if len(args) > 1: - msg = ' "%s"' % args[1] - else: - msg = '' - self.command_role('"'+args[0]+ '" none'+msg) + msg = '' + self.command_role('"'+args[0]+ '" none'+msg) - def command_ban(self, arg): + @command_args_parser.quoted(1, 1) + def command_ban(self, args): """ /ban <nick> [reason] """ def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if not args: + if args is None: return self.core.command_help('ban') if len(args) > 1: msg = args[1] @@ -661,7 +752,8 @@ class MucTab(ChatTab): if not res: self.core.information('Could not ban user', 'Error') - def command_role(self, arg): + @command_args_parser.quoted(2, 1, ['']) + def command_role(self, args): """ /role <nick> <role> [reason] Changes the role of an user @@ -670,24 +762,25 @@ class MucTab(ChatTab): def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if len(args) < 2: - self.core.command_help('role') - return - nick, role = args[0], args[1] - if len(args) > 2: - reason = ' '.join(args[2:]) - else: - reason = '' - if not self.joined or \ - not role in ('none', 'visitor', 'participant', 'moderator'): - return + + if args is None: + return self.core.command_help('role') + + nick, role, reason = args[0], args[1].lower(), args[2] + + valid_roles = ('none', 'visitor', 'participant', 'moderator') + + if not self.joined or role not in valid_roles: + return self.core.information('The role must be one of ' + ', '.join(valid_roles), + 'Error') + if not safeJID(self.name + '/' + nick): - return self.core('Invalid nick', 'Info') + return self.core.information('Invalid nick', 'Info') muc.set_user_role(self.core.xmpp, self.name, nick, reason, role, callback=callback) - def command_affiliation(self, arg): + @command_args_parser.quoted(2) + def command_affiliation(self, args): """ /affiliation <nick> <role> Changes the affiliation of an user @@ -696,16 +789,20 @@ class MucTab(ChatTab): def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if len(args) < 2: - self.core.command_help('affiliation') - return + + if args is None: + return self.core.command_help('affiliation') + nick, affiliation = args[0], args[1].lower() + if not self.joined: return - if affiliation not in ('outcast', 'none', 'member', 'admin', 'owner'): - self.core.command_help('affiliation') - return + + valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner') + if affiliation not in valid_affiliations: + return self.core.information('The affiliation must be one of ' + ', '.join(valid_affiliations), + 'Error') + if nick in [user.nick for user in self.users]: res = muc.set_user_affiliation(self.core.xmpp, self.name, affiliation, nick=nick, @@ -715,8 +812,9 @@ class MucTab(ChatTab): affiliation, jid=safeJID(nick), callback=callback) if not res: - self.core.information(_('Could not set affiliation'), _('Error')) + self.core.information('Could not set affiliation', 'Error') + @command_args_parser.raw def command_say(self, line, correct=False): """ /say <message> @@ -755,45 +853,48 @@ class MucTab(ChatTab): msg.send() self.chat_state = needed - def command_xhtml(self, arg): - message = self.generate_xhtml_message(arg) + @command_args_parser.raw + def command_xhtml(self, msg): + message = self.generate_xhtml_message(msg) if message: message['type'] = 'groupchat' message.send() - def command_ignore(self, arg): + @command_args_parser.quoted(1) + def command_ignore(self, args): """ /ignore <nick> """ - if not arg: - self.core.command_help('ignore') - return - nick = arg + if args is None: + return self.core.command_help('ignore') + + nick = args[0] user = self.get_user_by_name(nick) if not user: - self.core.information(_('%s is not in the room') % nick) + self.core.information('%s is not in the room' % nick) elif user in self.ignores: - self.core.information(_('%s is already ignored') % nick) + self.core.information('%s is already ignored' % nick) else: self.ignores.append(user) - self.core.information(_("%s is now ignored") % nick, 'info') + self.core.information("%s is now ignored" % nick, 'info') - def command_unignore(self, arg): + @command_args_parser.quoted(1) + def command_unignore(self, args): """ /unignore <nick> """ - if not arg: - self.core.command_help('unignore') - return - nick = arg + if args is None: + return self.core.command_help('unignore') + + nick = args[0] user = self.get_user_by_name(nick) if not user: - self.core.information(_('%s is not in the room') % nick) + self.core.information('%s is not in the room' % nick) elif user not in self.ignores: - self.core.information(_('%s is not ignored') % nick) + self.core.information('%s is not ignored' % nick) else: self.ignores.remove(user) - self.core.information(_('%s is now unignored') % nick) + self.core.information('%s is now unignored' % nick) def completion_unignore(self, the_input): if the_input.get_argument_position() == 1: @@ -980,12 +1081,14 @@ class MucTab(ChatTab): role = presence['muc']['role'] jid = presence['muc']['jid'] typ = presence['type'] + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + color = self.search_for_color(from_nick) if not self.joined: # user in the room BEFORE us. # ignore redondant presence message, see bug #1509 if (from_nick not in [user.nick for user in self.users] and typ != "unavailable"): new_user = User(from_nick, affiliation, show, - status, role, jid) + status, role, jid, deterministic, color) self.users.append(new_user) self.core.events.trigger('muc_join', presence, self) if '110' in status_codes or self.own_nick == from_nick: @@ -1015,9 +1118,9 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) self.add_message( - _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' - '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' - ' the chatroom') % + '\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' + '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' + ' the chatroom' % { 'nick': from_nick, 'spec': get_theme().CHAR_JOIN, @@ -1028,21 +1131,21 @@ class MucTab(ChatTab): typ=2) if '201' in status_codes: self.add_message( - _('\x19%(info_col)s}Info: The room ' - 'has been created') % + '\x19%(info_col)s}Info: The room ' + 'has been created' % {'info_col': info_col}, typ=0) if '170' in status_codes: self.add_message( - _('\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is publicly logged') % + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % {'info_col': info_col, 'warn_col': warn_col}, typ=0) if '100' in status_codes: self.add_message( - _('\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is not anonymous.') % + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % {'info_col': info_col, 'warn_col': warn_col}, typ=0) @@ -1065,7 +1168,7 @@ class MucTab(ChatTab): if not user: self.core.events.trigger('muc_join', presence, self) self.on_user_join(from_nick, affiliation, show, status, role, - jid) + jid, color) # nick change elif change_nick: self.core.events.trigger('muc_nickchange', presence, self) @@ -1105,8 +1208,8 @@ class MucTab(ChatTab): def on_non_member_kicked(self): """We have been kicked because the MUC is members-only""" self.add_message( - _('\x19%(info_col)s}You have been kicked because you ' - 'are not a member and the room is now members-only.') % { + '\x19%(info_col)s}You have been kicked because you ' + 'are not a member and the room is now members-only.' % { 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) self.disconnect() @@ -1114,18 +1217,19 @@ class MucTab(ChatTab): def on_muc_shutdown(self): """We have been kicked because the MUC service is shutting down""" self.add_message( - _('\x19%(info_col)s}You have been kicked because the' - ' MUC service is shutting down.') % { + '\x19%(info_col)s}You have been kicked because the' + ' MUC service is shutting down.' % { 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) self.disconnect() - def on_user_join(self, from_nick, affiliation, show, status, role, jid): + def on_user_join(self, from_nick, affiliation, show, status, role, jid, color): """ When a new user joins the groupchat """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) user = User(from_nick, affiliation, - show, status, role, jid) + show, status, role, jid, deterministic, color) self.users.append(user) hide_exit_join = config.get_by_tabname('hide_exit_join', self.general_jid) @@ -1139,17 +1243,17 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) char_join = get_theme().CHAR_JOIN if not jid.full: - msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} joined the chatroom') % { + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} joined the chatroom') % { 'nick': from_nick, 'spec': char_join, 'color': color, 'info_col': info_col, 'color_spec': spec_col, } else: - msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s ' - '\x19%(info_col)s}(\x19%(jid_color)s}%(jid)s\x19' - '%(info_col)s}) joined the chatroom') % { + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s ' + '\x19%(info_col)s}(\x19%(jid_color)s}%(jid)s\x19' + '%(info_col)s}) joined the chatroom') % { 'spec': char_join, 'nick': from_nick, 'color':color, 'jid':jid.full, 'info_col': info_col, @@ -1166,6 +1270,12 @@ class MucTab(ChatTab): self.own_nick = new_nick # also change our nick in all private discussions of this room self.core.on_muc_own_nickchange(self) + else: + color = config.get_by_tabname(new_nick, 'muc_colors') + if color != '': + deterministic = config.get_by_tabname('deterministic_nick_colors', + self.name) + user.change_color(color, deterministic) user.change_nick(new_nick) if config.get_by_tabname('display_user_color_in_join_part', @@ -1174,8 +1284,8 @@ class MucTab(ChatTab): else: color = 3 info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - self.add_message(_('\x19%(color)s}%(old)s\x19%(info_col)s} is' - ' now known as \x19%(color)s}%(new)s') % { + self.add_message('\x19%(color)s}%(old)s\x19%(info_col)s} is' + ' now known as \x19%(color)s}%(new)s' % { 'old':from_nick, 'new':new_nick, 'color':color, 'info_col': info_col}, typ=2) @@ -1198,13 +1308,13 @@ class MucTab(ChatTab): if from_nick == self.own_nick: # we are banned if by: - kick_msg = _('\x191}%(spec)s \x193}You\x19%(info_col)s}' - ' have been banned by \x194}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been banned by \x194}%(by)s') % { 'spec': char_kick, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x193}You\x19' - '%(info_col)s} have been banned.') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been banned.') % { 'spec': char_kick, 'info_col': info_col} self.core.disable_private_tabs(self.name, reason=kick_msg) self.disconnect() @@ -1233,20 +1343,20 @@ class MucTab(ChatTab): color = 3 if by: - kick_msg = _('\x191}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} ' - 'has been banned by \x194}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} ' + 'has been banned by \x194}%(by)s') % { 'spec': char_kick, 'nick': from_nick, 'color': color, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been banned') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been banned') % { 'spec': char_kick, 'nick': from_nick, 'color': color, 'info_col': info_col} if reason is not None and reason.text: - kick_msg += _('\x19%(info_col)s} Reason: \x196}' - '%(reason)s\x19%(info_col)s}') % { + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s\x19%(info_col)s}') % { 'reason': reason.text, 'info_col': info_col} self.add_message(kick_msg, typ=2) @@ -1266,14 +1376,14 @@ class MucTab(ChatTab): by = actor_elem.get('nick') or actor_elem.get('jid') if from_nick == self.own_nick: # we are kicked if by: - kick_msg = _('\x191}%(spec)s \x193}You\x19' - '%(info_col)s} have been kicked' - ' by \x193}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been kicked' + ' by \x193}%(by)s') % { 'spec': char_kick, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x193}You\x19%(info_col)s}' - ' have been kicked.') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been kicked.') % { 'spec': char_kick, 'info_col': info_col} self.core.disable_private_tabs(self.name, reason=kick_msg) @@ -1302,19 +1412,19 @@ class MucTab(ChatTab): else: color = 3 if by: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been kicked by ' - '\x193}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked by ' + '\x193}%(by)s') % { 'spec': char_kick, 'nick':from_nick, 'color':color, 'by':by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been kicked') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked') % { 'spec': char_kick, 'nick': from_nick, 'color':color, 'info_col': info_col} if reason is not None and reason.text: - kick_msg += _('\x19%(info_col)s} Reason: \x196}' - '%(reason)s') % { + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s') % { 'reason': reason.text, 'info_col': info_col} self.add_message(kick_msg, typ=2) @@ -1343,19 +1453,19 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) if not jid.full: - leave_msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} has left the ' - 'chatroom') % { + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} has left the ' + 'chatroom') % { 'nick':from_nick, 'color':color, 'spec':get_theme().CHAR_QUIT, 'info_col': info_col, 'color_spec': spec_col} else: jid_col = dump_tuple(get_theme().COLOR_MUC_JID) - leave_msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' - '%(jid)s\x19%(info_col)s}) has left the ' - 'chatroom') % { + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' + '%(jid)s\x19%(info_col)s}) has left the ' + 'chatroom') % { 'spec':get_theme().CHAR_QUIT, 'nick':from_nick, 'color':color, 'jid':jid.full, 'info_col': info_col, @@ -1381,33 +1491,29 @@ class MucTab(ChatTab): else: color = 3 if from_nick == self.own_nick: - msg = _('\x19%(color)s}You\x19%(info_col)s} changed: ') % { - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), - 'color': color} + msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'color': color} else: - msg = _('\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ') % { - 'nick': from_nick, 'color': color, - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} - if show not in SHOW_NAME: - self.core.information(_("%s from room %s sent an invalid show: %s") - % (from_nick, from_room, show), - _("Warning")) + msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % { + 'nick': from_nick, 'color': color, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} if affiliation != user.affiliation: - msg += _('affiliation: %s, ') % affiliation + msg += 'affiliation: %s, ' % affiliation display_message = True if role != user.role: - msg += _('role: %s, ') % role + msg += 'role: %s, ' % role display_message = True if show != user.show and show in SHOW_NAME: - msg += _('show: %s, ') % SHOW_NAME[show] + msg += 'show: %s, ' % SHOW_NAME[show] display_message = True if status != user.status: # if the user sets his status to nothing if status: - msg += _('status: %s, ') % status + msg += 'status: %s, ' % status display_message = True elif show in SHOW_NAME and show == user.show: - msg += _('show: %s, ') % SHOW_NAME[show] + msg += 'show: %s, ' % SHOW_NAME[show] display_message = True if not display_message: return @@ -1461,8 +1567,8 @@ class MucTab(ChatTab): """ if time is None and self.joined: # don't log the history messages if not logger.log_message(self.name, nickname, txt, typ=typ): - self.core.information(_('Unable to write in the log file'), - _('Error')) + self.core.information('Unable to write in the log file', + 'Error') def do_highlight(self, txt, time, nickname): """ @@ -1581,6 +1687,22 @@ class MucTab(ChatTab): else: # Re-send a self-ping in a few seconds self.enable_self_ping_event() + def search_for_color(self, nick): + """ + Search for the color of a nick in the config file. + Also, look at the colors of its possible aliases if nick_color_aliases + is set. + """ + color = config.get_by_tabname(nick, 'muc_colors') + if color != '': + return color + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + nick_alias = re.sub('^_*', '', nick) + nick_alias = re.sub('_*$', '', nick_alias) + color = config.get_by_tabname(nick_alias, 'muc_colors') + return color + def on_self_ping_failed(self, iq): self.command_part("the MUC server is not responding") self.core.refresh_window() diff --git a/src/tabs/privatetab.py b/src/tabs/privatetab.py index 4c01cd70..a715a922 100644 --- a/src/tabs/privatetab.py +++ b/src/tabs/privatetab.py @@ -10,8 +10,6 @@ both participant’s nicks. It also has slightly different features than the ConversationTab (such as tab-completion on nicks from the room). """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -27,6 +25,7 @@ from config import config from decorators import refresh_wrapper from logger import logger from theming import get_theme, dump_tuple +from decorators import command_args_parser class PrivateTab(OneToOneTab): """ @@ -48,15 +47,15 @@ class PrivateTab(OneToOneTab): self.key_func['^I'] = self.completion # commands self.register_command('info', self.command_info, - desc=_('Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.'), - shortdesc=_('Info about the user.')) + desc='Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.', + shortdesc='Info about the user.') self.register_command('unquery', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('close', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('version', self.command_version, - desc=_('Get the software version of the current interlocutor (usually its XMPP client and Operating System).'), - shortdesc=_('Get the software version of a jid.')) + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of a jid.') self.resize() self.parent_muc = self.core.get_tab_by_name(safeJID(name).bare, MucTab) self.on = True @@ -87,13 +86,14 @@ class PrivateTab(OneToOneTab): def load_logs(self, log_nb): logs = logger.get_logs(safeJID(self.name).full.replace('/', '\\'), log_nb) + return logs def log_message(self, txt, nickname, time=None, typ=1): """ Log the messages in the archives. """ if not logger.log_message(self.name, nickname, txt, date=time, typ=typ): - self.core.information(_('Unable to write in the log file'), 'Error') + self.core.information('Unable to write in the log file', 'Error') def on_close(self): self.parent_muc.privates.remove(self) @@ -120,6 +120,7 @@ class PrivateTab(OneToOneTab): empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) self.send_composing_chat_state(empty_after) + @command_args_parser.raw def command_say(self, line, attention=False, correct=False): if not self.on: return @@ -182,13 +183,15 @@ class PrivateTab(OneToOneTab): self.text_win.refresh() self.input.refresh() - def command_unquery(self, arg): + @command_args_parser.ignored + def command_unquery(self): """ /unquery """ self.core.close_tab() - def command_version(self, arg): + @command_args_parser.quoted(0, 1) + def command_version(self, args): """ /version """ @@ -196,22 +199,23 @@ class PrivateTab(OneToOneTab): if not res: return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') version = '%s is running %s version %s on %s' % (jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if arg: - return self.core.command_version(arg) + if args: + return self.core.command_version(args[0]) jid = safeJID(self.name) fixes.get_version(self.core.xmpp, jid, callback=callback) + @command_args_parser.quoted(0, 1) def command_info(self, arg): """ /info """ - if arg: - self.parent_muc.command_info(arg) + if arg and arg[0]: + self.parent_muc.command_info(arg[0]) else: user = safeJID(self.name).resource self.parent_muc.command_info(user) @@ -319,9 +323,9 @@ class PrivateTab(OneToOneTab): """ self.deactivate() if not status_message: - self.add_message(_('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room') % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) else: - self.add_message(_('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"') % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) return self.core.current_tab() is self @refresh_wrapper.conditional diff --git a/src/tabs/rostertab.py b/src/tabs/rostertab.py index 878e89ed..aaff7de3 100644 --- a/src/tabs/rostertab.py +++ b/src/tabs/rostertab.py @@ -5,15 +5,16 @@ rectangle shows the current contact info. This module also includes functions to match users in the roster. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) +import base64 import curses import difflib import os +import ssl from os import getenv, path +from functools import partial from . import Tab @@ -25,6 +26,7 @@ from contact import Contact, Resource from decorators import refresh_wrapper from roster import RosterGroup, roster from theming import get_theme, dump_tuple +from decorators import command_args_parser class RosterInfoTab(Tab): """ @@ -44,107 +46,315 @@ class RosterInfoTab(Tab): self.input = self.default_help_message self.state = 'normal' self.key_func['^I'] = self.completion - self.key_func[' '] = self.on_space self.key_func["/"] = self.on_slash - self.key_func["KEY_UP"] = self.move_cursor_up - self.key_func["KEY_DOWN"] = self.move_cursor_down - self.key_func["M-u"] = self.move_cursor_to_next_contact - self.key_func["M-y"] = self.move_cursor_to_prev_contact - self.key_func["M-U"] = self.move_cursor_to_next_group - self.key_func["M-Y"] = self.move_cursor_to_prev_group - self.key_func["M-[1;5B"] = self.move_cursor_to_next_group - self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group - self.key_func["l"] = self.command_last_activity - self.key_func["o"] = self.toggle_offline_show - self.key_func["v"] = self.get_contact_version - self.key_func["i"] = self.show_contact_info - self.key_func["n"] = self.change_contact_name - self.key_func["s"] = self.start_search - self.key_func["S"] = self.start_search_slow - self.register_command('deny', self.command_deny, - usage=_('[jid]'), - desc=_('Deny your presence to the provided JID (or the selected contact in your roster), who is asking you to be in his/here roster.'), - shortdesc=_('Deny an user your presence.'), - completion=self.completion_deny) - self.register_command('accept', self.command_accept, - usage=_('[jid]'), - desc=_('Allow the provided JID (or the selected contact in your roster), to see your presence.'), - shortdesc=_('Allow an user your presence.'), - completion=self.completion_deny) - self.register_command('add', self.command_add, - usage=_('<jid>'), - desc=_('Add the specified JID to your roster, ask him to allow you to see his presence, and allow him to see your presence.'), - shortdesc=_('Add an user to your roster.')) - self.register_command('name', self.command_name, - usage=_('<jid> <name>'), - shortdesc=_('Set the given JID\'s name.'), - completion=self.completion_name) - self.register_command('groupadd', self.command_groupadd, - usage=_('<jid> <group>'), - desc=_('Add the given JID to the given group.'), - shortdesc=_('Add an user to a group'), - completion=self.completion_groupadd) - self.register_command('groupmove', self.command_groupmove, - usage=_('<jid> <old group> <new group>'), - desc=_('Move the given JID from the old group to the new group.'), - shortdesc=_('Move an user to another group.'), - completion=self.completion_groupmove) - self.register_command('groupremove', self.command_groupremove, - usage=_('<jid> <group>'), - desc=_('Remove the given JID from the given group.'), - shortdesc=_('Remove an user from a group.'), - completion=self.completion_groupremove) - self.register_command('remove', self.command_remove, - usage=_('[jid]'), - desc=_('Remove the specified JID from your roster. This wil unsubscribe you from its presence, cancel its subscription to yours, and remove the item from your roster.'), - shortdesc=_('Remove an user from your roster.'), - completion=self.completion_remove) + # disable most of the roster features when in anonymous mode + if not self.core.xmpp.anon: + self.key_func[' '] = self.on_space + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["M-u"] = self.move_cursor_to_next_contact + self.key_func["M-y"] = self.move_cursor_to_prev_contact + self.key_func["M-U"] = self.move_cursor_to_next_group + self.key_func["M-Y"] = self.move_cursor_to_prev_group + self.key_func["M-[1;5B"] = self.move_cursor_to_next_group + self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group + self.key_func["l"] = self.command_last_activity + self.key_func["o"] = self.toggle_offline_show + self.key_func["v"] = self.get_contact_version + self.key_func["i"] = self.show_contact_info + self.key_func["s"] = self.start_search + self.key_func["S"] = self.start_search_slow + self.key_func["n"] = self.change_contact_name + self.register_command('deny', self.command_deny, + usage='[jid]', + desc='Deny your presence to the provided JID (or the ' + 'selected contact in your roster), who is asking' + 'you to be in his/here roster.', + shortdesc='Deny an user your presence.', + completion=self.completion_deny) + self.register_command('accept', self.command_accept, + usage='[jid]', + desc='Allow the provided JID (or the selected contact ' + 'in your roster), to see your presence.', + shortdesc='Allow an user your presence.', + completion=self.completion_deny) + self.register_command('add', self.command_add, + usage='<jid>', + desc='Add the specified JID to your roster, ask him to' + ' allow you to see his presence, and allow him to' + ' see your presence.', + shortdesc='Add an user to your roster.') + self.register_command('name', self.command_name, + usage='<jid> [name]', + shortdesc='Set the given JID\'s name.', + completion=self.completion_name) + self.register_command('groupadd', self.command_groupadd, + usage='<jid> <group>', + desc='Add the given JID to the given group.', + shortdesc='Add an user to a group', + completion=self.completion_groupadd) + self.register_command('groupmove', self.command_groupmove, + usage='<jid> <old group> <new group>', + desc='Move the given JID from the old group to the new group.', + shortdesc='Move an user to another group.', + completion=self.completion_groupmove) + self.register_command('groupremove', self.command_groupremove, + usage='<jid> <group>', + desc='Remove the given JID from the given group.', + shortdesc='Remove an user from a group.', + completion=self.completion_groupremove) + self.register_command('remove', self.command_remove, + usage='[jid]', + desc='Remove the specified JID from your roster. This ' + 'will unsubscribe you from its presence, cancel ' + 'its subscription to yours, and remove the item ' + 'from your roster.', + shortdesc='Remove an user from your roster.', + completion=self.completion_remove) + self.register_command('export', self.command_export, + usage='[/path/to/file]', + desc='Export your contacts into /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Export your roster to a file.', + completion=partial(self.completion_file, 1)) + self.register_command('import', self.command_import, + usage='[/path/to/file]', + desc='Import your contacts from /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Import your roster from a file.', + completion=partial(self.completion_file, 1)) + self.register_command('password', self.command_password, + usage='<password>', + shortdesc='Change your password') + self.register_command('reconnect', self.command_reconnect, - desc=_('Disconnect from the remote server if you are currently connected and then connect to it again.'), - shortdesc=_('Disconnect and reconnect to the server.')) + desc='Disconnect from the remote server if you are ' + 'currently connected and then connect to it again.', + shortdesc='Disconnect and reconnect to the server.') self.register_command('disconnect', self.command_disconnect, - desc=_('Disconnect from the remote server.'), - shortdesc=_('Disconnect from the server.')) - self.register_command('export', self.command_export, - usage=_('[/path/to/file]'), - desc=_('Export your contacts into /path/to/file if specified, or $HOME/poezio_contacts if not.'), - shortdesc=_('Export your roster to a file.'), - completion=self.completion_file) - self.register_command('import', self.command_import, - usage=_('[/path/to/file]'), - desc=_('Import your contacts from /path/to/file if specified, or $HOME/poezio_contacts if not.'), - shortdesc=_('Import your roster from a file.'), - completion=self.completion_file) + desc='Disconnect from the remote server.', + shortdesc='Disconnect from the server.') self.register_command('clear', self.command_clear, - shortdesc=_('Clear the info buffer.')) + shortdesc='Clear the info buffer.') self.register_command('last_activity', self.command_last_activity, - usage=_('<jid>'), - desc=_('Informs you of the last activity of a JID.'), - shortdesc=_('Get the activity of someone.'), + usage='<jid>', + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', completion=self.core.completion_last_activity) - self.register_command('password', self.command_password, - usage='<password>', - shortdesc=_('Change your password')) self.resize() self.update_commands() self.update_keys() def check_blocking(self, features): - if 'urn:xmpp:blocking' in features: + if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon: self.register_command('block', self.command_block, - usage=_('[jid]'), - shortdesc=_('Prevent a JID from talking to you.'), + usage='[jid]', + shortdesc='Prevent a JID from talking to you.', completion=self.completion_block) self.register_command('unblock', self.command_unblock, - usage=_('[jid]'), - shortdesc=_('Allow a JID to talk to you.'), + usage='[jid]', + shortdesc='Allow a JID to talk to you.', completion=self.completion_unblock) self.register_command('list_blocks', self.command_list_blocks, - shortdesc=_('Show the blocked contacts.')) + shortdesc='Show the blocked contacts.') self.core.xmpp.del_event_handler('session_start', self.check_blocking) self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message) + def check_saslexternal(self, features): + if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon: + self.register_command('certs', self.command_certs, + desc='List the fingerprints of certificates' + ' which can connect to your account.', + shortdesc='List allowed client certs.') + self.register_command('cert_add', self.command_cert_add, + desc='Add a client certificate to the authorized ones. ' + 'It must have an unique name and be contained in ' + 'a PEM file. [management] is a boolean indicating' + ' if a client connected using this certificate can' + ' manage the certificates itself.', + shortdesc='Add a client certificate.', + usage='<name> <certificate path> [management]', + completion=self.completion_cert_add) + self.register_command('cert_disable', self.command_cert_disable, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will not be ' + 'forcefully disconnected.', + shortdesc='Disable a certificate', + usage='<name>') + self.register_command('cert_revoke', self.command_cert_revoke, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will be ' + 'forcefully disconnected.', + shortdesc='Revoke a certificate', + usage='<name>') + self.register_command('cert_fetch', self.command_cert_fetch, + desc='Retrieve a certificate with its ' + 'name. It will be stored in <path>.', + shortdesc='Fetch a certificate', + usage='<name> <path>', + completion=self.completion_cert_fetch) + + @command_args_parser.ignored + def command_certs(self): + """ + /certs + """ + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to retrieve the certificate list.', + 'Error') + return + certs = [] + for item in iq['sasl_certs']['items']: + users = '\n'.join(item['users']) + certs.append((item['name'], users)) + + if not certs: + return self.core.information('No certificates found', 'Info') + msg = 'Certificates:\n' + msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs)) + self.core.information(msg, 'Info') + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3) + + @command_args_parser.quoted(2, 1) + def command_cert_add(self, args): + """ + /cert_add <name> <certfile> [cert-management] + """ + if not args or len(args) < 2: + return self.core.command_help('cert_add') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to add the certificate.', 'Error') + else: + self.core.information('Certificate added.', 'Info') + + name = args[0] + + try: + with open(args[1]) as fd: + crt = fd.read() + crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '') + except Exception as e: + self.core.information('Unable to read the certificate: %s' % e, 'Error') + return + + if len(args) > 2: + management = args[2] + if management: + management = management.lower() + if management not in ('false', '0'): + management = True + else: + management = False + else: + management = False + else: + management = True + + self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb, + allow_management=management) + + def completion_cert_add(self, the_input): + """ + completion for /cert_add <name> <path> [management] + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + elif n == 3: + return the_input.new_completion(['true', 'false'], n) + + @command_args_parser.quoted(1) + def command_cert_disable(self, args): + """ + /cert_disable <name> + """ + if not args: + return self.core.command_help('cert_disable') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to disable the certificate.', 'Error') + else: + self.core.information('Certificate disabled.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb) + + @command_args_parser.quoted(1) + def command_cert_revoke(self, args): + """ + /cert_revoke <name> + """ + if not args: + return self.core.command_help('cert_revoke') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to revoke the certificate.', 'Error') + else: + self.core.information('Certificate revoked.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb) + + + @command_args_parser.quoted(2) + def command_cert_fetch(self, args): + """ + /cert_fetch <name> <path> + """ + if not args or len(args) < 2: + return self.core.command_help('cert_fetch') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to fetch the certificate.', + 'Error') + return + + cert = None + for item in iq['sasl_certs']['items']: + if item['name'] == name: + cert = base64.b64decode(item['x509cert']) + break + + if not cert: + return self.core.information('Certificate not found.', 'Info') + + cert = ssl.DER_cert_to_PEM_cert(cert) + with open(path, 'w') as fd: + fd.write(cert) + + self.core.information('File stored at %s' % path, 'Info') + + name = args[0] + path = args[1] + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb) + + def completion_cert_fetch(self, the_input): + """ + completion for /cert_fetch <name> <path> + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + def on_blocked_message(self, message): """ When we try to send a message to a blocked contact @@ -158,7 +368,8 @@ class RosterInfoTab(Tab): } tab.add_message(message) - def command_block(self, arg): + @command_args_parser.quoted(0, 1) + def command_block(self, args): """ /block [jid] """ @@ -169,8 +380,8 @@ class RosterInfoTab(Tab): return self.core.information('Contact blocked.', 'Info') item = self.roster_win.selected_row - if arg: - jid = safeJID(arg) + if args: + jid = safeJID(args[0]) elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -185,7 +396,8 @@ class RosterInfoTab(Tab): jids = roster.jids() return the_input.new_completion(jids, 1, '', quotify=False) - def command_unblock(self, arg): + @command_args_parser.quoted(0, 1) + def command_unblock(self, args): """ /unblock [jid] """ @@ -196,8 +408,8 @@ class RosterInfoTab(Tab): return self.core.information('Contact unblocked.', 'Info') item = self.roster_win.selected_row - if arg: - jid = safeJID(arg) + if args: + jid = safeJID(args[0]) elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -218,7 +430,8 @@ class RosterInfoTab(Tab): self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result) return True - def command_list_blocks(self, arg=None): + @command_args_parser.ignored + def command_list_blocks(self): """ /list_blocks """ @@ -236,7 +449,8 @@ class RosterInfoTab(Tab): self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback) - def command_reconnect(self, args=None): + @command_args_parser.ignored + def command_reconnect(self): """ /reconnect """ @@ -245,19 +459,21 @@ class RosterInfoTab(Tab): else: self.core.xmpp.connect() - def command_disconnect(self, args=None): + @command_args_parser.ignored + def command_disconnect(self): """ /disconnect """ self.core.disconnect() - def command_last_activity(self, arg=None): + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): """ /activity [jid] """ item = self.roster_win.selected_row - if arg: - jid = arg + if args: + jid = args[0] elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -311,31 +527,45 @@ class RosterInfoTab(Tab): not self.input.help_message: self.complete_commands(self.input) - def completion_file(self, the_input): + def completion_file(self, complete_number, the_input): """ - Completion for /import and /export + Generic quoted completion for files/paths + (use functools.partial to use directly as a completion + for a command) """ text = the_input.get_text() - args = text.split() - n = len(args) - if n == 1: - home = os.getenv('HOME') or '/' - return the_input.auto_completion([home, '/tmp'], '') - else: - the_path = text[text.index(' ')+1:] + args = common.shell_split(text) + n = the_input.get_argument_position() + if n == complete_number: + if args[n-1] == '' or len(args) < n+1: + home = os.getenv('HOME') or '/' + return the_input.new_completion([home, '/tmp'], n, quotify=True) + path_ = args[n] + if path.isdir(path_): + dir_ = path_ + base = '' + else: + dir_ = path.dirname(path_) + base = path.basename(path_) try: - names = os.listdir(the_path) - except: + names = os.listdir(dir_) + except OSError: names = [] + names_filtered = [name for name in names if name.startswith(base)] + if names_filtered: + names = names_filtered + if not names: + names = [path_] end_list = [] for name in names: - value = os.path.join(the_path, name) + value = os.path.join(dir_, name) if not name.startswith('.'): end_list.append(value) - return the_input.auto_completion(end_list, '') + return the_input.new_completion(end_list, n, quotify=True) - def command_clear(self, arg=''): + @command_args_parser.ignored + def command_clear(self): """ /clear """ @@ -344,7 +574,8 @@ class RosterInfoTab(Tab): self.core.information_win.rebuild_everything(self.core.information_buffer) self.refresh() - def command_password(self, arg): + @command_args_parser.quoted(1) + def command_password(self, args): """ /password <password> """ @@ -352,19 +583,18 @@ class RosterInfoTab(Tab): if iq['type'] == 'result': self.core.information('Password updated', 'Account') if config.get('password'): - config.silent_set('password', arg) + config.silent_set('password', args[0]) else: self.core.information('Unable to change the password', 'Account') - self.core.xmpp.plugin['xep_0077'].change_password(arg, callback=callback) - + self.core.xmpp.plugin['xep_0077'].change_password(args[0], callback=callback) - - def command_deny(self, arg): + @command_args_parser.quoted(0, 1) + def command_deny(self, args): """ /deny [jid] Denies a JID from our roster """ - if not arg: + if not args: item = self.roster_win.selected_row if isinstance(item, Contact): jid = item.bare_jid @@ -372,7 +602,7 @@ class RosterInfoTab(Tab): self.core.information('No subscription to deny') return else: - jid = safeJID(arg).bare + jid = safeJID(args[0]).bare if not jid in [jid for jid in roster.jids()]: self.core.information('No subscription to deny') return @@ -383,14 +613,15 @@ class RosterInfoTab(Tab): self.core.information('Subscription to %s was revoked' % jid, 'Roster') + @command_args_parser.quoted(1) def command_add(self, args): """ Add the specified JID to the roster, and set automatically accept the reverse subscription """ - jid = safeJID(safeJID(args.strip()).bare) + jid = safeJID(safeJID(args[0]).bare) if not jid: - self.core.information(_('No JID specified'), 'Error') + self.core.information('No JID specified', 'Error') return if jid in roster and roster[jid].subscription in ('to', 'both'): return self.core.information('Already subscribed.', 'Roster') @@ -398,7 +629,8 @@ class RosterInfoTab(Tab): roster.modified() self.core.information('%s was added to the roster' % jid, 'Roster') - def command_name(self, arg): + @command_args_parser.quoted(1, 1) + def command_name(self, args): """ Set a name for the specified JID in your roster """ @@ -406,15 +638,14 @@ class RosterInfoTab(Tab): if not iq: self.core.information('The name could not be set.', 'Error') log.debug('Error in /name:\n%s', iq) - args = common.shell_split(arg) - if not args: + if args is None: return self.core.command_help('name') jid = safeJID(args[0]).bare name = args[1] if len(args) == 2 else '' contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return groups = set(contact.groups) @@ -424,24 +655,24 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(2) def command_groupadd(self, args): """ Add the specified JID to the specified group """ - args = common.shell_split(args) - if len(args) != 2: - return + if args is None: + return self.core.command_help('groupadd') jid = safeJID(args[0]).bare group = args[1] contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) if group in new_groups: - self.core.information(_('JID already in group'), 'Error') + self.core.information('JID already in group', 'Error') return roster.modified() @@ -464,12 +695,12 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) - def command_groupmove(self, arg): + @command_args_parser.quoted(3) + def command_groupmove(self, args): """ Remove the specified JID from the first specified group and add it to the second one """ - args = common.shell_split(arg) - if len(args) != 3: + if args is None: return self.core.command_help('groupmove') jid = safeJID(args[0]).bare group_from = args[1] @@ -477,7 +708,7 @@ class RosterInfoTab(Tab): contact = roster[jid] if not contact: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) @@ -485,19 +716,19 @@ class RosterInfoTab(Tab): new_groups.remove('none') if group_to == 'none' or group_from == 'none': - self.core.information(_('"none" is not a group.'), 'Error') + self.core.information('"none" is not a group.', 'Error') return if group_from not in new_groups: - self.core.information(_('JID not in first group'), 'Error') + self.core.information('JID not in first group', 'Error') return if group_to in new_groups: - self.core.information(_('JID already in second group'), 'Error') + self.core.information('JID already in second group', 'Error') return if group_to == group_from: - self.core.information(_('The groups are the same.'), 'Error') + self.core.information('The groups are the same.', 'Error') return roster.modified() @@ -519,19 +750,20 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(2) def command_groupremove(self, args): """ Remove the specified JID from the specified group """ - args = common.shell_split(args) - if len(args) != 2: - return + if args is None: + return self.core.command_help('groupremove') + jid = safeJID(args[0]).bare group = args[1] contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) @@ -540,7 +772,7 @@ class RosterInfoTab(Tab): except KeyError: pass if group not in new_groups: - self.core.information(_('JID not in group'), 'Error') + self.core.information('JID not in group', 'Error') return roster.modified() @@ -559,13 +791,14 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(0, 1) def command_remove(self, args): """ Remove the specified JID from the roster. i.e.: unsubscribe from its presence, and cancel its subscription to our. """ - if args.strip(): - jid = safeJID(args.strip()).bare + if args: + jid = safeJID(args[0]).bare else: item = self.roster_win.selected_row if isinstance(item, Contact): @@ -576,12 +809,12 @@ class RosterInfoTab(Tab): roster.remove(jid) del roster[jid] - def command_import(self, arg): + @command_args_parser.quoted(0, 1) + def command_import(self, args): """ Import the contacts """ - args = common.shell_split(arg) - if len(args): + if args: if args[0].startswith('/'): filepath = args[0] else: @@ -603,12 +836,12 @@ class RosterInfoTab(Tab): self.command_add(jid.lstrip('\n')) self.core.information('Contacts imported from %s' % filepath, 'Info') - def command_export(self, arg): + @command_args_parser.quoted(0, 1) + def command_export(self, args): """ Export the contacts """ - args = common.shell_split(arg) - if len(args): + if args: if args[0].startswith('/'): filepath = args[0] else: @@ -697,11 +930,12 @@ class RosterInfoTab(Tab): if contact.pending_in) return the_input.new_completion(jids, 1, '', quotify=False) - def command_accept(self, arg): + @command_args_parser.quoted(0, 1) + def command_accept(self, args): """ Accept a JID from in roster. Authorize it AND subscribe to it """ - if not arg: + if not args: item = self.roster_win.selected_row if isinstance(item, Contact): jid = item.bare_jid @@ -709,7 +943,7 @@ class RosterInfoTab(Tab): self.core.information('No subscription to accept') return else: - jid = safeJID(arg).bare + jid = safeJID(args[0]).bare nodepart = safeJID(jid).user jid = safeJID(jid) # crappy transports putting resources inside the node part @@ -769,13 +1003,15 @@ class RosterInfoTab(Tab): success = config.silent_set(option, str(not value)) roster.modified() if not success: - self.core.information(_('Unable to write in the config file'), 'Error') + self.core.information('Unable to write in the config file', 'Error') return True def on_slash(self): """ '/' is pressed, we enter "input mode" """ + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) self.input.resize(1, self.width, self.height-1, 0) @@ -951,6 +1187,8 @@ class RosterInfoTab(Tab): Start the search. The input should appear with a short instruction in it. """ + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter) self.input.resize(1, self.width, self.height-1, 0) @@ -961,6 +1199,8 @@ class RosterInfoTab(Tab): @refresh_wrapper.always def start_search_slow(self): + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter_slow) self.input.resize(1, self.width, self.height-1, 0) diff --git a/src/tabs/xmltab.py b/src/tabs/xmltab.py index 083e97c5..6899cd6f 100644 --- a/src/tabs/xmltab.py +++ b/src/tabs/xmltab.py @@ -5,52 +5,104 @@ in order to only show the relevant ones, and it can also be frozen or unfrozen on demand so that the relevant information is not drowned by the traffic. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) import curses import os from slixmpp.xmlstream import matcher -from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.tostring import tostring +from slixmpp.xmlstream.stanzabase import ElementBase +from xml.etree import ElementTree as ET from . import Tab +import text_buffer import windows from xhtml import clean_text +from decorators import command_args_parser +from common import safeJID + + +class MatchJID(object): + + def __init__(self, jid, dest=''): + self.jid = jid + self.dest = dest + + def match(self, xml): + from_ = safeJID(xml['from']) + to_ = safeJID(xml['to']) + if self.jid.full == self.jid.bare: + from_ = from_.bare + to_ = to_.bare + + if self.dest == 'from': + return from_ == self.jid + elif self.dest == 'to': + return to_ == self.jid + return self.jid in (from_, to_) + + def __repr__(self): + return '%s%s%s' % (self.dest, ': ' if self.dest else '', self.jid) + +MATCHERS_MAPPINGS = { + MatchJID: ('JID', lambda obj: repr(obj)), + matcher.MatcherId: ('ID', lambda obj: obj._criteria), + matcher.MatchXMLMask: ('XMLMask', lambda obj: tostring(obj._criteria)), + matcher.MatchXPath: ('XPath', lambda obj: obj._criteria) +} class XMLTab(Tab): def __init__(self): Tab.__init__(self) self.state = 'normal' self.name = 'XMLTab' - self.text_win = windows.TextWin() - self.core.xml_buffer.add_window(self.text_win) + self.filters = [] + + self.core_buffer = self.core.xml_buffer + self.filtered_buffer = text_buffer.TextBuffer() + self.info_header = windows.XMLInfoWin() + self.text_win = windows.XMLTextWin() + self.core_buffer.add_window(self.text_win) self.default_help_message = windows.HelpText("/ to enter a command") + self.register_command('close', self.close, - shortdesc=_("Close this tab.")) + shortdesc="Close this tab.") self.register_command('clear', self.command_clear, - shortdesc=_('Clear the current buffer.')) + shortdesc='Clear the current buffer.') self.register_command('reset', self.command_reset, - shortdesc=_('Reset the stanza filter.')) + shortdesc='Reset the stanza filter.') self.register_command('filter_id', self.command_filter_id, usage='<id>', - desc=_('Show only the stanzas with the id <id>.'), - shortdesc=_('Filter by id.')) + desc='Show only the stanzas with the id <id>.', + shortdesc='Filter by id.') self.register_command('filter_xpath', self.command_filter_xpath, usage='<xpath>', - desc=_('Show only the stanzas matching the xpath <xpath>.'), - shortdesc=_('Filter by XPath.')) + desc='Show only the stanzas matching the xpath <xpath>.' + ' Any occurrences of %n will be replaced by jabber:client.', + shortdesc='Filter by XPath.') + self.register_command('filter_jid', self.command_filter_jid, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from= or to=.', + shortdesc='Filter by JID.') + self.register_command('filter_from', self.command_filter_from, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from=.', + shortdesc='Filter by JID from.') + self.register_command('filter_to', self.command_filter_to, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in to=.', + shortdesc='Filter by JID to.') self.register_command('filter_xmlmask', self.command_filter_xmlmask, - usage=_('<xml mask>'), - desc=_('Show only the stanzas matching the given xml mask.'), - shortdesc=_('Filter by xml mask.')) + usage='<xml mask>', + desc='Show only the stanzas matching the given xml mask.', + shortdesc='Filter by xml mask.') self.register_command('dump', self.command_dump, - usage=_('<filename>'), - desc=_('Writes the content of the XML buffer into a file.'), - shortdesc=_('Write in a file.')) + usage='<filename>', + desc='Writes the content of the XML buffer into a file.', + shortdesc='Write in a file.') self.input = self.default_help_message self.key_func['^T'] = self.close self.key_func['^I'] = self.completion @@ -63,6 +115,34 @@ class XMLTab(Tab): self.filter_type = '' self.filter = '' + def gen_filter_repr(self): + if not self.filters: + self.filter_type = '' + self.filter = '' + return + filter_types = map(lambda x: MATCHERS_MAPPINGS[type(x)][0], self.filters) + filter_strings = map(lambda x: MATCHERS_MAPPINGS[type(x)][1](x), self.filters) + self.filter_type = ','.join(filter_types) + self.filter = ','.join(filter_strings) + + def update_filters(self, matcher): + if not self.filters: + messages = self.core_buffer.messages[:] + self.filtered_buffer.messages = [] + self.core_buffer.del_window(self.text_win) + self.filtered_buffer.add_window(self.text_win) + else: + messages = self.filtered_buffer.messages + self.filtered_buffer.messages = [] + self.filters.append(matcher) + new_messages = [] + for msg in messages: + if self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))): + new_messages.append(msg) + self.filtered_buffer.messages = new_messages + self.text_win.rebuild_everything(self.filtered_buffer) + self.gen_filter_repr() + def on_freeze(self): """ Freeze the display. @@ -70,58 +150,94 @@ class XMLTab(Tab): self.text_win.toggle_lock() self.refresh() - def command_filter_xmlmask(self, arg): + def match_stanza(self, stanza): + for matcher in self.filters: + if not matcher.match(stanza): + return False + return True + + @command_args_parser.raw + def command_filter_xmlmask(self, mask): """/filter_xmlmask <xml mask>""" try: - handler = Callback('custom matcher', matcher.MatchXMLMask(arg), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XML Mask Filter" - self.filter = arg + self.update_filters(matcher.MatchXMLMask(mask)) self.refresh() - except: - self.core.information('Invalid XML Mask', 'Error') + except Exception as e: + self.core.information('Invalid XML Mask: %s' % e, 'Error') self.command_reset('') - def command_filter_id(self, arg): + @command_args_parser.raw + def command_filter_to(self, jid): + """/filter_jid_to <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='to')) + self.refresh() + + @command_args_parser.raw + def command_filter_from(self, jid): + """/filter_jid_from <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='from')) + self.refresh() + + @command_args_parser.raw + def command_filter_jid(self, jid): + """/filter_jid <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj)) + self.refresh() + + @command_args_parser.quoted(1) + def command_filter_id(self, args): """/filter_id <id>""" - self.core.xmpp.remove_handler('custom matcher') - handler = Callback('custom matcher', matcher.MatcherId(arg), - self.core.incoming_stanza) - self.core.xmpp.register_handler(handler) - self.filter_type = "Id Filter" - self.filter = arg + if args is None: + return self.core.command_help('filter_id') + + self.update_filters(matcher.MatcherId(args[0])) self.refresh() - def command_filter_xpath(self, arg): + @command_args_parser.raw + def command_filter_xpath(self, xpath): """/filter_xpath <xpath>""" try: - handler = Callback('custom matcher', matcher.MatchXPath( - arg.replace('%n', self.core.xmpp.default_ns)), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XPath Filter" - self.filter = arg + self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns))) self.refresh() except: self.core.information('Invalid XML Path', 'Error') self.command_reset('') - def command_reset(self, arg): + @command_args_parser.ignored + def command_reset(self): """/reset""" - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(self.core.all_stanzas) + if self.filters: + self.filters = [] + self.filtered_buffer.del_window(self.text_win) + self.core_buffer.add_window(self.text_win) + self.text_win.rebuild_everything(self.core_buffer) self.filter_type = '' self.filter = '' self.refresh() - def command_dump(self, arg): + @command_args_parser.quoted(1) + def command_dump(self, args): """/dump <filename>""" - xml = self.core.xml_buffer.messages[:] - text = '\n'.join(('%s %s' % (msg.str_time, clean_text(msg.txt)) for msg in xml)) - filename = os.path.expandvars(os.path.expanduser(arg)) + if args is None: + return self.core.command_help('dump') + if self.filters: + xml = self.filtered_buffer.messages[:] + else: + xml = self.core_buffer.messages[:] + text = '\n'.join(('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) for msg in xml)) + filename = os.path.expandvars(os.path.expanduser(args[0])) try: with open(filename, 'w') as fd: fd.write(text) @@ -151,12 +267,17 @@ class XMLTab(Tab): def on_scroll_down(self): return self.text_win.scroll_down(self.text_win.height-1) - def command_clear(self, args): + @command_args_parser.ignored + def command_clear(self): """ /clear """ - self.core.xml_buffer.messages = [] - self.text_win.rebuild_everything(self.core.xml_buffer) + if self.filters: + buffer = self.core_buffer + else: + buffer = self.filtered_buffer + buffer.messages = [] + self.text_win.rebuild_everything(buffer) self.refresh() self.core.doupdate() diff --git a/src/text_buffer.py b/src/text_buffer.py index 59aa96e1..6bc3ee23 100644 --- a/src/text_buffer.py +++ b/src/text_buffer.py @@ -24,6 +24,9 @@ Message = collections.namedtuple('Message', message_fields) class CorrectionError(Exception): pass +class AckError(Exception): + pass + def other_elems(self): "Helper for the repr_message function" acc = ['Message('] @@ -84,7 +87,7 @@ class TextBuffer(object): @staticmethod def make_message(txt, time, nickname, nick_color, history, user, identifier, str_time=None, highlight=False, - old_message=None, revisions=0, jid=None, ack=None): + old_message=None, revisions=0, jid=None, ack=0): """ Create a new Message object with parameters, check for /me messages, and delayed messages @@ -125,7 +128,7 @@ class TextBuffer(object): def add_message(self, txt, time=None, nickname=None, nick_color=None, history=None, user=None, highlight=False, - identifier=None, str_time=None, jid=None, ack=None): + identifier=None, str_time=None, jid=None, ack=0): """ Create a message and add it to the text buffer """ @@ -161,16 +164,31 @@ class TextBuffer(object): return i return -1 - def ack_message(self, old_id): + def ack_message(self, old_id, jid): + """Mark a message as acked""" + return self.edit_ack(1, old_id, jid) + + def nack_message(self, error, old_id, jid): + """Mark a message as errored""" + return self.edit_ack(-1, old_id, jid, append=error) + + def edit_ack(self, value, old_id, jid, append=''): """ - Ack a message + Edit the ack status of a message, and optionally + append some text. """ i = self._find_message(old_id) if i == -1: return msg = self.messages[i] + if msg.jid != jid: + raise AckError('Wrong JID for message id %s (was %s, expected %s)' % + (old_id, msg.jid, jid)) + new_msg = list(msg) - new_msg[12] = True + new_msg[12] = value + if append: + new_msg[0] = new_msg[0] + append new_msg = Message(*new_msg) self.messages[i] = new_msg return new_msg diff --git a/src/theming.py b/src/theming.py index 1e9d6c40..ae71e48f 100755 --- a/src/theming.py +++ b/src/theming.py @@ -69,20 +69,18 @@ log = logging.getLogger(__name__) from config import config import curses -import imp import os from os import path -from sys import version_info -if version_info[1] >= 3: - from importlib import machinery - finder = machinery.PathFinder() +from importlib import machinery +finder = machinery.PathFinder() class Theme(object): """ - The theme class, from which all theme should inherit. - All of the following value can be replaced in subclasses, in + The theme class, from which all themes should inherit. + All of the following values can be replaced in subclasses, in order to create a new theme. + Do not edit this file if you want to change the theme to suit your needs. Create a new theme and share it if you think it can be useful for others. @@ -178,6 +176,13 @@ class Theme(object): CHAR_AFFILIATION_MEMBER = '+' CHAR_AFFILIATION_NONE = '-' + + # XML Tab + CHAR_XML_IN = 'IN ' + CHAR_XML_OUT = 'OUT' + COLOR_XML_IN = (1, -1) + COLOR_XML_OUT = (2, -1) + # Color for the /me message COLOR_ME_MESSAGE = (6, -1) @@ -305,6 +310,7 @@ class Theme(object): CHAR_ERROR = '✖' CHAR_EMPTY = ' ' CHAR_ACK_RECEIVED = CHAR_OK + CHAR_NACK = CHAR_ERROR CHAR_COLUMN_ASC = ' ▲' CHAR_COLUMN_DESC = ' ▼' CHAR_ROSTER_ERROR = CHAR_ERROR @@ -319,6 +325,7 @@ class Theme(object): CHAR_ROSTER_NONE = '⇹' COLOR_CHAR_ACK = (2, -1) + COLOR_CHAR_NACK = (1, -1) COLOR_ROSTER_GAMING = (6, -1) COLOR_ROSTER_MOOD = (2, -1) @@ -493,21 +500,13 @@ def reload_theme(): new_theme = None exc = None try: - if version_info[1] < 3: - file, filename, info = imp.find_module(theme_name, load_path) - imp.acquire_lock() - new_theme = imp.load_module(theme_name, file, filename, info) - else: - loader = finder.find_module(theme_name, load_path) - if not loader: - return 'Failed to load the theme %s' % theme_name - new_theme = loader.load_module() + loader = finder.find_module(theme_name, load_path) + if not loader: + return 'Failed to load the theme %s' % theme_name + new_theme = loader.load_module() except Exception as e: log.error('Failed to load the theme %s', theme_name, exc_info=True) exc = e - finally: - if version_info[1] < 3 and imp.lock_held(): - imp.release_lock() if not new_theme: return 'Failed to load theme: %s' % exc diff --git a/src/user.py b/src/user.py index 0d29569f..b1796bc3 100644 --- a/src/user.py +++ b/src/user.py @@ -12,9 +12,14 @@ An user is a MUC participant, not a roster contact (see contact.py) from random import choice from datetime import timedelta, datetime +from hashlib import md5 +import xhtml from theming import get_theme +import logging +log = logging.getLogger(__name__) + ROLE_DICT = { '':0, 'none':0, @@ -27,14 +32,26 @@ class User(object): """ keep trace of an user in a Room """ - def __init__(self, nick, affiliation, show, status, role, jid): + def __init__(self, nick, affiliation, show, status, role, jid, deterministic=True, color=''): self.last_talked = datetime(1, 1, 1) # The oldest possible time self.update(affiliation, show, status, role) self.change_nick(nick) - self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + if color != '': + self.change_color(color, deterministic) + else: + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) self.jid = jid self.chatstate = None + def set_deterministic_color(self): + theme = get_theme() + mod = len(theme.LIST_COLOR_NICKNAMES) + nick_pos = int(md5(self.nick.encode('utf-8')).hexdigest(), 16) % mod + self.color = theme.LIST_COLOR_NICKNAMES[nick_pos] + def update(self, affiliation, show, status, role): self.affiliation = affiliation self.show = show @@ -46,6 +63,17 @@ class User(object): def change_nick(self, nick): self.nick = nick + def change_color(self, color_name, deterministic=False): + color = xhtml.colors.get(color_name) + if color == None: + log.error('Unknown color "%s"' % color_name) + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + else: + self.color = (color, -1) + def set_last_talked(self, time): """ time: datetime object diff --git a/src/windows/__init__.py b/src/windows/__init__.py index 9e165201..5ec73961 100644 --- a/src/windows/__init__.py +++ b/src/windows/__init__.py @@ -5,15 +5,16 @@ used to display information on the screen from . base_wins import Win from . data_forms import FormWin +from . bookmark_forms import BookmarksWin from . info_bar import GlobalInfoBar, VerticalGlobalInfoBar from . info_wins import InfoWin, XMLInfoWin, PrivateInfoWin, MucListInfoWin, \ ConversationInfoWin, DynamicConversationInfoWin, MucInfoWin, \ - ConversationStatusMessageWin + ConversationStatusMessageWin, BookmarksInfoWin from . input_placeholders import HelpText, YesNoInput from . inputs import Input, HistoryInput, MessageInput, CommandInput from . list import ListWin, ColumnHeaderWin from . misc import VerticalSeparator from . muc import UserList, Topic from . roster_win import RosterWin, ContactInfoWin -from . text_win import TextWin +from . text_win import TextWin, XMLTextWin diff --git a/src/windows/bookmark_forms.py b/src/windows/bookmark_forms.py new file mode 100644 index 00000000..7cbd30cc --- /dev/null +++ b/src/windows/bookmark_forms.py @@ -0,0 +1,278 @@ +""" +Windows used inthe bookmarkstab +""" +import curses + +from . import Win +from . inputs import Input +from . data_forms import FieldInput +from theming import to_curses_attr, get_theme +from common import safeJID + +class BookmarkJIDInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + jid = safeJID(field.jid) + jid.resource = field.nick + self.text = jid.full + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def save(self): + jid = safeJID(self.get_text()) + self._field.jid = jid.bare + self._field.name = jid.bare + self._field.nick = jid.resource + + def get_help_message(self): + return 'Edit the text' + +class BookmarkMethodInput(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.options = ('local', 'remote') + # val_pos is the position of the currently selected option + self.val_pos = self.options.index(field.method) + + def do_command(self, key): + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + else: + return + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + if self.options: + option = self.options[self.val_pos] + self.addstr(0, self.width//2-len(option)//2, option) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.method = self.options[self.val_pos] + + def get_help_message(self): + return '←, →: Select a value amongst the others' + +class BookmarkPasswordInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + self.text = field.password or '' + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def rewrite_text(self): + self._win.erase() + if self.color: + self._win.attron(to_curses_attr(self.color)) + self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1])) + if self.color: + (y, x) = self._win.getyx() + size = self.width-x + self.addnstr(' '*size, size, to_curses_attr(self.color)) + self.addstr(0, self.pos, '') + if self.color: + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.password = self.get_text() or None + + def get_help_message(self): + return 'Edit the secret text' + +class BookmarkAutojoinWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.last_key = 'KEY_RIGHT' + self.value = field.autojoin + + def do_command(self, key): + if key == 'KEY_LEFT' or key == 'KEY_RIGHT': + self.value = not self.value + self.last_key = key + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + format_string = '←{:^%s}→' % 7 + inp = format_string.format(repr(self.value)) + self.addstr(0, 0, inp) + if self.last_key == 'KEY_RIGHT': + self.move(0, 8) + else: + self.move(0, 0) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.autojoin = self.value + + def get_help_message(self): + return '← and →: change the value between True and False' + + +class BookmarksWin(Win): + def __init__(self, bookmarks, height, width, y, x): + self._win = Win._tab_win.derwin(height, width, y, x) + self.scroll_pos = 0 + self._current_input = 0 + self.current_horizontal_input = 0 + self._bookmarks = list(bookmarks) + self.lines = [] + for bookmark in sorted(self._bookmarks, key=lambda x: x.jid): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + + @property + def current_input(self): + return self._current_input + + @current_input.setter + def current_input(self, value): + if 0 <= self._current_input < len(self.lines): + if 0 <= value < len(self.lines): + self.lines[self._current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self._current_input = value + else: + self._current_input = 0 + + def add_bookmark(self, bookmark): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input = 0 + self.current_input = len(self.lines) - 1 + if self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos = self.current_input - self.height + 1 + self.refresh() + + def del_current_bookmark(self): + if self.lines: + bm = self.lines[self.current_input][0]._field + to_delete = self.current_input + self.current_input -= 1 + del self.lines[to_delete] + if self.scroll_pos: + self.scroll_pos -= 1 + self.refresh() + return bm + + def resize(self, height, width, y, x): + self.height = height + self.width = width + self._win = Win._tab_win.derwin(height, width, y, x) + # Adjust the scroll position, if resizing made the window too small + # for the cursor to be visible + while self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos += 1 + + def go_to_next_line_input(self): + if not self.lines: + return + if self.current_input == len(self.lines) - 1: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input + 1 - self.scroll_pos > self.height-1: + self.current_input += 1 + self.scroll_pos += 1 + self.refresh() + else: + self.current_input += 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_line_input(self): + if not self.lines: + return + if self.current_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_input -= 1 + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input < self.scroll_pos: + self.scroll_pos = self.current_input + self.refresh() + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_next_horizontal_input(self): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input += 1 + if self.current_horizontal_input > 3: + self.current_horizontal_input = 0 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_horizontal_input(self): + if not self.lines: + return + if self.current_horizontal_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input -= 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def on_input(self, key): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].do_command(key) + + def refresh(self): + # store the cursor status + self._win.erase() + y = - self.scroll_pos + for i in range(len(self.lines)): + self.lines[i][0].resize(1, self.width//3, y + 1, 0) + self.lines[i][1].resize(1, self.width//3, y + 1, self.width//3) + self.lines[i][2].resize(1, self.width//6, y + 1, 2*self.width//3) + self.lines[i][3].resize(1, self.width//6, y + 1, 5*self.width//6) + y += 1 + self._refresh() + for i, inp in enumerate(self.lines): + if i < self.scroll_pos: + continue + if i >= self.height + self.scroll_pos: + break + for j in range(4): + inp[j].refresh() + + if self.lines and self.current_input < self.height-1: + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + self.lines[self.current_input][self.current_horizontal_input].refresh() + if not self.lines: + curses.curs_set(0) + else: + curses.curs_set(1) + + def refresh_current_input(self): + if self.lines: + self.lines[self.current_input][self.current_horizontal_input].refresh() + + def save(self): + for line in self.lines: + for item in line: + item.save() + diff --git a/src/windows/data_forms.py b/src/windows/data_forms.py index d6e2cc66..86f33350 100644 --- a/src/windows/data_forms.py +++ b/src/windows/data_forms.py @@ -469,4 +469,3 @@ class FormWin(object): return self.inputs[self.current_input]['input'].get_help_message() return '' - diff --git a/src/windows/funcs.py b/src/windows/funcs.py index d58d4683..f1401628 100644 --- a/src/windows/funcs.py +++ b/src/windows/funcs.py @@ -4,7 +4,6 @@ Standalone functions used by the modules import string -from config import config from . base_wins import FORMAT_CHAR, format_chars def find_first_format_char(text, chars=None): @@ -19,8 +18,7 @@ def find_first_format_char(text, chars=None): pos = p return pos -def truncate_nick(nick, size=None): - size = size or config.get('max_nick_length') +def truncate_nick(nick, size=10): if size < 1: size = 1 if nick and len(nick) > size: diff --git a/src/windows/info_bar.py b/src/windows/info_bar.py index e66343c5..abd956cd 100644 --- a/src/windows/info_bar.py +++ b/src/windows/info_bar.py @@ -28,6 +28,7 @@ class GlobalInfoBar(Win): show_names = config.get('show_tab_names') show_nums = config.get('show_tab_numbers') use_nicks = config.get('use_tab_nicks') + show_inactive = config.get('show_inactive_tabs') # ignore any remaining gap tabs if the feature is not enabled if create_gaps: sorted_tabs = self.core.tabs[:] @@ -37,8 +38,7 @@ class GlobalInfoBar(Win): for nb, tab in enumerate(sorted_tabs): if not tab: continue color = tab.color - if not config.get('show_inactive_tabs') and\ - color is get_theme().COLOR_TAB_NORMAL: + if not show_inactive and color is get_theme().COLOR_TAB_NORMAL: continue try: if show_nums or not show_names: @@ -87,9 +87,10 @@ class VerticalGlobalInfoBar(Win): sorted_tabs = sorted_tabs[-height:] else: sorted_tabs = sorted_tabs[pos-height//2 : pos+height//2] + asc_sort = (config.get('vertical_tab_list_sort') == 'asc') for y, tab in enumerate(sorted_tabs): color = tab.vertical_color - if not config.get('vertical_tab_list_sort') != 'asc': + if asc_sort: y = height - y - 1 self.addstr(y, 0, "%2d" % tab.nb, to_curses_attr(get_theme().COLOR_VERTICAL_TAB_NUMBER)) diff --git a/src/windows/info_wins.py b/src/windows/info_wins.py index 766afb75..80af4602 100644 --- a/src/windows/info_wins.py +++ b/src/windows/info_wins.py @@ -293,3 +293,17 @@ class ConversationStatusMessageWin(InfoWin): def write_status_message(self, resource): self.addstr(resource.status, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) +class BookmarksInfoWin(InfoWin): + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, preferred): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.write_remote_status(preferred) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_remote_status(self, preferred): + self.addstr('Remote storage: %s' % preferred, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + diff --git a/src/windows/input_placeholders.py b/src/windows/input_placeholders.py index 8bcf1524..496417d1 100644 --- a/src/windows/input_placeholders.py +++ b/src/windows/input_placeholders.py @@ -41,7 +41,7 @@ class YesNoInput(Win): A Window just displaying a Yes/No input Used to ask a confirmation """ - def __init__(self, text=''): + def __init__(self, text='', callback=None): Win.__init__(self) self.key_func = { 'y' : self.on_yes, @@ -49,6 +49,7 @@ class YesNoInput(Win): } self.txt = text self.value = None + self.callback = callback def on_yes(self): self.value = True @@ -68,17 +69,8 @@ class YesNoInput(Win): def do_command(self, key, raw=False): if key.lower() in self.key_func: self.key_func[key]() - - def prompt(self): - """Monopolizes the input while waiting for a recognized keypress""" - def cb(key): - if key in self.key_func: - self.key_func[key]() - if self.value is None: - # We didn’t finish with this prompt, continue monopolizing - # it again until value is set - keyboard.continuation_keys_callback = cb - keyboard.continuation_keys_callback = cb + if self.value is not None and self.callback is not None: + return self.callback() def on_delete(self): return diff --git a/src/windows/inputs.py b/src/windows/inputs.py index d345443b..12d3a9a2 100644 --- a/src/windows/inputs.py +++ b/src/windows/inputs.py @@ -43,6 +43,8 @@ class Input(Win): '^D': self.key_dc, 'M-b': self.jump_word_left, "M-[1;5D": self.jump_word_left, + "kRIT5": self.jump_word_right, + "kLFT5": self.jump_word_left, '^W': self.delete_word, 'M-d': self.delete_next_word, '^K': self.delete_end_of_line, @@ -534,6 +536,11 @@ class Input(Win): if self.view_pos < 0: self.view_pos = 0 + # text small enough to fit inside the window entirely: + # remove scrolling if present + if poopt.wcswidth(self.text) < self.width: + self.view_pos = 0 + assert(self.pos >= self.view_pos and self.pos <= self.view_pos + max(self.width, 3)) diff --git a/src/windows/muc.py b/src/windows/muc.py index 7e3541ba..c4e8df6e 100644 --- a/src/windows/muc.py +++ b/src/windows/muc.py @@ -37,7 +37,8 @@ class UserList(Win): if config.get('hide_user_list'): return # do not refresh if this win is hidden. self._win.erase() - if config.get('user_list_sort').lower() == 'asc': + asc_sort = (config.get('user_list_sort').lower() == 'asc') + if asc_sort: y, x = self._win.getmaxyx() y -= 1 users = sorted(users) @@ -55,7 +56,7 @@ class UserList(Win): self.addstr(y, 2, poopt.cut_by_columns(user.nick, self.width - 2), to_curses_attr(user.color)) - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: y -= 1 else: y += 1 @@ -63,12 +64,12 @@ class UserList(Win): break # draw indicators of position in the list if self.pos > 0: - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: self.draw_plus(self.height-1) else: self.draw_plus(0) if self.pos + self.height < len(users): - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: self.draw_plus(0) else: self.draw_plus(self.height-1) diff --git a/src/windows/roster_win.py b/src/windows/roster_win.py index 6ecb6128..a2e2badd 100644 --- a/src/windows/roster_win.py +++ b/src/windows/roster_win.py @@ -145,6 +145,12 @@ class RosterWin(Win): # draw the roster from the cache roster_view = self.roster_cache[self.start_pos-1:self.start_pos+self.height] + options = { + 'show_roster_sub': config.get('show_roster_subscriptions'), + 'show_s2s_errors': config.get('show_s2s_errors'), + 'show_roster_jids': config.get('show_roster_jids') + } + for item in roster_view: draw_selected = False if y -2 + self.start_pos == self.pos: @@ -155,7 +161,7 @@ class RosterWin(Win): self.draw_group(y, item, draw_selected) group = item.name elif isinstance(item, Contact): - self.draw_contact_line(y, item, draw_selected, group) + self.draw_contact_line(y, item, draw_selected, group, **options) elif isinstance(item, Resource): self.draw_resource_line(y, item, draw_selected) @@ -206,7 +212,8 @@ class RosterWin(Win): return name return name[:self.width - added - 1] + '…' - def draw_contact_line(self, y, contact, colored, group): + def draw_contact_line(self, y, contact, colored, group, show_roster_sub=False, + show_s2s_errors=True, show_roster_jids=False): """ Draw on a line all informations about one contact. This is basically the highest priority resource's informations @@ -229,15 +236,13 @@ class RosterWin(Win): self.addstr(y, 0, ' ') self.addstr(theme.CHAR_STATUS, to_curses_attr(color)) - show_roster_sub = config.get('show_roster_subscriptions') - self.addstr(' ') if resource: self.addstr('[+] ' if contact.folded(group) else '[-] ') added += 4 if contact.ask: added += len(get_theme().CHAR_ROSTER_ASKED) - if config.get('show_s2s_errors') and contact.error: + if show_s2s_errors and contact.error: added += len(get_theme().CHAR_ROSTER_ERROR) if contact.tune: added += len(get_theme().CHAR_ROSTER_TUNE) @@ -250,7 +255,7 @@ class RosterWin(Win): if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'): added += len(theme.char_subscription(contact.subscription, keep=show_roster_sub)) - if not config.get('show_roster_jids') and contact.name: + if not show_roster_jids and contact.name: display_name = '%s' % contact.name elif contact.name and contact.name != contact.bare_jid: display_name = '%s (%s)' % (contact.name, contact.bare_jid) @@ -268,7 +273,7 @@ class RosterWin(Win): self.addstr(theme.char_subscription(contact.subscription, keep=show_roster_sub), to_curses_attr(theme.COLOR_ROSTER_SUBSCRIPTION)) if contact.ask: self.addstr(get_theme().CHAR_ROSTER_ASKED, to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT)) - if config.get('show_s2s_errors') and contact.error: + if show_s2s_errors and contact.error: self.addstr(get_theme().CHAR_ROSTER_ERROR, to_curses_attr(get_theme().COLOR_ROSTER_ERROR)) if contact.tune: self.addstr(get_theme().CHAR_ROSTER_TUNE, to_curses_attr(get_theme().COLOR_ROSTER_TUNE)) diff --git a/src/windows/text_win.py b/src/windows/text_win.py index 6fe74f41..59c5230b 100644 --- a/src/windows/text_win.py +++ b/src/windows/text_win.py @@ -18,7 +18,7 @@ from config import config from theming import to_curses_attr, get_theme, dump_tuple -class TextWin(Win): +class BaseTextWin(Win): def __init__(self, lines_nb_limit=None): if lines_nb_limit is None: lines_nb_limit = config.get('max_lines_in_memory') @@ -30,19 +30,6 @@ class TextWin(Win): self.lock = False self.lock_buffer = [] - - # the Lines of the highlights in that buffer - self.highlights = [] - # the current HL position in that list NaN means that we’re not on - # an hl. -1 is a valid position (it's before the first hl of the - # list. i.e the separator, in the case where there’s no hl before - # it.) - self.hl_pos = float('nan') - - # Keep track of the number of hl after the separator. - # This is useful to make “go to next highlight“ work after a “move to separator”. - self.nb_of_highlights_after_separator = 0 - self.separator_after = None def toggle_lock(self): @@ -60,6 +47,114 @@ class TextWin(Win): self.built_lines.append(line) self.lock = False + def scroll_up(self, dist=14): + pos = self.pos + self.pos += dist + if self.pos + self.height > len(self.built_lines): + self.pos = len(self.built_lines) - self.height + if self.pos < 0: + self.pos = 0 + return self.pos != pos + + def scroll_down(self, dist=14): + pos = self.pos + self.pos -= dist + if self.pos <= 0: + self.pos = 0 + return self.pos != pos + + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): + """ + Take one message, build it and add it to the list + Return the number of lines that are built for the given + message. + """ + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) + if self.lock: + self.lock_buffer.extend(lines) + else: + self.built_lines.extend(lines) + if not lines or not lines[0]: + return 0 + if clean: + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + return len(lines) + + def build_message(self, message, timestamp=False, nick_size=10): + """ + Build a list of lines from a message, without adding it + to a list + """ + pass + + def refresh(self): + pass + + def write_text(self, y, x, txt): + """ + write the text of a line. + """ + self.addstr_colored(txt, y, x) + + def write_time(self, time): + """ + Write the date on the yth line of the window + """ + if time: + self.addstr(time) + self.addstr(' ') + + def resize(self, height, width, y, x, room=None): + if hasattr(self, 'width'): + old_width = self.width + else: + old_width = None + self._resize(height, width, y, x) + if room and self.width != old_width: + self.rebuild_everything(room) + + # reposition the scrolling after resize + # (see #2450) + buf_size = len(self.built_lines) + if buf_size - self.pos < self.height: + self.pos = buf_size - self.height + if self.pos < 0: + self.pos = 0 + + def rebuild_everything(self, room): + self.built_lines = [] + with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') + for message in room.messages: + self.build_new_message(message, clean=False, timestamp=with_timestamps, nick_size=nick_size) + if self.separator_after is message: + self.build_new_message(None) + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + + def __del__(self): + log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) + del self.built_lines + +class TextWin(BaseTextWin): + def __init__(self, lines_nb_limit=None): + BaseTextWin.__init__(self, lines_nb_limit) + + # the Lines of the highlights in that buffer + self.highlights = [] + # the current HL position in that list NaN means that we’re not on + # an hl. -1 is a valid position (it's before the first hl of the + # list. i.e the separator, in the case where there’s no hl before + # it.) + self.hl_pos = float('nan') + + # Keep track of the number of hl after the separator. + # This is useful to make “go to next highlight“ work after a “move to separator”. + self.nb_of_highlights_after_separator = 0 + + self.separator_after = None + def next_highlight(self): """ Go to the next highlight in the buffer. @@ -130,22 +225,6 @@ class TextWin(Win): if self.pos < 0 or self.pos >= len(self.built_lines): self.pos = 0 - def scroll_up(self, dist=14): - pos = self.pos - self.pos += dist - if self.pos + self.height > len(self.built_lines): - self.pos = len(self.built_lines) - self.height - if self.pos < 0: - self.pos = 0 - return self.pos != pos - - def scroll_down(self, dist=14): - pos = self.pos - self.pos -= dist - if self.pos <= 0: - self.pos = 0 - return self.pos != pos - def scroll_to_separator(self): """ Scroll until separator is centered. If no separator is @@ -187,13 +266,13 @@ class TextWin(Win): if room and room.messages: self.separator_after = room.messages[-1] - def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False): + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): """ Take one message, build it and add it to the list Return the number of lines that are built for the given message. """ - lines = self.build_message(message, timestamp=timestamp) + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) if self.lock: self.lock_buffer.extend(lines) else: @@ -210,7 +289,7 @@ class TextWin(Win): self.built_lines.pop(0) return len(lines) - def build_message(self, message, timestamp=False): + def build_message(self, message, timestamp=False, nick_size=10): """ Build a list of lines from a message, without adding it to a list @@ -226,10 +305,13 @@ class TextWin(Win): else: default_color = None ret = [] - nick = truncate_nick(message.nickname) + nick = truncate_nick(message.nickname, nick_size) offset = 0 if message.ack: - offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + if message.ack > 0: + offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1 if nick: offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length if message.revisions > 0: @@ -268,12 +350,14 @@ class TextWin(Win): else: lines = self.built_lines[-self.height-self.pos:-self.pos] with_timestamps = config.get("show_timestamps") + nick_size = config.get("max_nick_length") self._win.move(0, 0) self._win.erase() for y, line in enumerate(lines): if line: msg = line.msg if line.start_pos == 0: + nick = truncate_nick(msg.nickname, nick_size) if msg.nick_color: color = msg.nick_color elif msg.user: @@ -283,18 +367,21 @@ class TextWin(Win): if with_timestamps: self.write_time(msg.str_time) if msg.ack: - self.write_ack() + if msg.ack > 0: + self.write_ack() + else: + self.write_nack() if msg.me: self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE)) self.addstr('* ') - self.write_nickname(msg.nickname, color, msg.highlight) + self.write_nickname(nick, color, msg.highlight) if msg.revisions: self._win.attron(to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) self.addstr('%d' % msg.revisions) self._win.attrset(0) self.addstr(' ') else: - self.write_nickname(msg.nickname, color, msg.highlight) + self.write_nickname(nick, color, msg.highlight) if msg.revisions: self._win.attron(to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) self.addstr('%d' % msg.revisions) @@ -317,8 +404,7 @@ class TextWin(Win): # Offset for the nickname (if any) # plus a space and a > after it if line.msg.nickname: - offset += poopt.wcswidth( - truncate_nick(line.msg.nickname)) + offset += poopt.wcswidth(truncate_nick(line.msg.nickname, nick_size)) if line.msg.me: offset += 3 else: @@ -326,8 +412,11 @@ class TextWin(Win): offset += ceil(log10(line.msg.revisions + 1)) if line.msg.ack: - offset += 1 + poopt.wcswidth( - get_theme().CHAR_ACK_RECEIVED) + if msg.ack > 0: + offset += 1 + poopt.wcswidth( + get_theme().CHAR_ACK_RECEIVED) + else: + offset += 1 + poopt.wcswidth(get_theme().CHAR_NACK) self.write_text(y, offset, line.prepend+line.msg.txt[line.start_pos:line.end_pos]) @@ -343,12 +432,6 @@ class TextWin(Win): self.width, to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR)) - def write_text(self, y, x, txt): - """ - write the text of a line. - """ - self.addstr_colored(txt, y, x) - def write_ack(self): color = get_theme().COLOR_CHAR_ACK self._win.attron(to_curses_attr(color)) @@ -356,6 +439,13 @@ class TextWin(Win): self._win.attroff(to_curses_attr(color)) self.addstr(' ') + def write_nack(self): + color = get_theme().COLOR_CHAR_NACK + self._win.attron(to_curses_attr(color)) + self.addstr(get_theme().CHAR_NACK) + self._win.attroff(to_curses_attr(color)) + self.addstr(' ') + def write_nickname(self, nickname, color, highlight=False): """ Write the nickname, using the user's color @@ -371,53 +461,19 @@ class TextWin(Win): color = hl_color if color: self._win.attron(to_curses_attr(color)) - self.addstr(truncate_nick(nickname)) + self.addstr(nickname) if color: self._win.attroff(to_curses_attr(color)) if highlight and hl_color == "reverse": self._win.attroff(curses.A_REVERSE) - def write_time(self, time): - """ - Write the date on the yth line of the window - """ - if time: - self.addstr(time) - self.addstr(' ') - - def resize(self, height, width, y, x, room=None): - if hasattr(self, 'width'): - old_width = self.width - else: - old_width = None - self._resize(height, width, y, x) - if room and self.width != old_width: - self.rebuild_everything(room) - - # reposition the scrolling after resize - # (see #2450) - buf_size = len(self.built_lines) - if buf_size - self.pos < self.height: - self.pos = buf_size - self.height - if self.pos < 0: - self.pos = 0 - - def rebuild_everything(self, room): - self.built_lines = [] - with_timestamps = config.get('show_timestamps') - for message in room.messages: - self.build_new_message(message, clean=False, timestamp=with_timestamps) - if self.separator_after is message: - self.build_new_message(None) - while len(self.built_lines) > self.lines_nb_limit: - self.built_lines.pop(0) - def modify_message(self, old_id, message): """ Find a message, and replace it with a new one (instead of rebuilding everything in order to correct a message) """ with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') for i in range(len(self.built_lines)-1, -1, -1): if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id: index = i @@ -425,7 +481,7 @@ class TextWin(Win): self.built_lines.pop(index) index -= 1 index += 1 - lines = self.build_message(message, timestamp=with_timestamps) + lines = self.build_message(message, timestamp=with_timestamps, nick_size=nick_size) for line in lines: self.built_lines.insert(index, line) index += 1 @@ -435,3 +491,86 @@ class TextWin(Win): log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) del self.built_lines +class XMLTextWin(BaseTextWin): + def __init__(self): + BaseTextWin.__init__(self) + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + theme = get_theme() + if self.height <= 0: + return + if self.pos == 0: + lines = self.built_lines[-self.height:] + else: + lines = self.built_lines[-self.height-self.pos:-self.pos] + self._win.move(0, 0) + self._win.erase() + for y, line in enumerate(lines): + if line: + msg = line.msg + if line.start_pos == 0: + if msg.nickname == theme.CHAR_XML_OUT: + color = theme.COLOR_XML_OUT + elif msg.nickname == theme.CHAR_XML_IN: + color = theme.COLOR_XML_IN + self.write_time(msg.str_time) + self.write_prefix(msg.nickname, color) + self.addstr(' ') + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + for y, line in enumerate(lines): + offset = 0 + # Offset for the timestamp (if any) plus a space after it + offset += len(line.msg.str_time) + # space + offset += 1 + + # Offset for the prefix + offset += poopt.wcswidth(truncate_nick(line.msg.nickname)) + # space + offset += 1 + + self.write_text(y, offset, + line.prepend+line.msg.txt[line.start_pos:line.end_pos]) + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + self._refresh() + + def build_message(self, message, timestamp=False, nick_size=10): + txt = message.txt + ret = [] + default_color = None + nick = truncate_nick(message.nickname, nick_size) + offset = 0 + if nick: + offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length + if message.str_time: + offset += 1 + len(message.str_time) + if get_theme().CHAR_TIME_LEFT and message.str_time: + offset += 1 + if get_theme().CHAR_TIME_RIGHT and message.str_time: + offset += 1 + lines = poopt.cut_text(txt, self.width-offset-1) + prepend = default_color if default_color else '' + attrs = [] + for line in lines: + saved = Line(msg=message, start_pos=line[0], end_pos=line[1], prepend=prepend) + attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + ret.append(saved) + return ret + + def write_prefix(self, nickname, color): + self._win.attron(to_curses_attr(color)) + self.addstr(truncate_nick(nickname)) + self._win.attroff(to_curses_attr(color)) + diff --git a/src/xhtml.py b/src/xhtml.py index 01e2dfcd..b84ce943 100644 --- a/src/xhtml.py +++ b/src/xhtml.py @@ -183,6 +183,8 @@ whitespace_re = re.compile(r'\s+') xhtml_attr_re = re.compile(r'\x19-?\d[^}]*}|\x19[buaio]') xhtml_data_re = re.compile(r'data:image/([a-z]+);base64,(.+)') +poezio_color_double = re.compile(r'(?:\x19\d+}|\x19\d)+(\x19\d|\x19\d+})') +poezio_format_trim = re.compile(r'(\x19\d+}|\x19\d|\x19[buaio]|\x19o)+\x19o') xhtml_simple_attr_re = re.compile(r'\x19\d') @@ -303,7 +305,8 @@ class XHTMLHandler(sax.ContentHandler): @property def result(self): - return ''.join(self.builder).strip() + sanitized = re.sub(poezio_color_double, r'\1', ''.join(self.builder).strip()) + return re.sub(poezio_format_trim, '\x19o', sanitized) def append_formatting(self, formatting): self.formatting.append(formatting) diff --git a/test/test_common.py b/test/test_common.py index 50643733..0b2bd279 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -14,19 +14,6 @@ from common import (datetime_tuple, get_utc_time, get_local_time, shell_split, find_argument_quoted, find_argument_unquoted, parse_str_to_secs, parse_secs_to_str, safeJID) -def test_datetime_tuple(): - time.timezone = 0 - time.altzone = 0 - - assert datetime_tuple('20130226T06:23:12') == datetime.datetime(2013, 2, 26, 6, 23, 12) - assert datetime_tuple('2013-02-26T06:23:12+02:00') == datetime.datetime(2013, 2, 26, 4, 23, 12) - - time.timezone = -3600 - time.altzone = -3600 - - assert datetime_tuple('20130226T07:23:12') == datetime.datetime(2013, 2, 26, 8, 23, 12) - assert datetime_tuple('2013-02-26T07:23:12+02:00') == datetime.datetime(2013, 2, 26, 6, 23, 12) - def test_utc_time(): delta = timedelta(seconds=-3600) d = datetime.datetime.now() diff --git a/test/test_completion.py b/test/test_completion.py new file mode 100644 index 00000000..0e6e0492 --- /dev/null +++ b/test/test_completion.py @@ -0,0 +1,167 @@ +""" +Test the completions methods on an altered input object. +""" + +import string +import pytest +import random +import sys +import os + +sys.path.append('src') + +class ConfigShim(object): + def get(self, *args, **kwargs): + return '' + +import config +config.config = ConfigShim() + +from windows import Input + +@pytest.fixture(scope="function") +def input_obj(): + obj = Input() + obj.reset_completion() + obj.resize = lambda: None + obj.rewrite_text = lambda: None + obj.refresh = lambda: None + return obj + +@pytest.fixture(scope="module") +def random_unquoted_words(): + letters = string.ascii_lowercase + ((len(string.ascii_lowercase)//4)*' ') + acc = [random.choice(letters) for _ in range(200)] + words = ''.join(acc).split() + return words + +@pytest.fixture(scope="module") +def quoted_words(): + words = [] + letters = string.ascii_lowercase + ((len(string.ascii_lowercase)//4)*' ') + words_by_letter = {} + for start_letter in string.ascii_lowercase: + words_by_letter[start_letter] = [] + for _ in range(5): + size = random.randint(0, 15) + word = start_letter + ''.join(random.choice(letters) for i in range(size)) + words.append(word) + words_by_letter[start_letter].append(word) + return (words, words_by_letter) + + +def test_new_completion_1_unquoted(input_obj): + + input_obj.text = '/example ' + input_obj.pos = len(input_obj.text) - 1 + + input_obj.new_completion(['toto', 'titi'], 1, quotify=False) + assert input_obj.text == '/example toto' + + input_obj.new_completion(['toto', 'titi'], 1, quotify=False) + assert input_obj.text == '/example titi' + + input_obj.new_completion(['toto', 'titi'], 1, quotify=False) + assert input_obj.text == '/example toto' + + +def test_new_completion_1_quoted_spaces(input_obj): + input_obj.text = '/example ' + input_obj.pos = len(input_obj.text) - 1 + + input_obj.new_completion(['toto toto', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example "toto toto"' + + input_obj.new_completion(['toto toto', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example "titi titi"' + + input_obj.new_completion(['toto toto', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example "toto toto"' + + input_obj.text = '/example ' + input_obj.pos = len(input_obj.text) - 1 + input_obj.reset_completion() + + input_obj.new_completion(['toto toto', 'tata', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example "toto toto"' + + input_obj.new_completion(['toto toto', 'tata', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example tata' + + input_obj.new_completion(['toto toto', 'tata', 'titi titi'], 1, quotify=True) + assert input_obj.text == '/example "titi titi"' + +def test_new_completion_unquoted_random_override(input_obj, random_unquoted_words): + """ + Complete completely random words and ensure that the input is + changed adequately. + """ + words = random_unquoted_words + + # try the completion on the middle element without affecting the others + input_obj.text = '/example %s %s %s' % (words[0], words[1], words[2]) + base = len(input_obj.text) - len(words[2]) - 1 + input_obj.pos = base + def f(n): + return '/example %s %s' % (words[0], words[n]) + + for i in range(len(words)): + pos = input_obj.get_argument_position(False) + input_obj.new_completion(words[:], pos, quotify=False, override=True) + assert f(i) + " " + words[2] == input_obj.text + assert len(f(i)) == input_obj.pos + + assert input_obj.text == '/example %s %s %s' % (words[0], words[-1], words[2]) + + pos = input_obj.get_argument_position(False) + input_obj.new_completion(words[:], pos, quotify=False, override=True) + assert input_obj.text == '/example %s %s %s' % (words[0], words[0], words[2]) + + input_obj.reset_completion() + + # try the completion on the final element without affecting the others + input_obj.text = '/example %s %s %s' % (words[0], words[1], words[2]) + base = len(input_obj.text) + input_obj.pos = base + def f2(n): + return '/example %s %s %s' % (words[0], words[1], words[n]) + + print(words) + for i in range(len(words)): + pos = input_obj.get_argument_position(False) + input_obj.new_completion(words[:], pos, quotify=False, override=True) + assert f2(i) == input_obj.text + assert len(f2(i)) == input_obj.pos + + assert input_obj.text == '/example %s %s %s' % (words[0], words[1], words[-1]) + + +def test_new_completion_quoted_random(input_obj, quoted_words): + """ + Complete (possibly) quoted words starting with a specific letter. + And make sure that the quotes only appear when necessary. + """ + words = quoted_words[0] + words_l = quoted_words[1] + + letters = ('', 'a', 'b', 'c') + + # generate the text which is supposed to be present in the input + def f(p, i): + rep = words_l[letters[p]][i] if not ' ' in words_l[letters[p]][i] else '"'+words_l[letters[p]][i]+'"' + fst = letters[1] if p != 1 else rep + snd = letters[2] if p != 2 else rep + trd = letters[3] if p != 3 else rep + return '/example %s %s %s' % (fst, snd, trd) + + for pos in range(1, 4): + input_obj.text = '/example a b c' + input_obj.reset_completion() + input_obj.pos = len('/example') + pos * 2 + + extra = (3 - pos) * 2 + for i in range(5): + input_obj.new_completion(words[:], pos, quotify=True) + assert f(pos, i) == input_obj.text + assert len(f(pos, i)) - extra == input_obj.pos + diff --git a/test/test_xhtml.py b/test/test_xhtml.py index 58857d67..5afd08ff 100644 --- a/test/test_xhtml.py +++ b/test/test_xhtml.py @@ -38,6 +38,14 @@ def test_xhtml_to_poezio_colors(): xhtml = start + b'<a href="http://perdu.com">http://perdu.com</a>' + end assert xhtml_to_poezio_colors(xhtml) == '\x19uhttp://perdu.com\x19o' + xhtml = b'<div style="font-weight:bold">Allo <div style="color:red">test <div style="color: blue">test2</div></div></div>' + assert xhtml_to_poezio_colors(xhtml, force=True) == '\x19bAllo \x19196}test \x1921}test2\x19o' + + xhtml = (b'<div style="color:blue"><div style="color:yellow">' + b'<div style="color:blue">Allo <div style="color:red">' + b'test <div style="color: blue">test2</div></div></div></div></div>') + assert xhtml_to_poezio_colors(xhtml, force=True) == '\x1921}Allo \x19196}test \x1921}test2\x19o' + with pytest.raises(xml.sax._exceptions.SAXParseException): xhtml_to_poezio_colors(b'<p>Invalid xml') @@ -7,32 +7,50 @@ # Use launch.sh to start poezio directly from here cd "$(dirname "$0")" -VENV="poezio-venv" -VENV_COMMAND="pyvenv" +if [ -z "$POEZIO_VENV" ] +then + POEZIO_VENV="poezio-venv" +fi + +if [ -z "$POEZIO_VENV_COMMAND" ] +then + POEZIO_VENV_COMMAND="pyvenv" +fi +command -v $POEZIO_VENV_COMMAND > /dev/null 2>&1 || { + echo "'$POEZIO_VENV_COMMAND' executable not found. Check that you have python (>= 3.4) installed," + echo " and that \$POEZIO_VENV_COMMAND points to a valid virtualenv command." + if [ "$POEZIO_VENV_COMMAND" = 'pyvenv' ]; then + echo "If your distribution does not provide a 'pyvenv' command, maybe it has another name, like 'pyvenv-3.4'" + echo 'Set the $POEZIO_VENV_COMMAND env variable to the name of that executable and this script will use it.' + fi + exit 1 +} echo 'Updating poezio' -git pull origin slix || { +git pull origin master || { echo "The script failed to update poezio." exit 1 } -if [ -e "$VENV" ] +if [ -e "$POEZIO_VENV" ] then # In case of a python version upgrade echo 'Trying to upgrade the virtualenv' - $VENV_COMMAND --upgrade "$VENV" + $POEZIO_VENV_COMMAND --upgrade "$POEZIO_VENV" - . "$VENV/bin/activate" + . "$POEZIO_VENV/bin/activate" + python3 -c 'import sys;(print("Python 3.4 or newer is required") and exit(1)) if sys.version_info < (3, 4) else exit(0)' || exit 1 echo 'Updating the poezio dependencies' pip install -r requirements.txt --upgrade echo 'Updating the poezio plugin dependencies' pip install -r requirements-plugins.txt --upgrade else - echo "Creating the $VENV virtualenv" - $VENV_COMMAND "$VENV" + echo "Creating the $POEZIO_VENV virtualenv" + $POEZIO_VENV_COMMAND "$POEZIO_VENV" - . "$VENV/bin/activate" - cd "$VENV" # needed to download slixmpp inside the venv + . "$POEZIO_VENV/bin/activate" + cd "$POEZIO_VENV" # needed to download slixmpp inside the venv + python3 -c 'import sys;(print("Python 3.4 or newer is required") and exit(1)) if sys.version_info < (3, 4) else exit(0)' || exit 1 echo 'Installing the poezio dependencies using pip' pip install -r "../requirements.txt" |