#!/usr/bin/env python3
'''
A graphical symlink editor using GTK+ 3 and Python 3
For commandline options try 'symlink-editor --help'
'''
#=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~#
# Copyright (C) 2017 Brianna Rainey
#
# Symlink Editor is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program, in a file named "COPYING".  If not, see
# <http://www.gnu.org/licenses/>
#
#=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~#

import argparse
import os
import stat
import signal
from tempfile import gettempdir
from subprocess import call
try:
    import gi
    gi.require_version('Gtk', '3.0')
    from gi.repository import Gtk
except ModuleNotFoundError:
    print('You need to install python3 gtk3 bindings (python3-gi)')
    quit(1)

HOME = os.getenv("HOME")
DEFAULTPATH = '%s/Bookmarks' % HOME
SRCPATH = "%s-src" % os.path.realpath(__file__)
FOLDER = "%s/folder.png" % SRCPATH
ENTRYWIDTH = 25
OSERRORMSG =\
'''Couldn't complete file operation.

This could be because a file with that name already exists,
you don't have write permission, a file changed while you
were editing it, or the path contains a directory that does
not exist.'''

# TODO:
# give more specific OS error messages
# fix row getting unselected after each action
# make code readable by mortal eyes
# ability to escalate permission would be nice

class askWindow(Gtk.Dialog):
    def __init__(self, message):
        Gtk.Dialog.__init__(self, title="?")
        self.set_border_width(10)
        windowBody = self.get_content_area()
        windowBody.add(Gtk.Label(label=message))
        self.add_buttons(Gtk.STOCK_YES, Gtk.ResponseType.YES,
            Gtk.STOCK_NO, Gtk.ResponseType.NO)
        self.show_all()

class oopsWindow(Gtk.Dialog):
    def __init__(self, message):
        Gtk.Dialog.__init__(self, title="Oops")
        self.set_border_width(10)
        myBody = self.get_content_area()
        myBody.add(Gtk.Label(label=message))
        self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
        self.show_all()
        self.run()
        self.destroy()

