#@+leo-ver=5-thin
#@+node:ekr.20100103093121.5329: * @file stickynotes.py
#@+<< docstring >>
#@+node:vivainio2.20091008133028.5821: ** << docstring >>
''' Adds simple "sticky notes" feature (popout editors) for Qt gui.

Adds the following (``Alt-X``) commands:

``stickynote``
  pop out current node as a sticky note
``stickynoter``
  pop out current node as a rich text note
``stickynoteenc``
  pop out current node as an encrypted note
``stickynoteenckey``
  enter a new en/decryption key
``stickynoterekey``
  enter the old key for the node, followed by the new, to change keys
``tabula``
  add the current node to the stickynotes in the `Tabula`
  sticky note dock window, and show the window
``tabula-show``
  show the`Tabula` sticky note dock window
  (without adding the current node)
``tabula-marked``
  add all marked nodes to the stickynotes in the `Tabula`
  sticky note dock window, and show the window

Sticky notes are synchronized (both ways) with their parent Leo node.

Encrypted mode requires the python-crypto module.

The first time you open a note in encrypted mode you'll be asked for a pass
phrase. That phrase will be used for the rest of the session, you can change it
with ``Alt-X`` ``stickynoteenckey``, but probably won't need to.

The encrypted note is stored in base64 encoded *encrypted* text in the parent
Leo node, if you forget the pass phrase there's no way to un-encrypt it again.
Also, you must not edit the text in the Leo node.

When **creating an encrypted note**, you should **start with an empty node**.
If you want to encrypt text that already exists in a node, select-all cut it to
empty the node, then paste it into the note.

If your data doesn't decode, you may need to upgrade your key.  Use the
``Alt-X`` ``stickynoterekey`` command on the encryted node in Leo.  Prefix
the old key with "v0:" (vee zero colon without the quotes).  Enter whatever
you want for the new key, even the old key again without the "v0:".
The decoded data should appear in the popoup window, if not, close the Leo
file **without saving**.  If you have multiple encoded nodes, repeat this
process for each one.

'''
#@-<< docstring >>
#@+<< imports >>
#@+node:vivainio2.20091008133028.5823: ** << imports >>
import leo.core.leoGlobals as g

# Fail gracefully if the gui is not qt.
g.assertUi('qt')

import os
import time
# import webbrowser

encOK = False
try:
    from Crypto.Cipher import AES
    from Crypto.Hash import MD5, SHA
    import base64
    import textwrap
    __ENCKEY = [None]
    encOK = True
except ImportError:
    pass

from leo.core.leoQt import isQt5, Qt, QtWidgets

# pylint: disable=no-name-in-module
if isQt5:
    from PyQt5.QtCore import QTimer
    try:
        from PyQt5.QtCore import QString
    except ImportError:
        QString = str
    from PyQt5.QtGui import QFont,QTextCharFormat # QIcon
    from PyQt5.QtWidgets import (
        QAction,QInputDialog,QLineEdit,QMainWindow,QMdiArea,QTextEdit)
            # QPlainTextEdit,
else:
    from PyQt4.QtCore import QTimer
    try:
        from PyQt4.QtCore import QString
    except ImportError:
        QString = str
    from PyQt4.QtGui import QAction,QFont,QTextCharFormat,QTextEdit # QIcon
    from PyQt4.QtGui import QInputDialog,QMainWindow,QMdiArea,QLineEdit
        # QPlainTextEdit,
#@-<< imports >>
#@+others
#@+node:vivainio2.20091008140054.14555: ** decorate_window
def decorate_window(c, w):
    w.setStyleSheet(c.styleSheetManager.get_master_widget().styleSheet())
    g.app.gui.attachLeoIcon(w)
        ### w.setWindowIcon(QIcon(g.app.leoDir + "/Icons/leoapp32.png"))
    w.resize(600, 300)
#@+node:vivainio2.20091008133028.5824: ** init
def init ():
    '''Return True if the plugin has loaded successfully.'''
    ok = g.app.gui.guiName() == 'qt'
    if ok:
        g.plugin_signon(__name__)
    g.app.stickynotes = {}
    return ok
