#!/usr/bin/env python
################################################################################
## This is version 0.2 of PyBib, a little tool to produce an inline bibliography
## out of a .bib file and a .tex file, the latter containing references in the
## form \cite{ref1} (or multiple: \cite{ref1,ref2}) and the former containg the information
## associated with these references, e.g.:
## @article{ref1,author={van Schaik, K.},title={Garbage},journal={Dustbin},year={2010},volume={0},pages={0--10}}
## . This example would have this tool produce the following output, which can then be
## copy-pasted into your .tex file:
##
## \begin{thebibliography}{99}
## \bibitem{ref1} van Schaik, K. (2010) Garbage. \textit{Dustbin} \textbf{0}, 0--10.
## \end{thebibliography}
##
## You can change the actual formatting of this output by adjusting gl_formattingStyleDic.
## 
## Copyright 2010 Kees van Schaik (keesvanschaik@gmail.com).
##
## 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, see <http://www.gnu.org/licenses/>.
##
################################################################################

gl_formattingStyleDic={
    "article":r"\bibitem{[id]} [author] ([year]) [title]. <i>[journal]</i> <b>[volume]</b>, [pages].",
    "book":r"\bibitem{[id]} [author] ([year]) [title]. <i>[publisher]</i>."
}


class App:
    def __init__(self, master):
        self.pathToBibFile=None; self.pathToTexFile=None
        ###Main frame:
        frameOuter = Frame(master)
        frameOuter.grid()
        ###Frame for choosing .bib file:
        frameBibFile=LabelFrame(frameOuter,text="Choose .bib file:")
        Button(frameBibFile,text="Choose .bib file...",command=self.onClickSelectBibFile).grid(sticky=W)
        self.bibFileEntry=Entry(frameBibFile,width=70)
        self.bibFileEntry.grid(row=1,column=0)
        frameBibFile.grid(padx=5,pady=5,sticky=W+E)
        ###Frame for choosing .tex file:
        frameTexFile=LabelFrame(frameOuter,text="Choose .tex file:")
        Button(frameTexFile,text="Choose .tex file...",command=self.onClickSelectTexFile).grid(sticky=W)
        self.texFileEntry=Entry(frameTexFile,width=70)
        self.texFileEntry.grid(row=1,column=0)
        frameTexFile.grid(padx=5,pady=5,sticky=W+E)
        ###Frame for displaying warnings etc.:
        frameComments=LabelFrame(frameOuter,text="Comments:")
        self.commentsText=KScrolledText(frameComments,height=6)
        #self.commentsText.appendFromType(20*"tester")
        #for i in range(20):
        #    self.commentsText.appendFromType("Hallo Henk!",'error')
        self.commentsText.grid()
        frameComments.grid(padx=5,pady=5)
        ###Frame for displaying bibliography:
        frameBiblio=LabelFrame(frameOuter,text="Bibliography:")
        self.biblioText=KScrolledText(frameBiblio,height=10)
        #self.biblioText.set(20*"Hallo Henk!\n")
        self.biblioText.grid()
        frameBiblio.grid(padx=5,pady=5)        
        ###Frame for the main buttons:
        frameButtons=Frame(frameOuter,padx=5,pady=5)
        Button(frameButtons,text="Produce bibliography",command=self.onClickProduce).grid()
        Button(frameButtons,text="Close",command=self.onClickCancel).grid(row=0,column=1)
        frameButtons.grid(padx=5,pady=5) 
        
    def warningProcessor(self,s):
        """Used to tell the objects used for the actual work what to do with the warnings they might generate."""
        self.commentsText.appendFromType(s,type='warning')
        
    def onClickProduce(self):
        """Runs the main code."""
        ###Get the paths to the files:
        self.pathToBibFile=self.bibFileEntry.get(); self.pathToTexFile=self.texFileEntry.get()
        ###Show error dialog if one of the paths is empty:
        if self.pathToBibFile=="":
            showerror("Error","Please first select a .bib file...")
            return
        if self.pathToTexFile=="":
            showerror("Error","Please first select a .tex file...")
            return
        ###Parse .bib file:
        self.commentsText.appendFromType("Parsing .bib file...")
        try:
            BDb=BibEntriesDatabase(self.pathToBibFile,self.warningProcessor)
        except Exception,e:
            self.commentsText.appendFromType("the following Python error occurred:\n\n"+str(e),type='error')
            return
        self.commentsText.appendFromType("...done! Succesfully processed "+str(len(BDb.bibEntries))+" items, number of items skipped: "+str(BDb.noSkipped))
        ###Open .tex file, find all references and build an inline bibliography:
        self.commentsText.appendFromType("Reading \cite{...} commands from .tex file and creating inline bibliography...")
        try:
            Bib=Bibliography(self.pathToTexFile,self.warningProcessor)
            bibliography=Bib.createBibliography(BDb)
        except Exception,e:
            self.commentsText.appendFromType("the following Python error occurred:\n\n"+str(e),type='error')
            return
        self.commentsText.appendFromType("...done! Number of (unique) ref's found: "+str(Bib.noUniqueIds)+", of which succesfully processed: "+str(Bib.noUniqueIds-Bib.citeEntriesSkipped))
        ###Display the inline bibliography:
        self.biblioText.set(bibliography)
        
    def onClickCancel(self):
        root.destroy()
        
    def onClickSelectBibFile(self):
        res=tkFileDialog.askopenfilename()
        if len(res)>0:  ###otherwise the dialog was canceled
            #self.pathToBibFile=res
            self.bibFileEntry.delete(0,END)
            self.bibFileEntry.insert(0,res)
        
    def onClickSelectTexFile(self):
        res=tkFileDialog.askopenfilename()
        if len(res)>0:  ###otherwise the dialog was canceled
            #self.pathToBibFile=res
            self.texFileEntry.delete(0,END)
            self.texFileEntry.insert(0,res)
        
        
        