class MainWindow(Gtk.Window):
    def __init__(self):
        # call mom's initializer first :)
        Gtk.Window.__init__(self, title="Symlink Editor")

        # attributes
        self.selectedRow=-1
        self.rows=[]
        self.SHOWFOLDERS=False
        self.SHOWHIDDEN=False
        self.closeme=False

        # do the rest in show_all() in case we never wanna show this window...

    def show_all(self, recreate=True):
        if not recreate:
            # call superclass method to show the contents already created
            Gtk.Window.show_all(self)
            return
        
        # create the mainwindow
        self.set_size_request(640, 480)
        self.set_border_width(10)
        # make navigation widgets
        self.filepathText = Gtk.Entry()
        self.filepathText.set_icon_from_icon_name(Gtk.PositionType.LEFT, 'folder')
        self.filepathText.set_icon_tooltip_text(Gtk.PositionType.LEFT, 'Go up one directory')
        self.filepathText.connect('icon-press', lambda _, __, ___: self.goUpDirectory())
        self.filepathText.connect('activate', lambda _: self.changeDirectory())
        goButton = Gtk.Button(label="Go")
        goButton.connect("clicked", lambda _: self.changeDirectory())
        navBox = Gtk.Box()
        navBox.pack_start(self.filepathText, True, True, 0)
        navBox.pack_start(goButton, False, False, 0)
        # make action button widgets
        wrenchIcon = Gtk.Image(); wrenchIcon.set_from_file("%s/wrench.png" % SRCPATH)
        notepadIcon = Gtk.Image(); notepadIcon.set_from_file("%s/notepad.png" % SRCPATH)
        minusIcon = Gtk.Image(); minusIcon.set_from_file("%s/minus.png" % SRCPATH)
        plusIcon = Gtk.Image(); plusIcon.set_from_file("%s/plus.png" % SRCPATH)
        arrowIcon = Gtk.Image(); arrowIcon.set_from_file("%s/arrow.png" % SRCPATH)
        self.actionButton1 = Gtk.Button(label="Edit")
        self.actionButton1.set_image(wrenchIcon)
        self.actionButton1.connect("clicked", lambda _: self.editAction())
        self.actionButton1b = Gtk.Button(label="Copy")
        self.actionButton1b.set_image(notepadIcon)
        self.actionButton1b.connect("clicked", lambda _: self.copyAction())
        self.actionButton1c = Gtk.Button(label=" Move")
        self.actionButton1c.set_image(arrowIcon)
        self.actionButton1c.connect("clicked", lambda _: self.moveAction())
        self.actionButton2 = Gtk.Button(label="New")
        self.actionButton2.set_image(plusIcon)
        self.actionButton2.connect("clicked", lambda _: self.newAction())
        plusIcon = Gtk.Image(); plusIcon.set_from_file("%s/plus.png" % SRCPATH)
        self.actionButton3 = Gtk.Button(label="Delete")
        self.actionButton3.set_image(minusIcon)
        self.actionButton3.connect("clicked", lambda _: self.deleteAction())
        self.actionButton4 = Gtk.CheckButton()
        self.actionButton4.connect("toggled", lambda _: self.toggleShowFolders())
        # attach paramaters are: col, row, wide, tall
        self.actionButtonBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.actionButtonBox.pack_start(self.actionButton1, False, False, 0)
        self.actionButtonBox.pack_start(self.actionButton1b, False, False, 0)
        self.actionButtonBox.pack_start(self.actionButton1c, False, False, 0)
        self.actionButtonBox.pack_start(self.actionButton2, False, False, 0)
        self.actionButtonBox.pack_start(self.actionButton3, False, False, 0)
        box = Gtk.Box(); box.add(Gtk.Label(label="Show folders"))
        box.pack_start(self.actionButton4, False, False, 0)
        self.actionButtonBox.pack_start(box, False, False, 0)
        # add exclamation point for superuser
        superuserBox = Gtk.Box(expand=True)
        if os.getenv("USER") == 'root':
            exclamIcon = Gtk.Image()
            exclamIcon.set_from_file('%s/caution.png' % SRCPATH)
            lrabel = Gtk.Label()
            lrabel.set_markup("<b> Superuser</b>")
            superuserBox.pack_start(exclamIcon, False, False, 0)
            superuserBox.pack_start(lrabel, False, False, 0)
        # then the file listing widgets
        self.filetree = Gtk.ListBox(expand=True)
        self.filetree.set_activate_on_single_click=False
        self.filetree.connect("row-selected", lambda _, row: self.selectFile(row))
        self.filetree.connect("row-activated", lambda _, __: self.navigate())
        # make a scrolledwindow for the filetree in case it's long
        filetreeWindow = Gtk.ScrolledWindow()
        filetreeWindow.set_border_width(10)
        filetreeWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        filetreeWindow.add(self.filetree)
        # make 'open with file manager' button
        openFmBox = Gtk.Box()
        folderIcon = Gtk.Image()
        folderIcon.set_from_file("%s/folderb.png" % SRCPATH)
        openFmButton = Gtk.Button(label="File Manager")
        openFmButton.set_image(folderIcon)
        openFmButton.connect("clicked", lambda _: openFm())
        openFmBox.pack_start(openFmButton, True, True, 0)

        # place all the freaking widgets
        self.bodyGrid = Gtk.Grid()
        self.add(self.bodyGrid)
        self.bodyGrid.add(openFmBox)
        self.bodyGrid.attach(self.actionButtonBox, 0, 1, 1, 1) # action buttons
        self.bodyGrid.attach(navBox, 1, 0, 4, 1) # col, row, width, height
        self.bodyGrid.attach_next_to(filetreeWindow, navBox,
            Gtk.PositionType.BOTTOM, 6, 8)
        self.bodyGrid.attach_next_to(superuserBox, self.actionButtonBox,
            Gtk.PositionType.BOTTOM, 1, 3)

    def updateFileListing(self):
        global PWD
        if PWD != os.path.realpath(PWD):
            # this stops dirname of the root dir from duplicating
            # AND stops user from using a path containing symlinks
            PWD = os.path.realpath(PWD)
        self.filepathText.set_text(PWD)
        # empty the old contents of filetree
        for row in self.filetree:
            self.filetree.remove(row)
        self.rows = []
        try:
            # woohoo list comprehensions!
            allFiles = [ifile for ifile in os.listdir(PWD)]
            # list of file modes to ensure we use lstat() as little as needed
            fileModes = [os.lstat('%s/%s' % (PWD, ifile)).st_mode for ifile in allFiles]
            allFiles = list([ifile for i, ifile in enumerate(allFiles) if not stat.S_ISREG(fileModes[i])])
            fileModes = list([ifile for ifile in fileModes if not stat.S_ISREG(ifile)])
            if self.SHOWFOLDERS == True:
                # show folders if enabled
                if self.SHOWHIDDEN:
                    directories = [ifile for i, ifile in enumerate(allFiles) \
                                    if stat.S_ISDIR(fileModes[i]) and \
                                    not stat.S_ISLNK(fileModes[i])]
                else:
                    directories = [ifile for i, ifile in enumerate(allFiles) \
                                    if stat.S_ISDIR(fileModes[i]) and \
                                    not stat.S_ISLNK(fileModes[i]) and \
                                    not ifile.startswith(".")]
                for ifile in sorted(directories):
                    self.rows.append( [ifile, False] )
                    newRow = Gtk.ListBoxRow(); rowBody = Gtk.Box()
                    folderIcon = Gtk.Image()
                    folderIcon.set_from_file(FOLDER)
                    rowBody.pack_start(folderIcon, False, False, 0)
                    rowBody.pack_start(Gtk.Label(label=" %s" % ifile), False, False, 0)
                    newRow.add(rowBody)
                    self.filetree.add(newRow)
            # show symlinks
            symlinks = [ifile for i, ifile in enumerate(allFiles) if stat.S_ISLNK(fileModes[i])]
            for ifile in sorted(symlinks):
                linkTarget = os.readlink("%s/%s" % (PWD, ifile))
                self.rows.append( [ifile, linkTarget] )
                newRow = Gtk.ListBoxRow(); rowBody = Gtk.Box()
                label = Gtk.Label()
                if os.path.exists(linkTarget) or os.path.exists('%s/%s' % (PWD, linkTarget)):
                    label.set_markup("<b>%s →</b> %s" % (ifile, linkTarget))
                else:
                    # TODO: fix this so it knows when it fails due to permissions
                    label.set_markup("<b>%s <span foreground='#dd0000'>→</span></b> %s" % (ifile, linkTarget))
                    label.set_tooltip_text('Target invalid or wrong permissions')
                rowBody.pack_start(label, False, False, 0)
                newRow.add(rowBody)
                self.filetree.add(newRow)
            # if there are no folders this prevents the filetree being totally empty
            totalLinks = len(symlinks)
            if totalLinks==0:
                self.filetree.add(Gtk.Label(label="No links here :("))
                self.rows.append( [False, False] )
        except OSError:
            self.filetree.add(Gtk.Label(label="File doesn't exist (check permissions?)"))
            self.rows.append( [False, False] )
        self.show_all(recreate=False)

    def changeDirectory(self):
        global PWD; PWD = self.filepathText.get_text()
        self.updateFileListing()

    def goUpDirectory(self):
        self.filepathText.set_text(os.path.dirname(self.filepathText.get_text()))
        self.changeDirectory()

    def navigate(self):
        if self.selectedRow != -1:
            selectedLink = self.rows[self.selectedRow]
        if self.selectedRow != -1 and selectedLink[0] != False and selectedLink[1] == False:
            self.filepathText.set_text('%s/%s' % (PWD, selectedLink[0]))
            self.changeDirectory()

    def selectFile(self, row):
        if row:
            self.selectedRow = row.get_index()
        else:
            self.selectedRow = -1

    def toggleShowFolders(self):
        if self.SHOWFOLDERS == False:
            self.hiddenFolderBox = Gtk.Box(); self.hiddenFolderBox.add(Gtk.Label(label="Show hidden"))
            self.actionButton5 = Gtk.CheckButton()
            self.actionButton5.connect("toggled", lambda _: self.toggleShowHidden())
            self.hiddenFolderBox.pack_start(self.actionButton5, False, False, 0)
            self.actionButtonBox.pack_start(self.hiddenFolderBox, False, False, 0)
        else:
            self.actionButton5.set_active(False)
            self.hiddenFolderBox.destroy()
        self.SHOWFOLDERS = not self.SHOWFOLDERS
        self.updateFileListing()

    def toggleShowHidden(self):
        self.SHOWHIDDEN = not self.SHOWHIDDEN
        self.updateFileListing()

    def moveAction(self):
        global PWD
        if self.selectedRow != -1:
            selectedLink = self.rows[self.selectedRow]
        if self.selectedRow != -1 and selectedLink[0]!=False and selectedLink[1]!=False:
            window = Gtk.Dialog("Move")
            window.set_size_request(300, 100)
            window.set_border_width(10)
            window.set_transient_for(self)
            window.set_modal(True)
            window.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
            windowBody = window.get_content_area(); windowBody.set_orientation(Gtk.Orientation.VERTICAL)
            windowBody.pack_start(Gtk.Label(label="Enter a new path for this file:"), False, False, 0)
            otherBox = Gtk.Box(); pathEntry = Gtk.Entry()
            pathEntry.set_text(PWD)
            otherBox.pack_start(pathEntry, True, True, 0)
            otherBox.pack_start(Gtk.Label(label="/%s" % selectedLink[0]), False, False, 0)
            windowBody.pack_start(otherBox, False, False, 0)
            window.show_all()
            reply = window.run()
            if reply == Gtk.ResponseType.OK:
                newPath = pathEntry.get_text()
                if newPath.endswith('/'):
                    # remove trailing slash
                    newPath = newPath[:-1]
                if newPath != PWD:
                    try:
                        os.rename('%s/%s' % (PWD, selectedLink[0]), '%s/%s' % (newPath, selectedLink[0]))
                        # move the user to this directory so they can see where the file went
                        PWD = str(newPath)
                        self.updateFileListing()
                    except OSError:
                        oopsWindow(OSERRORMSG)
            window.destroy()

    def copyAction(self):
        if self.selectedRow != -1:
            selectedLink = self.rows[self.selectedRow]
        if self.selectedRow != -1 and selectedLink[0]!=False and selectedLink[1]!=False:
            window = Gtk.Dialog(title="Copy")
            window.set_transient_for(self)
            window.set_modal(True)
            window.set_border_width(10)
            windowBody = window.get_content_area()
            windowBody.set_orientation(Gtk.Orientation.VERTICAL)
            titleEntry = Gtk.Entry()
            titleEntry.set_text(selectedLink[0])
            windowBody.pack_start(Gtk.Label(label="Enter a new name for the copy"), False, False, 0)
            windowBody.pack_start(titleEntry, False, False, 0)
            window.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
            window.show_all()
            reply = window.run()
            newTitle = titleEntry.get_text()
            if reply == Gtk.ResponseType.OK and newTitle != selectedLink[0]:
                try:
                    os.symlink(selectedLink[1], '%s/%s' % (PWD, newTitle))
                except OSError:
                    oopsWindow(OSERRORMSG)
                self.updateFileListing()
            window.destroy()

    def editAction(self, **kwargs):
        selectedLink = (False, False)
        if 'path' in kwargs:
            selectedLink = kwargs['path']
        elif self.selectedRow != -1:
            selectedLink = self.rows[self.selectedRow]

        # selectedLink is false when the selected row is:
        # 1) a message row, 2) a folder row, 3) no row is selected
        if not (selectedLink[0]!=False and selectedLink[1]!=False):
            return

        editWindow = Gtk.Dialog(title="Edit")
        if self.closeme:
            editWindow.connect('destroy', lambda _: quit())
        editWindow.set_border_width(10)
        editWindow.set_transient_for(self)
        # block the main window until this dialog is dismissed
        editWindow.set_modal(True)
        editWindow.add_buttons(Gtk.STOCK_APPLY,  Gtk.ResponseType.APPLY,
                                Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
        editWindowBody = editWindow.get_content_area()
        editWindowBody.set_orientation(Gtk.Orientation.VERTICAL)
        box = Gtk.Box(); editWindowBody.pack_start(box, False, False, 0)
        larbel = os.path.join(PWD, selectedLink[0])
        linkTitle = Gtk.Entry(); linkTitle.set_text(larbel)
        box.pack_start(Gtk.Label(label="Title:  "), False, False, 0)
        box.pack_start(linkTitle, True, True, 0) # True means 'stretch to fill space'
        box2 = Gtk.Box(); editWindowBody.pack_start(box2, False, False, 0)
        linkTarget = Gtk.Entry(); linkTarget.set_text(selectedLink[1])
        linkTarget.set_width_chars(ENTRYWIDTH)
        # browse button
        browseButton = Gtk.Button(label="Browse…")
        browseButton.connect('clicked',
            lambda _: self.browseWindow(linkTarget))
        box2.pack_start(Gtk.Label(label="Target: "), False, False, 0)
        box2.pack_start(linkTarget, True, True, 0)
        box2.pack_start(browseButton, False, False, 0)
        linkTitle.grab_focus()
        # select name of symlink (skip trailing '/' if PWD is not root)
        linkTitle.select_region(len(PWD) + (PWD != '/'), -1)
        editWindow.show_all()
        reply = editWindow.run()
        if reply == Gtk.ResponseType.APPLY:
            linkTargetText = linkTarget.get_text()
            linkTitleText = linkTitle.get_text()
            if linkTargetText != selectedLink[1]:
                # if target changed, use 'os.symlink'
                source = '%s/%s' % (PWD, selectedLink[0])
                destination = '%s/gfhjkl%s' % (SRCPATH, selectedLink[0])
                # first make a copy in case there's a problem
                os.rename(source, destination)
                try:
                    os.symlink(linkTargetText, linkTitleText)
                except OSError:
                    # undelete original file by moving tempfile copy
                    if not os.path.exists(source):
                        os.rename(destination, source)
                    oopsWindow(OSERRORMSG)
                else:
                    # remove tempfile because everything went hunky-dory
                    os.remove(destination)
            elif linkTitleText != larbel:
                # otherwise just use os.rename
                try:
                    os.rename(larbel, linkTitleText)
                except OSError:
                    oopsWindow(OSERRORMSG)
        editWindow.destroy()
        if not self.closeme:
            self.updateFileListing()

    def browseWindow(self, targetEntry):
        # lets user pick a file to target
        window = Gtk.FileChooserDialog(title="Choose a file…")
        window.set_current_folder(PWD)
        window.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
        window.show_all()
        reply = window.run()
        targetPath = window.get_filename()
        if reply == Gtk.ResponseType.OK and targetPath:
            targetEntry.set_text(targetPath)
        window.destroy()

    def newAction(self, **kwargs):
        window = Gtk.Dialog(title="Create new symlink")
        window.set_border_width(10)
        windowBody = window.get_content_area()
        windowBody.set_orientation(Gtk.Orientation.VERTICAL)
        titleEntry = Gtk.Entry(); targetEntry = Gtk.Entry()
        if PWD == '/':
            label = PWD
        else:
            label = '%s/' % PWD
        titleEntry.set_text(label)
        targetEntry.set_width_chars(ENTRYWIDTH)
        if 'target' in kwargs:
            targetEntry.set_text(kwargs['target'])
        titleEntryBox = Gtk.Box()
        titleEntryBox.pack_start(Gtk.Label(label='Title: '), False, False, 0)
        titleEntryBox.pack_start(titleEntry, True, True, 0)
        windowBody.pack_start(titleEntryBox, False, False, 0)
        targetEntryBox = Gtk.Box()
        targetEntryBox.pack_start(Gtk.Label(label="Target: "), False, False, 0)
        targetEntryBox.pack_start(targetEntry, True, True, 0)
        # browse button
        browseButton = Gtk.Button(label="Browse…")
        browseButton.connect('clicked', lambda _: self.browseWindow(targetEntry))
        targetEntryBox.pack_start(browseButton, False, False, 0)
        windowBody.pack_start(targetEntryBox, False, False, 0)
        window.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
        titleEntry.grab_focus()
        titleEntry.select_region(-1, -1)
        window.show_all()
        reply = window.run()
        if reply == Gtk.ResponseType.OK \
            and len(targetEntry.get_text())>0 and len(titleEntry.get_text())>0:
            try:
                os.symlink(targetEntry.get_text(), titleEntry.get_text())
            except OSError:
                oopsWindow(OSERRORMSG)
        if not self.closeme:
            self.updateFileListing()
        window.destroy()

    def deleteAction(self):
        if self.selectedRow != -1:
            selectedLink = self.rows[self.selectedRow]
        if self.selectedRow != -1 \
            and selectedLink[0] != False and selectedLink[1] != False:
            window = askWindow("Are you sure you want to delete this?")
            window.set_transient_for(self)
            window.set_modal(True)
            reply = window.run()
            if reply == Gtk.ResponseType.YES:
                try:
                    os.remove('%s/%s' % (PWD, selectedLink[0]))
                    self.updateFileListing()
                except OSError:
                    oopsWindow(OSERRORMSG)
            window.destroy()

def openFm():
    call('xdg-open %s' % PWD,shell=True)

def main():
    global PWD
    # first parse command line arguments
    parser = argparse.ArgumentParser(description='graphically manage symbolic links')
    parser.add_argument('path-to-edit', help='open an edit dialog for this path', nargs='?')
    parser.add_argument('-new', help='open a dialog to create a new link', action='store_true')
    parser.add_argument('-dir', help='start in this path instead of the default', default=DEFAULTPATH)
    parser.add_argument('-leaveopen', help='leave main window open after editing', action='store_true')
    arg = vars(parser.parse_args()) # return arguments as a dictionary
    if os.path.isdir(arg['dir']):
        PWD = arg['dir']
    elif os.path.isdir(DEFAULTPATH):
        PWD = DEFAULTPATH
    else:
        PWD = HOME


    window = MainWindow()
    window.set_icon_from_file("%s/icon.svg" % SRCPATH)
    window.connect("delete-event", Gtk.main_quit)

    if arg['path-to-edit'] and not arg['leaveopen']:
        window.closeme=True
        window.hide()
    else:
        window.show_all()

    if arg['path-to-edit']:
        if arg['path-to-edit'].startswith('/'):
            # an absolute path
            PWD = os.path.dirname(arg['path-to-edit'])
        else:
            # hopefully a relative path
            PWD = os.getcwd()
        if os.path.islink(arg['path-to-edit']):
            window.editAction(path=(os.path.basename(arg['path-to-edit']), os.readlink(arg['path-to-edit'])))
        elif os.path.exists(arg['path-to-edit']) and not arg['leaveopen']:
            # if a non-symlink is opened, make a new link to it as target
            window.newAction(target=arg['path-to-edit'])
        else:
            # you mistyped the command or path is not a symlink; show the mainwindow after all!
            window.closeme = False
            window.show_all()
            window.updateFileListing()
    elif arg['new']:
        window.newAction()
    else:
        window.updateFileListing()
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    Gtk.main()

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        quit()