#@+node:ekr.20160403065412.1: ** commands
#@+node:vivainio2.20091008133028.5825: *3* g.command('stickynote')
@g.command('stickynote')
def stickynote_f(event):
    """ Launch editable 'sticky note' for c.p."""
    c = event['c']
    mknote(c, c.p)
#@+node:ville.20110304230157.6526: *3* g.command('stickynote-new')
@g.command('stickynote-new')
def stickynote_new_f(event):
    """Launch editable 'sticky note' for the node """
    c = event['c']
    p = find_or_create_stickynotes(c)
    p2 = p.insertAsLastChild()
    p2.h = time.asctime()
    mknote(c, p2)
    # Fix #249: Leo and Stickynote plugin do not request to save.
    c.setChanged(True)
    c.redraw(p2)
#@+node:ville.20091023181249.5266: *3* g.command('stickynoter')
@g.command('stickynoter')
def stickynoter_f(event):
    """
    Launch editable 'sticky note' for the node.
    The result is saved as rich text, that is, html...
    """
    c = event['c']
    p = c.p
    v = p.v

    def focusin():
        if v is c.p.v:
            nf.setHtml(g.u(v.b))
            nf.setWindowTitle(p.h)
            nf.dirty = False

    def focusout():
        if nf.dirty:
            v.b = g.u(nf.toHtml())
            v.setDirty()
            nf.dirty = False
            p = c.p
            if p.v is v:
                c.selectPosition(c.p)
            # Fix #249: Leo and Stickynote plugin do not request to save
            c.setChanged(True)
            c.redraw()

    nf = SimpleRichText(focusin, focusout)  # not LessSimpleRichText
    nf.dirty = False
    decorate_window(c, nf)
    nf.setWindowTitle(p.h)
    nf.setHtml(p.b)
    # Fix #249: Leo and Stickynote plugin do not request to save.
    # Do this only on focusout:
        # p.setDirty()
        # c.setChanged(True)
        # c.redraw()

    def textchanged_cb():
        nf.dirty = True

    nf.textChanged.connect(textchanged_cb)
    nf.show()
    g.app.stickynotes[p.gnx] = nf
#@+node:tbrown.20100120100336.7829: *3* g.command('stickynoteenc')
if encOK:
    @g.command('stickynoterekey')
    def stickynoteenc_rk(event):
        stickynoteenc_f(event, rekey=True)

    @g.command('stickynoteenc')
    def stickynoteenc_f(event, rekey=False):
        """ Launch editable 'sticky note' for the encrypted node """
        if not encOK:
            g.es('no en/decryption - need python-crypto module')
            return
        if not __ENCKEY[0] or rekey:
            sn_getenckey()
        c = event['c']
        p = c.p
        v = p.v

        def focusin():
            if v is c.p.v:
                decoded = sn_decode(v.b)
                if decoded is None:
                    return
                if decoded != nf.toPlainText():
                    # only when needed to avoid scroll jumping
                    nf.setPlainText(decoded)
                nf.setWindowTitle(p.h)
                nf.dirty = False

        def focusout():
            if not nf.dirty:
                return
            enc = sn_encode(str(nf.toPlainText()))
            if v.b != enc:
                v.b = enc
                v.setDirty()
                # Fix #249: Leo and Stickynote plugin do not request to save
                c.setChanged(True)
            nf.dirty = False
            p = c.p
            if p.v is v:
                c.selectPosition(c.p)
            c.redraw()

        if rekey:
            unsecret = sn_decode(v.b)
            if unsecret is None:
                return
            sn_getenckey()
            secret = sn_encode(unsecret)
            v.b = secret

        ### c = event['c']
        ### p = c.p
        decoded = sn_decode(v.b)
        if decoded is None:
            return
        nf = mknote(c,p, focusin=focusin, focusout=focusout)
        nf.setPlainText(decoded)
        if rekey:
            g.es("Key updated, data decoded with new key shown in window")