class BibEntry:
    """An object that represents an item from the bibliography, i.e. a book, article etc. The attributes holding the information
    about the item are prepended by 'bib_', i.e. 'bib_author'. Use self.addKey() and self.getKey() to deal with this prepending
    automatically.  
    """
    def __init__(self):
        pass
        
    def addKey(self,name,value):
        setattr(self,'bib_'+name,value)
        
    def getKey(self,name):
        return getattr(self,'bib_'+name)
    
    def getKeyOrEmpty(self,name):
        try:
            return self.getKey(name)
        except AttributeError:
            return ""
        
    def __repr__(self):
        vals=""
        for el in self.__dict__:
            if el[:4]=='bib_':
                vals+='\n'+el[4:]+' = '+getattr(self,el)
        if len(vals)==0:
            return "BibEntry() object with no fields"
        return "BibEntry() object with the fields:"+vals

class Style:
    """An object representing a style that determines how a BibEntry object should be transformed into a string (*) that can be used in
    the inline bibliography of a .tex file. This object uses the dictionary gl_formattingStyleDic (defined at the top of this file) 
    to map self.type to a string that describes the formatting. Call self.applyTo() on a BibEntry object to obtain the string (*). 
    """
    def __init__(self,type):
        self.type=type
        self.toEmpty=["<i></i>","<b></b>"]
        self.styleReplacements={"<i>":r"\textit{", "</i>":"}", "<b>":r"\textbf{", "</b>":"}"}
        self.baseString=gl_formattingStyleDic[type]  
        self.fieldsInBaseString=[]
        inField=False
        for ch in self.baseString:
            if ch=='[':
                inField=True; fieldName=''
            elif ch==']':
                inField=False; self.fieldsInBaseString.append(fieldName)
            elif inField:
                fieldName=fieldName+ch

    def applyTo(self,bibEntry):
        res=self.baseString
        for field in self.fieldsInBaseString:
            try:
                res=res.replace('['+field+']',bibEntry.getKey(field))
            except AttributeError:
                res=res.replace('['+field+']',"")
        for el in self.toEmpty:
            res=res.replace(el,"")
        for key in self.styleReplacements.keys():
            res=res.replace(key,self.styleReplacements[key])
        return res 


