Commits

Julio Biason  committed 296b0c3

Imported from svn by Bitbucket

  • Participants

Comments (0)

Files changed (9)

+Julio Biason <julio.biason@gmail.com>
+Gavin Panella <gavin@gromper.net>
+		    GNU GENERAL PUBLIC LICENSE
+		       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+     59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+			    Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Library General Public License instead.)  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
+this service 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 make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  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.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+		    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+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
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the 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 a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+			    NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE 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.
+
+		     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
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) 19yy  <name of author>
+
+    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 2 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, write to the Free Software
+    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) 19yy name of author
+    Gnomovision 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, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This 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 Library General
+Public License instead of this License.
+gup.py
+setup.py
+guplib/albums.py
+guplib/gallery.py
+guplib/__init__.py
+AUTHORS
+COPYING
+MANIFEST
+#!/usr/bin/python
+
+# This file is part of GUP.
+#
+# GUP 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 2 of the License, or (at your option) any
+# later version.
+#
+# GUP 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 GUP; if not, write to the Free Software Foundation, Inc., 51
+# Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
+#
+# Copyright (C) 2007 Julio Biason
+
+
+'''Web Gallery 2.0 upload utility'''
+__revision__ = '0.2.1'
+
+import logging
+import os.path
+import os
+
+from guplib.gallery import Gallery, GalleryError
+from guplib.albums import AlbumList, AlbumDoesntExistError
+from optparse import OptionParser
+from ConfigParser import ConfigParser
+from urllib2 import HTTPError
+
+# licence info
+LICENSE_TEXT = """
+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 2 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, write to the Free Software
+Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA"""
+
+# Exceptions
+class NoSuchGalleryError(Exception): 
+    '''The requested gallery doesn't exist.'''
+    pass
+
+class CantSelectAGalleryError(Exception): 
+    '''The system can't come with a gallery because there is more than one
+    saved.'''
+    pass
+
+def show_license():
+    '''Print the system license.'''
+    logging.info(LICENSE_TEXT)
+    return
+
+def parse_command_line():
+    '''Command line option parsing.'''
+    parser = OptionParser()
+    parser.add_option('-g', '--gallery',
+            help = 'Gallery configuration to be used',
+            dest = 'gallery',
+            action = 'store',
+            default = '')
+    parser.add_option('-r', '--reload',
+            help = 'Reload the album list from the server',
+            dest = 'reload',
+            action = 'store_true',
+            default = False)
+    parser.add_option('-l', '--list-albums',
+            help = 'List albums in the server',
+            dest = 'list',
+            action = 'store_true',
+            default = False)
+    parser.add_option('-s', '--search',
+            help = 'Search for an album',
+            dest = 'search',
+            action = 'store',
+            default = '')
+    parser.add_option('--search-id',
+            help = 'Show information about an album ID',
+            dest = 'id',
+            action = 'store',
+            default = '')
+    parser.add_option('-c', '--create',
+            help = 'Create a new album; requires three parameters: ' + \
+                    'parent album id (returned by the search options), ' + \
+                    'album name (used in the filesystem in the server) ' + \
+                    'and album title (used to display it in the albums)',
+            dest = 'create',
+            action = 'store',
+            nargs = 3)
+    parser.add_option('-a', '--album',
+            help = 'Album to upload pictures',
+            dest = 'album',
+            action = 'store',
+            default = '')
+    parser.add_option('--delete',
+            help = 'Delete the file locally if the upload succeeds',
+            dest = 'delete',
+            action = 'store_true',
+            default = False)
+    parser.add_option('-G', '--galery-url',
+            help = 'Gallery URL',
+            dest = 'url',
+            default = '')
+    parser.add_option('-u', '--user',
+            help = 'User',
+            dest = 'user',
+            default = '')
+    parser.add_option('-p', '--password',
+            help = 'Password',
+            dest = 'password',
+            default = '')
+    parser.add_option('--save',
+            help = 'Save URL, user and password in a config ' + \
+                    '(so future calls don''t need to specify them ' + \
+                    'again); must specify a name for the gallery ' + \
+                    'installation',
+            dest = 'save',
+            action = 'store',
+            default = '')
+    parser.add_option('--list-galleries',
+            help = 'List saved galleries',
+            dest = 'list_galleries',
+            action = 'store_true',
+            default = False)
+    parser.add_option('-v', '--verbose',
+            help = 'Verbose',
+            dest = 'verbose',
+            action = 'store_true',
+            default = False)
+    parser.add_option('--license',
+            help = 'Print this program license',
+            dest = 'license',
+            action = 'store_true',
+            default = False)
+    (options, args) = parser.parse_args()
+
+    return (options, args)
+
+def locate_gallery(configuration, name):
+    '''Look for a gallery in the configuration, or return the name of one
+    if there is only one gallery.'''
+    if name:
+        if not configuration.has_section(name):
+            raise NoSuchGalleryError, name
+        return name
+
+    sections = configuration.sections()
+    if len(sections) == 0:
+        return ''
+
+    if len(sections) > 1:
+        raise CantSelectAGalleryError
+
+    return sections[0]
+
+def merge_options(configuration, gallery, command_line_options):
+    '''Merge the options from the gallery in the configuration file and the
+    ones passed in the command line into a single location.'''
+    if configuration.has_option(gallery, 'url'):
+        config_url = configuration.get(gallery, 'url')
+
+    if configuration.has_option(gallery, 'user'):
+        config_user = configuration.get(gallery, 'user')
+
+    if configuration.has_option(gallery, 'password'):
+        config_password = configuration.get(gallery, 'password')
+
+    if not command_line_options.url:
+        command_line_options.url = config_url
+
+    if not command_line_options.user:
+        command_line_options.user = config_user
+
+    if not command_line_options.password:
+        command_line_options.password = config_password
+
+    return command_line_options
+
+def missing_options(options):
+    '''Check if the required options are available.'''
+    if not options.url:
+        logging.error('Missing gallery URL')
+        return True
+
+    if not options.user:
+        logging.error('Missing user')
+        return True
+
+    if not options.password:
+        logging.error('Missing password')
+        return True
+
+    return False
+
+def create_base_config_dir():
+    '''Create the base configuration directory, where configuration and
+    cache are stored.'''
+    if not os.access(os.path.expanduser('~/.gup'), os.F_OK):
+        os.mkdir(os.path.expanduser('~/.gup'))
+
+def save_gallery_info(configuration, gallery_name, options):
+    '''Save the gallery information back to the config file.'''
+    logging.debug('Saving gallery "%s" information...', gallery_name)
+    if not configuration.has_section(gallery_name):
+        configuration.add_section(gallery_name)
+    configuration.set(gallery_name, 'url'     , options.url)
+    configuration.set(gallery_name, 'user'    , options.user)
+    configuration.set(gallery_name, 'password', options.password)
+
+    create_base_config_dir()
+    contents = file(os.path.expanduser('~/.gup/config.ini'), 'w')
+    configuration.write(contents)
+    contents.close()
+
+def gallery_cache_name(gallery_name):
+    '''Return a string with the name of the gallery cache.'''
+    return os.path.expanduser('~/.gup/%s.cache' % gallery_name)
+
+def check_album_cache(gallery_name, options):
+    '''Check if the album cache exists and, if it doesn't or the reload
+    option was used, load it from the server. Returns a tuple with the
+    album cache and a bool indicating if it needs to be saved.'''
+    cache_name = gallery_cache_name(gallery_name)
+
+    if not options.reload:
+        try:
+            os.stat(cache_name)
+        except OSError:
+            options.reload = True
+            logging.info('There isn''t a cache for "%s", downloading...',
+                    gallery_name)
+    else:
+        logging.info('Downloading album information for "%s"...',
+                gallery_name)
+
+    albums = AlbumList()
+    if not options.reload:
+        albums.load(cache_name)
+        return (albums, False)
+
+    # load from server
+    connection = Gallery(options.url, options.user,
+            options.password)
+    for album in connection.albums():
+        albums.add(int(album[0]), album[1], int(album[2]))
+    
+    return (albums, True)
+
+def display_albums(tree):
+    '''Display the albums in a tree format.'''
+    for (level, name, album_id) in tree.tree():
+        logging.info('%s%s (%s)', '  ' * level, name, album_id)
+
+def upload(options, files):
+    '''Upload files to the gallery.'''
+    connection = Gallery(options.url, options.user, options.password)
+    total   = len(files)
+    current = 0
+    for image in files:
+        current += 1
+        logging.info('Uploading "%s" (%d/%d)...',
+                image, current, total)
+        try:
+            connection.add_item(str(options.album), image)
+            if options.delete:
+                logging.info('Removing "%s"...',
+                        image)
+                os.remove(image)
+        except HTTPError, e:
+            logging.error('HTTP error: %s' % (e))
+
+def save_album_cache(gallery_name, albums):
+    '''Save the gallery cache.'''
+    cache_name = gallery_cache_name(gallery_name)
+    create_base_config_dir()
+    albums.save(cache_name)
+
+def make_name(album_list, stop_id):
+    '''From a tree of albums, make a flat name for it.
+
+    album_list - the tree of the album
+    stop_id - id of the album (top stop generating the list, since the
+        search_id function will return the children of the id.'''
+
+    names = []
+    tree = album_list.tree()
+    tree.next() # skip the root node ('/')
+
+    for (_, name, album_id) in tree:
+        names.append(name)
+        if album_id == stop_id:
+            break
+
+    return '/'.join(names)
+
+def main():
+    '''Main program'''
+    (options, files) = parse_command_line()
+    if options is None:
+        return
+
+    if options.verbose:
+        logging_level = logging.DEBUG
+    else:
+        logging_level = logging.INFO
+
+    logging.basicConfig(level = logging_level,
+            format='%(message)s')
+
+    if options.license:
+        logging.info('GUP %s', __revision__)
+        show_license()
+        return
+
+    # read the config
+    config = ConfigParser()
+    config.read([os.path.expanduser('~/.gup/config.ini')])
+
+    # if list-galleries, list and exit
+    if options.list_galleries:
+        for section in config.sections():
+            logging.info(section)
+        return
+
+    # locate a gallery; if there more than one, try options; if there are
+    #    no options for default gallery, bail out
+    try:
+        gallery = locate_gallery(config, options.gallery)
+    except NoSuchGalleryError, gal:
+        logging.error('Gallery "%s" doesn''t exist', gal)
+        return
+    except CantSelectAGalleryError:
+        logging.error('There is more than one gallery saved;')
+        logging.error('Use the "-g" option to select one.')
+        return
+
+    # merge options
+    if gallery:
+        options = merge_options(config, gallery, options)
+
+    # check missing options
+    if missing_options(options):
+        return
+
+    # if save-options, save the config file
+    if options.save:
+        save_gallery_info(config, options.save, options)
+        gallery = options.save # that's the current gallery now
+
+    # if reload or cache doesn't exist, reload and keep going; mark cache
+    #   info as updated
+    try:
+        (albums, save_cache) = check_album_cache(gallery, options)
+    except GalleryError, msg:
+        logging.error(str(msg))
+        return
+
+    # check the list option (list albums)
+    if options.list:
+        display_albums(albums)
+
+    # check search option (search for an album with the name)
+    if options.search:
+        try:
+            search = albums.search_name(options.search)
+        except AlbumDoesntExistError:
+            logging.error('There isn''t an album named "%s"',
+                    options.search)
+            return
+
+        display_albums(search)
+
+    # check the search id option (search for an album with the id)
+    if options.id:
+        try:
+            search = albums.search_id(int(options.id))
+        except AlbumDoesntExistError:
+            logging.error('There isn''t an album with the ID "%s"',
+                    options.id)
+            return
+
+        display_albums(search)
+
+    # if create album, create album, add it to chache, and set it as
+    #   current album
+    if options.create:
+        # check if the parent album exists
+        try:
+            albums.get_name(int(options.create[0]))
+        except AlbumDoesntExistError:
+            logging.error("Parent album %s doesn't exist" %
+                    (options.create[0]))
+            return
+
+        logging.info('Creating album "%s"...', options.create[1])
+        connection = Gallery(options.url, options.user,
+                options.password)
+        album_id = connection.new_album(options.create[0],
+                options.create[1], options.create[2])
+        if album_id is not None:
+            logging.info('Created new album "%s", id "%s"' %
+                    (options.create[1], album_id))
+            options.album = album_id
+        else:
+            logging.error('Error creating album')
+            return
+
+        # add the album in the cache
+        albums.add(int(album_id), options.create[2],
+                int(options.create[0]))
+        save_cache = True
+
+    # if there is an album, add pictures to it
+    if options.album and files:
+        try:
+            album_list = albums.search_id(int(options.album))
+        except AlbumDoesntExistError:
+            logging.error('There isn''t an album with the ID "%s"',
+                    options.album)
+            return
+
+        logging.info('Uploading pictures to "%s"...' % 
+                make_name(album_list, int(options.album)))
+
+        upload(options, files)
+
+    # if there was a change in the cache, save it
+    if save_cache:
+        save_album_cache(gallery, albums)
+
+if __name__ == '__main__':
+    main()