#@+node:tbrown.20100120100336.7830: *3* g.command('stickynoteenckey')
if encOK:
    def sn_decode(s):
        try:
            return AES.new(__ENCKEY[0]).decrypt(base64.b64decode(s)).decode('utf-8').strip()
        except UnicodeDecodeError:
            g.es("Decode failed")
            __ENCKEY[0] = None
            return None

    def sn_encode(s):
        pad = ' '*(16-len(s)%16)
        txt = base64.b64encode(AES.new(__ENCKEY[0]).encrypt((s+pad).encode('utf-8')))
        if g.isPython3:
            txt = str(txt, 'utf-8')
        wrapped = textwrap.wrap(txt, break_long_words=True)
        return '\n'.join(wrapped)

    @g.command('stickynoteenckey')
    def sn_getenckey(dummy=None):
        txt,ok = QInputDialog.getText(None,
            'Enter key',
            'Enter key.\nData lost if key is lost.\nSee docs. for key upgrade notes.',
        )
        if not ok:
            return

        if str(txt).startswith('v0:'):
            txt = QString(txt[3:])
        else:
            txt = g.toUnicode(txt)

        # arbitrary kludge to convert string to 256 bits - don't change
        sha = SHA.new()
        md5 = MD5.new()
        sha.update(txt.encode('utf-8'))
        md5.update(txt.encode('utf-8'))
        __ENCKEY[0] = sha.digest()[:16] + md5.digest()[:16]
        if len(__ENCKEY[0]) != 32:
            raise Exception("sn_getenckey failed to build key")
#@+node:tbrown.20141214173054.3: ** class TextEditSearch
class TextEditSearch(QtWidgets.QWidget):
    """A QTextEdit with a search box

    Used to make decoded encoded body text searchable, so when you've decoded
    your password list you dont't have to scan through five pages of text to
    find the one you need.
    """

    @staticmethod
    def _call_old_first(oldfunc, newfunc):
        """Focus in/out methods need to call base class method"""
        def f(event, oldfunc=oldfunc, newfunc=newfunc):
            oldfunc(event)
            newfunc()
        return f

    def __init__(self, *args, **kwargs):
        QtWidgets.QWidget.__init__(self, *args, **kwargs)
        self.textedit = QtWidgets.QTextEdit(*args, **kwargs)
        # need to call focusin/out set on parent by FocusingPlaintextEdit / mknote
        self.textedit.focusInEvent = self._call_old_first(
            self.textedit.focusInEvent, self.focusin)
        self.textedit.focusOutEvent = self._call_old_first(
            self.textedit.focusOutEvent, self.focusout)
        self.searchbox = QtWidgets.QLineEdit()
        self.searchbox.focusInEvent = self._call_old_first(
            self.searchbox.focusInEvent, self.focusin)
        self.searchbox.focusOutEvent = self._call_old_first(
            self.searchbox.focusOutEvent, self.focusout)

        # invoke find when return pressed
        self.searchbox.returnPressed.connect(self.search)

        layout = QtWidgets.QVBoxLayout()
        self.setLayout(layout)
        layout.setSpacing(0)
        layout.setContentsMargins(0,0,0,0)
        layout.addWidget(self.textedit)
        hlayout = QtWidgets.QHBoxLayout()
        hlayout.setContentsMargins(0,0,0,0)
        hlayout.addWidget(QtWidgets.QLabel("Find:"))
        hlayout.addWidget(self.searchbox)
        layout.addLayout(hlayout)

    def __getattr__(self, name):
        """Delegate things QWidget doesn't have to QTextEdit, takes care
        of attempts to access the text."""
        return getattr(self.textedit, name)

    def search(self):
        """Search text"""
        text = self.searchbox.text()
        doc = self.textedit.document()
        result = doc.find(text, self.textedit.textCursor())
        if not result.isNull():
            self.textedit.setTextCursor(result)
#@+node:ville.20091008210853.7616: ** class FocusingPlainTextEdit
class FocusingPlaintextEdit(TextEditSearch):

    def __init__(self, focusin, focusout, closed = None, parent = None):
        self.focusin = focusin
        self.focusout = focusout
        self.closed = closed
        TextEditSearch.__init__(self, parent)

    def focusOutEvent (self, event):
        self.focusout()

    def focusInEvent (self, event):
        self.focusin()

    def closeEvent(self, event):
        event.accept()
        if self.closed:
            self.closed()
        self.focusout()
