from sys import argv from os import mkdir, remove from os.path import exists, join, dirname from uuid import uuid4 from re import compile, findall from tempfile import gettempdir from webbrowser import open as wbopen from PyQt5.QtGui import QIcon, QPalette, QColor from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QTreeWidget, QTreeWidgetItem, QApplication, QWidget, QMainWindow, QLineEdit, QVBoxLayout, QHBoxLayout, QSplitter, QAction, qApp, QLabel, QDesktopWidget, QPlainTextEdit, QMenu, QMessageBox, QFileDialog, QColorDialog, QInputDialog, QMessageBox, ) from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings from mistune import Markdown, Renderer, InlineLexer, InlineGrammar from nacl.secret import SecretBox from nacl.hash import generichash from nacl.exceptions import CryptoError from pickle import dump, load, dumps, loads myPath = dirname(__file__) DATADIR = join(myPath,"data") TMPDIR = join(gettempdir(),"namadino_"+str(uuid4())) mkdir(TMPDIR) HTMLtemplate = "" with open(join(DATADIR, "HTMLtemplate.html")) as f: HTMLtemplate = f.read() ################################################################################ class Note(object): def __init__(self, title="New note", markdown="Enter your notes here\n\n**Markdown** is supported. (Hello world)"): super().__init__() self.title = title self.markdown = markdown def toDict(self): return self.__dict__.copy() @classmethod def fromDict(cls, state): return cls(**state) def setMarkdown(self, markdown): self.markdown = markdown def setTitle(self, title): self.title = title def copy(self): return Note(self.title, self.markdown) class Folder(object): def __init__(self, title="New Folder"): super().__init__() self.title = title self.folders = [] self.notes = [] def __getstate__(self): return self.toDict() def __setstate__(self, state): self.__dict__.update( self.fromDict(state).__dict__ ) def toDict(self): """ Allow for folders to be pickled for saving """ state = { "title": self.title, "folders": [f.toDict() for f in self.folders], "notes": [n.toDict() for n in self.notes] } return state @classmethod def fromDict(cls, state): f = cls(state['title']) f.folders = [Folder.fromDict(fstate) for fstate in state["folders"]] f.notes = [Note.fromDict(nstate) for nstate in state["notes"]] return f def setTitle(self, title): self.title = title def addFolder(self, folder=None): if not folder: folder = Folder() elif isinstance(folder, str): folder = Folder(folder) self.folders.append(folder) return folder def addNote(self, note=None): if not note: note = Note() self.notes.append(note) return note def concatNotes(self): out = [] for f in self.folders: out += f.concatNotes() for n in self.notes: out.append(n) return out def deleteFolder(self, folder): self.folders = [f for f in self.folders if f != folder] def deleteNote(self, note): self.notes = [n for n in self.notes if n != note] def copy(self): """ Returns a copy of the folder, used for defaults """ out = Folder(self.title) out.folders = [f.copy() for f in self.folders] out.notes = [n.copy() for n in self.notes] return out ################################################################################ hoverNoteRe = compile(r'<([^}]+?)>\(([^)]+?)\)') class CustomRenderer(Renderer): def hover_note(self, base, hover): return '{0}'.format(base, hover) class CustomInlineGrammar(InlineGrammar): hover_note = hoverNoteRe class CustomInlineLexer(InlineLexer): grammar_class = CustomInlineGrammar def enable_hover_note(self): self.rules.hoverNote = hoverNoteRe self.default_rules.insert(0, 'hover_note') def output_hover_note(self, m): base = m.group(1) hover = m.group(2) return self.renderer.hover_note(base, hover) renderer = CustomRenderer() inline = CustomInlineLexer(renderer) inline.enable_hover_note() MD = Markdown(renderer, inline=inline) ################################################################################ DEFAULT_DATA = Folder("Untitled Project") DEFAULT_DATA.addNote() DEFAULT_DATA.notes[0].setTitle("Welcome") DEFAULT_DATA.notes[0].setMarkdown( """Welcome to the **Na**meless **Ma**rkdown **Di**rectory-organized **No**tation software, a.k.a. **Namadino** *(nah-mah-dee-no)* Here you can organize your thoughts into folders and notes. This program was originally written to aid in translating different media, however it can be applied to nearly any study. Notes support basic **Markdown** with one additional, custom pattern for "hover text". Hover text allows you to put hidden definitions or solutions directly above the relavent information when the note is rendered, like so: <日本>(にっぽん | Japan). It also acts as a quick template for making Anki flashcards. All instances of hover text in a project can be exported into a `txt` file that can be imported into an Anki deck by using the `Export Project To` menu at the top of the screen. The same can be done for all children in a folder or all hover text in a single note by using the `Export To` menu in the right-click menu. Projects, folders, and notes can also be exported to an interactive HTML file. These files can be shared easily and viewed with any web browser. Alternatively, the HTML file can be opened directly in your browser. """ ) class GUI(QMainWindow): def __init__(self): super().__init__() self.defaults() self.timer = QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.render) self.autosaveTime = 5*60*1000 self.autosavetimer = QTimer() self.autosavetimer.timeout.connect(self.autoSave) self.autosavetimer.start(self.autosaveTime) self.loadSettings() self.buildGUI() def defaults(self): self.data = DEFAULT_DATA.copy() self.lastSave = Folder() self.filepath = None self.selectedNote = None self.hashword = None def buildGUI(self): #self.setWindowIcon(QIcon(join(DATADIR,"d20_32x32.png"))) self.center() self.statusBar().showMessage('Welcome to the thing with no name',3000) self.setPalette(QPalette(QColor(self.settings["theme"]))) ######################################################################## menubar = self.menuBar() # File fileMenu = menubar.addMenu('&File') # File > New new = QAction(QIcon.fromTheme("document-new"), "&New", self) new.setShortcut("Ctrl+N") new.setStatusTip("New notes") new.triggered.connect(self.newProject) fileMenu.addAction(new) # File > Open op = QAction(QIcon.fromTheme("document-open"), "&Open", self) op.setShortcut("Ctrl+O") op.setStatusTip("Open notes") op.triggered.connect(self.openProject) fileMenu.addAction(op) # File > Save sav = QAction(QIcon.fromTheme("document-save"), "&Save", self) sav.setShortcut("Ctrl+S") sav.setStatusTip("Save notes") sav.triggered.connect(self.saveProject) fileMenu.addAction(sav) # File > Save As sas = QAction(QIcon.fromTheme("document-save-as"), "Save &As", self) sas.setShortcut("Ctrl+Shift+S") sas.setStatusTip("Save notes") sas.triggered.connect(self.saveProjectAs) fileMenu.addAction(sas) # File > View source code fileMenu.addSeparator() source = QAction(QIcon.fromTheme("applications-internet"),"View source code",self) source.triggered.connect(self.openGitLab) source.setStatusTip('Open source code in browser') fileMenu.addAction(source) fileMenu.addSeparator() # File > Quit exitAct = QAction(QIcon.fromTheme("application-exit"), '&Exit', self) exitAct.setShortcut('Ctrl+Q') exitAct.setStatusTip('Exit application') exitAct.triggered.connect(qApp.quit) fileMenu.addAction(exitAct) # Edit edtMenu = menubar.addMenu('&Edit') # Edit > Color co = QAction(QIcon.fromTheme("applications-graphics"), "Theme", self) co.setStatusTip("Pick a color to generate a theme from") co.triggered.connect(self.setTheme) edtMenu.addAction(co) # Export expMenu = menubar.addMenu('&Export Project To') expMenu.REF = self.data self.makeExportMenu(expMenu, expMenu) ######################################################################## self.main = QSplitter() self.main.setChildrenCollapsible(False) self.tree = QTreeWidget(self.main) self.tree.setColumnCount(1) self.tree.setHeaderHidden(True) self.traverseTree() self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.customContextMenuRequested.connect(self.treeContext) self.tree.currentItemChanged.connect(self.updateSel) self.tree.itemChanged.connect(self.endRename) self.note = QPlainTextEdit(self.main) self.note.textChanged.connect(self.noteUpdate) self.note.setEnabled(False) self.note.setPlaceholderText("Select a note...") self.renderer = QWebEngineView(self.main) self.getCSSInversion() self.renderer.setZoomFactor(1.5) self.renderer.settings().setAttribute( QWebEngineSettings.FocusOnNavigationEnabled, False) self.renderer.keyPressEvent = None self.renderHTML("") self.main.setStretchFactor(0,1) self.main.setStretchFactor(1,1) self.main.setStretchFactor(2,1) self.main.restoreState(self.settings["frame"]) # There is no event for when the handles are released # without keeping track of all of the handles' `mouseReleaseEvent`s # So instead we watch the constant update event with a timer. self.main.splitterMoved.connect(self.frameResizing) self.main.timer = QTimer() self.main.timer.setSingleShot(True) self.main.timer.timeout.connect(self.frameResized) # Finished loading GUI. self.setCentralWidget(self.main) self.show() def center(self): """ Resizes the window to be 80% of the available space and centers it """ space = QDesktopWidget().availableGeometry() self.resize( space.width() * 0.80, space.height() * 0.80 ) qr = self.frameGeometry() qr.moveCenter(space.center()) self.move(qr.topLeft()) def checkUnsaved(self): self.autosavetimer.stop() if self.data.toDict() == DEFAULT_DATA.toDict(): return True if self.data.toDict() != self.lastSave.toDict(): alert = QMessageBox.question(self, "Unsaved changes", "There are unsaved changes. Save before closing?".format(self.data.title), QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.No) if alert == QMessageBox.Yes: self.saveProject() elif alert == QMessageBox.Cancel: return False return True def closeEvent(self, event): _ = self.checkUnsaved() def newProject(self): if self.checkUnsaved(): self.defaults() self.traverseTree() self.resetNoteField() self.statusBar().showMessage("Blank project opened",3000) self.autosavetimer.start(self.autosaveTime) def openProject(self): if self.checkUnsaved(): options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog filepath, OK = QFileDialog.getOpenFileName(self, "Open...", self.data.title+".notes", "Notes Files (*.notes);;Encrypted Notes Files *.enotes)", options=options) if not OK: self.autosavetimer.start(self.autosaveTime) return None if filepath[-6:] == "enotes": if not self.encOpen(filepath): self.autosavetimer.start(self.autosaveTime) return self.showError("Error opening project") else: self.hashword = None self.openAction(filepath) self.filepath = filepath self.lastSave = self.data.copy() self.traverseTree() self.resetNoteField() self.statusBar().showMessage("Opened "+self.data.title,3000) self.autosavetimer.start(self.autosaveTime) def openAction(self, fp): with open(fp, "rb") as f: self.data = load(f) def encOpen(self, fp): pwd, OK = QInputDialog.getText(self, "Input password", "Type the password for this project", QLineEdit.Password) if not OK: return hashword = generichash(bytes(pwd, "utf-8"), digest_size=16) return self.encOpenAction(fp, hashword) def encOpenAction(self, fp, hashword): box = SecretBox(hashword) data = None with open(fp, "rb") as f: try: data = loads(box.decrypt(load(f))) except CryptoError: return self.showError("Invalid password") except Exception as e: return self.showError("Unknown error: "+str(e)) self.hashword = hashword self.data = data return True def saveProject(self): if not self.filepath: self.saveProjectAs() return None self.saveActionWrap() def saveProjectAs(self): self.autosavetimer.stop() options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog filepath, OK = QFileDialog.getSaveFileName(self, "Save as...", self.data.title+".notes", "Notes File (*.notes);;"+ "Encrypted Notes File (*.enotes)", options=options) if OK: self.filepath = filepath self.saveActionWrap(resetHash=True) self.autosavetimer.start(self.autosaveTime) def saveActionWrap(self, resetHash=False): if self.filepath[-6:].lower() == "enotes": if not self.encSave(resetHash): return else: self.hashword = None self.saveAction(self.filepath, self.data) self.lastSave = self.data.copy() windowTitle = self.data.title if self.hashword: windowTitle += " " self.setWindowTitle(windowTitle + " - Namadino") self.statusBar().showMessage("Saved {0} to {1}".format(self.data.title, self.filepath),3000) def autoSave(self): if not self.filepath: return if self.hashword: self.encSaveAction(self.filepath, self.hashword) else: self.saveAction(self.filepath, self.data) self.statusBar().showMessage("Project autosaved", 3000) def saveAction(self, fp, data, full=True): with open(fp, "wb") as f: dump(data, f) def encSave(self, resetHash): success = False if resetHash: success = self.encValidate(None) else: success = self.encValitade(self.hashword) if success: self.encSaveAction(self.filepath, self.hashword) def encValidate(self, pwd1): if not pwd1: pwd1, OK = QInputDialog.getText(self, "Input password", "Type a password for this project", QLineEdit.Password) if not OK: return self.showError("Save aborted") pwd1 = generichash(bytes(pwd1, "utf-8"), digest_size=16) pwd2, OK = QInputDialog.getText(self, "Confirm password", "Reype the password for this project", QLineEdit.Password) if not OK: return self.showError("Save aborted") pwd2 = generichash(bytes(pwd2, "utf-8"), digest_size=16) if pwd1 != pwd2: return self.showError("Passwords do not match") self.hashword = pwd1 return True def encSaveAction(self, fp, hashword): box = SecretBox(hashword) self.saveAction(fp, box.encrypt(dumps(self.data))) return True def showError(self, error): QMessageBox(QMessageBox.Critical, "Error", error ).exec_() return def traverseTree(self, parent=None, folder=None): """ Parse the data and populate the tree """ if not parent: self.tree.clear() trunk = QTreeWidgetItem([self.data.title]) trunk.REF = self.data if self.hashword: trunk.setIcon(0, QIcon.fromTheme("security-high")) else: trunk.setIcon(0, QIcon.fromTheme("package-x-generic")) self.tree.addTopLevelItem(self.traverseTree(trunk, self.data)) windowTitle = self.data.title if self.hashword: windowTitle += " " self.setWindowTitle(windowTitle + " - Namadino") return for f in folder.folders: branch = QTreeWidgetItem([f.title]) branch.REF = f branch.setIcon(0, QIcon.fromTheme("folder")) parent.addChild(self.traverseTree(branch, f)) for n in folder.notes: leaf = QTreeWidgetItem([n.title]) leaf.REF = n leaf.setIcon(0, QIcon.fromTheme("text-x-generic")) parent.addChild(leaf) return parent def noteUpdate(self): """ Called every time the text box changes Starts timer to save markdown """ self.timer.start(1000) def render(self): """ Saves note text to active Note object """ if self.selectedNote: self.selectedNote.setMarkdown(self.note.toPlainText()) self.renderHTML(MD(self.note.toPlainText())) def treeContext(self, point): sel = self.tree.currentItem() if sel: popMenu = QMenu(self) if isinstance(sel.REF, Folder): popMenu.new = QMenu("New...") popMenu.new.f = QAction("Folder") popMenu.new.f.triggered.connect(self.newFolder) popMenu.new.f.PARENT = sel popMenu.new.addAction(popMenu.new.f) popMenu.new.n = QAction("Note") popMenu.new.n.triggered.connect(self.newNote) popMenu.new.n.PARENT = sel popMenu.new.addAction(popMenu.new.n) popMenu.addMenu(popMenu.new) popMenu.rename = QAction("Rename") popMenu.rename.PARENT = sel popMenu.rename.triggered.connect(self.renameThing) popMenu.addAction(popMenu.rename) popMenu.export = QMenu("Export to...") self.makeExportMenu(popMenu.export, sel) popMenu.addMenu(popMenu.export) popMenu.addSeparator() if sel.REF != self.data: popMenu.delete = QAction("Delete") popMenu.delete.PARENT = sel popMenu.delete.triggered.connect(self.deleteThing) popMenu.addAction(popMenu.delete) popMenu.exec_(self.tree.mapToGlobal(point)) def makeExportMenu(self, menu, parent): menu.anki = QAction(QIcon.fromTheme("text-x-generic"), "Anki Text") menu.anki.triggered.connect(self.hoverTextAnkiExport) menu.anki.setStatusTip("Export all hover text pairs to Anki-importable file") menu.anki.PARENT = parent menu.addAction(menu.anki) menu.html = QAction(QIcon.fromTheme("text-html"), "HTML") menu.html.triggered.connect(self.htmlExportFile) menu.html.setStatusTip("Export to interactive HTML file") menu.html.PARENT = parent menu.addAction(menu.html) menu.web = QAction(QIcon.fromTheme("applications-internet"),"Web Browser") menu.web.triggered.connect(self.htmlExportWebpage) menu.web.setStatusTip("Open render directly in your browser") menu.web.PARENT = parent menu.addAction(menu.web) def updateSel(self, sel, old): if not sel: # This doesn't seem to be possible but I don't trust it self.resetNoteField() elif isinstance(sel.REF, Note): self.note.setEnabled(True) self.note.setPlainText(sel.REF.markdown) self.selectedNote = sel.REF self.timer.stop() self.render() else: self.resetNoteField() if self.tree.isPersistentEditorOpen(old,0): self.endRename(old) def renameThing(self, thing=None): if not thing: thing = self.sender().PARENT self.tree.openPersistentEditor(thing,0) self.tree.focusNextChild() def endRename(self, thing): self.tree.closePersistentEditor(thing) new = thing.data(0,thing.type()) if new: thing.REF.setTitle(new) else: thing.setText(0, thing.REF.title) def resetNoteField(self): self.selectedNote = None self.note.setEnabled(False) self.note.setPlainText("") self.note.setPlaceholderText("Select a note...") self.timer.stop() self.render() def newFolder(self): parent = self.sender().PARENT f = parent.REF.addFolder() self.newFinish(parent, f, "folder") def newNote(self): parent = self.sender().PARENT n = parent.REF.addNote() self.newFinish(parent, n, "text-x-generic") def newFinish(self, parent, item, icon): # This is janky but... oldKids = [c.REF for c in parent.takeChildren()] self.traverseTree(parent=parent, folder=parent.REF) newKids = parent.takeChildren() parent.addChildren(newKids) newKid = [k for k in newKids if k.REF not in oldKids][0] if not parent.isExpanded(): parent.setExpanded(True) self.renameThing(newKid) def deleteThing(self): thing = self.sender().PARENT if isinstance(thing.REF,Note): confirm = QMessageBox.question(self, "Confirm Deletion", "Permanently delete {}?".format(thing.REF.title), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if confirm == QMessageBox.Yes: thing.parent().REF.deleteNote(thing.REF) thing.parent().removeChild(thing) else: confirm = QMessageBox.question(self, "Confirm Deletion", "Permanently delete {} and everything in it?".format(thing.REF.title), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if confirm == QMessageBox.Yes: thing.parent().REF.deleteFolder(thing.REF) thing.parent().removeChild(thing) def allowExport(self): if self.hashword: confirm = QMessageBox.question(self, "Allow cleartext export", ("This project is password protected. Exporting it will allow " "its contents to be read in clear text. Proceed with export?"), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if confirm == QMessageBox.No: return False if not encValidate(self.hashword): return False return True def hoverTextAnkiExport(self): if not self.allowExport(): return None data = self.sender().PARENT.REF options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog fp, OK = QFileDialog.getSaveFileName(self, "Export to...", data.title+".txt","Anki Import Files (*.txt)", options=options) if not OK: return None out = "" if isinstance(data, Folder): notes = data.concatNotes() else: notes = [data] for n in notes: for front, back in findall(hoverNoteRe, n.markdown): out += front.replace("\t"," ")+"\t"+back.replace("\t"," ")+"\n" with open(fp,"w") as f: f.write(out) self.statusBar().showMessage("Exported {0} to {1}".format(self.data.title, fp)) def htmlExportWebpage(self): if not self.allowExport(): return None data = self.sender().PARENT.REF tmp = join(TMPDIR, str(uuid4())+".html") html = self.htmlExporter(data) with open(tmp,"w") as f: # format doesn't like the extra brackets from the CSS/JS f.write(html) wbopen(tmp) def htmlExportFile(self): if not self.allowExport(): return None data = self.sender().PARENT.REF options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog fp, OK = QFileDialog.getSaveFileName(self, "Export to...", data.title+".html","HTML Files (*.html)", options=options) if not OK: return None html = self.htmlExporter(data) with open(fp,"w") as f: # format doesn't like the extra brackets from the CSS/JS f.write(html) self.statusBar().showMessage("Exported {0} to {1}".format(data.title, fp)) def htmlExporter(self, data): out = "\n {0}\n\n