class BibEntriesDatabase:
    """An object that represents a collection of BibEntry objects. 
    
    It receives a filename, opens the .bib-file associated with it and
    reads in its content. It assumes that the file is built up of the following text blocks (may be distributed over multiple lines):
    @<type>{<id>,<lhs1>=<rhs1>,...,<lhsN>=<rhsN>}
    where <type> is article, book, ...; <id> is an identifer used to refer to this entry in the .tex-file;
    <lhs.>=<rhs.> is a field like author={Van Schaik, K.} etc.
    Characters following a % are ignored (comments).
    """
    def __init__(self,filename,warningProcessor=None):
        self.warningProcessor=warningProcessor
        self.bibEntries=[]   ###contains the BibEntry objects found in the .bib file filename
        commentSymbol='%'; separatorSymbol=','; self.noSkipped=0
        self.inEntry=False; lineCount=0; entry=""
        for line in open(filename,'r'):
            lineCount+=1
            line=line.rstrip('\n').strip()  ###Remove end of line symbol and white space
            if commentSymbol in line:
                line=line.split(commentSymbol)[0]  ###Remove commented part
            if len(line)>0:   ###Only do something if line still has contents
                if '@' in line:
                    parts=line.split('@')
                    for el in [entry+parts[0]]+['@'+part for part in parts[1:-1]]:
                        bibEntryTpl=self.parseStringToBibEntry(el)
                        if bibEntryTpl[0]:
                            if not bibEntryTpl[1] is None:
                                self.bibEntries.append(bibEntryTpl[1])
                        else:
                            self.noSkipped+=1
                            self.processWarning("could not parse the following part from the file "+filename+" (it ends on line number "+str(lineCount)+"):\n\n"+el+"\n\nReason:\n\n"+bibEntryTpl[1])
                    entry='@'+parts[-1]
                else:
                    entry+=line
        ###Parse last part:
        bibEntryTpl=self.parseStringToBibEntry(entry)
        if bibEntryTpl[0]:
            self.bibEntries.append(bibEntryTpl[1])
        else:
            self.processWarning("could not parse the following part from the file "+filename+" (it ends on line number "+str(lineCount)+"):\n\n"+entry+"\n\nReason:\n\n"+bibEntryTpl[1])        

                    
    def parseStringToBibEntry(self,strToParse):
        fieldsFound={}
        s=strToParse.strip()
        if s=="":
            return (True,None)
        if not s[0]=='@':
            return (False,"Received string does not start with '@', I'm therefore skipping this part")
        brack=s.find('{')
        if brack==-1:
            return (False,"Received string does not contain any opening bracket '{', I'm therefore skipping this part")
        elif brack==1:
            return (False,"Received string does not seem to contain a type declaration between '@' and the first '{', I'm therefore skipping this part")
        else:
            fieldsFound['type']=s[1:brack]
        if not s[-1]=='}':
            return (False,"Received string does not end with a closing bracket '}', I'm therefore skipping this part")
        s=s[brack+1:-1]
        ###Break s up in parts that are separated by commas not contained in a {}-block:
        #+"\n\nI'm therefore skipping this part"
        if len(s)==0:
            return (False,"Received string does not seem to contain any contents between '{' and '}', I'm therefore skipping this part")
        parts=[]; countBrack=0; mode='id'; lhs=[]; rhs=[None,]
        part=""; pos=0
        for ch in s:
            pos+=1
            if ch==',' and countBrack==0:
                ###We are entering a new field:
                if mode=='id':
                    ###We get here when we are between the first and the second field, hence part should be the entry identifier:
                    if part=="":
                        return (False,"Received string seems to contain an empty identifier, I'm therefore skipping this part")
                    else:
                        lhs.append(part.strip()); mode='lhs'; part=""
                elif mode=='lhs':
                    return (False,"At position "+str(pos)+", a ',' occurs while I haven't yet encountered a '=' in this field, I'm therefore skipping this part")
                else:
                    rhs.append(part.strip()); mode='lhs'; part=""
            elif ch=='=' and countBrack==0:
                ###We are switching from left to right hand side:
                if mode=='rhs':
                    return (False,"At position "+str(pos)+", a '=' occurs while I haven't yet encountered a ',' to indicate the start of a new field, I'm therefore skipping this part")
                else:
                    lhs.append(part.strip()); mode='rhs'; part=""
            else:
                part+=ch
            if ch=='{':
                countBrack+=1
            if ch=='}':
                countBrack+=-1            
        if len(part)>0 and len(rhs)==len(lhs)-1:
            ###the final field was not closed by a ,:
            rhs.append(part.strip())
        if not len(lhs)==len(rhs):
            return (False,"For some reason the amount of left hand sides (="+str(len(lhs))+") is different from the amount of right hand sides (="+str(len(rhs))+")..., I'm therefore skipping this part")
        ###Process the arrays lhs and rhs:
        if lhs[0]=='':
            return (False,"Received string does not seem to contain an identifier, I'm therefore skipping this part")
        fieldsFound['id']=lhs[0]
        for i in range(1,len(lhs)):
            if lhs[0]=='':
                return (False,"Received string does seem to contain an empty left hand side, I'm therefore skipping this part")
            fieldsFound[lhs[i]]=rhs[i]
        ###Create the BibEntry object with suitable attributes:
        bibEntry=BibEntry()
        for key in fieldsFound.keys():
            val=fieldsFound[key]
            if val[0]=='{' and val[-1]=='}':
                val=val[1:-1]
            try:
                bibEntry.addKey(key,val)
            except Exception,e:
                return (False,"Python error: trying to set the attribute "+'bib_'+key+" with value "+fieldsFound[key]+" yielded the error:\n\n"+str(e)+"\n\n, I'm therefore skipping this part")
        return (True,bibEntry)
    
    def processWarning(self,s):
        if self.warningProcessor is None:
            exec("print 'WARNING: "+s+"'")
        else:
            self.warningProcessor(s)
    
    def getBibEntryById(self,id):
        for el in self.bibEntries:
            if el.getKey('id')==id:
                return el
        return None
 