#@+node:ville.20091023181249.5264: ** class SimpleRichText
class SimpleRichText(QTextEdit):

    # pylint: disable=method-hidden

    def __init__(self, focusin, focusout):
        QTextEdit.__init__(self)
        self.focusin = focusin
        self.focusout = focusout
        self.createActions()

        #self.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)

    def focusOutEvent ( self, event ):
        self.focusout()

    def focusInEvent ( self, event ):
        self.focusin()


    def closeEvent(self, event):
        event.accept()

    def createActions(self):
        self.boldAct = QAction(self.tr("&Bold"), self)
        self.boldAct.setCheckable(True)
        self.boldAct.setShortcut(self.tr("Ctrl+B"))
        self.boldAct.setStatusTip(self.tr("Make the text bold"))
        self.boldAct.triggered.connect(self.setBold)
        ### self.connect(self.boldAct, SIGNAL("triggered()"), self.setBold)
        self.addAction(self.boldAct)

        boldFont = self.boldAct.font()
        boldFont.setBold(True)
        self.boldAct.setFont(boldFont)

        self.italicAct = QAction(self.tr("&Italic"), self)
        self.italicAct.setCheckable(True)
        self.italicAct.setShortcut(self.tr("Ctrl+I"))
        self.italicAct.setStatusTip(self.tr("Make the text italic"))
        self.italicAct.triggered.connect(self.setItalic)
        ### self.connect(self.italicAct, SIGNAL("triggered()"), self.setItalic)
        self.addAction(self.italicAct)

    def setBold(self):
        format = QTextCharFormat()
        if self.boldAct.isChecked():
            weight = QFont.Bold
        else:
            weight = QFont.Normal
        format.setFontWeight(weight)
        self.setFormat(format)

    def setItalic(self):
        format = QTextCharFormat()
        #format.setFontItalic(self.__italic.isChecked())
        format.setFontItalic(self.italicAct.isChecked())
        self.setFormat(format)

    def setUnderline(self):
        format = QTextCharFormat()
        format.setFontUnderline(self.__underline.isChecked())
        self.setFormat(format)

    def setFormat(self, format):
        self.textCursor().mergeCharFormat(format)
        self.mergeCurrentCharFormat(format)

    def bold(self):
        print("bold")

    def italic(self):
        print("italic")



#@+node:ekr.20160403065519.1: ** Utils
#@+node:ville.20100707205336.5610: *3* create_subnode
def create_subnode(c, heading):
    """  Find node with heading, then add new node as child under this heading

    Returns new position.
    """

    h = c.find_h(heading)
    if not h:
        p = c.rootPosition()
        p.moveToNodeAfterTree()
        p = p.insertAfter()
        p.h = heading
    else:
        p = h[0]

    chi = p.insertAsLastChild()
    return chi.copy()
#@+node:ekr.20160403065539.1: *3* find_or_create_stickynotes
def find_or_create_stickynotes(c):

    # Huh? This makes no sense, and can cause a crash.
        # wb = get_workbook()
        # assert wb,'no wb'
    aList = c.find_h('stickynotes')
    if aList:
        p = aList[0]
    else:
        p = c.rootPosition().insertAfter()
        c.redraw(p)
        p.h = "stickynotes"
    # return p, wb
    return p
#@+node:ville.20110304230157.6527: *3* get_workbook (no longer used)
def get_workbook():
    for co in g.app.commanders():
        if co.mFileName.endswith('workbook.leo'):
            return co
