#!/usr/bin/env python
#
# coool: Checks OpenOffice.Org Links
# Reports broken hyperlinks in documents
#
# Updates and more information available
# on http://free-electrons.com/community/tools/utils/coool
#
# Version 1.1
# Copyright (C) 2005-2007 Free Electrons
# Author: Michael Opdenacker <michael@free-electrons.com>
# Home page: http://free-electrons.com/community/tools/utils/coool
# 
# 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 SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
# NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# 
# 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.,
# 675 Mass Ave, Cambridge, MA 02139, USA.

import sys, os, zipfile, xml.parsers.expat, urllib2, urllib, urlparse, httplib, thread, time, configparse, string

lock = thread.allocate_lock()
host_lock = thread.allocate_lock()

##########################################################
# Common routines
##########################################################

def debug (s):

    # Display debug information if debug mode is set

    global options

    if options.debug:
       print '[DEBUG] ' + s
 
def repeat_string(s, number):

    # Returns a string containing number times the given string
    
    str = ''
    
    for i in range(number):
        str += s
 
    return str
       
def url_failure(url, why):

    global link_text, link_type, link_xml_file, input_file, found_errors

    # Report the file name the first time
    # an error is found on the current file

    if not found_errors:
       found_errors = True
       print input_file
       print '-' * len(input_file)

    print '[ERROR] URL failure: ' + url
    print '        Reason:', why 
    
    num_links = len(link_type[url])
    
    
    if num_links > 1:
        print '        ' + str(num_links) + ' links to this URL:'
    
    for i in range(num_links):

        print '        ' + string.capwords(link_type[url][i]) + ' link: "'+ link_text[url][i] + '"'

        if link_xml_file[url][i] == 'styles.xml':
           print '                   (found in style data: header, footer, master page...)'

            
def check_http_url(url):
    request=urllib2.Request(url)
    opener = urllib2.build_opener()
    # Add a browser-like User-Agent. Some sites (like wikipedia) don't seem to accept requests
    # with the default urllib2 User-Agent
    request.add_header('User-Agent', 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv \u2014 1.7.8) Gecko/20050511')
    
    try:
        opener.open(request).read()
    except urllib2.HTTPError, why:
        url_failure(url, why)
    except urllib2.URLError, why:
        #Doesn't work in all cases
        #url_failure(url, why.__getitem__(0)[1])
        url_failure(url, why)
    except httplib.BadStatusLine, why:
        url_failure(url, why)
    
def check_ftp_url(url):
    # urllib2 doesn't raise any exception when a
    # ftp url for an invalid file is given
    # Only invalid domains are reported
    # That's why we are using urllib.urlretrieve
    
    try:
        tmpfile = urllib.urlretrieve(url)[0]
    except IOError, why:
        #Doesn't work in all cases
        #url_failure(url, why.__getitem__(1)[1])
        url_failure(url, why)
    else:
        # With a non-existing file on a ftp URL,
	# we get a zero size output file
	# Even if a real zero size file exists,
	# it's worth highlighting anyway
	# (no point in making an hyperlink to it)	
 	if os.path.getsize(tmpfile) == 0:
 	   url_failure(url, 'Non existing or empty file')
	os.remove(tmpfile)
	
def check_internal_link(url):
    # Checks whether the internal link corresponds
    # to a correct reference.
    
    global internal_targets
    
    target = url[1:]
    
    # Remove a trailing ' (Notes)' substring if any
    # This assumes that if the page exists, its notes page
    # exists too.
    
    if target.endswith(' (Notes)'):
       target = target[:-8]
           
    if target not in internal_targets:
       url_failure(url, 'No such target within document')
    

def check_url(url):

    debug('Checking link: ' + url)

    protocol = urlparse.urlparse(url)[0]

    if url[0] == '#':
       check_internal_link(url)
    elif protocol == 'http' or protocol == 'https':
       check_http_url(url)
    elif protocol == 'ftp':
       check_ftp_url(url)
    else:
       # Still try to handle other protocols
       try:
           urllib2.urlopen(url)
       except:
           url_failure(url, 'Unknown - Please report this to the developers of this script!') 