class Bibliography:
    """An object that represents a bibliography to be included in a .tex file. Given the .tex file filename, it reads in the
    content of that file. Call self.createBibliography() on a BibEntriesDatabase object to do the following. From each \cite{...} block in 
    filename the id's are read (maybe more, separated by commas as usual) (e.g. \cite{Kees2004,Henk1986}). For each such id, the 
    corresponding BibEntry object from the BibEntriesDatabase object is found and, when not already encountered before, formatted 
    according to the style that is associated with its type (via the dictionary gl_formattingStyleDic), the thus generated string 
    appended to the output. The output is shown in the standard text editor. 
    """
    def __init__(self,filename,warningProcessor=None):
        self.warningProcessor=warningProcessor
        self.content=''.join(open(filename,'r').readlines())
        self.bibEntriesInText=[]; self.citeEntriesSkipped=0; self.noUniqueIds=0

    def createBibliography(self,bibEntriesDatabase):
        typeToStyleObject={}
        for type in gl_formattingStyleDic.keys():
            typeToStyleObject[type]=Style(type)
        ###Find all occurances of '\cite{...}' in self.content and put ... in idsFound:
        idsFound=[]; offset=0
        while True:
            index=self.content.find(r'\cite{',offset)
            if index==-1:
                break
            endIndex=self.content.find(r'}',index)
            ids=self.content[index+len(r'\cite{'):endIndex].split(",")
            for id in [el.strip() for el in ids]:
                if not id in idsFound:
                    idsFound.append(id)
            offset=endIndex
        self.noUniqueIds=len(idsFound)
        ###Try to match all idsFound with ids in the bibEntriesDatabase:
        for id in idsFound:
            bibEntry=bibEntriesDatabase.getBibEntryById(id)
            if bibEntry is None:
                self.processWarning("the ref. with id="+id+" from the .tex file has no matching entry in the .bib file")
                self.citeEntriesSkipped+=1
            else:
                self.bibEntriesInText.append(bibEntry)
        ###Build the output by sorting self.bibEntriesInText and formatting each entry:
        output=""  #sorted(self.bibEntriesInText, key=lambda bibentry: (bibentry.getKeyOrEmpty('author'),bibentry.getKeyOrEmpty('year'),bibentry.getKeyOrEmpty('title')))
        for bibEntry in sorted(self.bibEntriesInText, key=lambda bibentry: (bibentry.getKeyOrEmpty('author'),bibentry.getKeyOrEmpty('year'),bibentry.getKeyOrEmpty('title'))):
            if bibEntry.getKey('type') in typeToStyleObject.keys():
                output+=typeToStyleObject[bibEntry.getKey('type')].applyTo(bibEntry)+'\n'
            else:
                self.processWarning("the entry with id="+bibEntry.getKey('id')+" has type="+bibEntry.getKey('type')+", for this type no formatting style is defined")
                self.citeEntriesSkipped+=1
        ###Finally:
        return r'\begin{thebibliography}{'+str(self.noUniqueIds-self.citeEntriesSkipped)+'}'+'\n'+output+r'\end{thebibliography}'
    
    def processWarning(self,s):
        if self.warningProcessor is None:
            exec("print 'WARNING: "+s+"'")
        else:
            self.warningProcessor(s)
    


