Initial commit
This commit is contained in:
commit
4425615de4
5 changed files with 921 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 kiichan@git.kiichan.space
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice (including the next
|
||||||
|
paragraph) shall be included in all copies or substantial portions of the
|
||||||
|
Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||||
|
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||||
|
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
38
README.md
Normal file
38
README.md
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# Namadino
|
||||||
|
|
||||||
|
## The **Na**meless **Ma**rkdown **Di**rectory-organized **No**tation software
|
||||||
|
|
||||||
|
Namadino is a notation software that uses Markdown and directories to organize
|
||||||
|
your thoughts. It was originally designed to help with the translation of
|
||||||
|
written works.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Markdown
|
||||||
|
|
||||||
|
Basic Markdown support is inplemented using the `mistune` library. Additionally,
|
||||||
|
one custom pattern has been added for what is called "hover text." Hover text
|
||||||
|
will add HTML `title` text to a root word. This is useful for adding definitions
|
||||||
|
to unknown words or hiding answers to problems when reviewing later.
|
||||||
|
|
||||||
|
<This is an example of hover text>(This text would be hidden)
|
||||||
|
<日本>(にっぽん | Japan)
|
||||||
|
|
||||||
|
### Exporting
|
||||||
|
|
||||||
|
All notes can be exported into an interactive HTML file. Each directory will
|
||||||
|
appear as a button that hides the content beneath it. These HTML files can
|
||||||
|
easily be distributed to others and viewed in any web browser with JavaScript
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
### Integration with Anki
|
||||||
|
|
||||||
|
All instancecs of hover text can be exported into a file that can be imported
|
||||||
|
directly into Anki. There are options available to get all instances of hover
|
||||||
|
text on a single page, in a single folder, or across a whole project
|
||||||
|
|
||||||
|
### Password Protection
|
||||||
|
|
||||||
|
Notes can have a password set to limit who has access to a set of notes.
|
||||||
|
Exported files are saved in clear text however, so it is not recommended to use
|
||||||
|
those features if your notes contain sensitive information.
|
43
data/HTMLtemplate.html
Normal file
43
data/HTMLtemplate.html
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<HTML>
|
||||||
|
<script>
|
||||||
|
function toggle (elem) {
|
||||||
|
if (elem.className === "vis") {
|
||||||
|
elem.className = "hid"
|
||||||
|
} else {
|
||||||
|
elem.className = "vis";
|
||||||
|
}
|
||||||
|
x = elem.nextSibling;
|
||||||
|
if(x.style.display === "block") {
|
||||||
|
x.style.display = "none";
|
||||||
|
} else {
|
||||||
|
x.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
display: block;
|
||||||
|
padding: 1vw;
|
||||||
|
}
|
||||||
|
.vis {
|
||||||
|
font-style: normal;
|
||||||
|
background: lightgray;
|
||||||
|
}
|
||||||
|
.hid {
|
||||||
|
font-style: italic;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.hid + div {
|
||||||
|
display: None;
|
||||||
|
}
|
||||||
|
.ht {
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{}
|
||||||
|
</HTML>
|
816
namadino.py
Normal file
816
namadino.py
Normal file
|
@ -0,0 +1,816 @@
|
||||||
|
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. <Hover notes work like this>(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 '<a class="ht" href="" title="{1}">{0}</a>'.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 += " <Secure>"
|
||||||
|
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 += " <Secure>"
|
||||||
|
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 = "<head>\n <title>{0}</title>\n</head>\n<body><h1>{0}</h1>".format(data.title)
|
||||||
|
out = self.htmlExportRecurse(data, out)
|
||||||
|
out += "</body>"
|
||||||
|
return HTMLtemplate.replace("{}",out)
|
||||||
|
|
||||||
|
def htmlExportRecurse(self, data, out):
|
||||||
|
if isinstance(data, Folder):
|
||||||
|
for f in data.folders:
|
||||||
|
out += '<br/><button class="hid" onclick="toggle(this)">{}</button><div>'.format(f.title)
|
||||||
|
out = self.htmlExportRecurse(f, out)
|
||||||
|
out += "</div>"
|
||||||
|
for n in data.notes:
|
||||||
|
out += '<br/><button class="hid" onclick="toggle(this)">{}</button><div>'.format(n.title)
|
||||||
|
out += "<div>{}</div>".format(MD(n.markdown))
|
||||||
|
out += "</div>"
|
||||||
|
else:
|
||||||
|
out += '<br/><button class="hid" onclick="toggle(this)">{}</button><div>'.format(data.title)
|
||||||
|
out += "<div>{}</div>".format(MD(data.markdown))
|
||||||
|
out += "</div>"
|
||||||
|
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(
|
||||||
|
"<style> body {{ background-color: {0}; color: {1};}}</style><body>".format(
|
||||||
|
*self.renderer.CSS
|
||||||
|
) + self.renderer.HTML + "</body>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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_())
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pyqt5
|
||||||
|
pynacl
|
||||||
|
mistune
|
Loading…
Reference in a new issue