{0}

".format(data.title) out = self.htmlExportRecurse(data, out) out += "" return HTMLtemplate.replace("{}",out) def htmlExportRecurse(self, data, out): if isinstance(data, Folder): for f in data.folders: out += '
'.format(f.title) out = self.htmlExportRecurse(f, out) out += "
" for n in data.notes: out += '
'.format(n.title) out += "
{}
".format(MD(n.markdown)) out += "
" else: out += '
'.format(data.title) out += "
{}
".format(MD(data.markdown)) out += "
" return out def setTheme(self): col = QColorDialog.getColor() if col.isValid(): p = QPalette(col) self.setPalette(p) self.getCSSInversion() self.settings["theme"] = p.window().color().name() self.renderHTML(self.renderer.HTML) self.saveSettings() def getCSSInversion(self): """ If the palette says the background should be black, invert colors """ self.renderer.CSS = (self.palette().base().color().name(), self.palette().text().color().name()) def renderHTML(self, html): self.renderer.HTML = html self.renderer.setHtml( "".format( *self.renderer.CSS ) + self.renderer.HTML + "" ) def defaultSettings(self): self.settings = { "theme": "#ffffff", "frame": b'\x00\x00\x00\xff\x00\x00\x00\x01\x00\x00\x00\x03\x00\x01\x86\x9f\x00\x01\x86\x9f\x00\x01\x86\x9f\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00' } self.saveSettings() def saveSettings(self): with open(join(DATADIR, "preferences.pkl"),"wb") as f: dump(self.settings, f) def loadSettings(self): prefs = join(DATADIR, "preferences.pkl") if exists(prefs): with open(prefs, "rb") as f: self.settings = load(f) else: self.defaultSettings() def frameResizing(self, ix, pos): self.main.timer.start(3000) def frameResized(self): self.settings["frame"] = self.main.saveState() self.saveSettings() def openGitLab(self): # Launch web browser pointed towards this script's source code. wbopen("https://gitlab.com/WolfgangAxel/namadino") if __name__ == '__main__': app = 0 if QApplication.instance(): app = QApplication.instance() else: app = QApplication(argv) G = GUI() exit(app.exec_())