#@+node:ville.20100703194946.5587: *3* mknote
def mknote(c,p, parent=None, focusin=None, focusout=None):
    """ Launch editable 'sticky note' for the node """
    # pylint: disable=function-redefined
    # focusin and focusout are redefined elsewhere.
    v = p.v

    if focusin is None:
        def focusin():
            if v is c.p.v:
                if v.b.encode('utf-8') != nf.toPlainText():
                    # only when needed to avoid scroll jumping
                    nf.setPlainText(g.u(v.b))
                nf.setWindowTitle(v.h)
                nf.dirty = False

    if focusout is None:
        def focusout():
            if nf.dirty:
                if v.b.encode('utf-8') != nf.toPlainText():
                    v.b = g.u(nf.toPlainText())
                    v.setDirty()
                    # Fix #249: Leo and Stickynote plugin do not request to save
                    c.setChanged(True)
                nf.dirty = False
                p = c.p
                if p.v is v:
                    c.selectPosition(c.p)
                c.redraw()

    def closeevent():
        pass

    nf = FocusingPlaintextEdit(focusin, focusout, closeevent, parent = parent)
    decorate_window(c, nf)
    nf.dirty = False
    nf.resize(600, 300)
    nf.setWindowTitle(p.h)
    nf.setPlainText(p.b)
    # Fix #249: Leo and Stickynote plugin do not request to save
    # Don't set the node dirty unless it has been changed.
    # p.setDirty()

    def textchanged_cb():
        nf.dirty = True

    nf.textChanged.connect(textchanged_cb)
    nf.show()
    g.app.stickynotes[p.gnx] = nf
    return nf
#@+node:ville.20100703234124.9976: ** Tabula
#@+node:ville.20100704010850.5589: *3* def tabula_show
def tabula_show(c):
    try:
        t = c.tabula
    except AttributeError:
        t = c.tabula = Tabula(c)
    t.show()
    return t

#@+node:ville.20100703194946.5585: *3* @g.command('tabula')
@g.command('tabula')
def tabula_f(event):
    """ Show "tabula" - a MDI window with stickynotes that remember their status """
    c= event['c']
    t = tabula_show(c)
    p = c.p
    t.add_note(p)


#@+node:ville.20100704010850.5588: *3* @g.command('tabula-show')
@g.command('tabula-show')
def tabula_show_f(event):
    '''Show the`Tabula` sticky note dock window, without adding the current node.'''
    c= event['c']

    tabula_show(c)
#@+node:ville.20100704125228.5592: *3* @g.command('tabula-marked')
@g.command('tabula-marked')
def tabula_marked_f(event):
    """ Create tabula from all marked nodes """
    c= event['c']

    t=tabula_show(c)

    for p in c.all_unique_positions():
        if p.isMarked():
            t.add_note(p)

#@+node:ville.20101128205511.6114: *3* @g.command('tabula-subtree')
@g.command('tabula-subtree')
def tabula_subtree_f(event):
    """ Create tabula from all nodes in subtree """
    c= event['c']

    t=tabula_show(c)

    for p in c.p.self_and_subtree():
        t.add_note(p)