File guplib/__init__.py

Empty file added.

File guplib/albums.py

+# This file is part of GUP.
+#
+# GUP 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 2 of the License, or (at your option) any
+# later version.
+#
+# GUP 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 GUP; if not, write to the Free Software Foundation, Inc., 51
+# Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
+#
+# Copyright (C) 2007 Julio Biason
+
+'''Album list storage facility
+
+>>> a = AlbumList()
+>>> for album in a.tree():
+...    print album
+(0, '/', 0)
+>>> a.add(7, 'gallery', 0)
+>>> for album in a.tree():
+...    print album
+(0, '/', 0)
+(1, 'gallery', 7)
+>>> a.add(9, 'toons', 8)
+>>> for album in a.tree():
+...    print album
+(0, '/', 0)
+(1, 'gallery', 7)
+>>> a.add(8, 'pics', 7)
+>>> for album in a.tree():
+...    print album
+(0, '/', 0)
+(1, 'gallery', 7)
+(2, 'pics', 8)
+(3, 'toons', 9)
+'''
+
+import logging
+
+__revision__ = 0.1
+
+class AlbumError(Exception):
+    '''Generic exception used in the albums'''
+    pass
+
+class AlbumExistsError(AlbumError):
+    '''Album exists'''
+    pass
+
+class AlbumDoesntExistError(AlbumError):
+    '''The requested album doesn't exist'''
+    pass
+
+#pylint: disable-msg=R0903
+class Album(object):
+    '''A single album information'''
+    def __init__(self, album_id = '', name = '', parent = None):
+        self.album_id = album_id
+        self.name     = name
+        self.parent   = parent
+        self.child    = {}
+
+    def __eq__(self, other):
+        '''Equality check.'''
+        return self.album_id == other.album_id
+
+    def __cmp__(self, other):
+        '''Comparision (used to sort the album names).'''
+        if self.name == other.name:
+            return 0
+        elif self.name < other.name:
+            return -1
+        return 1
+
+class AlbumList(object):
+    '''Album list storage facility'''
+    def __init__(self):
+        self.start           = Album(0, '/')
+        self.list            = {0: self.start}
+        self.missing_parents = {}
+
+    def add(self, album_id, album_name, album_parent):
+        '''Add an album to the list.
+        
+        album_id - ID of the album
+        album_name - Name of the album
+        album_parent - ID of this album parent
+        
+        The parent doesn't need to exist before adding the album. If the
+        parent can't be found, it album will stay on an "orphan" list till
+        the parent is added.'''
+        logging.debug('Adding album "%s", id "%s", parent "%s"' % \
+                (album_name, album_id, album_parent))
+        if self.list.has_key(album_id):
+            raise AlbumExistsError
+
+        album_info = Album(album_id, album_name, album_parent)
+
+        # add in the tree
+        try:
+            album_pos = self.list[album_parent]
+        except KeyError:
+            # if the parent couldn't be found, we add the child
+            # in a list, waiting for a parent to appear and pick
+            # them up (and take them to the mall)
+            logging.debug('Missing parent: %s', album_parent)
+            try:
+                self.missing_parents[album_parent].append(album_info)
+            except KeyError:
+                self.missing_parents[album_parent] = [album_info]
+            return
+
+        album_pos.child[album_id] = album_info
+
+        # add in the quick search list
+        self.list[album_id] = album_info
+
+        # just check if the kids are waiting
+        if self.missing_parents.has_key(album_id):
+            logging.debug('New album is a parent: %d childs' % \
+                    len(self.missing_parents[album_id]))
+            for child in self.missing_parents[album_id]:
+                self.add(child.album_id, child.name, child.parent)
+
+    def get_name(self, album_id):
+        '''Return the album name from the album id.'''
+        try:
+            self.list[album_id]
+        except KeyError:
+            raise AlbumDoesntExistError
+
+        return self.list[album_id].name
+
+
+    def tree(self, level = 0, branch = None):
+        '''Return tuples with information about the album list to be
+        displayed in a tree fashion. The tuples are in the format (level,
+        name, id), where "level" is the tree node level of the album;
+        albums with the same level are siblings and albums preceding a
+        lower level are child of the previous one.
+        
+        The parameters "level" and "branch" shouldn't be used directly,
+        unless you are completely sure about that.'''
+        if branch is None:
+            branch = self.start
+        yield (level, branch.name, branch.album_id)
+
+        ordered_child = branch.child.values()
+        ordered_child.sort()
+
+        # this is SO ugly!
+        for child in ordered_child:
+            for result in self.tree(level+1, child):
+                yield result
+
+    def orphans(self):
+        '''Return the list of orphans.'''
+        return self.missing_parents
+
+    def save(self, name):
+        '''Save the album list.'''
+        contents = file(name, 'w')
+        for key, value in self.list.iteritems():
+            if key == 0:
+                continue    # we won't save root as it is always there
+            logging.debug('%s:%s:%s' % (value.album_id, value.parent,
+                value.name))
+            print >> contents, '%d:%d:%s' % \
+                    (int(value.album_id), int(value.parent), value.name)
+        contents.close()
+        return
+
+    def load(self, name):
+        '''Load the album list. Can be used to merge two album lists, but 
+        it won't make sense, as the remote server won't have one of the
+        sets. Also, you can load he same album twice without a problem.'''
+        contents = file(name, 'r')
+        for line in contents:
+            line   = line.strip()
+            fields = line.split(':')
+            self.add(int(fields[0]), ':'.join(fields[2:]), int(fields[1]))
+        contents.close()
+        return
+
+    def search_name(self, name):
+        '''Search for albums with the specified name. Returns a new
+        AlbumList object with the albums that have the searched name and
+        their parents. If no album could be found, a
+        "AlbumDoesntExistError" is thrown.'''
+
+        # build a list of albums with the searched name
+        albums_found = []
+        name = name.upper()
+        for album_info in self.list.itervalues():
+            if name in album_info.name.upper():
+                albums_found.append(album_info)
+
+        if len(albums_found) == 0:
+            raise AlbumDoesntExistError
+
+        # now we create a new AlbumList and put the albums there. It should
+        # take care of building the tree by itself.
+        result = AlbumList()
+        for album in albums_found:
+            try:
+                result.add(album.album_id, album.name, album.parent)
+            except AlbumExistsError:
+                pass
+
+            # add the parents!
+            parent = album.parent
+            while parent != 0:
+                parent_album = self.list[parent]
+
+                try:
+                    result.add(parent_album.album_id, parent_album.name,
+                            parent_album.parent)
+                except AlbumExistsError:
+                    pass
+
+                parent = parent_album.parent
+
+        return result
+
+    def search_id(self, album_id):
+        '''Search for an album with the specified ID. Returns a new
+        AlbumList with the album, its parents and its immediate
+        children.'''
+
+        try:
+            root_album = self.list[album_id]
+        except KeyError:
+            raise AlbumDoesntExistError
+
+        result = AlbumList()
+        result.add(root_album.album_id, root_album.name, root_album.parent)
+
+        # add the parents
+        parent = root_album.parent
+        while parent != 0:
+            parent_album = self.list[parent]
+
+            try:
+                result.add(parent_album.album_id, parent_album.name,
+                        parent_album.parent)
+            except AlbumExistsError:
+                pass
+
+            parent = parent_album.parent
+
+        # add the child nodes
+        for child in root_album.child.itervalues():
+            try:
+                result.add(child.album_id, child.name, child.parent)
+            except AlbumExistsError:
+                pass
+
+        return result
+
+def _test():
+    '''Calls doctest to test this module'''
+    import doctest
+    doctest.testmod()
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    _test()