def get_hostname(url):

    return urlparse.urlparse(url)[1]

def url_is_ignored(url):

    global options

    hostname = get_hostname(url)
    return (options.exclude_hosts.count(hostname))
     
def check_url_threaded(url):

    global lock, host_lock, thread_count, tokens_per_host, options

    # Check that we don't run too many parallel checks per host
    # That could bring the host down or at least be considered
    # as the Denial of Service attack.

    hostname = get_hostname(url)

    if options.verbose:
       print '[INFO]  Checking hyperlink:', url

    # Counting parallel requests to the same host.
    # Of course, ignore local links 

    if hostname != '':

       wait = True

       host_lock.acquire()

       if not tokens_per_host.has_key(hostname):
          tokens_per_host[hostname] = options.max_requests_per_host

       host_lock.release()

       while wait:
          host_lock.acquire()
          if tokens_per_host[hostname] > 0:
             tokens_per_host[hostname] -= 1
             host_lock.release()
             wait = False
	  else:
             host_lock.release()
	     time.sleep(1)
        
    # Do the URL check!

    check_url(url)

    # The thread is about to complete, decrement the counters

    lock.acquire()
    thread_count -= 1
    lock.release()

    if hostname != '':
       host_lock.acquire()
       tokens_per_host[hostname] += 1
       host_lock.release()

def xml_start_element(name, attributes):

    # Called by the xml parser on each xml start tag

    global link_text, link_type, link_xml_file, xml_file, current_url, inside_link, internal_targets, inside_frame, current_frame
    global inside_heading, heading_level
    
    # Process hyperlinks
    
    if attributes.has_key('xlink:href') and (name == 'text:a' or name == 'form:button' or name == 'draw:a'):
    
       inside_link = True
       current_url = attributes['xlink:href']
       
       if not link_text.has_key(current_url):
          link_text[current_url], link_type[current_url], link_xml_file[current_url] = [], [], []
       
       link_xml_file[current_url].append(xml_file)

       if name == 'text:a':
          link_type[current_url].append('text')
       elif name == 'form:button':
          link_text[current_url].append(attributes['form:label'])
	  link_type[current_url].append('button')
       elif name == 'draw:a':
          link_text[current_url].append(attributes['office:name'])
          link_type[current_url].append('image')
	  
    # Presentation: record page names, to which internal links can be made
    
    elif name == 'draw:page':
       internal_targets.add(attributes['draw:name'])
       
    # Text documents: things to which internal links can be made
    
    elif name == 'text:bookmark':
       internal_targets.add(attributes['text:name'])
    elif name == 'text:section':
       internal_targets.add(attributes['text:name'] + '|region')
    elif name == 'draw:frame' and attributes.has_key('draw:name'):
       current_frame = attributes['draw:name']
       inside_frame = True
    elif name == 'table:table':
       internal_targets.add(attributes['table:name']+ '|table')
    elif name == 'text:h':
       inside_heading = True
       heading_level = int(attributes['text:outline-level'])      
    elif inside_frame:
         if name == 'draw:image':
            internal_targets.add(current_frame + '|graphic')
	 elif name == 'draw:text-box':
            internal_targets.add(current_frame + '|frame')
	 elif name == 'draw:object':
            internal_targets.add(current_frame + '|ole')
       
def xml_char_data(data):
    
    global current_url, current_element, link_type, link_text
    global inside_heading, heading_level, internal_targets
    
    if inside_link:    
       if link_type[current_url][-1] == 'text':
       
       	  # Truncate link text if too long
       
          if len(data) > 77:
	     data = data[:77] +  '...'
	     
          link_text[current_url].append(data)

    if inside_heading:
       internal_targets.add(repeat_string('0.', heading_level,) + data + '|outline')            