#@+node:ville.20100703194946.5584: *3* class Tabula(QMainWindow)
class Tabula(QMainWindow):

    #@+others
    #@+node:ekr.20101114061906.5445: *4* __init__
    def __init__(self, c):

        QMainWindow.__init__(self)
        mdi = self.mdi = QMdiArea(self)
        self.setCentralWidget(mdi)
        self.create_actions()
        self.menuBar().setVisible(False)
        self.notes = {}
        self.c = c

        def delayed_load():
            self.load_states()

        QTimer.singleShot(0, delayed_load)
        self.setWindowTitle("Tabula " + os.path.basename(self.c.mFileName))
        g.registerHandler("end1",self.on_quit)
    #@+node:ekr.20101114061906.5443: *4* add_note
    def add_note(self, p):

        gnx = p.gnx
        if gnx in self.notes:
            n = self.notes[gnx]
            n.show()
            return n
        n = mknote(self.c, p, parent = self.mdi)
        sw = self.mdi.addSubWindow(n)
        # pylint: disable=maybe-no-member
        # Qt.WA_DeleteOnClose does exist.
        try:
            sw.setAttribute(Qt.WA_DeleteOnClose, False)
        except AttributeError:
            pass
        self.notes[gnx] = n
        n.show()
        return n
    #@+node:ekr.20101114061906.5442: *4* closeEvent
    def closeEvent(self,event):

        self.save_states()
        event.accept() # EKR: doesn't help: we don't get the event.
    #@+node:ekr.20101114061906.5444: *4* create_actions (has all toolbar commands!)
    def create_actions(self):

        self.tb = self.addToolBar("toolbar")
        self.tb.setObjectName("toolbar")
        #self.addToolBar(Qt.BottomToolBarArea, self.tb)

        def do_tile():
            self.mdi.setViewMode(QMdiArea.SubWindowView)
            self.mdi.tileSubWindows()
        
        def do_cascade():
            self.mdi.setViewMode(QMdiArea.SubWindowView)
            self.mdi.cascadeSubWindows()
            
        def do_un_tab():
            if self.mdi.viewMode() == QMdiArea.SubWindowView:
                self.mdi.setViewMode(QMdiArea.TabbedView)
            else:
                self.mdi.setViewMode(QMdiArea.SubWindowView)

        def do_close_all():
            for i in self.mdi.subWindowList():
                self.mdi.removeSubWindow(i)
            self.notes = {}

        def do_go():
            p, _ = self.get_current_pos()
            self.c.selectPosition(p)

        def do_new():
            n = create_subnode(self.c, "Tabula")
            n.h = time.asctime()
            self.c.redraw()
            self.add_note(n)

        def do_edit_h():
            p, w = self.get_current_pos()
            new, r = QInputDialog.getText(None,
                "Edit headline", "", 
                QLineEdit.Normal, p.h)
            if not r:
                return
            new = g.u(new)
            p.h = new
            w.setWindowTitle(new)

        self.tb.addAction("Tile", do_tile)
        self.tb.addAction("Cascade", do_cascade)
        self.tb.addAction("(Un)Tab", do_un_tab)
        self.tb.addAction("Go", do_go)
        self.tb.addAction("New", do_new)
        self.tb.addAction("Headline", do_edit_h)
        self.tb.addSeparator()
        self.tb.addAction("Close All", do_close_all)
        # ca = QAction("Close All", self.tb)
        # ca.setMenuRole(QAction.QuitRole)
        # ca.triggered.connect(do_close_all)
        # self.tb.addAction(ca)
    #@+node:ekr.20101114061906.5440: *4* load_states
    def load_states(self):

        if not self.c.db:
            return
        try:
            stored = self.c.db['tabulanotes']
        except KeyError:
            return

        mwstate = stored.pop("mainwindow")
        self.restoreState(mwstate)

        # still think leo should maintain a dict like this.
        # EKR: :-)
        ncache = dict((p.gnx, p.copy()) for p in self.c.all_unique_positions())

        for gnx, geom in stored.items():
            try:
                ncache[gnx]
            except KeyError:
                g.trace("lost note", gnx)
                continue

            n = self.add_note(ncache[gnx])
            n.parent().restoreGeometry(geom)
    #@+node:ekr.20101114061906.5446: *4* on_quit
    def on_quit(self,tag, kw):

        # saving when hidden nukes all

        if self.isVisible():
            self.save_states()

        # None of these work on Python 3.x.
        # self.destroy()

        # self.mdi.close()
        # self.close() # EKR
        # self.midi.delete() # EKR
    #@+node:ville.20101128212002.6111: *4* get_current_pos
    def get_current_pos(self):
        cur = self.mdi.activeSubWindow()
        active = [gnx for (gnx, n) in self.notes.items() if n.parent() == cur]
        if not active:
            g.trace("no node")
            return None,None
        tgt = active[0]

        p = next(p for p in self.c.all_unique_positions() if p.gnx == tgt)
        return p, cur

    #@+node:ekr.20101114061906.5441: *4* save_states
    def save_states(self):
        
        self.update_notes()

        # n.parent() because the wrapper QMdiSubWindow holds the geom relative to parent
        geoms = dict (
            (gnx, n.parent().saveGeometry())
                for (gnx, n) in self.notes.items() if n.isVisible())

        geoms['mainwindow'] = self.saveState()

        if self.c.db:
            self.c.db['tabulanotes'] = geoms
    #@+node:ekr.20180822134952.1: *4* update_nodes (new)
    def update_notes(self):
        
        # #940: update self.notes. Ensure note n still exists.
        visible = []
        for (gnx, n) in self.notes.items():
            try:
                if n.isVisible():
                    visible.append(gnx)
            except RuntimeError:
                pass
        self.notes = dict (
            (gnx, n) for (gnx, n) in self.notes.items()
                if gnx in visible
        )
    #@-others
#@-others
#@@language python
#@@tabwidth -4
#@-leo