File guplib/gallery.py

+# This file is part of GUP.
+#
+# GUP 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 2 of the License, or (at your option) any
+# later version.
+#
+# GUP 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 GUP; if not, write to the Free Software Foundation, Inc., 51
+# Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
+#
+# Copyright (C) 2007 Julio Biason
+
+'''Classes used to talk to Web Gallery 2.0'''
+__revision__ = 0.1
+
+import logging
+import urllib2
+import cookielib
+import mimetypes
+
+# Exceptions
+class GalleryError(Exception):
+    '''Generic error in the request'''
+    def __str__(self):
+        if self.message:
+            return '%s: %s' % (self.__doc__, self.message)
+        else:
+            return self.__doc__
+
+class ConnectionError(GalleryError):
+    '''Connection error.'''
+
+class MajorVersionInvalidError(GalleryError):
+    '''The protocol major version the client is using is not supported.'''
+
+class MinorVersionInvalidError(GalleryError):
+    '''The protocol minor version the client is using is not supported.'''
+
+class ProtocolFormatInvalidError(GalleryError):
+    '''The format of the protocol version string the client sent
+    in the request is invalid.'''
+
+class MissingProtocolVersionError(GalleryError):
+    '''The request did not contain the required protocol_version key.'''
+
+class InvalidPasswordError(GalleryError):
+    '''The password and/or username the client send in the request
+    is invalid.'''
+
+class MissingLoginError(GalleryError):
+    '''The client used the login command in the request but failed
+    to include either the username or password (or both) in the
+    request.'''
+
+class InvalidCommandError(GalleryError):
+    '''The value of the cmd key is not valid.'''
+
+class NoPermissionError(GalleryError):
+    '''The user does not have permission to add an item to the
+    gallery.'''
+
+class NoFilenameError(GalleryError):
+    '''No filename was specified.'''
+
+class PhotoUploadError(GalleryError):
+    '''The file was received, but could not be processed or
+    added to the album.'''
+
+class NoWritePermissionError(GalleryError):
+    '''No write permission to destination album.'''
+
+class NoViewPermissionError(GalleryError):
+    '''No view permission for this image.'''
+
+class NoAlbumPermissionError(GalleryError):
+    '''A new album could not be created because the user does
+    not have permission to do so.'''
+
+class AlbumCreateError(GalleryError):
+    '''A new album could not be created, for a different reason
+    (name conflict).'''
+
+class MoveAlbumError(GalleryError):
+    '''The album could not be moved.'''
+
+class ImageRotateError(GalleryError):
+    '''The image could not be rotated'''
+
+def _multipart(boundary, arguments, file_info):
+    '''Generates the body of a multipart data'''
+    parts = []
+    for key, value in arguments.iteritems():
+        assert '"' not in key, 'Key cannot contain " (double-quotes).'
+        assert boundary not in value, (
+            'Multipart encapsulation failure: '
+            'boundary found in form data.')
+
+        logging.debug('Adding "%s"...', key)
+        parts.append('--%s' % boundary)
+        parts.append('Content-disposition: form-data; name="%s"' % key)
+        parts.append('')
+        parts.append(value)
+
+    if file_info is not None:
+        key, filename = file_info
+        assert '"' not in key, 'Key cannot contain " (double-quotes).'
+
+        content_type = mimetypes.guess_type(filename)[0] or \
+                'application/octet-stream'
+
+        logging.debug('Adding file "%s" (%s) to "%s"...' %
+                (filename, content_type, key))
+        parts.append('--%s' % (boundary,))
+        parts.append('Content-disposition: form-data; ' + \
+                'name="%s"; filename="%s"' %
+                (key, filename))
+        parts.append('Content-Type: %s' % content_type)
+        parts.append('Content-Transfer-Encoding: base64')
+        parts.append('')
+
+        image = file(filename, 'rb')
+        try:
+            contents = image.read()
+        finally:
+            image.close()
+
+        assert boundary not in contents, (
+            'Multipart encapsulation failure: '
+            'boundary found in form data.')
+
+        parts.append(contents)
+
+    parts.append('--%s--' % boundary)
+
+    return '\r\n'.join(parts)
+
+def _yesno(boolean):
+    if boolean:
+        return 'yes'
+    else:
+        return 'no'
+
+# Main class
+class Gallery(object):
+    '''Gallery interaction class'''
+    def __init__(self, url, user, password):
+        '''Class initiator.
+
+        url      - Gallery installation URL
+        user     - login user
+        password - user passowrd'''
+
+        self.logged   = False
+        self.cookie   = None
+        self.url      = url + '/main.php'
+        self.user     = user
+        self.password = password
+        self.last_authtoken = ''
+
+        # maps return codes to the exceptions
+        self._return_codes = {
+                101: MajorVersionInvalidError,
+                102: MinorVersionInvalidError,
+                103: ProtocolFormatInvalidError,
+                104: MissingProtocolVersionError,
+                201: InvalidPasswordError,
+                202: MissingLoginError,
+                301: InvalidCommandError,
+                401: NoPermissionError,
+                402: NoFilenameError,
+                403: PhotoUploadError,
+                404: NoWritePermissionError,
+                405: NoViewPermissionError,
+                501: NoAlbumPermissionError,
+                502: AlbumCreateError,
+                503: MoveAlbumError,
+                504: ImageRotateError
+                }
+
+    def request(self, command, version, arguments, file_info = None):
+        '''Send a request to the remote server. Returns a dictionary with
+        the resulting variables.
+
+        command - the command to be send
+        version - command version
+        arguments - dictionary with the arguments to the command
+        file_info - a tuple with the field name and the filename to be added
+          in the body'''
+        if not self.logged and not command == 'login':
+            # hate those hardcoded options
+            self.login()
+
+        logging.debug('Opening request with "%s"', self.url)
+        boundary = '------GUP_Boundary'
+
+        # those are the default fields
+        data = {
+                'g2_controller'            : 'remote:GalleryRemote',
+                'g2_form[cmd]'             : command,
+                'g2_form[protocol_version]': str(version),
+                'g2_authToken'             : self.last_authtoken
+                }
+        data.update(arguments)
+
+        request = urllib2.Request(self.url)
+        request.add_header('User-agent', 'GUP %s' % (__revision__))
+        request.add_header('Content-type',
+                'multipart/form-data; boundary=%s' % boundary)
+        request.add_header('Accept', 'text/plain')
+        if self.cookie is not None:
+            self.cookie.add_cookie_header(request)
+        else:
+            cookiejar = cookielib.CookieJar()
+            cookie_opener = urllib2.build_opener(
+                    urllib2.HTTPCookieProcessor(cookiejar))
+            urllib2.install_opener(cookie_opener)
+
+        request.add_data(_multipart(boundary, data, file_info))
+
+        response = urllib2.urlopen(request)
+
+        if self.cookie is None:
+            self.cookie = cookiejar
+
+        data = response.read()
+        logging.debug('== DATA ==')
+        logging.debug(data)
+        logging.debug('== /DATA ==')
+
+        status = 0
+        return_value = []
+
+        for line in data.split('\n'):
+            if len(line) == 0:
+                continue
+
+            if line[0] == '#':
+                continue
+
+            values = line.split('=')
+            if len(values) < 2:
+                continue
+
+            if values[0] == 'status':
+                status = int(values[1])
+            elif values[0] == 'auth_token':
+                self.last_authtoken = values[1]
+
+            return_value.append( (values[0], values[1]) )
+
+        if status is None:
+            raise ConnectionError
+
+        if not status == 0:
+            raise self._return_codes[status]
+
+        return (status, return_value)
+
+    def login(self):
+        '''Login in the system'''
+        arguments = {'g2_form[uname]': self.user,
+                'g2_form[password]': self.password}
+        logging.debug('User [%s] Password [%s]',
+                self.user, self.password)
+
+        (status, _) = self.request('login', '2.0', arguments)
+        self.logged = True
+
+    def albums(self, perms=True, prune=True):
+        '''Request a list of albums'''
+        request_args = {'no_perms': _yesno(not perms)}
+
+        if prune:
+            (status, data) = self.request(
+                'fetch-albums-prune', '2.2', request_args)
+        else:
+            (status, data) = self.request(
+                'fetch-albums', '2.0', request_args)
+
+        last_album    = ''
+        album_name    = None
+        album_parent  = None
+        album_title   = None
+
+        for info in data:
+            if info[0][-2:] != last_album:
+                last_album = info[0][-2:]
+
+                if album_name is not None and \
+                        album_title is not None and \
+                        album_parent is not None:
+                    yield (int(album_name), album_title, int(album_parent))
+
+                album_name   = None
+                album_parent = None
+                album_title  = None
+
+            fields = info[0].split('.')
+            if not fields[0] == 'album':
+                continue
+
+            if fields[1] == 'name':
+                album_name = info[1]
+            elif fields[1] == 'parent':
+                album_parent = info[1]
+            elif fields[1] == 'title':
+                album_title = info[1]
+
+        if album_name is not None and \
+                album_title is not None and \
+                album_parent is not None:
+            yield (int(album_name), album_title, int(album_parent))
+
+    def new_album(self, album_parent, album_name, album_title):
+        '''Create a new album'''
+        (status, data) = self.request('new-album', '2.1', {
+            'g2_form[set_albumName]': str(album_parent),
+            'g2_form[newAlbumName]': album_name,
+            'g2_form[newAlbumTitle]': album_title,
+            'g2_form[newAlbumDesc]': ''})
+
+        for info in data:
+            if info[0] == 'album_name':
+                logging.debug('Created album "%s"', info[1])
+                return int(info[1])
+
+        return None
+
+    def add_item(self, album, filename):
+        '''Add an item to an album'''
+        (status, _) = self.request('add-item', '2.0', {
+            'g2_form[set_albumName]': str(album),
+            'g2_userfile_name': filename
+            },
+            ('g2_userfile', filename)
+            )
+
+    def album_properties(self, album):
+        '''Fetch information on an album'''
+        (status, data) = self.request('album-properties', '2.0', {
+                'g2_form[set_albumName]': str(album),
+                })
+
+        return data
+
+    def album_images(self, album, albums_too=True,
+                           random=False, limit=None,
+                           extra_fields=True):
+        '''Fetch album images'''
+        request_args = {
+            'g2_form[set_albumName]': str(album),
+            'g2_form[albums_too]': _yesno(albums_too),
+            'g2_form[extrafields]': _yesno(extra_fields),
+            }
+        if random and limit is not None:
+            request_args['g2_form[random]'] = _yesno(random)
+            request_args['g2_form[limit]'] = str(limit)
+
+        (status, data) = self.request(
+            'fetch-album-images', '2.4', request_args)
+
+        meta = {}
+        imagemap = {}
+        images = []
+        for key, value in data:
+            if key.startswith('image.'):
+                key, index = key[6:].rsplit('.', 1)
+                index = int(index)
+                if index not in imagemap:
+                    imagemap[index] = {key: value}
+                    images.append(imagemap[index])
+                else:
+                    imagemap[index][key] = value
+            else:
+                meta[key] = value
+
+        return (meta, images)
+
+    def image_properties(self, image):
+        '''Fetch image properties'''
+        (status, data) = self.request('image-properties', '***', {
+                'id': str(image),
+                })
+
+        return data
+
+    def no_op(self):
+        '''Check the remote site is operational'''
+        (status, data) = self.request('no-op', '***', {})
+        return data
+from distutils.core import setup
+setup(name='gup',
+        version='0.2.3',
+        description='Gallery uploader',
+        author='Julio Biason',
+        author_email='slow@slowhome.org',
+        url='http://slowhome.org/projects/gup',
+        packages=['guplib'],
+        scripts=['gup.py'],
+        license='GPL',
+        )

