pax_global_header 0000666 0000000 0000000 00000000064 13560061035 0014511 g ustar 00root root 0000000 0000000 52 comment=1e41ec0f13b14902b66f93602c43ac5a9d2c09be
khard-0.15.1/ 0000775 0000000 0000000 00000000000 13560061035 0012666 5 ustar 00root root 0000000 0000000 khard-0.15.1/.gitignore 0000664 0000000 0000000 00000000055 13560061035 0014656 0 ustar 00root root 0000000 0000000 build
dist/
khard/version.py
doc/source/api/
khard-0.15.1/.travis.yml 0000664 0000000 0000000 00000000623 13560061035 0015000 0 ustar 00root root 0000000 0000000 language: python
python:
- "3.5"
- "3.6"
- "3.7"
- "nightly"
- "pypy3.5"
env:
- JOB=tests
- JOB=docs
jobs:
allow_failures:
- python: "nightly"
- python: "pypy3.5"
install: |
case $JOB in
tests) pip install .;;
docs) pip install sphinx;;
esac
script: |
case $JOB in
tests) python setup.py test;;
docs) python setup.py build; make -C doc html man;;
esac
khard-0.15.1/AUTHORS 0000664 0000000 0000000 00000000117 13560061035 0013735 0 ustar 00root root 0000000 0000000 Eric Scheibler - email [at] eric-scheibler [dot] de - http://eric-scheibler.de
khard-0.15.1/CHANGES 0000664 0000000 0000000 00000020437 13560061035 0013667 0 ustar 00root root 0000000 0000000 Change Log
==========
v0.15.0: 2019-10-24
- Require either default_action or a subcommand in the future (add deprecation
warning for now)
- add man page for the config file
- make all options in the config file optional except for the address book
definitions
- allow lists of strings for editor an merge_editor config options
- add a CONTRIBUTING file on Github
- handle ABLABELs on most fields
- add formatted name to the yaml template
- show formatted name in contact details
- make it possible to sort by and display formatted name in linstings
- remove the khard-runner.py helper script
- validate the config file upon loading it
- internal code refactoring
v0.14.0: 2019-06-21
- Display ABLABELs for URLs and Private Objects
- Allow vcard selections to be aborted explicitly
- Unify edit and source subcommands
- Merge export and show subcommands
- Turn template export into a seperate command
- Require python >= 3.5
- Add html documentation (generated with sphinx)
- Add man page (generated with sphinx)
v0.13.0: 2018-12-25
- New action postaddress: lists all postal (addresses analog to email and phone actions, #196)
- New zsh completion function for email addresses
- New config variables for the contact table section in khard.conf: preferred_email_address_type and preferred_phone_number_type
- Slight speed improvements
- Test suite created
- Several bug fixes
v0.12.2: 2018-01-21
- Fixed: Found contact twice when the whole uid was used with -u (#161)
- Fixed: A minor bug in the contact search function (#160)
v0.12.1: 2018-01-14
- Fix for issue #148: Config variable "sort" not longer mandatory in config file
v0.12.0: 2018-01-12
- Vcard: Support for anniversary attribute (#138)
- Config: New config parameter: localize_dates (#118)
- Action list: -p / --parsable prints a tab separated contact list (#141)
- Action remove: --force: Remove contact without confirmation (#140)
- Mutt: You have to update the query command in mutt's config file: set query_command= "khard email --parsable '%s'"
- Minor changes in khard example config, zsh completion function and twinkle scripts
- Fix: Results of phone number search improved
- Fix: Yaml parser switched from pyyaml to ruamel.yaml to allow special unicode characters in the contact template (#133)
- Fix: Accentuated characters sorted wrong in contact list (#127)
v0.11.4: 2017-02-16
- Unpinned vobject library version (base64 decoding/encoding bug was fixed upstream)
- New option: -c / --config /path/to/config.file
- Changed short option of --search-in-source-files from -c to -f to avoid confusion with the new -c / --config option
- Minor bug fixes
v0.11.3: 2016-09-20
- Pinned version of vcard library vobject to version 0.9.2 due to bug https://github.com/eventable/vobject/issues/39
- Added some new action aliases
- Fix for birthday date processing (#95)
v0.11.2: 2016-08-31
- Extended the photo parsing workaround from khard version 0.11.1 to all base64 encoded vcard attributes (#86 and #87)
- Show additional/middle names in name column of contact table (#89)
- Added khard-runner.py helper script to simplify source code launching
v0.11.1: 2016-07-31
- Workaround for the photo attribute parsing issue of vobject library 0.9.2 (#80)
- setup.py: try to convert readme file into restructured text format (pypi requirement) and specify encoding explicitly (#83)
v0.11.0: 2016-07-17
- Made khard python3 compatible (#59)
- Enhanced read and write support for vcard versions 3.0 and 4.0
- user input parser:
- Improved robustness and error handling
- Fixed org attribute parsing (#57)
- Support for private vcard extensions (#51)
- New action birthdays (#64)
- New options:
--display to display contacts by first or last name (#66)
--search-in-source-files to speed up program initialization (#75)
--skip-unparsable to skip unparsable vcard files (#75)
--strict-search to narrow the contact search to the name field
- Added some aliases for program actions (#65)
- Removed davcontroler module due to the python3 incompatibility (script moved into the misc folder)
- Updated zsh completion function and khards example config file
v0.10.0: 2016-05-02
- New Action birthday: list birthdays, sorted by month and day
- option -p, --pretty was renamed to -p, --parsable: So, the pretty formatted email or
phone number table is the default now. Please adapt the configuration files of potential email and
phone applications (e.g.: mutt email -p %s)
v0.9.0: 2016-03-17
- Fully restructured command line interface for better usability:
- general help with: khard -h
- help for a specific action: khard action -h
- Updated zsh completion function
- New Action addressbooks
- New option -p|--pretty for email and phone actions to get pretty formatted output
- Fix: Only delete contact after modify, copy or move action was completed successfully
v0.8.1: 2016-01-16
- New option "show_uids" in config file to disable uid column in contact table
v0.8.0: 2016-01-15
- Sort contact table by first or last name (take note of changed behaviour of "sort" option)
- New option -g, --group-by-addressbook to group contact table by address book
- Changes in config file:
- New group: contact table
- new option: sort to sort contact table by first or last name
- New option: group_by_addressbook to group contact table by address book
- Moved show_nicknames option from group "general" to group "contact table"
v0.7.4: 2016-01-11
- Fixed uid dictionary creation
v0.7.3: 2016-01-08
- Cancel without further actions if the opened contacts editor is closed without saving (determined
by modification date of template file)
v0.7.2: 2016-01-03
- Use of module atomicwrites to securely write vcards to disk
v0.7.1: 2016-01-01
- Added support for multiple instances of one vcard attribute
v0.7.0: 2015-12-18
- Support for vobject library version >= 0.8.2 from https://github.com/tBaxter/vobject
- Contact template syntax switched to yaml
- alot and mutt actions summarized to new email action (please have a look into the readme file for configuration changes)
- Support for extended name attributes
- Create and modify contact from stdin or from template file
- New action "export" to export data of existing contact in yaml format
- New argument --open-editor to open the preferred text editor after successful creation of new
contact from stdin or template file
- New argument {-u, --uid} to select contact by uid
- Added write support for categories attribute
- Added wrapper script for sdiff
- Fixed a bug, which prevented the creation of new contacts with the add-email action
v0.6.3: 2015-10-24
- Added note attribute
v0.6.2: 2015-10-10
- Added completion function for zsh
v0.6.1: 2015-10-06
- Added title and role attribute
- Removed input restrictions of name and post address fields
- Parameter default_country in config file is not longer in use
v0.6.0: 2015-09-20
- New options copy contact and move contact
- Changed behavior of merge command (have a look into the readme file for details)
- Get path to editor and merge editor from the $PATH variable
- Code cleanup: new object class AddressBook
v0.5.0: 2015-09-05
- New option to merge two contacts
- Support for XDG_CONFIG_HOME parameter
- Post address: Added support for street and house number field with multiple lines
v0.4.1: 2015-07-16
- improved search results for phone, mutt and alot
v0.4.0: 2015-06-15
- new option "add-email": Get full email from stdin, filter senders mail address and add that
address to an existing contact or create a new one. Feature tested with email client mutt.
v0.3.3: 2015-05-07
- twinkle option was renamed to phone: Now it prints all phone numbers like the mutt option does for
e-mail addresses (more general approach for external phone clients)
- adapted twinkle plugin for use with the new phone option
v0.3.2: 2015-03-27
- Read-only support for Categories attribute
v0.3.1: 2015-03-26
- Nickname attribute added
- New parameter in config file: show_nicknames = yes / no
v0.3.0: 2015-03-25
- Added support for jabber, skype, twitter and webpage
- Created a filter for malformed vcard attributes
v0.2.2: 2015-03-14
- Added support for alot (MUA)
v0.2.1: 2015-01-14
- created pypi package
- missing attribute "fn" in VCard file is handled correctly now.
v0.2.0: 2014-10-01
- new project structure
- added twinkle plugin
- extended readme file
v0.1.0: 2014-09-18:
- initial release.
khard-0.15.1/CONTRIBUTING.rst 0000777 0000000 0000000 00000000000 13560061035 0022630 2doc/source/contributing.rst ustar 00root root 0000000 0000000 khard-0.15.1/LICENSE 0000664 0000000 0000000 00000104513 13560061035 0013677 0 ustar 00root root 0000000 0000000 GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
khard-0.15.1/MANIFEST.in 0000664 0000000 0000000 00000000164 13560061035 0014425 0 ustar 00root root 0000000 0000000 include AUTHORS
include CHANGES
include LICENSE
include README.md
recursive-include misc *
recursive-include test *
khard-0.15.1/README.md 0000664 0000000 0000000 00000005045 13560061035 0014151 0 ustar 00root root 0000000 0000000 khard
=====
Khard is an address book for the Linux console. It creates, reads, modifies and
removes carddav address book entries at your local machine. Khard is also
compatible to the email clients mutt and alot and the SIP client twinkle. You
can find more information about khard and the whole synchronization process
[here](http://eric-scheibler.de/en/blog/2014/10/Sync-calendars-and-address-books-between-Linux-and-Android/).
Warning: If you want to create or modify contacts with khard, beware that the
vcard standard is very inconsistent and lacks interoperability. Different
actors in that sector have defined their own extensions and even produce
non-standard output. A good example is the type value, which is tied to phone
numbers, email and post addresses. Khard tries to avoid such incompatibilities
but if you sync your contacts with an Android or iOS device, expect problems.
You are on the safe side, if you only use khard to read contacts. For further
information about the vcard compatibility issues have a look into [this blog
post](http://alessandrorossini.org/2012/11/15/the-sad-story-of-the-vcard-format-and-its-lack-of-interoperability/).
Installation
------------
Khard is already packaged for quite some distributions. Chances are you can
install it with your default package manager. Further instructions can be
found in the [documentation](doc/source/index.rst#installation).
Usage
-----
There is an [example config file](misc/khard/khard.conf.example) which you can
copy to the default config file location: `~/.config/khard/khard.conf`. `khard`
has several subcommands which are all documented by their `--help` option.
[The docs](doc/source/index.rst) also have a chapter on [command line
usage](doc/source/commandline.rst) and
[configuration](doc/source/index.rst#configuration).
Development
-----------
Khard is developed [on GitHub](https://github.com/scheibler/khard) where you
are welcome to post [bug reports](https://github.com/scheibler/khard/issues)
and [feature requests](https://github.com/scheibler/khard/pulls). Also see the
[notes for contributors](doc/source/contributing.rst).
Related projects
----------------
If you need a console based calendar too, try out
[khal](https://github.com/geier/khal).
khard-0.15.1/doc/ 0000775 0000000 0000000 00000000000 13560061035 0013433 5 ustar 00root root 0000000 0000000 khard-0.15.1/doc/Makefile 0000664 0000000 0000000 00000001355 13560061035 0015077 0 ustar 00root root 0000000 0000000 # Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = khard
SOURCEDIR = source
BUILDDIR = build
SPHINXAPIDOC = sphinx-apidoc
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile generate-api-docs
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile generate-api-docs
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
generate-api-docs:
@$(SPHINXAPIDOC) -e -M -o "$(SOURCEDIR)/api/" ../khard
khard-0.15.1/doc/source/ 0000775 0000000 0000000 00000000000 13560061035 0014733 5 ustar 00root root 0000000 0000000 khard-0.15.1/doc/source/bench.rst 0000664 0000000 0000000 00000001464 13560061035 0016551 0 ustar 00root root 0000000 0000000 Benchmarking, profiling and performance
---------------------------------------
When benchmarking code it is important to reduce other load on the system
(music player, web browser for example). One can use the python ``timeit``
module or a command line utility like `hyperfine`_:
.. code-block:: shell
python -m timeit -s 'from khard.khard import main' 'main(["list"])'
hyperfine 'python -m khard list'
For profiling the ``cProfile`` python module works well. With the help of
`gprof2dot`_ one can generate quite useful graphs:
.. code-block:: shell
python -m cProfile -o output.file -m khard list
gprof2dot -f pstats --show-samples output.file | dot -T png > graph.png
xdg-open graph.png
.. _hyperfine: https://github.com/sharkdp/hyperfine
.. _gprof2dot: https://github.com/jrfonseca/gprof2dot
khard-0.15.1/doc/source/commandline.rst 0000664 0000000 0000000 00000011056 13560061035 0017756 0 ustar 00root root 0000000 0000000 Command line usage
==================
The following subsections give an overview of khard's main features.
You may get general help and all available actions with
.. code-block:: shell
khard --help
If you need help on a specific action, use:
.. code-block:: shell
khard action --help
Beware, that the order of the command line parameters matters.
Show contacts
-------------
After you have created a new address book or calendar and you have synced it to your local machine,
you can list all available contacts with the following command:
.. code-block:: shell
khard list
or if you have more than one address book and you want to filter the output:
.. code-block:: shell
khard list -a addressbook1,addressbook2
The resulting contact table only contains the first phone number and email address. If you want to view all contact
details you can pick one from the list:
.. code-block:: shell
khard details
or search for it:
.. code-block:: shell
khard details [--strict-search] name of contact
or select the contact by it's uid, which you can find at the contacts table:
.. code-block:: shell
khard details -u ID
The parameters -a and -u from the examples above are always optional. If you don't use them or your
input produces unambiguous results, you may pick the contacts from a list instead.
The search parameter searches in all data fields. Therefore you aren't limited to the contact's name
but you also could for example search for a part of a phone number, email address or post address.
However if you explicitly want to narrow your search to the name field, you may use the
--strict-search parameter instead.
Create contact
--------------
Add new contact with the following command:
.. code-block:: shell
khard new [-a "address book name"]
The template for the new contact opens in the text editor, which you can set in the khard.conf file.
It follows the yaml syntax.
Alternatively you can create the contact from stdin:
.. code-block:: shell
echo """
First name : John
Last name : Smith
Email :
work : john.smith@example.org
Phone :
home : xxx 555 1234
Categories :
- cat1
- cat2
- cat3
""" | khard new [-a "address book name"]
or create from input template file:
.. code-block:: shell
khard new -i contact.yaml [-a "address book name"]
You may get an empty contact template with the following command:
.. code-block:: shell
khard export --empty-contact-template -o empty.yaml
Per default khard creates vcards of version 3.0. If your other contact applications support vcards
of the more recent version 4.0, you may change this with the option --vcard-version. Example:
.. code-block:: shell
khard new --vcard-version=4.0 [-a "address book name"]
For a more permanent solution you may set the preferred_version parameter in the vcard section of
the khard config file (see misc/khard/khard.conf.example for more details). But beware, that khard
cannot convert already existing contacts from version 3.0 to 4.0. Therefore this setting is not
applicable to the modify action.
Edit contacts
-------------
Use the following to modify the contact after successful creation:
.. code-block:: shell
khard modify [-a addr_name] [-u uid|search terms [search terms ...]]
If you want to edit the contact elsewhere, you can export the filled contact template:
.. code-block:: shell
khard export -o contact.yaml [-a addr_name] [-u uid|search terms [search terms ...]]
Edit the yaml file and re-import either through stdin:
.. code-block:: shell
cat contact.yaml | khard modify [-a addr_name] [-u uid|search terms [search terms ...]]
or file name:
.. code-block:: shell
khard modify -i contact.yaml [-a addr_name] [-u uid|search terms [search terms ...]]
If you want to merge contacts use the following to select a first and then a second contact:
.. code-block:: shell
khard merge [-a source_abook] [-u uid|search terms [search terms ...]] [-A target_abook] [-U target_uid|-t target_search_terms]
You will be launched into your merge_editor ( see the "merge_editor" option in khard.conf)
where you can merge all changes from the first selected contact onto the second.
Once you are finished, the first contact is deleted and the second one updated.
Copy or move contact:
.. code-block:: shell
khard copy [-a source_abook] [-u uid|search terms [search terms ...]] [-A target_abook]
khard move [-a source_abook] [-u uid|search terms [search terms ...]] [-A target_abook]
Remove contact:
.. code-block:: shell
khard remove [-a addr_name] [-u uid|search terms [search terms ...]]
khard-0.15.1/doc/source/conf.py 0000775 0000000 0000000 00000010006 13560061035 0016232 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# khard documentation build configuration file, created by
# sphinx-quickstart on Sun Jan 14 10:35:27 2018.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../..'))
from khard.version import version as original_version
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['sphinx.ext.autodoc',
'sphinx.ext.todo']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'khard'
copyright = '2018, Eric Scheibler'
author = 'Eric Scheibler'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '.'.join(original_version.split('.')[0:2])
# The full version, including alpha/beta/rc tags.
release = original_version
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# This is required for the alabaster theme
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
html_sidebars = {
'**': [
'relations.html', # needs 'show_related': True theme option to display
'searchbox.html',
]
}
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'kharddoc'
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('man/khard', 'khard', 'Console carddav client', '', 1),
('man/khard.conf', 'khard.conf', 'configuration file for khard', '', 5),
]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'khard', 'khard Documentation',
author, 'khard', 'One line description of project.',
'Miscellaneous'),
]
khard-0.15.1/doc/source/contributing.rst 0000664 0000000 0000000 00000005212 13560061035 0020174 0 ustar 00root root 0000000 0000000 Contributing
============
**Thank you for considering contributing to khard!**
.. toctree::
:maxdepth: 1
self
bench
Khard is developed on `Github`_ where you are welcome to post `bug reports`_,
`feature requests`_ or join the discussion in general.
Bug reports
-----------
If you want to report a bug keep in mind that the following things make it much
easier for maintainers to help:
- update to the latest version if possible and verify the bug there
- report the version(s) that are affected
- state the python version you are using
- if there are stack tracebacks post them with your bug report
- supply a minimal configuration (config file and vcards) to reproduce the
error
Feature requests
----------------
Please stick to the following standards when you open pull requests:
- Khard's development tries to follow `Vincent's branching model`_ so normal
pull requests should be made against the `develop`_ branch. Only important
bug fixes that affect the current release should be opened against `master`_.
- Write "good" commit messages, especially a proper subject line. This is also
explained in `the Git book`_.
- Format your python code according to `PEP 8`_.
- Khard has a test suite, please provide tests for bugs that you fix and also
for new code and new features that are introduced.
- Please verify *all* tests pass before sending a pull request, they will be
checked again by travis but it might be a lot faster to check locally first:
|travis|
Development
-----------
In order to start coding you need to fetch the develop branch:
.. code-block:: shell
git clone https://github.com/scheibler/khard
cd khard
git fetch --all
git checkout develop
python -m kard --help
# or
pip3 install --editable .
khard --help
Alternatively you can use the ``setup.py`` script directly. If you want to
isolate khard from your system Python environment you can use a `virtualenv`_
to do so.
.. _bug reports: https://github.com/scheibler/khard/issues
.. _the Git book: https://www.git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines
.. _develop: https://github.com/scheibler/khard/tree/develop
.. _feature requests: https://github.com/scheibler/khard/pulls
.. _Github: https://github.com/scheibler/khard
.. _master: https://github.com/scheibler/khard/tree/master
.. _PEP 8: https://www.python.org/dev/peps/pep-0008/
.. |travis| image:: https://travis-ci.org/scheibler/khard.svg?branch=develop
:target: https://travis-ci.org/scheibler/khard
:alt: build status
.. _Vincent's branching model:
http://nvie.com/posts/a-successful-git-branching-model/
.. _virtualenv: https://virtualenv.pypa.io/en/stable/
khard-0.15.1/doc/source/davcontroller.rst 0000664 0000000 0000000 00000003273 13560061035 0020350 0 ustar 00root root 0000000 0000000 Davcontroller
-------------
Khard also contains a helper script called davcontroller. It's designed to
create and remove address books and calendars at the server. I have created
davcontroller cause my previously used CalDAV server (Darwin calendarserver)
offered no simple way to create new address books and calendars. But
davcontroller should be considered as a hacky solution and it's only tested
against the Darwin calendarserver. So if your CalDAV server offers a way to
create new address books and calendars I recommend to prefer that method over
davcontroller.
If you nonetheless want to try davcontroller, you have to install the
CalDAVClientLibrary first. Unfortunately that library isn't compatible to
python3 so you have to create an extra python2 virtual environment and install
in there:
.. code-block:: shell
# create python2 virtual environment
virtualenv -p python2 ~/.virtualenvs/davcontroller
# get library from svn repository
sudo aptitude install subversion
svn checkout http://svn.calendarserver.org/repository/calendarserver/CalDAVClientLibrary/trunk CalDAVClientLibrary
cd CalDAVClientLibrary
# install library
~/.virtualenvs/davcontroller/bin/python setup.py install
# start davcontroller script
~/.virtualenvs/davcontroller/bin/python /path/to/khard-x.x.x/misc/davcontroller/davcontroller.py
This small script helps to create and remove new address books and calendars at
the carddav and caldav server.
List available resources:
.. code-block:: shell
davcontroller -H example.com -p 11111 -u USERNAME -P PASSWORD list
Possible actions are: list, new-addressbook, new-calendar and remove. After
creating or removing you must adapt your vdirsyncer config.
khard-0.15.1/doc/source/index.rst 0000664 0000000 0000000 00000013511 13560061035 0016575 0 ustar 00root root 0000000 0000000 .. khard documentation master file, created by
sphinx-quickstart on Sun Jan 14 10:35:27 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to khard's documentation!
=================================
.. toctree::
:maxdepth: 1
index
commandline
davcontroller
contributing
man
API documentation for developers
indices
Khard is an address book for the Linux command line. It can read, create,
modify and delete carddav address book entries. Khard only works with a local
store of VCARD files. It is intended to be used in conjunction with other
programs like an email client, text editor, vdir synchronizer or VOIP client.
Installation
============
.. image:: https://repology.org/badge/tiny-repos/khard.svg
:target: https://repology.org/project/khard/versions
:alt: Packaging status
Khard is available as a native package for some Linux distributions so you
should check your package manager first. If you want or need to install
manually you can use the release from `PyPi`_:
.. code-block:: shell
pip3 install khard
If you want to help the development or need more advanced installation
instructions see :doc:`contributing`.
Configuration
=============
The configuration file of khard is stored in the XDG conform config directory.
If the environment variable ``$XDG_CONFIG_HOME`` is set, it is
``$XDG_CONFIG_HOME/khard/khard.conf`` and it defaults to
``~/.config/khard/khard.conf`` otherwise.
A minimal configuration is provided in the source tree. It looks like this:
.. literalinclude:: ../../misc/khard/khard.conf.example
:language: ini
Integration with other programs
-------------------------------
Khard can be used together with email or SIP clients or a synchronisation
program like `vdirsyncer`_.
.. _vdirsyncer: https://github.com/pimutils/vdirsyncer/
mutt
~~~~
Khard may be used as an external address book for the email client mutt. To
accomplish that, add the following to your mutt config file (mostly
``~/.mutt/muttrc``):
.. code-block:: muttrc
set query_command= "khard email --parsable %s"
bind editor complete-query
bind editor ^T complete
Then you can complete email addresses by pressing the Tab-key in mutt's new
mail dialog. If your address books contain hundreds or even thousands of
contacts and the query process is very slow, you may try the
``--search-in-source-files`` option to speed up the search:
.. code-block:: muttrc
set query_command= "khard email --parsable --search-in-source-files %s"
If you want to complete multi-word search strings like "john smith" then you
may try out the following instead:
.. code-block:: muttrc
set query_command = "echo %s | xargs khard email --parsable --"
To add email addresses to khard's address book, you may also add the following
lines to your muttrc file:
.. code-block:: muttrc
macro index,pager A \
"khard add-email" \
"add the sender email address to khard"
Then navigate to an email message in mutt's index view and press "A" to start
the address import dialog.
Alot
~~~~
Add the following lines to your alot config file:
.. code-block:: ini
[accounts]
[[youraccount]]
[[[abook]]]
type = shellcommand
command = khard email --parsable
regexp = '^(?P[^@]+@[^\t]+)\t+(?P[^\t]+)'
ignorecase = True
Twinkle
~~~~~~~
For those who also use the SIP client twinkle to take phone calls, khard can be
used to query incoming numbers. The plugin tries to find the incoming caller id
and speaks it together with the phone's ring tone. The plugin needs the
following programs:
.. code-block:: shell
sudo aptitude install ffmpeg espeak sox mpc
sox and ffmpeg are used to cut and convert the new ring tone and espeak speaks
the caller id. mpc is a client for the music player daemon (mpd). It's
required to stop music during an incoming call. Skip the last, if you don't use
mpd. Don't forget to set the "stop_music"-parameter in the ``config.py`` file
to ``False``, too.
After the installation, copy the scripts and sounds folders to your twinkle
config folder:
.. code-block:: shell
cp -R misc/twinkle/* ~/.twinkle/
Then edit your twinkle config file (mostly ``~/.twinkle/twinkle.cfg``) like
this:
.. code-block:: ini
# RING TONES
# We need a default ring tone. Otherwise the phone would not ring at all, if
# something with the custom ring tone creation goes wrong.
ringtone_file=/home/USERNAME/.twinkle/sounds/incoming_call.wav
ringback_file=/home/USERNAME/.twinkle/sounds/outgoing_call.wav
# SCRIPTS
script_incoming_call=/home/USERNAME/.twinkle/scripts/incoming_call.py
script_in_call_answered=
script_in_call_failed=/home/USERNAME/.twinkle/scripts/incoming_call_failed.py
script_outgoing_call=
script_out_call_answered=
script_out_call_failed=
script_local_release=/home/USERNAME/.twinkle/scripts/incoming_call_ended.py
script_remote_release=/home/USERNAME/.twinkle/scripts/incoming_call_ended.py
Zsh
~~~
The file ``misc/zsh/_khard`` contains a khard cli completion function for the
zsh and ``misc/zsh/_email-khard`` completes email addresses.
Install by copying to a directory where zsh searches for completion functions
(the ``$fpath`` array). If you, for example, put all completion functions into
the folder ``~/.zsh/completions`` you must add the following to your zsh main
config file:
.. code-block:: zsh
fpath=( $HOME/.zsh/completions $fpath )
autoload -U compinit
compinit
sdiff
~~~~~
Use the wrapper script ``misc/sdiff/sdiff_khard_wrapper.sh`` if you want to use
sdiff as your contact merging tool. Just make the script executable and set it
as your merge editor in khard's config file:
.. code-block:: ini
merge_editor = /path/to/sdiff_khard_wrapper.sh
.. _PyPi: https://pypi.python.org/pypi/khard/
khard-0.15.1/doc/source/indices.rst 0000664 0000000 0000000 00000000133 13560061035 0017100 0 ustar 00root root 0000000 0000000 Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
khard-0.15.1/doc/source/man.rst 0000664 0000000 0000000 00000000235 13560061035 0016240 0 ustar 00root root 0000000 0000000 Manpages
========
The following man pages are available for khard:
.. toctree::
:maxdepth: 1
khard(1)
khard.conf(1)
khard-0.15.1/doc/source/man/ 0000775 0000000 0000000 00000000000 13560061035 0015506 5 ustar 00root root 0000000 0000000 khard-0.15.1/doc/source/man/khard.conf.rst 0000664 0000000 0000000 00000006762 13560061035 0020270 0 ustar 00root root 0000000 0000000 khard.conf
==========
Summary
-------
The config file for :manpage:`khard` is a plain text file with an ini-like
syntax. Many options have a corresponding command line option. The only
mandatory section in the config file is the definition of the available address
books.
Location
--------
The file is looked up at :file:`$XDG_CONFIG_HOME/khard/khard.conf`. If the
environment variable :file:`$XDG_CONFIG_HOME` is unset :file:`~/.config/` is
used in its stead.
The location can be changed with the environment variable :file:`$KHARD_CONFIG`
or the command line option :option:`-c` (which takes precedence).
Syntax
------
The syntax of the config file is ini-style dialect. It is parsed with
the configobj library. The precise definition of the corresponding ini syntax
can be found at
https://configobj.readthedocs.io/en/latest/configobj.html#the-config-file-format
.
It supports sections marked with square brackets and nested sections with more
square brackets. Each section contains several keys with values delimited by
equal signs. The values are typed and type checked.
Options
-------
The config file consists of these four sections:
adressbooks
This section contains several subsections, but at least one. Each subsection
can have an arbitrary name which will be the name of an addressbook known to
khard. Each of these subsections **must** have a *path* key with the path to
the folder containing the vcard files for that addressbook.
general
This section allows one to configure some general features about khard. The
following keys are available in this section:
- *debug*: a boolean indication weather the logging level should be set to
*debug* by default (same effect as the :option:`--debug` option on the
command line)
- *default_action*: the default action/subcommand to use if the first non
option argument does not match any of the available subcommands
- *editor*: the text editor to use to edit address book entries, if not given
:file:`$EDITOR` will be used
- *merge_editor*: a command used to merge two cards interactively, if not
given, :file:`$MERGE_EDITOR` will be used
contact table
This section is used to configure the behaviour of different output listings
of khard. The following keys are available:
- *display*: which part of the name to use in listings; this can be one of
``first_name``, ``last_name`` or ``formatted_name``
- *group_by_addressbook*: weather or not to group contacts by address book in
listings
- *localize_dates*: weather to localize dates or to use ISO date formats
- *preferred_email_address_type*: labels of email addresses to prefer
- *preferred_phone_number_type*: labels of telephone numbers to prefer
- *reverse*: weather to reverse the order of contact listings or not
- *show_nicknames*: weather to show nick names
- *show_uids*: weather to show uids
- *sort*: field by which to sort contact listings
vcard
- *private_objects*: a list of strings, these are the names of private vCard
fields (starting with ``X-``) that will be loaded and displayed by khard
- *search_in_source_files*: weather to search in the vcard files before
parsing them in order to speed up searches
- *skip_unparsable*: weather to skip unparsable vcards, otherwise khard exits
on the first unparsable card it encounters
- *preferred_version*: the preferred vcard version to use for new cards
Example
-------
This is the example config file:
.. literalinclude :: ../../../misc/khard/khard.conf.example
:language: ini
khard-0.15.1/doc/source/man/khard.rst 0000664 0000000 0000000 00000004102 13560061035 0017326 0 ustar 00root root 0000000 0000000 khard
=====
Synopsis
--------
khard [-c CONFIG] [--debug] [--skip-unparsable] SUBCOMMAND ...
khard -h|--help
khard -v|--version
Description
-----------
Khard is an address book for the Linux command line. It can read, create,
modify and delete carddav address book entries. Khard only works with a local
store of VCARD files. It is intended to be used in conjunction with other
programs like an email client, text editor, vdir synchronizer or VOIP client.
Options
-------
-c CONFIG, --config CONFIG
configuration file (default: ~/.config/khard/khard.conf)
--debug
output debugging information
--skip-unparsable
skip unparsable vcards when reading the address books
Subcommands
-----------
The functionality of khard is divided into several subcommands. All of these
have their own help text which can be seen with ``khard SUBCOMMAND --help``.
Listing subcommands
~~~~~~~~~~~~~~~~~~~
These subcommands list information of several contacts who match a search
query.
list
list all (selected) contacts
birthdays
list birthdays (sorted by month and day)
email
list email addresses
phone
list phone numbers
postaddress
list postal addresses
filename
list filenames of all matching contacts
Detailed display
~~~~~~~~~~~~~~~~
These subcommands display detailed information about one subcommand.
details
display detailed information about one contact
export
export a contact to the custom yaml format that is also used for editing and
creating contacts
Modifying subcommands
~~~~~~~~~~~~~~~~~~~~~
These subcommands are used to modify contacts.
source
edit the vcard file of a contact directly
new
create a new contact
add-email
Extract email address from the "From:" field of an email header and add to an
existing contact or create a new one
merge
merge two contacts
modify
edit the data of a contact
copy
copy a contact to a different addressbook
move
move a contact to a different addressbook
remove
remove a contact
Other subcommands
~~~~~~~~~~~~~~~~~
addressbooks
list all address books
Configuration
-------------
See :manpage:`khard.conf(5)`.
khard-0.15.1/khard/ 0000775 0000000 0000000 00000000000 13560061035 0013757 5 ustar 00root root 0000000 0000000 khard-0.15.1/khard/__init__.py 0000664 0000000 0000000 00000000000 13560061035 0016056 0 ustar 00root root 0000000 0000000 khard-0.15.1/khard/__main__.py 0000664 0000000 0000000 00000000070 13560061035 0016046 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
from .khard import main
main()
khard-0.15.1/khard/actions.py 0000664 0000000 0000000 00000004267 13560061035 0016002 0 ustar 00root root 0000000 0000000 """Names and aliases for the subcommands on the command line"""
class Actions:
"""A class to manage the names and aliases of the command line
subcommands."""
action_map = {
"add-email": [],
"addressbooks": ["abooks"],
"birthdays": ["bdays"],
"copy": ["cp"],
"email": [],
"export": [],
"filename": ["file"],
"list": ["ls"],
"merge": [],
"edit": ["modify", "ed"],
"move": ["mv"],
"new": ["add"],
"phone": [],
"postaddress": ["post", "postaddr"],
"remove": ["delete", "del", "rm"],
"show": ["details"],
"source": ["src"],
"template": [],
}
@classmethod
def get_action(cls, alias):
"""Find the name of the action for the supplied alias. If no action is
asociated with the given alias, None is returned.
:param alias: the alias to look up
:type alias: str
:rturns: the name of the corresponding action or None
:rtype: str or NoneType
"""
for action, alias_list in cls.action_map.items():
if alias in alias_list:
return action
return None
@classmethod
def get_aliases(cls, action):
"""Find all aliases for the given action. If there is no such action,
None is returned.
:param action: the action name to look up
:type action: str
:returns: the list of aliases corresponding to the action or None
:rtype: list(str) or NoneType
"""
return cls.action_map.get(action)
@classmethod
def get_actions(cls):
"""Find the names of all defined actions.
:returns: all action names
:rtype: iterable(str)
"""
return cls.action_map.keys()
@classmethod
def get_all(cls):
"""Find the names of all defined actions and their aliases.
:returns: the names of all actions and aliases
:rtype: generator(str)
"""
for key, value in cls.action_map.items():
yield key
yield from value
khard-0.15.1/khard/address_book.py 0000664 0000000 0000000 00000032074 13560061035 0016776 0 ustar 00root root 0000000 0000000 """A simple class to load and manage the vcard files from disk."""
import abc
import glob
import logging
import os
import re
import vobject.base
from .carddav_object import CarddavObject
class AddressBookParseError(Exception):
"""Indicate an error while parsing data from an address book backend."""
def __init__(self, filename, abook, reason, *args, **kwargs):
"""Store the filename that caused the error."""
super().__init__(*args, **kwargs)
self.filename = filename
self.abook = abook
self.reason = reason
def __str__(self):
return "Error when parsing {} in address book {}: {}".format(
self.filename, self.abook, self.reason)
class AddressBook(metaclass=abc.ABCMeta):
"""The base class of all address book implementations."""
def __init__(self, name, private_objects=tuple(), localize_dates=True,
skip=False):
"""
:param name: the name to identify the address book
:type name: str
:param private_objects: the names of private vCard extension fields to
load
:type private_objects: iterable(str)
:param localize_dates: wheater to display dates in the local format
:type localize_dates: bool
:param skip: skip unparsable vCard files
:type skip: bool
"""
self._loaded = False
self.contacts = {}
self._short_uids = None
self.name = name
self._private_objects = private_objects
self._localize_dates = localize_dates
self._skip = skip
def __str__(self):
return self.name
def __eq__(self, other):
return isinstance(other, type(self)) and self.name == other.name
def __ne__(self, other):
return not self == other
@staticmethod
def _compare_uids(uid1, uid2):
"""Calculate the minimum length of initial substrings of uid1 and uid2
for them to be different.
:param uid1: first uid to compare
:type uid1: str
:param uid2: second uid to compare
:type uid2: str
:returns: the length of the shortes unequal initial substrings
:rtype: int
"""
return len(os.path.commonprefix((uid1, uid2)))
def _search_all(self, query):
"""Search in all fields for contacts matching query.
:param query: the query to search for
:type query: str
:yields: all found contacts
:rtype: generator(carddav_object.CarddavObject)
"""
regexp = re.compile(query, re.IGNORECASE | re.DOTALL)
for contact in self.contacts.values():
# search in all contact fields
contact_details = contact.print_vcard()
if regexp.search(contact_details) is not None:
yield contact
else:
# find phone numbers with special chars like /
clean_contact_details = re.sub("[^a-zA-Z0-9\n]", "",
contact_details)
if regexp.search(clean_contact_details) is not None \
and len(re.sub(r"\D", "", query)) >= 3:
yield contact
def _search_names(self, query):
"""Search in the name filed for contacts matching query.
:param query: the query to search for
:type query: str
:yields: all found contacts
:rtype: generator(carddav_object.CarddavObject)
"""
regexp = re.compile(query, re.IGNORECASE | re.DOTALL)
for contact in self.contacts.values():
# only search in contact name
if regexp.search(contact.formatted_name) is not None:
yield contact
def _search_uid(self, query):
"""Search for contacts with a matching uid.
:param query: the query to search for
:type query: str
:yields: all found contacts
:rtype: generator(carddav_object.CarddavObject)
"""
try:
# First we treat the argument as a full UID and try to match it
# exactly.
yield self.contacts[query]
except KeyError:
# If that failed we look for all contacts whos UID start with the
# given query.
for uid in self.contacts:
if uid.startswith(query):
yield self.contacts[uid]
def search(self, query, method="all"):
"""Search this address book for contacts matching the query.
The method can be one of "all", "name" and "uid". The backend for this
address book migth be load()ed if needed.
:param query: the query to search for
:type query: str
:param method: the type of fileds to use when seaching
:type method: str
:returns: all found contacts
:rtype: list(carddav_object.CarddavObject)
"""
logging.debug('address book %s, searching with %s', self.name, query)
if not self._loaded:
self.load(query)
if method == "all":
return self._search_all(query)
elif method == "name":
return self._search_names(query)
elif method == "uid":
return self._search_uid(query)
raise ValueError(
'Only the search methods "all", "name" and "uid" are supported.')
def get_short_uid_dict(self, query=None):
"""Create a dictionary of shortend UIDs for all contacts.
All arguments are only used if the address book is not yet initialized
and will just be handed to self.load().
:param query: see self.load()
:type query: str
:returns: the contacts mapped by the shortes unique prefix of their UID
:rtype: dict(str: CarddavObject)
"""
if self._short_uids is None:
if not self._loaded:
self.load(query)
if not self.contacts:
self._short_uids = {}
elif len(self.contacts) == 1:
self._short_uids = {uid[0:1]: contact
for uid, contact in self.contacts.items()}
else:
self._short_uids = {}
sorted_uids = sorted(self.contacts)
# Prepare for the loop; the first and last items are handled
# seperatly.
item0, item1 = sorted_uids[:2]
same1 = self._compare_uids(item0, item1)
self._short_uids[item0[:same1 + 1]] = self.contacts[item0]
for item_new in sorted_uids[2:]:
# shift the items and the common prefix lenght one further
item0, item1 = item1, item_new
same0, same1 = same1, self._compare_uids(item0, item1)
# compute the final prefix length for item1
same = max(same0, same1)
self._short_uids[item0[:same + 1]] = self.contacts[item0]
# Save the last item.
self._short_uids[item1[:same1 + 1]] = self.contacts[item1]
return self._short_uids
def get_short_uid(self, uid):
"""Get the shortend UID for the given UID.
:param uid: the full UID to shorten
:type uid: str
:returns: the shortend uid or the empty string
:rtype: str
"""
if uid:
short_uids = self.get_short_uid_dict()
for length_of_uid in range(len(uid), 0, -1):
if short_uids.get(uid[:length_of_uid]) is not None:
return uid[:length_of_uid]
return ""
@abc.abstractmethod
def load(self, query=None):
"""Load the vCards from the backing store.
If a query is given loading is limited to entries which match the
query. If the query is None all entries will be loaded.
:param query: the query to limit loading to matching entries
:type query: str
:returns: the number of loaded contacts and the number of errors
:rtype: (int, int)
"""
class VdirAddressBook(AddressBook):
"""An AddressBook implementation based on a vdir.
This address book can load contacts from vcard files that reside in one
direcotry on disk.
"""
def __init__(self, name, path, **kwargs):
"""
:param name: the name to identify the address book
:type name: str
:param path: the path to the backing structure on disk
:type path: str
:param **kwargs: further arguments for the parent constructor
"""
self.path = os.path.expanduser(path)
if not os.path.isdir(self.path):
raise FileNotFoundError("[Errno 2] The path {} to the address book"
" {} does not exist.".format(path, name))
super().__init__(name, **kwargs)
def load(self, query=None, search_in_source_files=False):
"""Load all vcard files in this address book from disk.
If a search string is given only files which contents match that will
be loaded.
:param query: a regular expression to limit the results
:type query: str
:param search_in_source_files: apply search regexp directly on the .vcf
files to speed up parsing (less accurate)
:type search_in_source_files: bool
:returns: the number of successfully loaded cards and the number of
errors
:rtype: int, int
:throws: AddressBookParseError
"""
if self._loaded:
return
logging.debug('Loading Vdir %s with query %s', self.name, query)
errors = 0
for filename in glob.glob(os.path.join(self.path, "*.vcf")):
try:
card = CarddavObject.from_file(
self, filename, query if search_in_source_files else None,
self._private_objects, self._localize_dates)
if card is None:
continue
except (IOError, vobject.base.ParseError) as err:
verb = "open" if isinstance(err, IOError) else "parse"
logging.debug("Error: Could not %s file %s\n%s", verb,
filename, err)
if self._skip:
errors += 1
else:
raise AddressBookParseError(filename, self.name, err)
else:
uid = card.uid
if not uid:
logging.warning("Card %s from address book %s has no UID "
"and will not be available.", card,
self.name)
elif uid in self.contacts:
logging.warning(
"Card %s and %s from address book %s have the same "
"UID. The former will not be available.", card,
self.contacts[uid], self.name)
else:
self.contacts[uid] = card
self._loaded = True
if errors:
logging.warning(
"%d of %d vCard files of address book %s could not be parsed.",
errors, len(self.contacts) + errors, self)
logging.debug('Loded %s contacts from address book %s.',
len(self.contacts), self.name)
class AddressBookCollection(AddressBook):
"""A collection of several address books.
This represents a temporary merege of the contact collections provided by
the underlying adress books. On load all contacts from all subadressbooks
are copied into a dict in this address book. This allow this class to use
all other methods from the parent AddressBook class.
"""
def __init__(self, name, abooks, **kwargs):
"""
:param name: the name to identify the address book
:type name: str
:param abooks: a list of address books to combine in this collection
:type abooks: list(AddressBook)
:param **kwargs: further arguments for the parent constructor
"""
super().__init__(name, **kwargs)
self._abooks = abooks
def load(self, query=None):
if self._loaded:
return
logging.debug('Loading collection %s with query %s', self.name, query)
for abook in self._abooks:
abook.load(query)
for uid in abook.contacts:
if uid in self.contacts:
logging.warning(
"Card %s from address book %s will not be available "
"because there is already another card with the same "
"UID: %s", abook.contacts[uid], abook, uid)
else:
self.contacts[uid] = abook.contacts[uid]
self._loaded = True
logging.debug('Loded %s contacts from address book %s.',
len(self.contacts), self.name)
def get_abook(self, name):
"""Get one of the backing abdress books by its name,
:param name: the name of the address book to get
:type name: str
:returns: the matching address book or None
:rtype: AddressBook or NoneType
"""
for abook in self._abooks:
if abook.name == name:
return abook
khard-0.15.1/khard/carddav_object.py 0000664 0000000 0000000 00000215365 13560061035 0017277 0 ustar 00root root 0000000 0000000 """Classes and logic to handle vCards in khard.
This module explicitly supports the vCard specifications version 3.0 and 4.0
which can be found here:
- version 3.0: https://tools.ietf.org/html/rfc2426
- version 4.0: https://tools.ietf.org/html/rfc6350
"""
import copy
import datetime
import locale
import logging
import os
import re
import sys
import time
from atomicwrites import atomic_write
import vobject
import ruamel.yaml
from ruamel.yaml import YAML
from . import helpers
from .object_type import ObjectType
def convert_to_vcard(name, value, allowed_object_type):
"""converts user input into vcard compatible data structures
:param str name: object name, only required for error messages
:param value: user input
:type value: str or list(str)
:param allowed_object_type: set the accepted return type for vcard
attribute
:type allowed_object_type: enum of type ObjectType
:returns: cleaned user input, ready for vcard or a ValueError
:rtype: str or list(str)
"""
if isinstance(value, str):
if allowed_object_type == ObjectType.list_with_strings:
return [value.strip()]
return value.strip()
if isinstance(value, list):
if allowed_object_type == ObjectType.string:
raise ValueError("Error: " + name + " must contain a string.")
if not all(isinstance(entry, str) for entry in value):
raise ValueError("Error: " + name +
" must not contain a nested list")
# filter out empty list items and strip leading and trailing space
return [x.strip() for x in value if x.strip()]
if allowed_object_type == ObjectType.string:
raise ValueError("Error: " + name + " must be a string.")
if allowed_object_type == ObjectType.list_with_strings:
raise ValueError("Error: " + name + " must be a list with strings.")
raise ValueError("Error: " + name +
" must be a string or a list with strings.")
def multi_property_key(item):
"""key function to pass to sorted(), allowing sorting of dicts with lists
and strings. Dicts will be sorted by their label, after other types.
:param item: member of the list being sorted
:type item: a dict with a single entry or any sortable type
:returns: a list with two members. The first is int(isinstance(item, dict).
The second is either the key from the dict or the unchanged item if it
is not a dict.
:rtype list(int, type(item)) or list(int, str)
"""
if isinstance(item, dict):
return [1, list(item)[0]]
else:
return [0, item]
class VCardWrapper:
"""Wrapper class around a vobject.vCard object.
This class can wrap a single vCard and presents its data in a manner
suitable for khard. Additionally some details of the vCard specifications
in RFC 2426 (version 3.0) and RFC 6350 (version 4.0) that are not enforced
by the vobject library are enforced here.
"""
_default_version = "3.0"
_supported_versions = ("3.0", "4.0")
# vcard v3.0 supports the following type values
phone_types_v3 = ("bbs", "car", "cell", "fax", "home", "isdn", "msg",
"modem", "pager", "pcs", "video", "voice", "work")
email_types_v3 = ("home", "internet", "work", "x400")
address_types_v3 = ("dom", "intl", "home", "parcel", "postal", "work")
# vcard v4.0 supports the following type values
phone_types_v4 = ("text", "voice", "fax", "cell", "video", "pager",
"textphone", "home", "work")
email_types_v4 = ("home", "internet", "work")
address_types_v4 = ("home", "work")
def __init__(self, vcard, version=None):
"""Initialize the wrapper around the given vcard.
:param vobject.vCard vcard: the vCard to wrap
:param version: the version of the RFC to use (if the card has none)
:type version: str or None
"""
self.vcard = vcard
if not self.version:
version = version or self._default_version
logging.warning("Wrapping unversioned vCard object, setting "
"version to %s.", version)
self.version = version
elif self.version not in self._supported_versions:
logging.warning("Wrapping vCard with unsupported version %s, this "
"might change any incompatible attributes.",
version)
def __str__(self):
return self.formatted_name
def _get_string_field(self, field):
"""Get a string field from the underlying vCard.
:param str field: the field value to get
:returns: the field value or the empty string
:rtype: str
"""
try:
return getattr(self.vcard, field).value
except AttributeError:
return ""
def _get_multi_property(self, name):
"""Get a vCard property that can exist more than once.
It does not matter what the individual vcard properties store as their
value. This function returnes them untouched inside an agregating
list.
If the property is part of a group containing exactly two items, with
exactly one ABLABEL. the property will be prefixed with that ABLABEL.
:param str name: the name of the property (should be UPPER case)
:returns: the values from all occurences of the named property
:rtype: list
"""
values = []
for child in self.vcard.getChildren():
if child.name == name:
ablabel = self._get_ablabel(child)
if ablabel:
values.append({ablabel: child.value})
else:
values.append(child.value)
return sorted(values, key=multi_property_key)
def _delete_vcard_object(self, name):
"""Delete all fields with the given name from the underlying vCard.
If a field that will be deleted is in a group with an X-ABLABEL field,
that X-ABLABEL field will also be deleted. These fields are commonly
added by the Apple address book to attach custom labels to some fields.
:param str name: the name of the fields to delete
:returns: None
"""
# first collect all vcard items, which should be removed
to_be_removed = []
for child in self.vcard.getChildren():
if child.name == name:
if child.group:
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and \
label.group == child.group:
to_be_removed.append(label)
to_be_removed.append(child)
# then delete them one by one
for item in to_be_removed:
self.vcard.remove(item)
@staticmethod
def _parse_type_value(types, supported_types):
"""Parse type value of phone numbers, email and post addresses.
:param list(str) types: list of type values
:param list(str) supported_types: all allowed standard types
:returns: tuple of standard and custom types and pref integer
:rtype: tuple(list(str), list(str), int)
"""
custom_types = []
standard_types = []
pref = 0
for type in types:
type = type.strip()
if type:
if type.lower() in supported_types:
standard_types.append(type)
elif type.lower() == "pref":
pref += 1
elif re.match(r"^pref=\d{1,2}$", type.lower()):
pref += int(type.split("=")[1])
else:
if type.lower().startswith("x-"):
custom_types.append(type[2:])
standard_types.append(type)
else:
custom_types.append(type)
standard_types.append("X-{}".format(type))
return (standard_types, custom_types, pref)
def _get_types_for_vcard_object(self, object, default_type):
"""get list of types for phone number, email or post address
:param vobject.base.ContentLine object: vcard class object
:param str default_type: use if the object contains no type
:returns: list of type labels
:rtype: list(str)
"""
type_list = []
# try to find label group for custom value type
if object.group:
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group == object.group:
custom_type = label.value.strip()
if custom_type:
type_list.append(custom_type)
# then load type from params dict
standard_types = object.params.get("TYPE")
if standard_types is not None:
if not isinstance(standard_types, list):
standard_types = [standard_types]
for type in standard_types:
type = type.strip()
if type and type.lower() != "pref":
if not type.lower().startswith("x-"):
type_list.append(type)
elif type[2:].lower() not in [x.lower()
for x in type_list]:
# add x-custom type in case it's not already added by
# custom label for loop above but strip x- before
type_list.append(type[2:])
# try to get pref parameter from vcard version 4.0
try:
type_list.append("pref=%d" % int(object.params.get("PREF")[0]))
except (IndexError, TypeError, ValueError):
# else try to determine, if type params contain pref attribute
try:
for type in object.params.get("TYPE"):
if type.lower() == "pref" and "pref" not in type_list:
type_list.append("pref")
except TypeError:
pass
# return type_list or default type
if type_list:
return type_list
return [default_type]
@property
def version(self):
return self._get_string_field("version")
@version.setter
def version(self, value):
if value not in self._supported_versions:
logging.warning("Setting vcard version to unsupported version %s",
value)
# All vCards should only always have one version, this is a requirement
# for version 4 but also makes sense for all other versions.
self._delete_vcard_object("VERSION")
version = self.vcard.add("version")
version.value = convert_to_vcard("version", value, ObjectType.string)
@property
def uid(self):
return self._get_string_field("uid")
@uid.setter
def uid(self, value):
# All vCards should only always have one UID, this is a requirement
# for version 4 but also makes sense for all other versions.
self._delete_vcard_object("UID")
uid = self.vcard.add('uid')
uid.value = convert_to_vcard("uid", value, ObjectType.string)
def _update_revision(self):
# All vCards should only always have one revision, this is a
# requirement for version 4 but also makes sense for all other
# versions.
self._delete_vcard_object("REV")
rev = self.vcard.add('rev')
rev.value = datetime.datetime.now().strftime("%Y%mdT%H%M%SZ")
@property
def birthday(self):
"""Return the birthday as a datetime object or a string depending on
weather it is of type text or not. If no birthday is present in the
vcard None is returned.
:returns: contacts birthday or None if not available
:rtype: datetime.datetime or str or NoneType
"""
# vcard 4.0 could contain a single text value
try:
if self.vcard.bday.params.get("VALUE")[0] == "text":
return self.vcard.bday.value
except (AttributeError, IndexError, TypeError):
pass
# else try to convert to a datetime object
try:
return helpers.string_to_date(self.vcard.bday.value)
except (AttributeError, ValueError):
pass
return None
@birthday.setter
def birthday(self, date):
"""Store the given date as BDAY in the vcard.
:param date: the new date to store as birthday
:type date: datetime.datetime or str
"""
value, text = self._prepare_birthday_value(date)
if value is None:
logging.warning('Failed to set anniversary to %s', date)
return
bday = self.vcard.add('bday')
bday.value = value
if text:
bday.params['VALUE'] = ['text']
@property
def anniversary(self):
""":returns: contacts anniversary or None if not available
:rtype: datetime.datetime or str
"""
# vcard 4.0 could contain a single text value
try:
if self.vcard.anniversary.params.get("VALUE")[0] == "text":
return self.vcard.anniversary.value
except (AttributeError, IndexError, TypeError):
pass
# else try to convert to a datetime object
try:
return helpers.string_to_date(self.vcard.anniversary.value)
except (AttributeError, ValueError):
# vcard 3.0: x-anniversary (private object)
try:
return helpers.string_to_date(self.vcard.x_anniversary.value)
except (AttributeError, ValueError):
pass
return None
def _get_ablabel(self, item):
"""Get an ABLABEL for a specified item in the vCard.
Will return the ABLABEL only if the item is part of a group with exactly
two items, exactly one of which is an ABLABEL.
:param vobject.base.ContentLine item: the item to be labelled
:returns: the ABLABEL in the circumstances above or an empty string
:rtype: str
"""
label = ""
if item.group:
count = 0
for child in self.vcard.getChildren():
if child.group and child.group == item.group:
count += 1
if child.name == "X-ABLABEL":
if label == "":
label = child.value
else:
return ""
if count != 2:
label = ""
return label
def _get_new_group(self, group_type=""):
"""Get an unused group name for adding new groups. Uses the form item123
or itemgroup_type123 if a grouptype is specified.
:param str group_type: (Optional) a string to add between "item" and
the number
:returns: the name of the first unused group of the specified form
:rtype: str
"""
counter = 1
while True:
group_name = "item%s%d" % (group_type, counter)
for child in self.vcard.getChildren():
if child.group and child.group == group_name:
counter += 1
break
else:
return group_name
def _add_labelled_object(self, obj_type, user_input, name_groups=False,
allowed_object_type=ObjectType.string):
"""Add an object to the VCARD. If user_input is a dict, the object will
be added to a group with an ABLABEL created from the key of the dict.
:param str obj_type: type of object to add to the VCARD.
:param user_input: Contents of the object to add. If a dict
:type user_input: str or list(str) or dict(str) or dict(list(str))
:param bool name_groups: (Optional) If True, use the obj_type in the
group name for labelled objects.
:param allowed_object_type: (Optional) set the accepted return type
for vcard attribute
:type allowed_object_type: enum of type ObjectType
:returns: None
"""
obj = self.vcard.add(obj_type)
if isinstance(user_input, dict):
if len(user_input) > 1:
raise ValueError("Error: %s must be a string or a dict "
"containing one key/value pair." % obj_type)
label = list(user_input)[0]
group_name = self._get_new_group(obj_type if name_groups else "")
obj.group = group_name
obj.value = convert_to_vcard(obj_type, user_input[label],
allowed_object_type)
ablabel_obj = self.vcard.add('X-ABLABEL')
ablabel_obj.group = group_name
ablabel_obj.value = label
else:
obj.value = convert_to_vcard(obj_type, user_input,
allowed_object_type)
@anniversary.setter
def anniversary(self, date):
value, text = self._prepare_birthday_value(date)
if value is None:
logging.warning('Failed to set anniversary to %s', date)
return
if text:
anniversary = self.vcard.add('anniversary')
anniversary.params['VALUE'] = ['text']
anniversary.value = value
elif self.version == "4.0":
self.vcard.add('anniversary').value = value
else:
self.vcard.add('x-anniversary').value = value
def _prepare_birthday_value(self, date):
"""Prepare a value to be stored in a BDAY or ANNIVERSARY attribute.
:param date: the date like value to be stored
:type date: datetime.datetime or str
:returns: the object to set as the .value for the attribute and weather
it should be stored as plain text
:rtype: tuple(str,bool)
"""
if isinstance(date, str):
if self.version == "4.0":
return date.strip(), True
return None, False
if date.year == 1900 and date.month != 0 and date.day != 0 \
and date.hour == 0 and date.minute == 0 and date.second == 0 \
and self.version == "4.0":
fmt = '--%m%d'
elif date.tzname() and date.tzname()[3:]:
if self.version == "4.0":
fmt = "%Y%m%dT%H%M%S{}".format(date.tzname()[3:])
else:
fmt = "%Y-%m-%dT%H:%M:%S{}".format(date.tzname()[3:])
elif date.hour != 0 or date.minute != 0 or date.second != 0:
if self.version == "4.0":
fmt = "%Y%m%dT%H%M%SZ"
else:
fmt = "%Y-%m-%dT%H:%M:%SZ"
else:
if self.version == "4.0":
fmt = "%Y%m%d"
else:
fmt = "%Y-%m-%d"
return date.strftime(fmt), False
@property
def formatted_name(self):
return self._get_string_field("fn")
@formatted_name.setter
def formatted_name(self, value):
"""Set the FN field to the new value.
All previously existing FN fields are deleted. Version 4 of the specs
requires the vCard to only habe one FN field. For other versions we
enforce this equally.
:param str value: the new formatted name
:returns: None
"""
self._delete_vcard_object("FN")
if value:
value = convert_to_vcard("FN", value, ObjectType.string)
elif self._get_first_names() or self._get_last_names():
# autofill the FN field from the N field
names = [self._get_name_prefixes(),
self._get_first_names(),
self._get_last_names(),
self._get_name_suffixes()]
names = [x for x in names if x]
value = helpers.list_to_string(names, " ")
else: # add an empty FN
value = ""
self.vcard.add("FN").value = value
def _get_names_part(self, part):
"""Get some part of the "N" entry in the vCard as a list
:param str part: the name to get e.g. "prefix" or "given"
:returns: a list of entries for this name part
:rtype: list(str)
"""
try:
the_list = getattr(self.vcard.n.value, part)
except AttributeError:
return []
else:
# check if list only contains empty strings
if not ''.join(the_list):
return []
return the_list if isinstance(the_list, list) else [the_list]
def _get_name_prefixes(self):
return self._get_names_part("prefix")
def _get_first_names(self):
return self._get_names_part("given")
def _get_additional_names(self):
return self._get_names_part("additional")
def _get_last_names(self):
return self._get_names_part("family")
def _get_name_suffixes(self):
return self._get_names_part("suffix")
def get_first_name_last_name(self):
"""
:rtype: str
"""
names = self._get_first_names() + self._get_additional_names() + \
self._get_last_names()
if names:
return helpers.list_to_string(names, " ")
return self.formatted_name
def get_last_name_first_name(self):
"""
:rtype: str
"""
last_names = []
if self._get_last_names():
last_names += self._get_last_names()
first_and_additional_names = self._get_first_names() + \
self._get_additional_names()
if last_names and first_and_additional_names:
return "{}, {}".format(
helpers.list_to_string(last_names, " "),
helpers.list_to_string(first_and_additional_names, " "))
if last_names:
return helpers.list_to_string(last_names, " ")
if first_and_additional_names:
return helpers.list_to_string(first_and_additional_names, " ")
return self.formatted_name
def _add_name(self, prefix, first_name, additional_name, last_name,
suffix):
name_obj = self.vcard.add('n')
stringlist = ObjectType.string_or_list_with_strings
name_obj.value = vobject.vcard.Name(
prefix=convert_to_vcard("name prefix", prefix, stringlist),
given=convert_to_vcard("first name", first_name, stringlist),
additional=convert_to_vcard("additional name", additional_name,
stringlist),
family=convert_to_vcard("last name", last_name, stringlist),
suffix=convert_to_vcard("name suffix", suffix, stringlist))
@property
def organisations(self):
"""
:returns: list of organisations, sorted alphabetically
:rtype: list(list(str) or dict(list(str)))
"""
return self._get_multi_property("ORG")
def _add_organisation(self, organisation):
"""Add one ORG entry to the underlying vcard
:param str|list(str) organisation: the value to add
:returns: None
"""
self._add_labelled_object("org", organisation, True,
ObjectType.list_with_strings)
# check if fn attribute is already present
if not self.vcard.getChildValue("fn") and self.organisations:
# if not, set fn to organisation name
first_org = self.organisations[0]
if isinstance(first_org, dict):
first_org = list(first_org.values())[0]
org_value = helpers.list_to_string(first_org, ", ")
self.formatted_name = org_value.replace("\n", " ").replace("\\",
"")
showas_obj = self.vcard.add('x-abshowas')
showas_obj.value = "COMPANY"
@property
def titles(self):
"""
:rtype: list(str or dict(str))
"""
return self._get_multi_property("TITLE")
def _add_title(self, title):
self._add_labelled_object("title", title, True)
@property
def roles(self):
"""
:rtype: list(str or dict(str))
"""
return self._get_multi_property("ROLE")
def _add_role(self, role):
self._add_labelled_object("role", role, True)
@property
def nicknames(self):
"""
:rtype: list(str or dict(str))
"""
return self._get_multi_property("NICKNAME")
def _add_nickname(self, nickname):
self._add_labelled_object("nickname", nickname, True)
@property
def notes(self):
"""
:rtype: list(str or dict(str))
"""
return self._get_multi_property("NOTE")
def _add_note(self, note):
self._add_labelled_object("note", note, True)
@property
def webpages(self):
"""
:rtype: list(str or dict(str))
"""
return self._get_multi_property("URL")
def _add_webpage(self, webpage):
self._add_labelled_object("url", webpage, True)
@property
def categories(self):
"""
:rtype: list(str) or list(list(str))
"""
category_list = []
for child in self.vcard.getChildren():
if child.name == "CATEGORIES":
value = child.value
category_list.append(
value if isinstance(value, list) else [value])
if len(category_list) == 1:
return category_list[0]
return sorted(category_list)
def _add_category(self, categories):
""" categories variable must be a list """
categories_obj = self.vcard.add('categories')
categories_obj.value = convert_to_vcard("category", categories,
ObjectType.list_with_strings)
@property
def phone_numbers(self):
"""
:returns: dict of type and phone number list
:rtype: dict(str, list(str))
"""
phone_dict = {}
for child in self.vcard.getChildren():
if child.name == "TEL":
# phone types
type = helpers.list_to_string(
self._get_types_for_vcard_object(child, "voice"), ", ")
if type not in phone_dict:
phone_dict[type] = []
# phone value
#
# vcard version 4.0 allows URI scheme "tel" in phone attribute value
# Doc: https://tools.ietf.org/html/rfc6350#section-6.4.1
# example: TEL;VALUE=uri;PREF=1;TYPE="voice,home":tel:+1-555-555-5555;ext=5555
if child.value.lower().startswith("tel:"):
# cut off the "tel:" uri prefix
phone_dict[type].append(child.value[4:])
else:
# free text field
phone_dict[type].append(child.value)
# sort phone number lists
for number_list in phone_dict.values():
number_list.sort()
return phone_dict
def _add_phone_number(self, type, number):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","), self.phone_types_v4 if
self.version == "4.0" else self.phone_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for phone number " + number +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: phone number " + number + " got more "
"than one custom label: " +
helpers.list_to_string(custom_types, ", "))
phone_obj = self.vcard.add('tel')
if self.version == "4.0":
phone_obj.value = "tel:%s" % convert_to_vcard(
"phone number", number, ObjectType.string)
phone_obj.params['VALUE'] = ["uri"]
if pref > 0:
phone_obj.params['PREF'] = str(pref)
else:
phone_obj.value = convert_to_vcard("phone number", number,
ObjectType.string)
if pref > 0:
standard_types.append("pref")
if standard_types:
phone_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itemtel"):
custom_label_count += 1
group_name = "itemtel%d" % (custom_label_count + 1)
phone_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
@property
def emails(self):
"""
:returns: dict of type and email address list
:rtype: dict(str, list(str))
"""
email_dict = {}
for child in self.vcard.getChildren():
if child.name == "EMAIL":
type = helpers.list_to_string(
self._get_types_for_vcard_object(child, "internet"), ", ")
if type not in email_dict:
email_dict[type] = []
email_dict[type].append(child.value)
# sort email address lists
for email_list in email_dict.values():
email_list.sort()
return email_dict
def add_email(self, type, address):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","), self.email_types_v4 if
self.version == "4.0" else self.email_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for email address " + address +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: email address " + address + " got more "
"than one custom label: " +
helpers.list_to_string(custom_types, ", "))
email_obj = self.vcard.add('email')
email_obj.value = convert_to_vcard("email address", address,
ObjectType.string)
if self.version == "4.0":
if pref > 0:
email_obj.params['PREF'] = str(pref)
else:
if pref > 0:
standard_types.append("pref")
if standard_types:
email_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itememail"):
custom_label_count += 1
group_name = "itememail%d" % (custom_label_count + 1)
email_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
@property
def post_addresses(self):
"""
:returns: dict of type and post address list
:rtype: dict(str, list(dict(str,list|str)))
"""
post_adr_dict = {}
for child in self.vcard.getChildren():
if child.name == "ADR":
type = helpers.list_to_string(self._get_types_for_vcard_object(
child, "home"), ", ")
if type not in post_adr_dict:
post_adr_dict[type] = []
post_adr_dict[type].append({"box": child.value.box,
"extended": child.value.extended,
"street": child.value.street,
"code": child.value.code,
"city": child.value.city,
"region": child.value.region,
"country": child.value.country})
# sort post address lists
for post_adr_list in post_adr_dict.values():
post_adr_list.sort(key=lambda x: (
helpers.list_to_string(x['city'], " ").lower(),
helpers.list_to_string(x['street'], " ").lower()))
return post_adr_dict
def get_formatted_post_addresses(self):
formatted_post_adr_dict = {}
for type, post_adr_list in self.post_addresses.items():
formatted_post_adr_dict[type] = []
for post_adr in post_adr_list:
strings = []
if "street" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("street"), "\n"))
if "box" in post_adr and "extended" in post_adr:
strings.append("{} {}".format(
helpers.list_to_string(post_adr.get("box"), " "),
helpers.list_to_string(post_adr.get("extended"), " ")))
elif "box" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("box"), " "))
elif "extended" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("extended"), " "))
if "code" in post_adr and "city" in post_adr:
strings.append("{} {}".format(
helpers.list_to_string(post_adr.get("code"), " "),
helpers.list_to_string(post_adr.get("city"), " ")))
elif "code" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("code"), " "))
elif "city" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("city"), " "))
if "region" in post_adr and "country" in post_adr:
strings.append("{}, {}".format(
helpers.list_to_string(post_adr.get("region"), " "),
helpers.list_to_string(post_adr.get("country"), " ")))
elif "region" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("region"), " "))
elif "country" in post_adr:
strings.append(
helpers.list_to_string(post_adr.get("country"), " "))
formatted_post_adr_dict[type].append('\n'.join(strings))
return formatted_post_adr_dict
def _add_post_address(self, type, box, extended, street, code, city,
region, country):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","),
self.address_types_v4 if self.version == "4.0" else
self.address_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for post address " + street +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: post address " + street + " got more "
"than one custom " "label: " +
helpers.list_to_string(custom_types, ", "))
adr_obj = self.vcard.add('adr')
adr_obj.value = vobject.vcard.Address(
box=convert_to_vcard("box address field", box,
ObjectType.string_or_list_with_strings),
extended=convert_to_vcard("extended address field", extended,
ObjectType.string_or_list_with_strings),
street=convert_to_vcard("street", street,
ObjectType.string_or_list_with_strings),
code=convert_to_vcard("post code", code,
ObjectType.string_or_list_with_strings),
city=convert_to_vcard("city", city,
ObjectType.string_or_list_with_strings),
region=convert_to_vcard("region", region,
ObjectType.string_or_list_with_strings),
country=convert_to_vcard("country", country,
ObjectType.string_or_list_with_strings))
if self.version == "4.0":
if pref > 0:
adr_obj.params['PREF'] = str(pref)
else:
if pref > 0:
standard_types.append("pref")
if standard_types:
adr_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itemadr"):
custom_label_count += 1
group_name = "itemadr%d" % (custom_label_count + 1)
adr_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
class YAMLEditable(VCardWrapper):
"""Conversion of vcards to YAML and updateing the vcard from YAML"""
def __init__(self, vcard, supported_private_objects=None, version=None,
localize_dates=False):
"""Initialize atributes needed for yaml conversions
:param supported_private_objects: the list of private property names
that will be loaded from the actual vcard and represented in this
pobject
:type supported_private_objects: list(str) or NoneType
:param version: the version of the RFC to use in this card
:type version: str or None
:param bool localize_dates: should the formatted output of anniversary
and birthday be localized or should the isoformat be used instead
"""
self.supported_private_objects = supported_private_objects or []
self.localize_dates = localize_dates
super().__init__(vcard, version)
#####################
# getters and setters
#####################
def _get_private_objects(self):
"""
:rtype: dict(str, list(str))
"""
supported = [x.lower() for x in self.supported_private_objects]
private_objects = {}
for child in self.vcard.getChildren():
lower = child.name.lower()
if lower.startswith("x-") and lower[2:] in supported:
key_index = supported.index(lower[2:])
key = self.supported_private_objects[key_index]
if key not in private_objects:
private_objects[key] = []
ablabel = self._get_ablabel(child)
private_objects[key].append(
{ablabel: child.value} if ablabel else child.value)
# sort private object lists
for value in private_objects.values():
value.sort(key=multi_property_key)
return private_objects
def _add_private_object(self, key, value):
self._add_labelled_object('X-' + key.upper(), value)
def get_formatted_anniversary(self):
return self._format_date_object(self.anniversary, self.localize_dates)
def get_formatted_birthday(self):
return self._format_date_object(self.birthday, self.localize_dates)
#######################
# object helper methods
#######################
@staticmethod
def _format_date_object(date, localize):
if not date:
return ""
if isinstance(date, str):
return date
if date.year == 1900 and date.month != 0 and date.day != 0 \
and date.hour == 0 and date.minute == 0 and date.second == 0:
return "--%.2d-%.2d" % (date.month, date.day)
if (date.tzname() and date.tzname()[3:]) or (
date.hour != 0 or date.minute != 0 or date.second != 0):
if localize:
return date.strftime(locale.nl_langinfo(locale.D_T_FMT))
utc_offset = -time.timezone / 60 / 60
return date.strftime("%Y-%m-%dT%H:%M:%S+{}:00".format(
str(int(utc_offset)).zfill(2)))
if localize:
return date.strftime(locale.nl_langinfo(locale.D_FMT))
return date.strftime("%Y-%m-%d")
@staticmethod
def _filter_invalid_tags(contents):
for pat, repl in [('aim', 'AIM'), ('gadu', 'GADUGADU'),
('groupwise', 'GROUPWISE'), ('icq', 'ICQ'),
('xmpp', 'JABBER'), ('msn', 'MSN'),
('yahoo', 'YAHOO'), ('skype', 'SKYPE'),
('irc', 'IRC'), ('sip', 'SIP')]:
contents = re.sub('X-messaging/'+pat+'-All', 'X-'+repl, contents,
flags=re.IGNORECASE)
return contents
@staticmethod
def _parse_yaml(input):
"""Parse a YAML document into a dictinary and validate the data to some
degree.
:param str input: the YAML document to parse
:returns: the parsed datastructure
:rtype: dict
"""
yaml_parser = YAML(typ='base')
# parse user input string
try:
contact_data = yaml_parser.load(input)
except (ruamel.yaml.parser.ParserError,
ruamel.yaml.scanner.ScannerError) as err:
raise ValueError(err)
else:
if not contact_data:
raise ValueError("Error: Found no contact information")
# check for available data
# at least enter name or organisation
if not (contact_data.get("First name") or contact_data.get("Last name")
or contact_data.get("Organisation")):
raise ValueError(
"Error: You must either enter a name or an organisation")
return contact_data
@staticmethod
def _set_string_list(setter, key, data):
"""Prepocess a string or list and set each value with the given setter
:param callable setter: the setter method to add a value to a card
:param value: the new value to set
:type value: str or list(str)
:returns: None
"""
value = data.get(key)
if value:
if isinstance(value, str):
setter(value)
elif isinstance(value, list):
for val in value:
if val:
setter(val)
else:
raise ValueError(
"{} must be a string or a list of strings".format(key))
def _set_date(self, target, key, data):
new = data.get(key)
if not new:
return
if not isinstance(new, str):
raise ValueError("Error: {} must be a string object.".format(key))
if re.match(r"^text[\s]*=.*$", new):
if self.version == "4.0":
v = ', '.join(x.strip() for x in re.split(r"text[\s]*=", new)
if x.strip())
if v:
setattr(self, target, v)
return
raise ValueError("Error: Free text format for {} only usable with "
"vcard version 4.0.".format(key.lower()))
if re.match(r"^--\d\d-?\d\d$", new) and self.version != "4.0":
raise ValueError(
"Error: {} format --mm-dd and --mmdd only usable with "
"vcard version 4.0. You may use 1900 as placeholder, if "
"the year is unknown.".format(key))
try:
v = helpers.string_to_date(new)
if v:
setattr(self, target, v)
return
except ValueError:
pass
raise ValueError("Error: Wrong {} format or invalid date\n"
"Use format yyyy-mm-dd or "
"yyyy-mm-ddTHH:MM:SS".format(key.lower()))
def update(self, input):
"""Update this vcard with some yaml input
:param str input: a yaml string to parse and then use to update self
:returns: None
"""
contact_data = self._parse_yaml(input)
# update rev
self._update_revision()
# name
self._delete_vcard_object("N")
# although the "n" attribute is not explisitely required by the vcard
# specification,
# the vobject library throws an exception, if it doesn't exist
# so add the name regardless if it's empty or not
self._add_name(
contact_data.get("Prefix", ""), contact_data.get("First name", ""),
contact_data.get("Additional", ""),
contact_data.get("Last name", ""), contact_data.get("Suffix", ""))
if "Formatted name" in contact_data:
self.formatted_name = contact_data.get("Formatted name")
if not self.formatted_name:
# Trigger the auto filling code in the setter.
self.formatted_name = ""
# nickname
self._delete_vcard_object("NICKNAME")
self._set_string_list(self._add_nickname, "Nickname", contact_data)
# organisation
self._delete_vcard_object("ORG")
self._delete_vcard_object("X-ABSHOWAS")
self._set_string_list(self._add_organisation, "Organisation",
contact_data)
# role
self._delete_vcard_object("ROLE")
self._set_string_list(self._add_role, "Role", contact_data)
# title
self._delete_vcard_object("TITLE")
self._set_string_list(self._add_title, "Title", contact_data)
# phone
self._delete_vcard_object("TEL")
if contact_data.get("Phone"):
if isinstance(contact_data.get("Phone"), dict):
for type, number_list in contact_data.get("Phone").items():
if isinstance(number_list, str):
number_list = [number_list]
if isinstance(number_list, list):
for number in number_list:
if number:
self._add_phone_number(type, number)
else:
raise ValueError(
"Error: got no number or list of numbers for the "
"phone number type " + type)
else:
raise ValueError(
"Error: missing type value for phone number field")
# email
self._delete_vcard_object("EMAIL")
if contact_data.get("Email"):
if isinstance(contact_data.get("Email"), dict):
for type, email_list in contact_data.get("Email").items():
if isinstance(email_list, str):
email_list = [email_list]
if isinstance(email_list, list):
for email in email_list:
if email:
self.add_email(type, email)
else:
raise ValueError(
"Error: got no email or list of emails for the "
"email address type " + type)
else:
raise ValueError(
"Error: missing type value for email address field")
# post addresses
self._delete_vcard_object("ADR")
if contact_data.get("Address"):
if isinstance(contact_data.get("Address"), dict):
for type, post_adr_list in contact_data.get("Address").items():
if isinstance(post_adr_list, dict):
post_adr_list = [post_adr_list]
if isinstance(post_adr_list, list):
for post_adr in post_adr_list:
if isinstance(post_adr, dict):
address_not_empty = False
for key, value in post_adr.items():
if key in ["Box", "Extended", "Street",
"Code", "City", "Region",
"Country"] and value:
address_not_empty = True
break
if address_not_empty:
self._add_post_address(
type, post_adr.get("Box", ""),
post_adr.get("Extended", ""),
post_adr.get("Street", ""),
post_adr.get("Code", ""),
post_adr.get("City", ""),
post_adr.get("Region", ""),
post_adr.get("Country", ""))
else:
raise ValueError(
"Error: one of the " + type + " type "
"address list items does not contain an "
"address")
else:
raise ValueError(
"Error: got no address or list of addresses for "
"the post address type " + type)
else:
raise ValueError(
"Error: missing type value for post address field")
# categories
self._delete_vcard_object("CATEGORIES")
if contact_data.get("Categories"):
if isinstance(contact_data.get("Categories"), str):
self._add_category([contact_data.get("Categories")])
elif isinstance(contact_data.get("Categories"), list):
only_contains_strings = True
for sub_category in contact_data.get("Categories"):
if not isinstance(sub_category, str):
only_contains_strings = False
break
# if the category list only contains strings, pack all of them
# in a single CATEGORIES vcard tag
if only_contains_strings:
self._add_category(contact_data.get("Categories"))
else:
for sub_category in contact_data.get("Categories"):
if sub_category:
if isinstance(sub_category, str):
self._add_category([sub_category])
else:
self._add_category(sub_category)
else:
raise ValueError(
"Error: category must be a string or a list of strings")
# urls
self._delete_vcard_object("URL")
self._set_string_list(self._add_webpage, "Webpage", contact_data)
# anniversary
self._delete_vcard_object("ANNIVERSARY")
self._delete_vcard_object("X-ANNIVERSARY")
self._set_date('anniversary', 'Anniversary', contact_data)
# birthday
self._delete_vcard_object("BDAY")
self._set_date('birthday', 'Birthday', contact_data)
# private objects
for supported in self.supported_private_objects:
self._delete_vcard_object("X-{}".format(supported.upper()))
if contact_data.get("Private"):
if isinstance(contact_data.get("Private"), dict):
for key, value_list in contact_data.get("Private").items():
if key in self.supported_private_objects:
if isinstance(value_list, str):
value_list = [value_list]
if isinstance(value_list, list):
for value in value_list:
if value:
self._add_private_object(key, value)
else:
raise ValueError(
"Error: got no value or list of values for "
"the private object " + key)
else:
raise ValueError(
"Error: private object key " + key + " was "
"changed.\nSupported private keys: " + ', '.join(
self.supported_private_objects))
else:
raise ValueError("Error: private objects must consist of a "
"key : value pair.")
# notes
self._delete_vcard_object("NOTE")
self._set_string_list(self._add_note, "Note", contact_data)
def get_template(self):
strings = []
for line in helpers.get_new_contact_template().splitlines():
if line.startswith("#"):
strings.append(line)
elif line == "":
strings.append(line)
elif line.lower().startswith("formatted name"):
strings += helpers.convert_to_yaml(
"Formatted name", self.formatted_name, 0, 15, True)
elif line.lower().startswith("prefix"):
strings += helpers.convert_to_yaml(
"Prefix", self._get_name_prefixes(), 0, 11, True)
elif line.lower().startswith("first name"):
strings += helpers.convert_to_yaml(
"First name", self._get_first_names(), 0, 11, True)
elif line.lower().startswith("additional"):
strings += helpers.convert_to_yaml(
"Additional", self._get_additional_names(), 0, 11, True)
elif line.lower().startswith("last name"):
strings += helpers.convert_to_yaml(
"Last name", self._get_last_names(), 0, 11, True)
elif line.lower().startswith("suffix"):
strings += helpers.convert_to_yaml(
"Suffix", self._get_name_suffixes(), 0, 11, True)
elif line.lower().startswith("nickname"):
strings += helpers.convert_to_yaml(
"Nickname", self.nicknames, 0, 9, True)
elif line.lower().startswith("organisation"):
strings += helpers.convert_to_yaml(
"Organisation", self.organisations, 0, 13, True)
elif line.lower().startswith("title"):
strings += helpers.convert_to_yaml(
"Title", self.titles, 0, 6, True)
elif line.lower().startswith("role"):
strings += helpers.convert_to_yaml(
"Role", self.roles, 0, 6, True)
elif line.lower().startswith("phone"):
strings.append("Phone :")
if not self.phone_numbers:
strings.append(" cell : ")
strings.append(" home : ")
else:
longest_key = max(self.phone_numbers.keys(), key=len)
for type, number_list in sorted(
self.phone_numbers.items(),
key=lambda k: k[0].lower()):
strings += helpers.convert_to_yaml(
type, number_list, 4, len(longest_key) + 1, True)
elif line.lower().startswith("email"):
strings.append("Email :")
if not self.emails:
strings.append(" home : ")
strings.append(" work : ")
else:
longest_key = max(self.emails.keys(), key=len)
for type, email_list in sorted(self.emails.items(),
key=lambda k: k[0].lower()):
strings += helpers.convert_to_yaml(
type, email_list, 4, len(longest_key) + 1, True)
elif line.lower().startswith("address"):
strings.append("Address :")
if not self.post_addresses:
strings.append(" home :")
strings.append(" Box : ")
strings.append(" Extended : ")
strings.append(" Street : ")
strings.append(" Code : ")
strings.append(" City : ")
strings.append(" Region : ")
strings.append(" Country : ")
else:
for type, post_adr_list in sorted(
self.post_addresses.items(),
key=lambda k: k[0].lower()):
strings.append(" %s:" % type)
for post_adr in post_adr_list:
indentation = 8
if len(post_adr_list) > 1:
indentation += 4
strings.append(" -")
strings += helpers.convert_to_yaml(
"Box", post_adr.get("box"), indentation, 9,
True)
strings += helpers.convert_to_yaml(
"Extended", post_adr.get("extended"),
indentation, 9, True)
strings += helpers.convert_to_yaml(
"Street", post_adr.get("street"), indentation,
9, True)
strings += helpers.convert_to_yaml(
"Code", post_adr.get("code"), indentation, 9,
True)
strings += helpers.convert_to_yaml(
"City", post_adr.get("city"), indentation, 9,
True)
strings += helpers.convert_to_yaml(
"Region", post_adr.get("region"), indentation,
9, True)
strings += helpers.convert_to_yaml(
"Country", post_adr.get("country"),
indentation, 9, True)
elif line.lower().startswith("private"):
strings.append("Private :")
if self.supported_private_objects:
longest_key = max(self.supported_private_objects, key=len)
for object in self.supported_private_objects:
strings += helpers.convert_to_yaml(
object,
self._get_private_objects().get(object, ""), 4,
len(longest_key) + 1, True)
elif line.lower().startswith("anniversary"):
anniversary = self.anniversary
if anniversary:
if isinstance(anniversary, str):
strings.append("Anniversary : text= %s" % anniversary)
elif (anniversary.year == 1900 and anniversary.month != 0
and anniversary.day != 0 and anniversary.hour == 0
and anniversary.minute == 0
and anniversary.second == 0
and self.version == "4.0"):
strings.append("Anniversary : --%.2d-%.2d"
% (anniversary.month, anniversary.day))
elif ((anniversary.tzname() and anniversary.tzname()[3:])
or anniversary.hour != 0 or anniversary.minute != 0
or anniversary.second != 0):
strings.append("Anniversary : %s" %
anniversary.isoformat())
else:
strings.append("Anniversary : %.4d-%.2d-%.2d" % (
anniversary.year, anniversary.month,
anniversary.day))
else:
strings.append("Anniversary : ")
elif line.lower().startswith("birthday"):
birthday = self.birthday
if birthday:
if isinstance(birthday, str):
strings.append("Birthday : text= %s" % birthday)
elif birthday.year == 1900 and birthday.month != 0 and \
birthday.day != 0 and birthday.hour == 0 and \
birthday.minute == 0 and birthday.second == 0 and \
self.version == "4.0":
strings.append("Birthday : --%.2d-%.2d"
% (birthday.month, birthday.day))
elif (birthday.tzname() and birthday.tzname()[3:]) or \
(birthday.hour != 0 or birthday.minute != 0
or birthday.second != 0):
strings.append("Birthday : %s" % birthday.isoformat())
else:
strings.append("Birthday : %.4d-%.2d-%.2d" % (
birthday.year, birthday.month, birthday.day))
else:
strings.append("Birthday : ")
elif line.lower().startswith("categories"):
strings += helpers.convert_to_yaml(
"Categories", self.categories, 0, 11, True)
elif line.lower().startswith("note"):
strings += helpers.convert_to_yaml(
"Note", self.notes, 0, 5, True)
elif line.lower().startswith("webpage"):
strings += helpers.convert_to_yaml(
"Webpage", self.webpages, 0, 8, True)
# posix standard: eof char must be \n
return '\n'.join(strings) + "\n"
class CarddavObject(YAMLEditable):
def __init__(self, vcard, address_book, filename,
supported_private_objects=None, vcard_version=None,
localize_dates=False):
"""Initialize the vcard object.
:param vobject.vCard vcard: the vCard to wrap
:param address_book.AddressBook address_book: a reference to the
address book where this vcard is stored
:param str filename: the path to the file where this vcard is stored
:param supported_private_objects: the list of private property names
that will be loaded from the actual vcard and represented in this
pobject
:type supported_private_objects: list(str) or NoneType
:param vcard_version: the version of the RFC to use
:type vcard_version: str or None
:param localize_dates: should the formatted output of anniversary and
birthday be localized or should the isoformat be used instead
:type localize_dates: bool
"""
self.address_book = address_book
self.filename = filename
super().__init__(vcard, supported_private_objects, vcard_version,
localize_dates)
#######################################
# factory methods to create new contact
#######################################
@classmethod
def new(cls, address_book, supported_private_objects=None, version=None,
localize_dates=False):
"""Create a new CarddavObject from scratch"""
vcard = vobject.vCard()
uid = helpers.get_random_uid()
filename = os.path.join(address_book.path, uid + ".vcf")
card = cls(vcard, address_book, filename, supported_private_objects,
version, localize_dates)
card.uid = uid
return card
@classmethod
def from_file(cls, address_book, filename, query,
supported_private_objects=None, localize_dates=False):
"""Load a CarddavObject object from a .vcf file if the plain file
matches the query.
:param address_book.AddressBook address_book: the address book where
this contact is stored
:param str filename: the file name of the .vcf file
:param re.Pattern|str|NoneType query: the regex to search in the source
file or None to load the file unconditionally
:param list(str)|NoneType supported_private_objects: the list of
private property names that will be loaded from the actual vcard
and represented in this pobject
:param str|NoneType vcard_version: the version of the RFC to use
:param bool localize_dates: should the formatted output of anniversary
and birthday be localized or should the isoformat be used instead
:returns: the loaded CarddavObject or None if the file didn't match
:rtype: CarddavObject or NoneType
"""
with open(filename, "r") as file:
contents = file.read()
if query is None or re.search(query, contents,
re.IGNORECASE | re.DOTALL):
try:
vcard = vobject.readOne(contents)
except Exception:
logging.warning("Filtering some problematic tags from %s",
filename)
# if creation fails, try to repair some vcard attributes
vcard = vobject.readOne(cls._filter_invalid_tags(contents))
return cls(vcard, address_book, filename, supported_private_objects,
None, localize_dates)
@classmethod
def from_yaml(cls, address_book, yaml, supported_private_objects=None,
version=None, localize_dates=False):
"""Use this if you want to create a new contact from user input."""
contact = cls.new(address_book, supported_private_objects, version,
localize_dates=localize_dates)
contact.update(yaml)
return contact
@classmethod
def clone_with_yaml_update(cls, contact, yaml, localize_dates=False):
"""
Use this if you want to clone an existing contact and replace its data
with new user input in one step.
"""
contact = cls(
copy.deepcopy(contact.vcard), address_book=contact.address_book,
filename=contact.filename,
supported_private_objects=contact.supported_private_objects,
localize_dates=localize_dates)
contact.update(yaml)
return contact
######################################
# overwrite some default class methods
######################################
def __eq__(self, other):
return isinstance(other, CarddavObject) and \
self.print_vcard(show_address_book=False, show_uid=False) == \
other.print_vcard(show_address_book=False, show_uid=False)
def __ne__(self, other):
return not self == other
def print_vcard(self, show_address_book=True, show_uid=True):
strings = []
# Every vcard must have an FN field per the RFC.
strings.append("Name: {}".format(self.formatted_name))
# name
if self._get_first_names() or self._get_last_names():
names = self._get_name_prefixes() + self._get_first_names() + \
self._get_additional_names() + self._get_last_names() + \
self._get_name_suffixes()
strings.append("Full name: {}".format(
helpers.list_to_string(names, " ")))
# organisation
if self.organisations:
strings += helpers.convert_to_yaml(
"Organisation", self.organisations, 0, -1, False)
# address book name
if show_address_book:
strings.append("Address book: %s" % self.address_book)
# person related information
if (self.birthday is not None or self.anniversary is not None
or self.nicknames or self.roles or self.titles):
strings.append("General:")
if self.anniversary:
strings.append(" Anniversary: %s"
% self.get_formatted_anniversary())
if self.birthday:
strings.append(
" Birthday: {}".format(self.get_formatted_birthday()))
if self.nicknames:
strings += helpers.convert_to_yaml(
"Nickname", self.nicknames, 4, -1, False)
if self.roles:
strings += helpers.convert_to_yaml(
"Role", self.roles, 4, -1, False)
if self.titles:
strings += helpers.convert_to_yaml(
"Title", self.titles, 4, -1, False)
# phone numbers
if self.phone_numbers:
strings.append("Phone")
for type, number_list in sorted(self.phone_numbers.items(),
key=lambda k: k[0].lower()):
strings += helpers.convert_to_yaml(
type, number_list, 4, -1, False)
# email addresses
if self.emails:
strings.append("E-Mail")
for type, email_list in sorted(self.emails.items(),
key=lambda k: k[0].lower()):
strings += helpers.convert_to_yaml(
type, email_list, 4, -1, False)
# post addresses
if self.post_addresses:
strings.append("Address")
for type, post_adr_list in sorted(
self.get_formatted_post_addresses().items(),
key=lambda k: k[0].lower()):
strings += helpers.convert_to_yaml(
type, post_adr_list, 4, -1, False)
# private objects
if self._get_private_objects().keys():
strings.append("Private:")
for object in self.supported_private_objects:
if object in self._get_private_objects():
strings += helpers.convert_to_yaml(
object, self._get_private_objects().get(object), 4, -1,
False)
# misc stuff
if self.categories or self.webpages or self.notes or (
show_uid and self.uid):
strings.append("Miscellaneous")
if show_uid and self.uid:
strings.append(" UID: {}".format(self.uid))
if self.categories:
strings += helpers.convert_to_yaml(
"Categories", self.categories, 4, -1, False)
if self.webpages:
strings += helpers.convert_to_yaml(
"Webpage", self.webpages, 4, -1, False)
if self.notes:
strings += helpers.convert_to_yaml(
"Note", self.notes, 4, -1, False)
return '\n'.join(strings)
def write_to_file(self, overwrite=False):
# make sure, that every contact contains a uid
if not self.uid:
self.uid = helpers.get_random_uid()
try:
with atomic_write(self.filename, overwrite=overwrite) as f:
f.write(self.vcard.serialize())
except vobject.base.ValidateError as err:
print("Error: Vcard is not valid.\n{}".format(err))
sys.exit(4)
except IOError as err:
print("Error: Can't write\n{}".format(err))
sys.exit(4)
except OSError as err:
print("Error: vcard with the file name {} already exists\n"
"{}".format(os.path.basename(self.filename), err))
sys.exit(4)
def delete_vcard_file(self):
if os.path.exists(self.filename):
os.remove(self.filename)
else:
print("Error: Vcard file {} does not exist.".format(self.filename))
sys.exit(4)
khard-0.15.1/khard/cli.py 0000664 0000000 0000000 00000050226 13560061035 0015105 0 ustar 00root root 0000000 0000000 """Command line parsing and configuration logic for khard"""
import argparse
import logging
import sys
from .actions import Actions
from .config import Config
from .version import version as khard_version
def create_parsers():
"""Create two argument parsers.
The first parser is manly used to find the config file which can than be
used to set some default values on the second parser. The second parser
can parse the remainder of the command line with the subcommand and all
further options and arguments.
:returns: the two parsers for the first and the second parsing pass
:rtype: (argparse.ArgumentParser, argparse.ArgumentParser)
"""
# Create the base argument parser. It will be reused for the first and
# second round of argument parsing.
base = argparse.ArgumentParser(
description="Khard is a carddav address book for the console",
formatter_class=argparse.RawTextHelpFormatter, add_help=False)
base.add_argument("-c", "--config", help="config file to use")
base.add_argument("--debug", action="store_true",
help="enable debug output")
base.add_argument("--skip-unparsable", action="store_true",
help="skip unparsable vcard files")
base.add_argument("-v", "--version", action="version",
version="Khard version %s" % khard_version)
# Create the first argument parser. Its main job is to set the correct
# config file. The config file is needed to get the default command if no
# subcommand is given on the command line. This parser will ignore most
# arguments, as they will be parsed by the second parser.
first_parser = argparse.ArgumentParser(parents=[base])
first_parser.add_argument('remainder', nargs=argparse.REMAINDER)
# Create the main argument parser. It will handle the complete command
# line only ignoring the config and debug options as these have already
# been set.
parser = argparse.ArgumentParser(parents=[base])
# create address book subparsers with different help texts
default_addressbook_parser = argparse.ArgumentParser(add_help=False)
default_addressbook_parser.add_argument(
"-a", "--addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify one or several comma separated address book names to "
"narrow the list of contacts")
new_addressbook_parser = argparse.ArgumentParser(add_help=False)
new_addressbook_parser.add_argument(
"-a", "--addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify address book in which to create the new contact")
copy_move_addressbook_parser = argparse.ArgumentParser(add_help=False)
copy_move_addressbook_parser.add_argument(
"-a", "--addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify one or several comma separated address book names to "
"narrow the list of contacts")
copy_move_addressbook_parser.add_argument(
"-A", "--target-addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify target address book in which to copy / move the "
"selected contact")
merge_addressbook_parser = argparse.ArgumentParser(add_help=False)
merge_addressbook_parser.add_argument(
"-a", "--addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify one or several comma separated address book names to "
"narrow the list of source contacts")
merge_addressbook_parser.add_argument(
"-A", "--target-addressbook", default=[],
type=lambda x: [y.strip() for y in x.split(",")],
help="Specify one or several comma separated address book names to "
"narrow the list of target contacts")
# create input file subparsers with different help texts
email_header_input_file_parser = argparse.ArgumentParser(add_help=False)
email_header_input_file_parser.add_argument(
"-i", "--input-file", default="-",
help="Specify input email header file name or use stdin by default")
template_input_file_parser = argparse.ArgumentParser(add_help=False)
template_input_file_parser.add_argument(
"-i", "--input-file", default="-",
help="Specify input template file name or use stdin by default")
template_input_file_parser.add_argument(
"--open-editor", "--edit", action="store_true", help="Open the "
"default text editor after successful creation of new contact")
# create sort subparser
sort_parser = argparse.ArgumentParser(add_help=False)
sort_parser.add_argument(
"-d", "--display",
choices=("first_name", "last_name", "formatted_name"),
help="Display names in contact table by first or last name")
sort_parser.add_argument(
"-g", "--group-by-addressbook", action="store_true",
help="Group contact table by address book")
sort_parser.add_argument(
"-r", "--reverse", action="store_true",
help="Reverse order of contact table")
sort_parser.add_argument(
"-s", "--sort", choices=("first_name", "last_name", "formatted_name"),
help="Sort contact table by first or last name")
# create search subparsers
default_search_parser = argparse.ArgumentParser(add_help=False)
default_search_parser.add_argument(
"-f", "--search-in-source-files", action="store_true",
help="Look into source vcf files to speed up search queries in "
"large address books. Beware that this option could lead "
"to incomplete results.")
default_search_parser.add_argument(
"-e", "--strict-search", action="store_true",
help="narrow contact search to name field")
default_search_parser.add_argument(
"-u", "--uid", default="", help="select contact by uid")
default_search_parser.add_argument(
"search_terms", nargs="*", metavar="search terms",
help="search in all fields to find matching contact")
merge_search_parser = argparse.ArgumentParser(add_help=False)
merge_search_parser.add_argument(
"-f", "--search-in-source-files", action="store_true",
help="Look into source vcf files to speed up search queries in "
"large address books. Beware that this option could lead "
"to incomplete results.")
merge_search_parser.add_argument(
"-e", "--strict-search", action="store_true",
help="narrow contact search to name fields")
merge_search_parser.add_argument(
"-t", "--target-contact", "--target", default="",
help="search in all fields to find matching target contact")
merge_search_parser.add_argument(
"-u", "--uid", default="", help="select source contact by uid")
merge_search_parser.add_argument(
"-U", "--target-uid", default="", help="select target contact by uid")
merge_search_parser.add_argument(
"source_search_terms", nargs="*", metavar="source",
help="search in all fields to find matching source contact")
# create subparsers for actions
subparsers = parser.add_subparsers(dest="action")
list_parser = subparsers.add_parser(
"list",
aliases=Actions.get_aliases("list"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="list all (selected) contacts",
help="list all (selected) contacts")
list_parser.add_argument(
"-p", "--parsable", action="store_true",
help="Machine readable format: uid\\tcontact_name\\taddress_book_name")
show_parser = subparsers.add_parser(
"show",
aliases=Actions.get_aliases("show"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="display detailed information about one contact",
help="display detailed information about one contact")
show_parser.add_argument(
"--format", choices=("pretty", "yaml", "vcard"), default="pretty",
help="select the output format")
show_parser.add_argument(
"-o", "--output-file", default=sys.stdout,
type=argparse.FileType("w"),
help="Specify output template file name or use stdout by default")
subparsers.add_parser("template", help="print an empty yaml template")
export_parser = subparsers.add_parser(
"export",
aliases=Actions.get_aliases("export"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="DEPRECATED (an alias for 'show --format=yaml')",
help="DEPRECATED (an alias for 'show --format=yaml')")
export_parser.add_argument(
"-o", "--output-file", default=sys.stdout,
type=argparse.FileType("w"),
help="Specify output template file name or use stdout by default")
birthdays_parser = subparsers.add_parser(
"birthdays",
aliases=Actions.get_aliases("birthdays"),
parents=[default_addressbook_parser, default_search_parser],
description="list birthdays (sorted by month and day)",
help="list birthdays (sorted by month and day)")
birthdays_parser.add_argument(
"-d", "--display",
choices=("first_name", "last_name", "formatted_name"),
help="Display names in birthdays table by first or last name")
birthdays_parser.add_argument(
"-p", "--parsable", action="store_true",
help="Machine readable format: name\\tdate")
email_parser = subparsers.add_parser(
"email",
aliases=Actions.get_aliases("email"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="list email addresses",
help="list email addresses")
email_parser.add_argument(
"-p", "--parsable", action="store_true",
help="Machine readable format: address\\tname\\ttype")
email_parser.add_argument(
"--remove-first-line", action="store_true",
help="remove \"searching for '' ...\" line from parsable output "
"(that line is required by mutt)")
phone_parser = subparsers.add_parser(
"phone",
aliases=Actions.get_aliases("phone"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="list phone numbers",
help="list phone numbers")
phone_parser.add_argument(
"-p", "--parsable", action="store_true",
help="Machine readable format: number\\tname\\ttype")
post_address_parser = subparsers.add_parser(
"postaddress",
aliases=Actions.get_aliases("postaddress"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="list postal addresses",
help="list postal addresses")
post_address_parser.add_argument(
"-p", "--parsable", action="store_true",
help="Machine readable format: address\\tname\\ttype")
subparsers.add_parser(
"source",
aliases=Actions.get_aliases("source"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="DEPRECATED (an alias for 'edit --format=vcard')",
help="DEPRECATED (an alias for 'edit --format=vcard')")
new_parser = subparsers.add_parser(
"new",
aliases=Actions.get_aliases("new"),
parents=[new_addressbook_parser, template_input_file_parser],
description="create a new contact",
help="create a new contact")
new_parser.add_argument(
"--vcard-version", choices=("3.0", "4.0"), dest='preferred_version',
help="Select preferred vcard version for new contact")
add_email_parser = subparsers.add_parser(
"add-email",
aliases=Actions.get_aliases("add-email"),
parents=[default_addressbook_parser, email_header_input_file_parser,
default_search_parser, sort_parser],
description="Extract email address from the \"From:\" field of an "
"email header and add to an existing contact or create a new one",
help="Extract email address from the \"From:\" field of an email "
"header and add to an existing contact or create a new one")
add_email_parser.add_argument(
"--vcard-version", choices=("3.0", "4.0"), dest='preferred_version',
help="Select preferred vcard version for new contact")
subparsers.add_parser(
"merge",
aliases=Actions.get_aliases("merge"),
parents=[merge_addressbook_parser, merge_search_parser, sort_parser],
description="merge two contacts",
help="merge two contacts")
edit_parser = subparsers.add_parser(
"edit",
aliases=Actions.get_aliases("edit"),
parents=[default_addressbook_parser, template_input_file_parser,
default_search_parser, sort_parser],
description="edit the data of a contact",
help="edit the data of a contact")
edit_parser.add_argument(
"--format", choices=("yaml", "vcard"), default="yaml",
help="specify the file format to use when editing")
subparsers.add_parser(
"copy",
aliases=Actions.get_aliases("copy"),
parents=[copy_move_addressbook_parser, default_search_parser,
sort_parser],
description="copy a contact to a different addressbook",
help="copy a contact to a different addressbook")
subparsers.add_parser(
"move",
aliases=Actions.get_aliases("move"),
parents=[copy_move_addressbook_parser, default_search_parser,
sort_parser],
description="move a contact to a different addressbook",
help="move a contact to a different addressbook")
remove_parser = subparsers.add_parser(
"remove",
aliases=Actions.get_aliases("remove"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="remove a contact",
help="remove a contact")
remove_parser.add_argument(
"--force", action="store_true",
help="Remove contact without confirmation")
subparsers.add_parser(
"addressbooks",
aliases=Actions.get_aliases("addressbooks"),
description="list addressbooks",
help="list addressbooks")
subparsers.add_parser(
"filename",
aliases=Actions.get_aliases("filename"),
parents=[default_addressbook_parser, default_search_parser,
sort_parser],
description="list filenames of all matching contacts",
help="list filenames of all matching contacts")
# Replace the print_help method of the first parser with the print_help
# method of the main parser. This makes it possible to have the first
# parser handle the help option so that command line help can be printed
# without parsing the config file first (which is a problem if there are
# errors in the config file). The config file will still be parsed before
# the full command line is parsed so errors in the config file might be
# reported before command line syntax errors.
first_parser.print_help = parser.print_help
return first_parser, parser
def parse_args(argv):
"""Parse the command line arguments and return the namespace that was
creates by argparse.ArgumentParser.parse_args().
:param list(str) argv: the command line arguments
:returns: the namespace parsed from the command line
:rtype: argparse.Namespace
"""
first_parser, parser = create_parsers()
# Parese the command line with the first argument parser. It will handle
# the config option (its main job) and also the help, version and debug
# options as these do not depend on anything else.
args = first_parser.parse_args(argv)
remainder = args.remainder
# Set the loglevel to debug if given on the command line. This is done
# before parsing the config file to make it possible to debug the parsing
# of the config file.
if "debug" in args and args.debug:
logging.basicConfig(level=logging.DEBUG)
# Create the config instance.
config = Config(args.config)
logging.debug("Finished parsing config=%s", vars(config))
# Check the log level again and merge the value from the command line with
# the config file.
if ("debug" in args and args.debug) or config.debug:
logging.basicConfig(level=logging.DEBUG)
logging.debug("first args=%s", args)
logging.debug("remainder=%s", remainder)
# Set the default command from the config file if none was given on the
# command line.
if not remainder or remainder[0] not in Actions.get_all():
if config.default_action is None:
exit("Missing subcommand on command line or default action "
"parameter in config.")
remainder.insert(0, config.default_action)
logging.debug("updated remainder=%s", remainder)
# Save the last option that needs to be carried from the first parser run
# to the second.
skip = args.skip_unparsable
# Parse the remainder of the command line. All options from the previous
# run have already been processed and are not needed any more.
args = parser.parse_args(remainder)
# Restore settings that are left from the first parser run.
args.skip_unparsable = skip
logging.debug("second args=%s", args)
# An integrity check for some options.
if "uid" in args and args.uid and (
("search_terms" in args and args.search_terms) or
("source_search_terms" in args and args.source_search_terms)):
# If an uid was given we require that no search terms where given.
parser.error("You can not give arbitrary search terms and --uid at the"
" same time.")
# Normalize all deprecated subcommands and emit warnings.
if args.action == "export":
logging.warning("Deprecated subcommand: use 'show --format=yaml'.")
args.action = "show"
args.format = "yaml"
elif args.action == "source":
logging.warning("Deprecated subcommand: use 'edit --format=vcard'.")
args.action = "edit"
args.format = "vcard"
return args, config
def merge_args_into_config(args, config):
"""Merge the parsed arguments from argparse into the config object.
:param args: the parsed command line arguments
:type args: argparse.Namespace
:param config: the parsed config file
:type config: config.Config
:returns: the merged config object
:rtype: config.Config
"""
merge = {'general': ['debug'],
'contact table': ['reverse', 'group_by_addressbook', 'display',
'sort'],
'vcard': ['search_in_source_files', 'skip_unparsable',
'preferred_version'],
}
merge = {k1: {k2: getattr(args, k2)
for k2 in v1 if k2 in args and getattr(args, k2) is not None}
for k1, v1 in merge.items()}
logging.debug('Merging in %s', merge)
config.merge(merge)
logging.debug('Merged: %s', vars(config))
# Now we can savely initialize the address books as all command line
# options have been incorporated into the config object.
config.load_address_books()
# If the user could but did not specify address books on the command line
# it means they want to use all address books in that place.
if "addressbook" in args and not args.addressbook:
args.addressbook = [abook.name for abook in config.abooks]
if "target_addressbook" in args and not args.target_addressbook:
args.target_addressbook = [abook.name for abook in config.abooks]
return config
def init(argv):
"""Initialize khard by parsing the command line and reading the config file
:param list(str) argv: the command line arguments
:returns: the parsed command line and the fully initialized config
:rtype: (argparse.Namespace, Config)
"""
args, conf = parse_args(argv)
# if args.action isn't one of the defined actions, it must be an alias
if args.action not in Actions.get_actions():
# convert alias to corresponding action
# example: "ls" --> "list"
args.action = Actions.get_action(args.action)
return args, merge_args_into_config(args, conf)
khard-0.15.1/khard/config.py 0000664 0000000 0000000 00000016414 13560061035 0015604 0 ustar 00root root 0000000 0000000 """Loading and validation of the configuration file"""
import locale
import logging
import os
import re
import shlex
import sys
import configobj
import validate
from .actions import Actions
from .address_book import AddressBookCollection, VdirAddressBook
def exit(message, prefix="Error in config file\n"):
"""Exit with a message and a return code indicating an error in the config
file.
This function doesn't return, it calls sys.exit.
:param message: the message to print
:type message: str
:param prefix: the prefix to put in front of the message
:type prefix: str
:returns: does not return
"""
print(prefix + message)
sys.exit(3)
def validate_command(value):
"""Special validator to check shell commands
The input must either be a list of strings or a string that shlex.split can
parse into such.
:param value: the config value to validate
:returns: the command after validation
:rtype: list(str)
:raises: validate.ValidateError
"""
logging.debug("validating %s", value)
try:
return validate.is_string_list(value)
except validate.VdtTypeError:
logging.debug('continue with %s', value)
if isinstance(value, str):
try:
return shlex.split(value)
except ValueError as err:
raise validate.ValidateError(
'Error when parsing shell command "{}": {}'.format(
value, err))
raise
def validate_action(value):
"""Check that the given value is a valid action.
:param value: the config value to check
:returns: the same value
:rtype: str
:raises: validate.ValidateError
"""
return validate.is_option(value, *Actions.get_actions())
def validate_private_objects(value):
"""Check that the private objects are reasonable
:param value: the config value to check
:returns: the list of private objects
:rtype: list(str)
:raises: validate.ValidateError
"""
value = validate.is_string_list(value)
for obj in value:
if re.search("[^a-z0-9-]", obj, re.IGNORECASE):
raise validate.ValidateError(
'Private objects may only contain letters, digits and the'
' \"-\" character.')
if obj.startswith("-") or obj.endswith("-"):
raise validate.ValidateError(
"A \"-\" in a private object label must be at least "
"surrounded by one letter or digit.")
return value
class Config:
supported_vcard_versions = ("3.0", "4.0")
def __init__(self, config_file=None):
self.config = None
self.abooks = []
self.abook = None
locale.setlocale(locale.LC_ALL, '')
config = self._load_config_file(config_file)
self.config = self._validate(config)
self._set_attributes()
@classmethod
def _load_config_file(cls, config_file):
"""Find and load the config file.
:param str config_file: the path to the config file to load
:returns: the loaded config file
"""
if not config_file:
xdg_config_home = os.getenv("XDG_CONFIG_HOME",
os.path.expanduser("~/.config"))
config_file = os.getenv("KHARD_CONFIG", os.path.join(
xdg_config_home, "khard", "khard.conf"))
configspec = os.path.join(os.path.dirname(__file__),
'data', 'config.spec')
try:
return configobj.ConfigObj(
infile=config_file, configspec=configspec,
interpolation=False, file_error=True)
except configobj.ConfigObjError as err:
exit(str(err))
@staticmethod
def _validate(config):
vdr = validate.Validator()
vdr.functions.update({'command': validate_command,
'action': validate_action,
'private_objects': validate_private_objects})
result = config.validate(vdr, preserve_errors=True)
result = configobj.flatten_errors(config, result)
if not config['addressbooks'].keys():
result.append((['addressbooks'], '__any__',
'No address book entries available'))
for path, key, exception in result:
logging.error("Error in config file, %s: %s",
".".join([*path, key]), exception)
if result:
sys.exit(3)
return config
def _set_attributes(self):
"""Set the attributes from the internal config instance on self.
:returns: None
"""
general = self.config["general"]
self.debug = general["debug"]
self.editor = general["editor"] or os.environ.get("EDITOR", "vim")
self.merge_editor = general["merge_editor"] \
or os.environ.get("MERGE_EDITOR", "vimdiff")
self.default_action = general["default_action"]
if self.default_action is None:
# When these two lines are replaced with "pass" khard requires a
# subcommand on the command line as long as no default_action is
# explicitly given in the config file.
logging.warning(
"No default_action was set in the config. Currently this "
"will default to default_action='list' but will require the "
"use of a subcommand on the command line in a future version "
"of khard.")
self.default_action = "list"
table = self.config["contact table"]
vcard = self.config["vcard"]
self.sort = table["sort"]
# if display by name attribute is not present in the config file use
# the sort attribute value for backwards compatibility
self.display = table.get("display", self.sort)
self.localize_dates = table['localize_dates']
self.private_objects = vcard['private_objects']
self.preferred_vcard_version = vcard['preferred_version']
self.search_in_source_files = vcard['search_in_source_files']
self.skip_unparsable = vcard['skip_unparsable']
self.group_by_addressbook = table['group_by_addressbook']
self.reverse = table['reverse']
self.show_nicknames = table['show_nicknames']
self.preferred_email_address_type = table['preferred_email_address_type']
self.preferred_phone_number_type = table['preferred_phone_number_type']
self.show_uids = table['show_uids']
def load_address_books(self):
section = self.config['addressbooks']
kwargs = {'private_objects': self.private_objects,
'localize_dates': self.localize_dates,
'skip': self.skip_unparsable}
try:
self.abook = AddressBookCollection(
"tmp", [VdirAddressBook(name, section[name]['path'], **kwargs)
for name in section], **kwargs)
except IOError as err:
exit(str(err))
self.abooks = [self.abook.get_abook(name) for name in section]
def merge(self, other):
"""Merge the config with some other dict or ConfigObj
:param other: the other dict or ConfigObj to merge into self
:returns: None
"""
self.config.merge(other)
self._validate(self.config)
self._set_attributes()
khard-0.15.1/khard/data/ 0000775 0000000 0000000 00000000000 13560061035 0014670 5 ustar 00root root 0000000 0000000 khard-0.15.1/khard/data/config.spec 0000664 0000000 0000000 00000001566 13560061035 0017021 0 ustar 00root root 0000000 0000000 [general]
debug = boolean(default=False)
default_action = action(default=None)
editor = command(default=None)
merge_editor = command(default=None)
[contact table]
display = option('first_name', 'last_name', default='first_name')
group_by_addressbook = boolean(default=False)
localize_dates = boolean(default=True)
preferred_email_address_type = string_list(default=list('pref'))
preferred_phone_number_type = string_list(default=list('pref'))
reverse = boolean(default=False)
show_nicknames = boolean(default=False)
show_uids = boolean(default=True)
sort = option('first_name', 'last_name', 'formatted_name', default='first_name')
[vcard]
preferred_version = option('3.0', '4.0', default='3.0')
private_objects = private_objects(default=list()))
search_in_source_files = boolean(default=False)
skip_unparsable = boolean(default=False)
[addressbooks]
[[__many__]]
path = string
khard-0.15.1/khard/data/template.yaml 0000664 0000000 0000000 00000005730 13560061035 0017374 0 ustar 00root root 0000000 0000000 # Every contact must contain a formatted name, it will be autofilled
# from the full name below if not given.
Formatted name :
# name components
# every entry may contain a string or a list of strings
# format:
# First name : name1
# Additional :
# - name2
# - name3
# Last name : name4
Prefix :
First name :
Additional :
Last name :
Suffix :
# nickname
# may contain a string or a list of strings
Nickname :
# important dates
# Formats:
# vcard 3.0 and 4.0: yyyy-mm-dd or yyyy-mm-ddTHH:MM:SS
# vcard 4.0 only: --mm-dd or text= string value
# anniversary
Anniversary :
# birthday
Birthday :
# organisation
# format:
# Organisation : company
# or
# Organisation :
# - company1
# - company2
# or
# Organisation :
# -
# - company
# - unit
Organisation :
# organisation title and role
# every entry may contain a string or a list of strings
#
# title at organisation
# example usage: research scientist
Title :
# role at organisation
# example usage: project leader
Role :
# phone numbers
# format:
# Phone:
# type1, type2: number
# type3:
# - number1
# - number2
# custom: number
# allowed types:
# vcard 3.0: At least one of bbs, car, cell, fax, home, isdn, msg, modem,
# pager, pcs, pref, video, voice, work
# vcard 4.0: At least one of home, work, pref, text, voice, fax, cell, video,
# pager, textphone
# Alternatively you may use a single custom label (only letters).
# But beware, that not all address book clients will support custom labels.
Phone :
cell :
home :
# email addresses
# format like phone numbers above
# allowed types:
# vcard 3.0: At least one of home, internet, pref, work, x400
# vcard 4.0: At least one of home, internet, pref, work
# Alternatively you may use a single custom label (only letters).
Email :
home :
work :
# post addresses
# allowed types:
# vcard 3.0: At least one of dom, intl, home, parcel, postal, pref, work
# vcard 4.0: At least one of home, pref, work
# Alternatively you may use a single custom label (only letters).
Address :
home :
Box :
Extended :
Street :
Code :
City :
Region :
Country :
# categories or tags
# format:
# Categories : single category
# or
# Categories :
# - category1
# - category2
Categories :
# web pages
# may contain a string or a list of strings
Webpage :
# private objects
# define your own private objects in the vcard section of your khard config file
# example:
# [vcard]
# private_objects = Jabber, Skype, Twitter
# these objects are stored with a leading "X-" before the object name in the
# vcard files.
# every entry may contain a string or a list of strings
Private : {}
# notes
# may contain a string or a list of strings
# for multi-line notes use:
# Note : |
# line one
# line two
Note :
khard-0.15.1/khard/helpers.py 0000664 0000000 0000000 00000017361 13560061035 0016003 0 ustar 00root root 0000000 0000000 """Some helper functions for khard"""
import os
import pathlib
import random
import string
from datetime import datetime
def pretty_print(table, justify="L"):
# support for multiline columns
line_break_table = []
for row in table:
# get line break count
most_line_breaks_in_row = 0
for col in row:
if str(col).count("\n") > most_line_breaks_in_row:
most_line_breaks_in_row = col.count("\n")
# fill table rows
for index in range(0, most_line_breaks_in_row+1):
line_break_row = []
for col in row:
try:
line_break_row.append(str(col).split("\n")[index])
except IndexError:
line_break_row.append("")
line_break_table.append(line_break_row)
# replace table variable
table = line_break_table
# get width for every column
column_widths = [0] * len(table[0])
offset = 3
for row in table:
for index, col in enumerate(row):
width = len(str(col))
if width > column_widths[index]:
column_widths[index] = width
table_row_list = []
for row in table:
single_row_list = []
for col_index, col in enumerate(row):
if justify == "R": # justify right
formated_column = str(col).rjust(column_widths[col_index] +
offset)
elif justify == "L": # justify left
formated_column = str(col).ljust(column_widths[col_index] +
offset)
elif justify == "C": # justify center
formated_column = str(col).center(column_widths[col_index] +
offset)
single_row_list.append(formated_column)
table_row_list.append(' '.join(single_row_list))
return '\n'.join(table_row_list)
def list_to_string(input, delimiter):
"""converts list to string recursively so that nested lists are supported
:param input: a list of strings and lists of strings (and so on recursive)
:type input: list
:param delimiter: the deimiter to use when joining the items
:type delimiter: str
:returns: the recursively joined list
:rtype: str
"""
if isinstance(input, list):
return delimiter.join(
list_to_string(item, delimiter) for item in input)
return input
def string_to_list(input, delimiter):
if isinstance(input, list):
return input
return [x.strip() for x in input.split(delimiter)]
def string_to_date(string):
"""Convert a date string into a date object.
:param string: the date string to parse
:type string: str
:returns: the parsed datetime object
:rtype: datetime.datetime
"""
# try date formats --mmdd, --mm-dd, yyyymmdd, yyyy-mm-dd and datetime
# formats yyyymmddThhmmss, yyyy-mm-ddThh:mm:ss, yyyymmddThhmmssZ,
# yyyy-mm-ddThh:mm:ssZ.
for fmt in ("--%m%d", "--%m-%d", "%Y%m%d", "%Y-%m-%d", "%Y%m%dT%H%M%S",
"%Y-%m-%dT%H:%M:%S", "%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
return datetime.strptime(string, fmt)
except ValueError:
continue # with the next format
# try datetime formats yyyymmddThhmmsstz and yyyy-mm-ddThh:mm:sstz where tz
# may look like -06:00.
for fmt in ("%Y%m%dT%H%M%S%z", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(''.join(string.rsplit(":", 1)), fmt)
except ValueError:
continue # with the next format
raise ValueError
def get_random_uid():
return ''.join([random.choice(string.ascii_lowercase + string.digits)
for _ in range(36)])
def file_modification_date(filename):
return datetime.fromtimestamp(os.path.getmtime(filename))
def convert_to_yaml(name, value, indentation, index_of_colon,
show_multi_line_character):
"""converts a value list into yaml syntax
:param str name: name of object (example: phone)
:param value: object contents
:type value: str, list(str), list(list(str)), list(dict)
:param int indentation: indent all by number of spaces
:param int index_of_colon: use to position : at the name string (-1 for no
space)
:param bool show_multi_line_character: option to hide "|"
:returns: yaml formatted string array of name, value pair
:rtype: list(str)
"""
strings = []
if isinstance(value, list):
# special case for single item lists:
if len(value) == 1 and isinstance(value[0], str):
# value = ["string"] should not be converted to
# name:
# - string
# but to "name: string" instead
value = value[0]
elif len(value) == 1 and isinstance(value[0], list) \
and len(value[0]) == 1 and isinstance(value[0][0], str):
# same applies to value = [["string"]]
value = value[0][0]
if isinstance(value, str):
strings.append("%s%s%s: %s" % (
' ' * indentation, name, ' ' * (index_of_colon-len(name)),
indent_multiline_string(value, indentation+4,
show_multi_line_character)))
elif isinstance(value, list):
strings.append("%s%s%s: " % (
' ' * indentation, name, ' ' * (index_of_colon-len(name))))
for outer in value:
# special case for single item sublists
if isinstance(outer, list) and len(outer) == 1 \
and isinstance(outer[0], str):
# outer = ["string"] should not be converted to
# -
# - string
# but to "- string" instead
outer = outer[0]
if isinstance(outer, str):
strings.append("%s- %s" % (
' ' * (indentation+4), indent_multiline_string(
outer, indentation+8, show_multi_line_character)))
elif isinstance(outer, list):
strings.append("%s- " % (' ' * (indentation+4)))
for inner in outer:
if isinstance(inner, str):
strings.append("%s- %s" % (
' ' * (indentation+8), indent_multiline_string(
inner, indentation+12,
show_multi_line_character)))
elif isinstance(outer, dict):
# ABLABEL'd lists
for k in outer:
strings += convert_to_yaml(
"- " + k, outer[k], indentation+4, index_of_colon,
show_multi_line_character)
return strings
def indent_multiline_string(input, indentation, show_multi_line_character):
# if input is a list, convert to string first
if isinstance(input, list):
input = list_to_string(input, "")
# format multiline string
if "\n" in input or ": " in input:
lines = ["|"] if show_multi_line_character else [""]
for line in input.split("\n"):
lines.append("%s%s" % (' ' * indentation, line.strip()))
return '\n'.join(lines)
return input.strip()
def get_new_contact_template(supported_private_objects=None):
formatted_private_objects = []
if supported_private_objects:
formatted_private_objects.append("")
longest_key = max(supported_private_objects, key=len)
for object in supported_private_objects:
formatted_private_objects += convert_to_yaml(
object, "", 12, len(longest_key)+1, True)
template = pathlib.Path(__file__).parent / 'data' / 'template.yaml'
with template.open() as template:
return template.read().format('\n'.join(formatted_private_objects))
khard-0.15.1/khard/khard.py 0000664 0000000 0000000 00000163542 13560061035 0015435 0 ustar 00root root 0000000 0000000 """Main application logic of khard includeing command line handling"""
import datetime
from email import message_from_string
from email.policy import SMTP as SMTP_POLICY
import logging
import os
import re
import subprocess
import sys
from tempfile import NamedTemporaryFile
from unidecode import unidecode
from . import helpers
from .address_book import AddressBookCollection, AddressBookParseError
from .carddav_object import CarddavObject
from . import cli
from .version import version as khard_version
config = None
def write_temp_file(text=""):
"""Create a new temporary file and write some initial text to it.
:param text: the text to write to the temp file
:type text: str
:returns: the file name of the newly created temp file
:rtype: str
"""
with NamedTemporaryFile(mode='w+t', suffix='.yml', delete=False) as tmp:
tmp.write(text)
return tmp.name
def edit(*filenames, merge=False):
"""Edit the given files with the configured editor or merge editor"""
editor = config.merge_editor if merge else config.editor
editor = [editor] if isinstance(editor, str) else editor
editor.extend(filenames)
child = subprocess.Popen(editor)
child.communicate()
def create_new_contact(address_book):
# create temp file
template = (
"# create new contact\n# Address book: %s\n# Vcard version: %s\n"
"# if you want to cancel, exit without saving\n\n%s"
% (address_book, config.preferred_vcard_version,
helpers.get_new_contact_template(config.private_objects)))
temp_file_name = write_temp_file(template)
temp_file_creation = helpers.file_modification_date(temp_file_name)
while True:
edit(temp_file_name)
if temp_file_creation == helpers.file_modification_date(
temp_file_name):
new_contact = None
os.remove(temp_file_name)
break
# read temp file contents after editing
with open(temp_file_name, "r") as tmp:
new_contact_yaml = tmp.read()
# try to create new contact
try:
new_contact = CarddavObject.from_yaml(
address_book, new_contact_yaml, config.private_objects,
config.preferred_vcard_version, config.localize_dates)
except ValueError as err:
print("\n%s\n" % err)
while True:
input_string = input(
"Do you want to open the editor again (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
os.remove(temp_file_name)
sys.exit(0)
if input_string.lower() == "y":
break
else:
os.remove(temp_file_name)
break
# create carddav object from temp file
if new_contact is None or template == new_contact_yaml:
print("Canceled")
else:
new_contact.write_to_file()
print("Creation successful\n\n%s" % new_contact.print_vcard())
def modify_existing_contact(old_contact):
# create temp file and open it with the specified text editor
temp_file_name = write_temp_file(
"# Edit contact: %s\n# Address book: %s\n# Vcard version: %s\n"
"# if you want to cancel, exit without saving\n\n%s"
% (old_contact, old_contact.address_book, old_contact.version,
old_contact.get_template()))
temp_file_creation = helpers.file_modification_date(temp_file_name)
while True:
edit(temp_file_name)
if temp_file_creation == helpers.file_modification_date(
temp_file_name):
new_contact = None
os.remove(temp_file_name)
break
# read temp file contents after editing
with open(temp_file_name, "r") as tmp:
new_contact_template = tmp.read()
# try to create contact from user input
try:
new_contact = CarddavObject.clone_with_yaml_update(
old_contact, new_contact_template, config.localize_dates)
except ValueError as err:
print("\n%s\n" % err)
while True:
input_string = input(
"Do you want to open the editor again (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
os.remove(temp_file_name)
sys.exit(0)
if input_string.lower() == "y":
break
else:
os.remove(temp_file_name)
break
# check if the user changed anything
if new_contact is None or old_contact == new_contact:
print("Nothing changed\n\n%s" % old_contact.print_vcard())
else:
new_contact.write_to_file(overwrite=True)
print("Modification successful\n\n%s" % new_contact.print_vcard())
def merge_existing_contacts(source_contact, target_contact,
delete_source_contact):
# show warning, if target vcard version is not 3.0 or 4.0
if target_contact.version not in config.supported_vcard_versions:
print("Warning:\nThe target contact in which to merge is based on "
"vcard version %s but khard only supports the modification of "
"vcards with version 3.0 and 4.0.\nIf you proceed, the contact "
"will be converted to vcard version %s but beware: This could "
"corrupt the contact file or cause data loss."
% (target_contact.version, config.preferred_vcard_version))
while True:
input_string = input("Do you want to proceed anyway (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
# create temp files for each vcard
# source vcard
source_temp_file_name = write_temp_file(
"# merge from %s\n# Address book: %s\n# Vcard version: %s\n"
"# if you want to cancel, exit without saving\n\n%s"
% (source_contact, source_contact.address_book, source_contact.version,
source_contact.get_template()))
# target vcard
target_temp_file_name = write_temp_file(
"# merge into %s\n# Address book: %s\n# Vcard version: %s\n"
"# if you want to cancel, exit without saving\n\n%s"
% (target_contact, target_contact.address_book, target_contact.version,
target_contact.get_template()))
target_temp_file_creation = helpers.file_modification_date(
target_temp_file_name)
while True:
edit(source_temp_file_name, target_temp_file_name, merge=True)
if target_temp_file_creation == helpers.file_modification_date(
target_temp_file_name):
merged_contact = None
os.remove(source_temp_file_name)
os.remove(target_temp_file_name)
break
# load target template contents
with open(target_temp_file_name, "r") as target_tf:
merged_contact_template = target_tf.read()
# try to create contact from user input
try:
merged_contact = CarddavObject.clone_with_yaml_update(
target_contact, merged_contact_template, config.localize_dates)
except ValueError as err:
print("\n%s\n" % err)
while True:
input_string = input(
"Do you want to open the editor again (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
os.remove(source_temp_file_name)
os.remove(target_temp_file_name)
sys.exit(0)
if input_string.lower() == "y":
break
else:
os.remove(source_temp_file_name)
os.remove(target_temp_file_name)
break
# compare them
if merged_contact is None or target_contact == merged_contact:
print("Target contact unmodified\n\n%s" % target_contact.print_vcard())
sys.exit(0)
print("Merge contact {} from address book {} into contact {} from address "
"book {}\n\n".format(source_contact, source_contact.address_book,
merged_contact, merged_contact.address_book))
if delete_source_contact:
print("To be removed")
else:
print("Keep unchanged")
print("\n\n{}\n\nMerged\n\n{}\n".format(source_contact.print_vcard(),
merged_contact.print_vcard()))
while True:
input_string = input("Are you sure? (y/n): ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
return
if input_string.lower() == "y":
break
# save merged_contact to disk and delete source contact
merged_contact.write_to_file(overwrite=True)
if delete_source_contact:
source_contact.delete_vcard_file()
print("Merge successful\n\n%s" % merged_contact.print_vcard())
def copy_contact(contact, target_address_book, delete_source_contact):
source_contact_filename = ""
if delete_source_contact:
# if source file should be moved, get its file location to delete after
# successful movement
source_contact_filename = contact.filename
if not delete_source_contact or not contact.uid:
# if copy contact or contact has no uid yet
# create a new uid
contact.uid = helpers.get_random_uid()
# set destination file name
contact.filename = os.path.join(target_address_book.path,
"%s.vcf" % contact.uid)
# save
contact.write_to_file()
# delete old file
if os.path.isfile(source_contact_filename):
os.remove(source_contact_filename)
print("%s contact %s from address book %s to %s" % (
"Moved" if delete_source_contact else "Copied", contact,
contact.address_book, target_address_book))
def list_address_books(address_book_list):
table = [["Index", "Address book"]]
for index, address_book in enumerate(address_book_list):
table.append([index + 1, address_book.name])
print(helpers.pretty_print(table))
def list_contacts(vcard_list):
selected_address_books = []
for contact in vcard_list:
if contact.address_book not in selected_address_books:
selected_address_books.append(contact.address_book)
table = []
# table header
if len(selected_address_books) == 1:
print("Address book: %s" % str(selected_address_books[0]))
table_header = ["Index", "Name", "Phone", "E-Mail"]
else:
print("Address books: %s" % ', '.join(
[str(book) for book in selected_address_books]))
table_header = ["Index", "Name", "Phone", "E-Mail", "Address book"]
if config.show_uids:
table_header.append("UID")
abook_collection = AddressBookCollection(
'short uids collection', selected_address_books,
private_objects=config.private_objects,
localize_dates=config.localize_dates, skip=config.skip_unparsable)
table.append(table_header)
# table body
for index, vcard in enumerate(vcard_list):
row = []
row.append(index + 1)
if vcard.nicknames and config.show_nicknames:
if config.display == "first_name":
row.append("%s (Nickname: %s)" % (
vcard.get_first_name_last_name(), vcard.nicknames[0]))
elif config.display == "formatted_name":
row.append("{} (Nickname: {})".format(vcard.formatted_name,
vcard.nicknames[0]))
else:
row.append("%s (Nickname: %s)" % (
vcard.get_last_name_first_name(), vcard.nicknames[0]))
else:
if config.display == "first_name":
row.append(vcard.get_first_name_last_name())
elif config.display == "formatted_name":
row.append(vcard.formatted_name)
else:
row.append(vcard.get_last_name_first_name())
if vcard.phone_numbers:
phone_dict = vcard.phone_numbers
# filter out preferred phone type if set in config file
phone_keys = []
for pref_type in config.preferred_phone_number_type:
for phone_type in phone_dict:
if pref_type.lower() in phone_type.lower():
phone_keys.append(phone_type)
if phone_keys:
break
if not phone_keys:
phone_keys = [x for x in phone_dict if "pref" in x.lower()] \
or phone_dict.keys()
# get first key in alphabetical order
first_type = sorted(phone_keys, key=lambda k: k[0].lower())[0]
row.append("%s: %s" % (first_type,
sorted(phone_dict.get(first_type))[0]))
else:
row.append("")
if vcard.emails:
email_dict = vcard.emails
# filter out preferred email type if set in config file
email_keys = []
for pref_type in config.preferred_email_address_type:
for email_type in email_dict:
if pref_type.lower() in email_type.lower():
email_keys.append(email_type)
if email_keys:
break
if not email_keys:
email_keys = [x for x in email_dict if "pref" in x.lower()] \
or email_dict.keys()
# get first key in alphabetical order
first_type = sorted(email_keys, key=lambda k: k[0].lower())[0]
row.append("%s: %s" % (first_type,
sorted(email_dict.get(first_type))[0]))
else:
row.append("")
if len(selected_address_books) > 1:
row.append(vcard.address_book.name)
if config.show_uids:
if abook_collection.get_short_uid(vcard.uid):
row.append(abook_collection.get_short_uid(vcard.uid))
else:
row.append("")
table.append(row)
print(helpers.pretty_print(table))
def list_birthdays(birthday_list):
table = [["Name", "Birthday"]]
for row in birthday_list:
table.append(row.split("\t"))
print(helpers.pretty_print(table))
def list_phone_numbers(phone_number_list):
table = [["Name", "Type", "Phone"]]
for row in phone_number_list:
table.append(row.split("\t"))
print(helpers.pretty_print(table))
def list_post_addresses(post_address_list):
table = [["Name", "Type", "Post address"]]
for row in post_address_list:
table.append(row.split("\t"))
print(helpers.pretty_print(table))
def list_email_addresses(email_address_list):
table = [["Name", "Type", "E-Mail"]]
for row in email_address_list:
table.append(row.split("\t"))
print(helpers.pretty_print(table))
def choose_address_book_from_list(header_string, address_book_list):
if not address_book_list:
return None
if len(address_book_list) == 1:
return address_book_list[0]
print(header_string)
list_address_books(address_book_list)
while True:
try:
input_string = input("Enter Index: ")
if input_string in ["", "q", "Q"]:
print("Canceled")
sys.exit(0)
addr_index = int(input_string)
if addr_index > 0:
# make sure the address book is loaded afterwards
selected_address_book = address_book_list[addr_index - 1]
else:
raise ValueError
except (EOFError, IndexError, ValueError):
print("Please enter an index value between 1 and %d or nothing"
" to exit." % len(address_book_list))
else:
break
print("")
return selected_address_book
def choose_vcard_from_list(header_string, vcard_list, include_none=False):
if not vcard_list:
return None
if len(vcard_list) == 1 and not include_none:
return vcard_list[0]
print(header_string)
list_contacts(vcard_list)
while True:
try:
prompt_string = "Enter Index ({}q to quit): ".format(
"0 for None, " if include_none else "")
input_string = input(prompt_string)
if input_string in ["", "q", "Q"]:
print("Canceled")
sys.exit(0)
addr_index = int(input_string)
if addr_index == 0 and include_none:
return None
if addr_index > 0:
selected_vcard = vcard_list[addr_index - 1]
else:
raise ValueError
except (EOFError, IndexError, ValueError):
print("Please enter an index value between 1 and {} or nothing"
" to exit.".format(len(vcard_list)))
else:
break
print("")
return selected_vcard
def get_contact_list_by_user_selection(address_books, search, strict_search):
"""returns a list of CarddavObject objects
:param address_books: list of selected address books
:type address_books: list(address_book.AddressBook)
:param search: filter contact list
:type search: str
:param strict_search: if True, search only in full name field
:type strict_search: bool
:returns: list of CarddavObject objects
:rtype: list(CarddavObject)
"""
return get_contacts(address_books, search,
"name" if strict_search else "all", config.reverse,
config.group_by_addressbook, config.sort)
def get_contacts(address_books, query, method="all", reverse=False,
group=False, sort="first_name"):
"""Get a list of contacts from one or more address books.
:param address_books: the address books to search
:type address_books: list(address_book.AddressBook)
:param str query: a search query to select contacts
:param str method: the search method, one of "all", "name" or "uid"
:param bool reverse: reverse the order of the returned contacts
:param bool group: group results by address book
:param str sort: the field to use for sorting, one of "first_name",
"last_name", "formatted_name"
:returns: contacts from the address_books that match the query
:rtype: list(CarddavObject)
"""
# Search for the contacts in all address books.
contacts = []
for address_book in address_books:
contacts.extend(address_book.search(query, method=method))
# Sort the contacts.
if group:
if sort == "first_name":
return sorted(contacts, reverse=reverse, key=lambda x: (
unidecode(x.address_book.name).lower(),
unidecode(x.get_first_name_last_name()).lower()))
if sort == "last_name":
return sorted(contacts, reverse=reverse, key=lambda x: (
unidecode(x.address_book.name).lower(),
unidecode(x.get_last_name_first_name()).lower()))
if sort == "formatted_name":
return sorted(contacts, reverse=reverse, key=lambda x: (
unidecode(x.address_book.name).lower(),
unidecode(x.formatted_name.lower())))
else:
if sort == "first_name":
return sorted(contacts, reverse=reverse, key=lambda x:
unidecode(x.get_first_name_last_name()).lower())
if sort == "last_name":
return sorted(contacts, reverse=reverse, key=lambda x:
unidecode(x.get_last_name_first_name()).lower())
if sort == "formatted_name":
return sorted(contacts, reverse=reverse, key=lambda x:
unidecode(x.formatted_name.lower()))
raise ValueError('sort must be "first_name", "last_name" or '
'"formatted_name" not {}.'.format(sort))
def load_address_books(names, config, search_queries):
"""Load all address books with the given names from the config.
:param names: the address books to load
:type names: list(str)
:param config: the config instance to use when looking up address books
:type config: config.Config
:param search_queries: a mapping of address book names to search queries
:type search_queries: dict
:yields: the loaded address books
:ytype: addressbook.AddressBook
"""
all_names = {str(book) for book in config.abooks}
if not names:
names = all_names
elif not all_names.issuperset(names):
sys.exit('Error: The entered address books "{}" do not exist.\n'
'Possible values are: {}'.format(
'", "'.join(set(names) - all_names),
', '.join(all_names)))
# load address books which are defined in the configuration file
for name in names:
address_book = config.abook.get_abook(name)
try:
address_book.load(
search_queries[address_book.name],
search_in_source_files=config.search_in_source_files)
except AddressBookParseError as err:
sys.exit("{}\nUse --debug for more information or "
"--skip-unparsable to proceed".format(err))
yield address_book
def prepare_search_queries(args):
"""Prepare the search query string from the given command line args.
Each address book can get a search query string to filter vcards befor
loading them. Depending on the question if the address book is used for
source or target searches different regexes have to be combined into one
search string.
:param args: the parsed command line
:type args: argparse.Namespace
:returns: a dict mapping abook names to their loading queries, if the query
is None it means that all cards should be loaded
:rtype: dict(str:str or None)
"""
# get all possible search queries for address book parsing
source_queries = []
target_queries = []
if "source_search_terms" in args and args.source_search_terms:
escaped_term = ".*".join(re.escape(x)
for x in args.source_search_terms)
source_queries.append(escaped_term)
args.source_search_terms = escaped_term
if "search_terms" in args and args.search_terms:
escaped_term = ".*".join(re.escape(x) for x in args.search_terms)
source_queries.append(escaped_term)
args.search_terms = escaped_term
if "target_contact" in args and args.target_contact:
escaped_term = re.escape(args.target_contact)
target_queries.append(escaped_term)
args.target_contact = escaped_term
if "uid" in args and args.uid:
source_queries.append(args.uid)
if "target_uid" in args and args.target_uid:
target_queries.append(args.target_uid)
# create and return regexp, None means that no query is given and hence all
# contacts should be searched.
source_queries = "^.*(%s).*$" % ')|('.join(source_queries) \
if source_queries else None
target_queries = "^.*(%s).*$" % ')|('.join(target_queries) \
if target_queries else None
logging.debug('Created source query regex: %s', source_queries)
logging.debug('Created target query regex: %s', target_queries)
# Get all possible search queries for address book parsing, always
# depending on the fact if the address book is used to find source or
# target contacts or both.
queries = {abook.name: [] for abook in config.abook._abooks}
for name in queries:
if "addressbook" in args and name in args.addressbook:
queries[name].append(source_queries)
if "target_addressbook" in args and name in args.target_addressbook:
queries[name].append(target_queries)
# If None is included in the search queries of an address book it means
# that either no source or target query was given and this address book
# is affected by this. All contacts should be loaded from that address
# book.
if None in queries[name]:
queries[name] = None
else:
queries[name] = "({})".format(')|('.join(queries[name]))
logging.debug('Created query regex: %s', queries)
return queries
def generate_contact_list(args):
"""TODO: Docstring for generate_contact_list.
:param args: the command line arguments
:type args: argparse.Namespace
:returns: the contacts for further processing (TODO)
:rtype: list(TODO)
"""
# fill contact list
vcard_list = []
if "uid" in args and args.uid:
# If an uid was given we use it to find the contact.
logging.debug("args.uid=%s", args.uid)
# set search terms to the empty query to prevent errors in
# phone and email actions
args.search_terms = ".*"
vcard_list = get_contacts(args.addressbook, args.uid, method="uid")
# We require that the uid given can uniquely identify a contact.
if not vcard_list:
sys.exit("Found no contact for {}uid {}".format(
"source " if args.action == "merge" else "", args.uid))
elif len(vcard_list) != 1:
print("Found multiple contacts for {}uid {}".format(
"source " if args.action == "merge" else "", args.uid))
for vcard in vcard_list:
print(" {}: {}".format(vcard, vcard.uid))
sys.exit(1)
else:
# No uid was given so we try to use the search terms to select a
# contact.
if "source_search_terms" in args:
# exception for merge command
if args.source_search_terms:
args.search_terms = args.source_search_terms
else:
args.search_terms = ".*"
elif "search_terms" in args:
if args.search_terms:
args.search_terms = args.search_terms
else:
args.search_terms = ".*"
else:
# If no search terms where given on the command line we match
# everything with the empty search pattern.
args.search_terms = ".*"
logging.debug("args.search_terms=%s", args.search_terms)
vcard_list = get_contact_list_by_user_selection(
args.addressbook, args.search_terms,
args.strict_search if "strict_search" in args else False)
return vcard_list
def new_subcommand(selected_address_books, input_from_stdin_or_file,
open_editor):
"""Create a new contact.
:param selected_address_books: a list of addressbooks that were selected on
the command line
:type selected_address_books: list of address_book.AddressBook
:param input_from_stdin_or_file: the data for the new contact as a yaml
formatted string
:type input_from_stdin_or_file: str
:param open_editor: whether to open the new contact in the edior after
creation
:type open_editor: bool
:returns: None
:rtype: None
"""
# ask for address book, in which to create the new contact
selected_address_book = choose_address_book_from_list(
"Select address book for new contact", selected_address_books)
if selected_address_book is None:
sys.exit("Error: address book list is empty")
# if there is some data in stdin
if input_from_stdin_or_file:
# create new contact from stdin
try:
new_contact = CarddavObject.from_yaml(
selected_address_book, input_from_stdin_or_file,
config.private_objects, config.preferred_vcard_version,
config.localize_dates)
except ValueError as err:
sys.exit(err)
else:
new_contact.write_to_file()
if open_editor:
modify_existing_contact(new_contact)
else:
print("Creation successful\n\n%s" % new_contact.print_vcard())
else:
create_new_contact(selected_address_book)
def add_email_subcommand(text, abooks):
"""Add a new email address to contacts, creating new contacts if necessary.
:param str text: the input text to search for the new email
:param abooks: the addressbooks that were selected on the command line
:type abooks: list of address_book.AddressBook
:returns: None
:rtype: None
"""
# get name and email address
message = message_from_string(text, policy=SMTP_POLICY)
print("Khard: Add email address to contact")
if not message['From'] or not message['From'].addresses:
sys.exit("Found no email address")
email_address = message['From'].addresses[0].addr_spec
name = message['From'].addresses[0].display_name
print("Email address: %s" % email_address)
if not name:
name = input("Contact's name: ")
# search for an existing contact
selected_vcard = choose_vcard_from_list(
"Select contact for the found e-mail address",
get_contact_list_by_user_selection(abooks, name, True))
if selected_vcard is None:
# create new contact
while True:
input_string = input("Contact %s does not exist. Do you want "
"to create it (y/n)? " % name)
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
# ask for address book, in which to create the new contact
selected_address_book = choose_address_book_from_list(
"Select address book for new contact", config.abooks)
if selected_address_book is None:
sys.exit("Error: address book list is empty")
# ask for name and organisation of new contact
while True:
first_name = input("First name: ")
last_name = input("Last name: ")
organisation = input("Organisation: ")
if not first_name and not last_name and not organisation:
print("Error: All fields are empty.")
else:
break
selected_vcard = CarddavObject.from_yaml(
selected_address_book,
"First name : %s\nLast name : %s\nOrganisation : %s" % (
first_name, last_name, organisation),
config.private_objects, config.preferred_vcard_version,
config.localize_dates)
# check if the contact already contains the email address
for _, email_list in sorted(selected_vcard.emails.items(),
key=lambda k: k[0].lower()):
for email in email_list:
if email == email_address:
print("The contact %s already contains the email address %s" %
(selected_vcard, email_address))
sys.exit(0)
# ask for confirmation again
while True:
input_string = input(
"Do you want to add the email address %s to the contact %s (y/n)? "
% (email_address, selected_vcard))
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
# ask for the email label
print("\nAdding email address %s to contact %s\n"
"Enter email label\n"
" vcard 3.0: At least one of home, internet, pref, work, x400\n"
" vcard 4.0: At least one of home, internet, pref, work\n"
" Or a custom label (only letters" %
(email_address, selected_vcard))
while True:
label = input("email label [internet]: ") or "internet"
try:
selected_vcard.add_email(label, email_address)
except ValueError as err:
print(err)
else:
break
# save to disk
selected_vcard.write_to_file(overwrite=True)
print("Done.\n\n%s" % selected_vcard.print_vcard())
def birthdays_subcommand(vcard_list, parsable):
"""Print birthday contact table.
:param vcard_list: the vcards to search for matching entries which should
be printed
:type vcard_list: list of carddav_object.CarddavObject
:param parsable: machine readable output: columns devided by tabulator (\t)
:type parsable: bool
:returns: None
:rtype: None
"""
# filter out contacts without a birthday date
vcard_list = [
vcard for vcard in vcard_list if vcard.birthday is not None]
# sort by date (month and day)
# The sort function should work for strings and datetime objects. All
# strings will besorted before any datetime objects.
vcard_list.sort(key=lambda x: (x.birthday.month, x.birthday.day)
if isinstance(x.birthday, datetime.datetime)
else (0, 0, x.birthday))
# add to string list
birthday_list = []
for vcard in vcard_list:
date = vcard.birthday
if parsable:
date = "%04d.%02d.%02d" % (date.year, date.month, date.day)
if config.display == "first_name":
birthday_list.append("{}\t{}".format(
date, vcard.get_first_name_last_name()))
elif config.display == "formatted_name":
birthday_list.append("{}\t{}".format(date,
vcard.formatted_name))
else:
birthday_list.append("{}\t{}".format(
date, vcard.get_last_name_first_name()))
else:
date = vcard.get_formatted_birthday()
if config.display == "first_name":
birthday_list.append("{}\t{}".format(
vcard.get_first_name_last_name(), date))
elif config.display == "formatted_name":
birthday_list.append("{}\t{}".format(vcard.formatted_name,
date))
else:
birthday_list.append("{}\t{}".format(
vcard.get_last_name_first_name(), date))
if birthday_list:
if parsable:
print('\n'.join(birthday_list))
else:
list_birthdays(birthday_list)
else:
if not parsable:
print("Found no birthdays")
sys.exit(1)
def phone_subcommand(search_terms, vcard_list, parsable):
"""Print a phone application friendly contact table.
:param search_terms: used as search term to filter the contacts before
printing
:type search_terms: str
:param vcard_list: the vcards to search for matching entries which should
be printed
:type vcard_list: list of carddav_object.CarddavObject
:param parsable: machine readable output: columns devided by tabulator (\t)
:type parsable: bool
:returns: None
:rtype: None
"""
all_phone_numbers_list = []
matching_phone_number_list = []
for vcard in vcard_list:
for type, number_list in sorted(vcard.phone_numbers.items(),
key=lambda k: k[0].lower()):
for number in sorted(number_list):
if config.display == "first_name":
name = vcard.get_first_name_last_name()
elif config.display == "last_name":
name = vcard.get_last_name_first_name()
else:
name = vcard.formatted_name
# create output lines
line_formatted = "\t".join([name, type, number])
line_parsable = "\t".join([number, name, type])
if parsable:
# parsable option: start with phone number
phone_number_line = line_parsable
else:
# else: start with name
phone_number_line = line_formatted
if re.search(search_terms,
"%s\n%s" % (line_formatted, line_parsable),
re.IGNORECASE | re.DOTALL):
matching_phone_number_list.append(phone_number_line)
elif len(re.sub(r"\D", "", search_terms)) >= 3:
# The user likely searches for a phone number cause the
# search string contains at least three digits. So we
# remove all non-digit chars from the phone number field
# and match against that.
if re.search(re.sub(r"\D", "", search_terms),
re.sub(r"\D", "", number), re.IGNORECASE):
matching_phone_number_list.append(phone_number_line)
# collect all phone numbers in a different list as fallback
all_phone_numbers_list.append(phone_number_line)
if matching_phone_number_list:
if parsable:
print('\n'.join(matching_phone_number_list))
else:
list_phone_numbers(matching_phone_number_list)
elif all_phone_numbers_list:
if parsable:
print('\n'.join(all_phone_numbers_list))
else:
list_phone_numbers(all_phone_numbers_list)
else:
if not parsable:
print("Found no phone numbers")
sys.exit(1)
def post_address_subcommand(search_terms, vcard_list, parsable):
"""Print a contact table. with all postal / mailing addresses
:param search_terms: used as search term to filter the contacts before
printing
:type search_terms: str
:param vcard_list: the vcards to search for matching entries which should
be printed
:type vcard_list: list of carddav_object.CarddavObject
:param parsable: machine readable output: columns devided by tabulator (\t)
:type parsable: bool
:returns: None
:rtype: None
"""
all_post_address_list = []
matching_post_address_list = []
for vcard in vcard_list:
# vcard name
if config.display == "first_name":
name = vcard.get_first_name_last_name()
elif config.display == "last_name":
name = vcard.get_last_name_first_name()
else:
name = vcard.formatted_name
# create post address line list
post_address_line_list = []
if parsable:
for type, post_address_list in sorted(
vcard.get_post_addresses().items(),
key=lambda k: k[0].lower()):
for post_address in post_address_list:
post_address_line_list.append(
"\t".join([str(post_address), name, type]))
else:
for type, post_address_list in sorted(
vcard.get_formatted_post_addresses().items(),
key=lambda k: k[0].lower()):
for post_address in sorted(post_address_list):
post_address_line_list.append(
"\t".join([name, type, post_address]))
# add to matching and all post address lists
for post_address_line in post_address_line_list:
if re.search(search_terms,
"%s\n%s" % (post_address_line, post_address_line),
re.IGNORECASE | re.DOTALL):
matching_post_address_list.append(post_address_line)
# collect all post addresses in a different list as fallback
all_post_address_list.append(post_address_line)
if matching_post_address_list:
if parsable:
print('\n'.join(matching_post_address_list))
else:
list_post_addresses(matching_post_address_list)
elif all_post_address_list:
if parsable:
print('\n'.join(all_post_address_list))
else:
list_post_addresses(all_post_address_list)
else:
if not parsable:
print("Found no post adresses")
sys.exit(1)
def email_subcommand(search_terms, vcard_list, parsable, remove_first_line):
"""Print a mail client friendly contacts table that is compatible with the
default format used by mutt.
Output format:
.. code-block:: text
single line of text
email_address\tname\ttype
email_address\tname\ttype
[...]
:param search_terms: used as search term to filter the contacts before
printing
:type search_terms: str
:param vcard_list: the vcards to search for matching entries which should
be printed
:type vcard_list: list of carddav_object.CarddavObject
:param parsable: machine readable output: columns devided by tabulator (\t)
:type parsable: bool
:param remove_first_line: remove first line (searching for '' ...)
:type remove_first_line: bool
:returns: None
:rtype: None
"""
matching_email_address_list = []
all_email_address_list = []
for vcard in vcard_list:
for type, email_list in sorted(vcard.emails.items(),
key=lambda k: k[0].lower()):
for email in sorted(email_list):
if config.display == "first_name":
name = vcard.get_first_name_last_name()
elif config.display == "last_name":
name = vcard.get_last_name_first_name()
else:
name = vcard.formatted_name
# create output lines
line_formatted = "\t".join([name, type, email])
line_parsable = "\t".join([email, name, type])
if parsable:
# parsable option: start with email address
email_address_line = line_parsable
else:
# else: start with name
email_address_line = line_formatted
if re.search(search_terms,
"%s\n%s" % (line_formatted, line_parsable),
re.IGNORECASE | re.DOTALL):
matching_email_address_list.append(email_address_line)
# collect all email addresses in a different list as fallback
all_email_address_list.append(email_address_line)
if matching_email_address_list:
if parsable:
if not remove_first_line:
# at least mutt requires that line
print("searching for '%s' ..." % search_terms)
print('\n'.join(matching_email_address_list))
else:
list_email_addresses(matching_email_address_list)
elif all_email_address_list:
if parsable:
if not remove_first_line:
# at least mutt requires that line
print("searching for '%s' ..." % search_terms)
print('\n'.join(all_email_address_list))
else:
list_email_addresses(all_email_address_list)
else:
if not parsable:
print("Found no email addresses")
elif not remove_first_line:
print("searching for '%s' ..." % search_terms)
sys.exit(1)
def list_subcommand(vcard_list, parsable):
"""Print a user friendly contacts table.
:param vcard_list: the vcards to print
:type vcard_list: list of carddav_object.CarddavObject
:param parsable: machine readable output: columns devided by tabulator (\t)
:type parsable: bool
:returns: None
:rtype: None
"""
if not vcard_list:
if not parsable:
print("Found no contacts")
sys.exit(1)
elif parsable:
contact_line_list = []
for vcard in vcard_list:
if config.display == "first_name":
name = vcard.get_first_name_last_name()
elif config.display == "last_name":
name = vcard.get_last_name_first_name()
else:
name = vcard.formatted_name
contact_line_list.append('\t'.join([vcard.uid, name,
vcard.address_book.name]))
print('\n'.join(contact_line_list))
else:
list_contacts(vcard_list)
def modify_subcommand(selected_vcard, input_from_stdin_or_file, open_editor,
source=False):
"""Modify a contact in an external editor.
:param selected_vcard: the contact to modify
:type selected_vcard: carddav_object.CarddavObject
:param input_from_stdin_or_file: new data from stdin (or a file) that
should be incorperated into the contact, this should be a yaml
formatted string
:type input_from_stdin_or_file: str
:param open_editor: whether to open the new contact in the edior after
creation
:type open_editor: bool
:param source: edit the source file or a yaml version?
:type source: bool
:returns: None
:rtype: None
"""
if source:
edit(selected_vcard.filename)
return
# show warning, if vcard version of selected contact is not 3.0 or 4.0
if selected_vcard.version not in config.supported_vcard_versions:
print("Warning:\nThe selected contact is based on vcard version %s "
"but khard only supports the creation and modification of vcards"
" with version 3.0 and 4.0.\nIf you proceed, the contact will be"
" converted to vcard version %s but beware: This could corrupt "
"the contact file or cause data loss."
% (selected_vcard.version, config.preferred_vcard_version))
while True:
input_string = input("Do you want to proceed anyway (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
# if there is some data in stdin
if input_from_stdin_or_file:
# create new contact from stdin
try:
new_contact = CarddavObject.clone_with_yaml_update(
selected_vcard, input_from_stdin_or_file,
config.localize_dates)
except ValueError as err:
sys.exit(err)
if selected_vcard == new_contact:
print("Nothing changed\n\n%s" % new_contact.print_vcard())
else:
print("Modification\n\n%s\n" % new_contact.print_vcard())
while True:
input_string = input("Do you want to proceed (y/n)? ")
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
break
if input_string.lower() == "y":
new_contact.write_to_file(overwrite=True)
if open_editor:
modify_existing_contact(new_contact)
else:
print("Done")
break
else:
modify_existing_contact(selected_vcard)
def remove_subcommand(selected_vcard, force):
"""Remove a contact from the addressbook.
:param selected_vcard: the contact to delete
:type selected_vcard: carddav_object.CarddavObject
:param force: delete without confirmation
:type force: bool
:returns: None
:rtype: None
"""
if not force:
while True:
input_string = input(
"Deleting contact %s from address book %s. Are you sure? "
"(y/n): " % (selected_vcard, selected_vcard.address_book))
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
selected_vcard.delete_vcard_file()
print("Contact %s deleted successfully" % selected_vcard.formatted_name)
def merge_subcommand(vcard_list, selected_address_books, search_terms,
target_uid):
"""Merge two contacts into one.
:param vcard_list: the vcards from which to choose contacts for mergeing
:type vcard_list: list of carddav_object.CarddavObject
:param selected_address_books: the addressbooks to use to find the target
contact
:type selected_address_books: list(addressbook.AddressBook)
:param search_terms: the search terms to find the target contact
:type search_terms: str
:param target_uid: the uid of the target contact or empty
:type target_uid: str
:returns: None
:rtype: None
"""
# Check arguments.
if target_uid != "" and search_terms != "":
sys.exit("You can not specify a target uid and target search terms "
"for a merge.")
# Find possible target contacts.
if target_uid != "":
target_vcards = get_contacts(selected_address_books, target_uid,
method="uid")
# We require that the uid given can uniquely identify a contact.
if len(target_vcards) != 1:
if not target_vcards:
print("Found no contact for target uid %s" % target_uid)
else:
print("Found multiple contacts for target uid %s" % target_uid)
for vcard in target_vcards:
print(" %s: %s" % (vcard, vcard.uid))
sys.exit(1)
else:
target_vcards = get_contact_list_by_user_selection(
selected_address_books, search_terms, False)
# get the source vcard, from which to merge
source_vcard = choose_vcard_from_list("Select contact from which to merge",
vcard_list)
if source_vcard is None:
sys.exit("Found no source contact for merging")
else:
print("Merge from %s from address book %s\n\n"
% (source_vcard, source_vcard.address_book))
# get the target vcard, into which to merge
target_vcard = choose_vcard_from_list("Select contact into which to merge",
target_vcards)
if target_vcard is None:
sys.exit("Found no target contact for merging")
else:
print("Merge into %s from address book %s\n\n"
% (target_vcard, target_vcard.address_book))
# merging
if source_vcard == target_vcard:
print("The selected contacts are already identical")
else:
merge_existing_contacts(source_vcard, target_vcard, True)
def copy_or_move_subcommand(action, vcard_list, target_address_book_list):
"""Copy or move a contact to a different address book.
:action: the string "copy" or "move" to indicate what to do
:type action: str
:param vcard_list: the contact list from which to select one for the action
:type vcard_list: list of carddav_object.CarddavObject
:param target_address_book_list: the list of target address books
:type target_address_book_list: list(addressbook.AddressBook)
:returns: None
:rtype: None
"""
# get the source vcard, which to copy or move
source_vcard = choose_vcard_from_list(
"Select contact to %s" % action.title(), vcard_list)
if source_vcard is None:
sys.exit("Found no contact")
else:
print("%s contact %s from address book %s"
% (action.title(), source_vcard, source_vcard.address_book))
# get target address book
if len(target_address_book_list) == 1 \
and target_address_book_list[0] == source_vcard.address_book:
sys.exit("The address book %s already contains the contact %s"
% (target_address_book_list[0], source_vcard))
else:
available_address_books = [abook for abook in target_address_book_list
if abook != source_vcard.address_book]
selected_target_address_book = choose_address_book_from_list(
"Select target address book", available_address_books)
if selected_target_address_book is None:
sys.exit("Error: address book list is empty")
# check if a contact already exists in the target address book
target_vcard = choose_vcard_from_list(
"Select target contact to overwrite (or None to add a new entry)",
get_contact_list_by_user_selection([selected_target_address_book],
source_vcard.formatted_name, True),
True)
# If the target contact doesn't exist, move or copy the source contact into
# the target address book without further questions.
if target_vcard is None:
copy_contact(source_vcard, selected_target_address_book,
action == "move")
elif source_vcard == target_vcard:
# source and target contact are identical
print("Target contact: %s" % target_vcard)
if action == "move":
copy_contact(source_vcard, selected_target_address_book, True)
else:
print("The selected contacts are already identical")
else:
# source and target contacts are different
# either overwrite the target one or merge into target contact
print("The address book %s already contains the contact %s\n\n"
"Source\n\n%s\n\nTarget\n\n%s\n\nPossible actions:\n"
" a: %s anyway\n"
" m: Merge from source into target contact\n"
" o: Overwrite target contact\n"
" q: Quit" % (
target_vcard.address_book, source_vcard,
source_vcard.print_vcard(), target_vcard.print_vcard(),
action.title()))
while True:
input_string = input("Your choice: ")
if input_string.lower() == "a":
copy_contact(source_vcard, selected_target_address_book,
action == "move")
break
if input_string.lower() == "o":
copy_contact(source_vcard, selected_target_address_book,
action == "move")
target_vcard.delete_vcard_file()
break
if input_string.lower() == "m":
merge_existing_contacts(source_vcard, target_vcard,
action == "move")
break
if input_string.lower() in ["", "q"]:
print("Canceled")
break
def main(argv=sys.argv[1:]):
args, conf = cli.init(argv)
# store the config instance in the module level variable
global config
config = conf
# Check some of the simpler subcommands first. These don't have any
# options and can directly be run.
if args.action == "addressbooks":
print('\n'.join(str(book) for book in config.abooks))
return
if args.action == "template":
print("# Contact template for khard version %s\n#\n"
"# Use this yaml formatted template to create a new contact:\n"
"# either with: khard new -a address_book -i template.yaml\n"
"# or with: cat template.yaml | khard new -a address_book\n"
"\n%s" % (khard_version, helpers.get_new_contact_template(
config.private_objects)))
return
search_queries = prepare_search_queries(args)
# load address books
if "addressbook" in args:
args.addressbook = list(load_address_books(args.addressbook, config,
search_queries))
if "target_addressbook" in args:
args.target_addressbook = list(load_address_books(
args.target_addressbook, config, search_queries))
vcard_list = generate_contact_list(args)
if args.action == "filename":
print('\n'.join(contact.filename for contact in vcard_list))
return
# read from template file or stdin if available
input_from_stdin_or_file = ""
if "input_file" in args:
if args.input_file != "-":
# try to read from specified input file
try:
with open(args.input_file, "r") as infile:
input_from_stdin_or_file = infile.read()
except IOError as err:
sys.exit("Error: %s\n File: %s" % (err.strerror,
err.filename))
elif not sys.stdin.isatty():
# try to read from stdin
try:
input_from_stdin_or_file = sys.stdin.read()
except IOError:
sys.exit("Error: Can't read from stdin")
# try to reopen console
# otherwise further user interaction is not possible (for example
# selecting a contact from the contact table)
try:
sys.stdin = open('/dev/tty')
except IOError:
pass
if args.action == "new":
new_subcommand(args.addressbook, input_from_stdin_or_file,
args.open_editor)
elif args.action == "add-email":
add_email_subcommand(input_from_stdin_or_file, args.addressbook)
elif args.action == "birthdays":
birthdays_subcommand(vcard_list, args.parsable)
elif args.action == "phone":
phone_subcommand(args.search_terms, vcard_list, args.parsable)
elif args.action == "postaddress":
post_address_subcommand(args.search_terms, vcard_list, args.parsable)
elif args.action == "email":
email_subcommand(args.search_terms, vcard_list,
args.parsable, args.remove_first_line)
elif args.action == "list":
list_subcommand(vcard_list, args.parsable)
elif args.action in ["show", "edit", "remove"]:
selected_vcard = choose_vcard_from_list(
"Select contact for %s action" % args.action.title(), vcard_list)
if selected_vcard is None:
sys.exit("Found no contact")
if args.action == "show":
if args.format == "pretty":
output = selected_vcard.print_vcard()
elif args.format == "vcard":
output = open(selected_vcard.filename).read()
else:
output = "# Contact template for khard version {}\n" \
"# Name: {}\n# Vcard version: {}\n\n{}".format(
khard_version, selected_vcard,
selected_vcard.version,
selected_vcard.get_template())
args.output_file.write(output)
elif args.action == "edit":
modify_subcommand(selected_vcard, input_from_stdin_or_file,
args.open_editor, args.format == 'vcard')
elif args.action == "remove":
remove_subcommand(selected_vcard, args.force)
elif args.action == "merge":
merge_subcommand(vcard_list, args.target_addressbook,
args.target_contact, args.target_uid)
elif args.action in ["copy", "move"]:
copy_or_move_subcommand(
args.action, vcard_list, args.target_addressbook)
khard-0.15.1/khard/object_type.py 0000664 0000000 0000000 00000000270 13560061035 0016637 0 ustar 00root root 0000000 0000000 """Helper module for validating typed vcard properties"""
from enum import Enum
class ObjectType(Enum):
string = 1
list_with_strings = 2
string_or_list_with_strings = 3
khard-0.15.1/misc/ 0000775 0000000 0000000 00000000000 13560061035 0013621 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/davcontroller/ 0000775 0000000 0000000 00000000000 13560061035 0016477 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/davcontroller/davcontroller.py 0000664 0000000 0000000 00000012340 13560061035 0021727 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python2
# -*- coding: utf-8 -*-
import sys
import argparse
from caldavclientlibrary.client.account import CalDAVAccount
from caldavclientlibrary.protocol.url import URL
def main():
# create the args parser
parser = argparse.ArgumentParser(
description="Davcontroller creates, lists and removes caldav "
"calendars and carddav address books from server")
parser.add_argument("-v", "--version", action="store_true",
help="Get current program version")
parser.add_argument("-H", "--hostname", default="")
parser.add_argument("-p", "--port", default="")
parser.add_argument("-u", "--username", default="")
parser.add_argument("-P", "--password", default="")
parser.add_argument("action", nargs="?", default="",
help="Actions: new-addressbook, new-calendar, list, "
"and remove")
args = parser.parse_args()
# version
if args.version is True:
print("davcontroller version 0.1")
sys.exit(0)
# check the server's parameter
if args.hostname == "":
print("Missing host name")
sys.exit(1)
if args.port == "":
print("Missing host port")
sys.exit(1)
if args.username == "":
print("Missing user name")
sys.exit(1)
if args.password == "":
print("Missing password")
sys.exit(1)
if args.action == "":
print("Please specify an action. Possible values are: "
"new-addressbook, new-calendar, list and remove")
sys.exit(1)
elif args.action not in ["new-addressbook", "new-calendar", "list",
"remove"]:
print("The specified action \"%s\" is not supported. Possible values "
"are: new-addressbook, new-calendar, list and remove" %
args.action)
sys.exit(1)
# try to connect to the caldav server
account = CalDAVAccount(args.hostname, args.port, ssl=True,
user=args.username, pswd=args.password, root="/",
principal="")
if account.getPrincipal() is None:
print("Error: Connection refused")
sys.exit(2)
if args.action in ["list", "remove"]:
# address books
print("Available address books")
addressbook_list = account.getPrincipal().listAddressBooks()
if addressbook_list.__len__() == 0:
print("No address books found")
else:
for index, addressbook in enumerate(addressbook_list):
print("%d. %s" % (index+1, addressbook.getDisplayName()))
print()
# calendars
print("Available calendars")
calendar_list = account.getPrincipal().listCalendars()
if calendar_list.__len__() == 0:
print("No calendars found")
else:
for index, calendar in enumerate(calendar_list):
print("%d. %s" % (addressbook_list.__len__() + index + 1,
calendar.getDisplayName()))
item_list = addressbook_list + calendar_list
if item_list.__len__() == 0:
sys.exit(2)
if args.action == "remove":
print()
while True:
input_string = input("Enter Id: ")
if input_string == "":
sys.exit(0)
try:
id = int(input_string)
if id > 0 and id <= item_list.__len__():
break
except ValueError:
pass
print("Please enter an Id between 1 and %d or nothing to exit."
% item_list.__len__())
item = item_list[id-1]
while True:
input_string = input("Deleting %s. Are you sure? (y/n): " %
item.getDisplayName())
if input_string.lower() in ["", "n", "q"]:
print("Canceled")
sys.exit(0)
if input_string.lower() == "y":
break
account.session.deleteResource(URL(url=item.path))
if args.action.startswith("new-"):
# get full host url
host_url = "https://%s:%s" % (account.session.server,
account.session.port)
# enter new name
if args.action == "new-addressbook":
input_string = input("Enter new address book name or nothing to "
"cancel: ")
else:
input_string = input("Enter new calendar name or nothing to "
"cancel: ")
if input_string == "":
sys.exit(0)
res_name = input_string
res_path = input_string.replace(" ", "_").lower()
# create new resource
if args.action == "new-addressbook":
u = URL(url=host_url + account.principal.adbkhomeset[0].path +
res_path + "/")
account.session.makeAddressBook(u, res_name)
else:
u = URL(url=host_url + account.principal.homeset[0].path +
res_path + "/")
account.session.makeCalendar(u, res_name)
print("Creation successful")
if __name__ == "__main__":
main()
khard-0.15.1/misc/khard/ 0000775 0000000 0000000 00000000000 13560061035 0014712 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/khard/khard.conf.example 0000664 0000000 0000000 00000003252 13560061035 0020306 0 ustar 00root root 0000000 0000000 # example configuration file for khard version > 0.14.0
# place it under ~/.config/khard/khard.conf
[addressbooks]
[[family]]
path = ~/.contacts/family/
[[friends]]
path = ~/.contacts/friends/
[general]
debug = no
default_action = list
# These are either strings or comma seperated lists
editor = vim, -i, NONE
merge_editor = vimdiff
[contact table]
# display names by first or last name: first_name / last_name / formatted_name
display = first_name
# group by address book: yes / no
group_by_addressbook = no
# reverse table ordering: yes / no
reverse = no
# append nicknames to name column: yes / no
show_nicknames = no
# show uid table column: yes / no
show_uids = yes
# sort by first or last name: first_name / last_name / formatted_name
sort = last_name
# localize dates: yes / no
localize_dates = yes
# set a comma separated list of preferred phone number types in descending priority
# or nothing for non-filtered alphabetical order
preferred_phone_number_type = pref, cell, home
# set a comma separated list of preferred email address types in descending priority
# or nothing for non-filtered alphabetical order
preferred_email_address_type = pref, work, home
[vcard]
# extend contacts with your own private objects
# these objects are stored with a leading "X-" before the object name in the vcard files
# every object label may only contain letters, digits and the - character
# example:
# private_objects = Jabber, Skype, Twitter
private_objects = Jabber, Skype, Twitter
# preferred vcard version: 3.0 / 4.0
preferred_version = 3.0
# Look into source vcf files to speed up search queries: yes / no
search_in_source_files = no
# skip unparsable vcard files: yes / no
skip_unparsable = no
khard-0.15.1/misc/khard/template_for_contact_creation.yaml 0000664 0000000 0000000 00000006344 13560061035 0023665 0 ustar 00root root 0000000 0000000 # Contact template for khard version 0.15.0
#
# Use this yaml formatted template to create a new contact:
# either with: khard new -a address_book -i template.yaml
# or with: cat template.yaml | khard new -a address_book
# Every contact must contain a formatted name, it will be autofilled
# from the full name below if not given.
Formatted name :
# name components
# every entry may contain a string or a list of strings
# format:
# First name : name1
# Additional :
# - name2
# - name3
# Last name : name4
Prefix :
First name :
Additional :
Last name :
Suffix :
# nickname
# may contain a string or a list of strings
Nickname :
# important dates
# Formats:
# vcard 3.0 and 4.0: yyyy-mm-dd or yyyy-mm-ddTHH:MM:SS
# vcard 4.0 only: --mm-dd or text= string value
# anniversary
Anniversary :
# birthday
Birthday :
# organisation
# format:
# Organisation : company
# or
# Organisation :
# - company1
# - company2
# or
# Organisation :
# -
# - company
# - unit
Organisation :
# organisation title and role
# every entry may contain a string or a list of strings
#
# title at organisation
# example usage: research scientist
Title :
# role at organisation
# example usage: project leader
Role :
# phone numbers
# format:
# Phone:
# type1, type2: number
# type3:
# - number1
# - number2
# custom: number
# allowed types:
# vcard 3.0: At least one of bbs, car, cell, fax, home, isdn, msg, modem,
# pager, pcs, pref, video, voice, work
# vcard 4.0: At least one of home, work, pref, text, voice, fax, cell, video,
# pager, textphone
# Alternatively you may use a single custom label (only letters).
# But beware, that not all address book clients will support custom labels.
Phone :
cell :
home :
# email addresses
# format like phone numbers above
# allowed types:
# vcard 3.0: At least one of home, internet, pref, work, x400
# vcard 4.0: At least one of home, internet, pref, work
# Alternatively you may use a single custom label (only letters).
Email :
home :
work :
# post addresses
# allowed types:
# vcard 3.0: At least one of dom, intl, home, parcel, postal, pref, work
# vcard 4.0: At least one of home, pref, work
# Alternatively you may use a single custom label (only letters).
Address :
home :
Box :
Extended :
Street :
Code :
City :
Region :
Country :
# categories or tags
# format:
# Categories : single category
# or
# Categories :
# - category1
# - category2
Categories :
# web pages
# may contain a string or a list of strings
Webpage :
# private objects
# define your own private objects in the vcard section of your khard config file
# example:
# [vcard]
# private_objects = Jabber, Skype, Twitter
# these objects are stored with a leading "X-" before the object name in the
# vcard files.
# every entry may contain a string or a list of strings
Private :
Jabber :
Skype :
Twitter :
# notes
# may contain a string or a list of strings
# for multi-line notes use:
# Note : |
# line one
# line two
Note :
khard-0.15.1/misc/sdiff/ 0000775 0000000 0000000 00000000000 13560061035 0014714 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/sdiff/sdiff_khard_wrapper.sh 0000664 0000000 0000000 00000000733 13560061035 0021257 0 ustar 00root root 0000000 0000000 #!/bin/sh
# khard requires the second file in the diff-operation to be modified in order to recognize a
# successful merge. However the file provided to sdiff's "-o" switch cannot be any of the source files.
#
# If you want to use sdiff to merge contacts you must set this wrapper script as your merge
# editor in khard's config file
#
# merge_editor = /path/to/sdiff_khard_wrapper.sh
FIRST="$1"
SECOND=`mktemp`
mv -f "$2" "$SECOND" && sdiff "${FIRST}" "${SECOND}" -o "$2"
khard-0.15.1/misc/twinkle/ 0000775 0000000 0000000 00000000000 13560061035 0015276 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/twinkle/scripts/ 0000775 0000000 0000000 00000000000 13560061035 0016765 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/twinkle/scripts/config.py 0000664 0000000 0000000 00000001421 13560061035 0020602 0 ustar 00root root 0000000 0000000 #!/usr/bin/python
# -*- coding: utf-8 -*-
import os
# twinkle config folder
twinkle_config = os.path.join(os.environ['HOME'], ".twinkle")
# khard executable
khard_exe = os.path.join(os.environ['HOME'], ".virtualenvs", "bin", "khard")
# user language
language = "de"
# stop mpd
stop_music = True
mpd_host = "192.168.2.100"
mpd_port = 6600
# log file for calls
call_log_file = os.path.join(twinkle_config, "calls.log")
# audio files
constant_ringtone_segment = os.path.join(twinkle_config, "sounds", "ringtone_segment.wav")
new_ringtone = os.path.join(twinkle_config, "sounds", "special_ringtone.wav")
# temp files
tmp_mono_file = "/tmp/caller_id.wav"
tmp_file_stereo = "/tmp/caller_id_stereo.wav"
mpd_lockfile = "/tmp/mpd_stopped"
caller_id_filename = "/tmp/current_caller_id"
khard-0.15.1/misc/twinkle/scripts/incoming_call.py 0000775 0000000 0000000 00000007175 13560061035 0022152 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# This script speaks the incoming caller ID for the SIP client Twinkle
# The following programs are needed: espeak, ffmpeg and sox, mpc is optional
# aptitude install ffmpeg espeak sox mpc
# Further information about Twinkle scripts can be found at
# http://mfnboer.home.xs4all.nl/twinkle/manual.html#profile_scripts
import os, subprocess, sys, re
import config
def get_caller_id(from_hdr):
caller_id = from_hdr[from_hdr.find(":")+1:from_hdr.find("@")]
# remove all non digits from caller id
caller_id = re.sub("\D", "", caller_id)
# remove two digit country identification if present
if not caller_id.startswith("0"):
return caller_id[2:]
return caller_id
def caller_from_addressbook(caller_id):
try:
callers = subprocess.check_output([config.khard_exe, "phone", "--parsable", caller_id]).strip()
except subprocess.CalledProcessError:
return caller_id
if len(callers.split("\n")) == 1:
return callers.split("\t")[1]
else:
# the contact contains multiple phone numbers and we have to obtain the right phone label
regexp = re.compile(caller_id, re.IGNORECASE)
for entry in callers.split("\n"):
if regexp.search(re.sub("\D", "", entry.split("\t")[0])) != None:
return "%s (%s)" % (entry.split("\t")[1], entry.split("\t")[2])
return callers.split("\n")[0].split("\t")[1]
def create_ringtone(caller_id):
if os.path.exists(config.new_ringtone) == True:
os.remove(config.new_ringtone)
if config.language == "de":
subprocess.call(["espeak", "-v", "de", "-s", "300", "-w", config.tmp_mono_file, caller_id])
else:
subprocess.call(["espeak", "-v", "en-us", "-s", "300", "-w", config.tmp_mono_file, caller_id])
subprocess.call(["ffmpeg", "-i", config.tmp_mono_file, "-ar", "48000", "-ac", "2", "-y", config.tmp_file_stereo],
stdout=open('/dev/null', 'w'), stderr=open('/dev/null', 'w'))
subprocess.call(["sox", config.constant_ringtone_segment, config.tmp_file_stereo, config.new_ringtone])
# main part of the script
if os.path.exists(config.constant_ringtone_segment) == False:
print("The constant part of the ringtone file is missing. Create the sounds folder in your twinkle config and put a wav file in it")
sys.exit(1)
# pause the music playback
# I use a MPD server for playing music so I pause it with the client MPC
# You can disable that in the config.py file
if config.stop_music:
mpc_output = subprocess.check_output(["mpc", "-h", config.mpd_host, "-p", str(config.mpd_port), "status"])
if "playing" in mpc_output:
subprocess.call(["mpc", "-h", config.mpd_host, "-p", str(config.mpd_port), "pause"])
music_tmp_file = open(config.mpd_lockfile, "w")
music_tmp_file.close()
if "SIP_FROM" in os.environ:
from_hdr = os.environ["SIP_FROM"]
# parse the caller ID of the string
caller_id = get_caller_id(from_hdr)
# look into the addressbook
if caller_id != "":
caller_id = caller_from_addressbook(caller_id)
else:
caller_id = "anonymous"
# create the ringtone
if config.language == "de":
create_ringtone("Anruf von " + caller_id)
else:
create_ringtone("Call from " + caller_id)
# save the caller id for later use
with open(config.caller_id_filename, "w") as caller_id_file:
caller_id_file.write(caller_id)
# if the file creation was successful and the file exists, tell twinkle to use it as the ringtone
# else do nothing and play the standard ringtone
if os.path.exists(config.new_ringtone) == True:
print("ringtone=" + config.new_ringtone)
sys.exit()
khard-0.15.1/misc/twinkle/scripts/incoming_call_ended.py 0000775 0000000 0000000 00000002121 13560061035 0023273 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import os, subprocess, time, datetime
import config
# current date and time
datetime = datetime.datetime.now()
current_date = "%.2d.%.2d.%.4d" % (datetime.day, datetime.month, datetime.year)
current_time = "%.2d:%.2d:%.2d" % (datetime.hour, datetime.minute, datetime.second)
# if music was stopped, resume again
if os.path.exists(config.mpd_lockfile) == True:
os.remove(config.mpd_lockfile)
subprocess.call(["mpc", "-h", config.mpd_host, "-p", str(config.mpd_port), "play"])
# try to get the caller name / id from the previously created temp file
try:
caller_id_file = open(config.caller_id_filename, "r")
caller_id = caller_id_file.read().strip()
caller_id_file.close()
except:
caller_id = "anonymous"
if config.language == "de":
message = "Anruf von %s am %s um %s\n" % (caller_id, current_date, current_time)
else:
message = "Call of %s in %s at %s\n" % (caller_id, current_date, current_time)
try:
os.remove(config.caller_id_filename)
except:
pass
# log into file
log = open(config.call_log_file, "a")
log.write(message)
log.close()
khard-0.15.1/misc/twinkle/scripts/incoming_call_failed.py 0000775 0000000 0000000 00000002131 13560061035 0023441 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import os, subprocess, time, datetime
import config
# current date and time
datetime = datetime.datetime.now()
current_date = "%.2d.%.2d.%.4d" % (datetime.day, datetime.month, datetime.year)
current_time = "%.2d:%.2d:%.2d" % (datetime.hour, datetime.minute, datetime.second)
# if music was stopped, resume again
if os.path.exists(config.mpd_lockfile) == True:
os.remove(config.mpd_lockfile)
subprocess.call(["mpc", "-h", config.mpd_host, "-p", str(config.mpd_port), "play"])
# try to get the caller name / id from the previously created temp file
try:
with open(config.caller_id_filename, "r") as caller_id_file:
caller_id = caller_id_file.read().strip()
except:
caller_id = "anonymous"
if config.language == "de":
message = "Anruf in Abwesenheit von %s am %s um %s\n" % (caller_id, current_date, current_time)
else:
message = "Call in absence of %s in %s at %s\n" % (caller_id, current_date, current_time)
try:
os.remove(config.caller_id_filename)
except:
pass
# log into file
with open(config.call_log_file, "a") as log:
log.write(message)
khard-0.15.1/misc/twinkle/sounds/ 0000775 0000000 0000000 00000000000 13560061035 0016611 5 ustar 00root root 0000000 0000000 khard-0.15.1/misc/twinkle/sounds/incoming_call.wav 0000664 0000000 0000000 00022527514 13560061035 0022150 0 ustar 00root root 0000000 0000000 RIFFDJ WAVEfmt data J x~}|zz}~||{yx}t{|}tjziuw}zw|~~~~}~~zz|w~}|{}}{x|~~ ) ' . 9 3 > : L D Z U p X ` W Q U u M x I w @ r 2 r 0 o D t O H 6 ~ # c J I P
F 1 # %
3 <