def xml_end_element(name):

    global inside_link, inside_frame, inside_heading
    
    if inside_link and (name == 'text:a' or name == 'form:button' or name == 'draw:a'):
       inside_link = False
    elif inside_frame and (name == 'draw:frame'):
       inside_frame = False
    elif inside_heading and (name == 'text:h'):
       inside_heading = False
       
       
##########################################################
# Main program
##########################################################

# Command parameters
# Either default values, found in a configuration file
# or on the command line
# 

usage = 'usage: %prog [options] [OpenOffice.org document files]'
description = 'Checks OpenOffice.org documents for broken Links'

optparser = configparse.OptionParser(usage=usage, version='coool 1.0', description=description)

optparser.add_option('-v', '--verbose',
		     config='true',
                     action='store_true', dest='verbose', default=False,
                     help='display progress information')

optparser.add_option('-d', '--debug',
		     config='true',
                     action='store_true', dest='debug', default=False,
                     help='display debug information')

optparser.add_option('-t', '--max-threads',
		     config='true',
                     action='store', type='int', dest='max_threads', default=100,
                     help='set the maximum number of parallel threads to create')

optparser.add_option('-r', '--max-requests-per-host',
		     config='true',
                     action='store', type='int', dest='max_requests_per_host', default=5,
                     help='set the maximum number of parallel requests per host')

optparser.add_option('-x', '--exclude-hosts',
		     config='true',
                     action='store', type='string', dest='exclude_hosts', default='',
                     help='ignore urls which host name belongs to the given list')

(options, args) = optparser.parse_args(files=[os.path.expanduser("~/.cooolrc")])

if len(args) == 0:
   print 'No files to check. Exiting.'
   sys.exit()

# Turn options.exclude_hosts into a list, for exact matching

options.exclude_hosts = options.exclude_hosts.split()

# Iterate on all given documents

for input_file in args:

    if options.verbose:
       print '[INFO]  Checking links in file', input_file, '...'

    link_text = dict()
    link_type = dict()
    link_xml_file = dict()
    internal_targets = set()
    thread_count = 0
    tokens_per_host = dict()

    # Unzip the OpenOffice (Open Document Format) document

    try:
    	zip = zipfile.ZipFile(input_file, 'r')
    except IOError, (errno, why):
    	print '   ERROR: Cannot open file', input_file, ':', why
    	sys.exit()
    except zipfile.BadZipfile:
    	print '   ERROR: not an OpenOffice.org Open Document Format document:', input_file
    	sys.exit()

    # Parse xml files and record urls
        
    global xml_file

    for xml_file in ['styles.xml', 'content.xml']: 
 
 	try:
 	    xml_contents = zip.read(xml_file)
 	except KeyError:
 	    print '   ERROR: not an OpenOffice.org Open Document Format document:', input_file
	    sys.exit()
	    
	inside_link, inside_frame, inside_heading = False, False, False

	parser = xml.parsers.expat.ParserCreate()
        parser.StartElementHandler = xml_start_element
	parser.CharacterDataHandler = xml_char_data
	parser.EndElementHandler = xml_end_element
 	parser.Parse(xml_contents)	
	
    zip.close()
    
    # Run URL checks
    
    found_errors = False

    for url in link_type.keys():
    
	if not url_is_ignored(url):

	   # Do not exceed the max number of threads

	   while thread_count > options.max_threads:
	     time.sleep(1)

           # Keep track of the number of pending URL checks
	   # It will be up to the checking thread to decrease thread_count
	
           lock.acquire()
           thread_count += 1
	   lock.release()

           args = url,
           thread.start_new_thread(check_url_threaded, args)
    
    # Wait for all threads to complete
 
    while thread_count > 0:
    	  if options.verbose:
    	     print '[INFO]  Waiting for URL checks to complete:', thread_count, 'threads left'
    	  time.sleep(1)
 
    if options.verbose:
       print '[INFO]  Hyperlink checking complete.'