File test_album.py

+# This file is part of GUP.
+#
+# GUP 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 2 of the License, or (at your option) any
+# later version.
+#
+# GUP 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 GUP; if not, write to the Free Software Foundation, Inc., 51
+# Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
+#
+# Copyright (C) 2007 Julio Biason
+
+import logging
+from albums import AlbumList
+
+logging.basicConfig(level=logging.DEBUG)
+
+album = AlbumList()
+for line in file('album.list'):
+    line   = line.strip()
+    fields = line.split(' : ')
+
+    id     = int(fields[0])
+    parent = int(fields[1])
+    name   = ''.join(fields[2:])
+
+    print 'ID:', id, '- Name:', name, '- Parent:', parent
+    album.add(id, name, parent)
+
+print 80 * '-'
+print 'Orphans:'
+album.orphans()
+print 80 * '-'
+for (level, name, id) in album.tree():
+    print '  ' * level, name
+
+album.save('original')
+
+print 80 * '-'
+other = AlbumList()
+other.load('original')
+for (level, name, id) in album.tree():
+    print '---' * level, name, id
+
+print 80 * '-'
+search = album.search_name('10')
+for (level, name, id) in search.tree():
+    print '%s %s (%s)' % ('  ' * level, name, id)
+
+print 80 * '-'
+print 'Search ID'
+search = album.search_id(3239)
+for (level, name, id) in search.tree():
+    print '%s %s (%s)' % ('  ' * level, name, id)
+
+print 80 * '-'
+print 'Search ID'
+search = album.search_id(4646)
+for (level, name, id) in search.tree():
+    print '%s %s (%s)' % ('  ' * level, name, id)