if __name__ == '__main__':
    from Tkinter import *
    import tkFileDialog
    from tkMessageBox import showerror
            
    class KScrolledText(Frame):
        """A Text widget extended with a horizontal and vertical scrollbar, text that is too
        wide is not wrapped."""
        def __init__(self, master, **kwargs):
            apply(Frame.__init__, (self, master))
            self.config(bd=2,relief=SUNKEN)
            self.grid_rowconfigure(0, weight=1)
            self.grid_columnconfigure(0, weight=1)
            scrollbarVert=Scrollbar(master)
            scrollbarHoriz=Scrollbar(master,orient=HORIZONTAL)
            self.Text=Text(master,kwargs,bd=0,wrap=NONE,yscrollcommand=scrollbarVert.set,xscrollcommand=scrollbarHoriz.set)
            scrollbarVert.config(command=self.Text.yview)
            scrollbarHoriz.config(command=self.Text.xview)
            self.Text.grid()
            scrollbarVert.grid(row=0,column=1,sticky=N+S)
            scrollbarHoriz.grid(row=1,column=0,sticky=E+W)
            self.Text.tag_config('warning', foreground='orange')
            self.Text.tag_config('error', foreground='red')
            self.Text.tag_config('message', foreground='green')
            
        def get(self):
            return self.Text.get(1.0, END)
        
        def isEmpty(self):
            if len(self.get())<=1:
                return True
            return False
        
        def set(self,s):
            #self.Text.config(state=NORMAL)
            self.Text.delete(1.0, END)
            self.Text.insert(END,s)
            #self.Text.config(state=DISABLED)
            
        def append(self,s,newLine=True,tag=None):
            if newLine:
                s='\n'+s
            self.Text.insert(END,s,tag)
            
        def appendFromType(self,s,type=None):
            ###type can be 'warning' or 'error':
            typeToText={'warning':'Warning: ', 'error':'Error: '}
            if self.isEmpty():
                newLine=False
            else:
                newLine=True
            if type is None:
                self.append(s,newLine)
            else:
                self.append("  "+typeToText[type],newLine,tag=type)
                self.append(s,newLine=False)    
    
    root = Tk()
    #root.configure(height=200,width=200)
    root.geometry("%+d%+d" % (200, 50))
    root.title('PyBib')
    app = App(root)
    root.mainloop()
    