From 752571b90fe246c9d54f19dae58ac7323e540706 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 28 Apr 2019 12:05:10 -0700 Subject: [PATCH] re-add accidentally deleted files --- yomi_base/japanese/dictionary.py | 115 ++++++ yomi_base/japanese/translate.py | 96 +++++ yomi_base/preference_data.py | 78 ++++ yomi_base/preferences.py | 226 ++++++++++++ yomi_base/reader.py | 604 +++++++++++++++++++++++++++++++ yomi_base/reader_util.py | 293 +++++++++++++++ yomi_base/updates.py | 72 ++++ yomichan.py | 97 +++++ 8 files changed, 1581 insertions(+) create mode 100644 yomi_base/japanese/dictionary.py create mode 100644 yomi_base/japanese/translate.py create mode 100644 yomi_base/preference_data.py create mode 100644 yomi_base/preferences.py create mode 100644 yomi_base/reader.py create mode 100644 yomi_base/reader_util.py create mode 100644 yomi_base/updates.py create mode 100755 yomichan.py diff --git a/yomi_base/japanese/dictionary.py b/yomi_base/japanese/dictionary.py new file mode 100644 index 0000000..e94675a --- /dev/null +++ b/yomi_base/japanese/dictionary.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +import operator +import sqlite3 + + +class Dictionary: + def __init__(self, filename, index=True): + self.db = sqlite3.connect(filename) + self.indices = set() + + + def findTerm(self, text, wildcards=False): + self.requireIndex('Vocab', 'expression') + self.requireIndex('Vocab', 'reading') + self.requireIndex('VocabGloss', 'vocabId') + + cursor = self.db.cursor() + + definitions = [] + cursor.execute('SELECT * FROM Vocab WHERE expression {0} ? OR reading=?'.format('LIKE' if wildcards else '='), (text, text)) + for vocabId, expression, reading, tags in cursor.fetchall(): + tags = tags.split() + + cursor.execute('SELECT glossary From VocabGloss WHERE vocabId=?', (vocabId,)) + glossary = map(operator.itemgetter(0), cursor) + + # + # TODO: Handle addons through data. + # + + addons = [] + for tag in tags: + if tag.startswith('v5') and tag != 'v5': + addons.append('v5') + elif tag.startswith('vs-'): + addons.append('vs') + + definitions.append({ + 'id': vocabId, + 'expression': expression, + 'reading': reading, + 'glossary': glossary, + 'tags': tags + addons, + 'addons': addons + }) + + return definitions + + + def findKanji(self, text): + assert len(text) == 1 + + self.requireIndex('Kanji', 'character') + self.requireIndex('KanjiGloss', 'kanjiId') + + cursor = self.db.cursor() + + cursor.execute('SELECT * FROM Kanji WHERE character=? LIMIT 1', text) + query = cursor.fetchone() + if query is None: + return + + kanjiId, character, kunyomi, onyomi = query + cursor.execute('SELECT glossary From KanjiGloss WHERE kanjiId=?', (kanjiId,)) + glossary = map(operator.itemgetter(0), cursor) + + return { + 'id': kanjiId, + 'character': character, + 'kunyomi': [] if kunyomi is None else kunyomi.split(), + 'onyomi': [] if onyomi is None else onyomi.split(), + 'glossary': glossary + } + + + def requireIndex(self, table, column): + name = 'index_{0}_{1}'.format(table, column) + if not self.hasIndex(name): + self.buildIndex(name, table, column) + + + def buildIndex(self, name, table, column): + cursor = self.db.cursor() + cursor.execute('CREATE INDEX {0} ON {1}({2})'.format(name, table, column)) + self.db.commit() + + + def hasIndex(self, name): + if name in self.indices: + return True + + cursor = self.db.cursor() + cursor.execute('SELECT * FROM sqlite_master WHERE name=?', (name,)) + if len(cursor.fetchall()) == 0: + return False + + self.indices.update([name]) + return True diff --git a/yomi_base/japanese/translate.py b/yomi_base/japanese/translate.py new file mode 100644 index 0000000..db38c98 --- /dev/null +++ b/yomi_base/japanese/translate.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# This module is based on Rikaichan code written by Jonathan Zarate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +import re + + +class Translator: + def __init__(self, deinflector, dictionary): + self.deinflector = deinflector + self.dictionary = dictionary + + + def findTerm(self, text, wildcards=False): + if wildcards: + text = re.sub(u'[\**]', u'%', text) + text = re.sub(u'[\??]', u'_', text) + + groups = {} + for i in xrange(len(text), 0, -1): + term = text[:i] + + dfs = self.deinflector.deinflect(term, lambda term: [d['tags'] for d in self.dictionary.findTerm(term)]) + if dfs is None: + continue + + for df in dfs: + self.processTerm(groups, **df) + + definitions = groups.values() + definitions = sorted( + definitions, + reverse=True, + key=lambda d: ( + len(d['source']), + 'P' in d['tags'], + -len(d['rules']), + d['expression'] + ) + ) + + length = 0 + for result in definitions: + length = max(length, len(result['source'])) + + return definitions, length + + + def findKanji(self, text): + processed = {} + results = [] + for c in text: + if c not in processed: + match = self.dictionary.findKanji(c) + if match is not None: + results.append(match) + processed[c] = match + + return results + + + def processTerm(self, groups, source, tags, rules=[], root='', wildcards=False): + for entry in self.dictionary.findTerm(root, wildcards): + if entry['id'] in groups: + continue + + matched = len(tags) == 0 + for tag in tags: + if tag in entry['tags']: + matched = True + break + + if matched: + groups[entry['id']] = { + 'expression': entry['expression'], + 'reading': entry['reading'], + 'glossary': entry['glossary'], + 'tags': entry['tags'], + 'source': source, + 'rules': rules + } diff --git a/yomi_base/preference_data.py b/yomi_base/preference_data.py new file mode 100644 index 0000000..666b8e4 --- /dev/null +++ b/yomi_base/preference_data.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +import codecs +import json +import operator +import os + + +class Preferences(object): + def __init__(self): + self.filename = os.path.expanduser(u'~/.yomichan.json') + self.defaults = os.path.join(os.path.dirname(__file__), u'defaults.json') + self.settings = {} + + + def __getitem__(self, name): + return self.settings.get(name) + + + def __setitem__(self, name, value): + self.settings[name] = value + + + def load(self): + with codecs.open(self.defaults, 'rb', 'utf-8') as fp: + self.settings = json.load(fp) + + try: + if os.path.exists(self.filename): + with codecs.open(self.filename, 'rb', 'utf-8') as fp: + self.settings.update(json.load(fp)) + except ValueError: + pass + + + def save(self): + with codecs.open(self.filename, 'wb', 'utf-8') as fp: + json.dump(self.settings, fp, indent=4, sort_keys=True) + + + def filePosition(self, filename): + matches = filter(lambda f: f['path'] == filename, self['recentFiles']) + return 0 if len(matches) == 0 else matches[0]['position'] + + + def recentFiles(self): + return map(operator.itemgetter('path'), self['recentFiles']) + + + def updateFactTags(self, tags): + if tags in self['tags']: + self['tags'].remove(tags) + self['tags'].insert(0, tags) + + + def updateRecentFile(self, filename, position): + self['recentFiles'] = filter(lambda f: f['path'] != filename, self['recentFiles']) + self['recentFiles'].insert(0, {'path': filename, 'position': position}) + + + def clearRecentFiles(self): + self['recentFiles'] = [] diff --git a/yomi_base/preferences.py b/yomi_base/preferences.py new file mode 100644 index 0000000..e28960f --- /dev/null +++ b/yomi_base/preferences.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +from PyQt4 import QtGui, QtCore +import copy +import gen.preferences_ui + + +class DialogPreferences(QtGui.QDialog, gen.preferences_ui.Ui_DialogPreferences): + def __init__(self, parent, preferences, anki): + QtGui.QDialog.__init__(self, parent) + self.setupUi(self) + + self.accepted.connect(self.onAccept) + self.buttonColorBg.clicked.connect(self.onButtonColorBgClicked) + self.buttonColorFg.clicked.connect(self.onButtonColorFgClicked) + self.comboBoxDeck.currentIndexChanged.connect(self.onDeckChanged) + self.comboBoxModel.currentIndexChanged.connect(self.onModelChanged) + self.comboFontFamily.currentFontChanged.connect(self.onFontFamilyChanged) + self.radioButtonKanji.toggled.connect(self.onProfileChanged) + self.radioButtonVocab.toggled.connect(self.onProfileChanged) + self.spinFontSize.valueChanged.connect(self.onFontSizeChanged) + self.tableFields.itemChanged.connect(self.onFieldsChanged) + + self.preferences = preferences + self.anki = anki + + self.dataToDialog() + + + def dataToDialog(self): + self.checkCheckForUpdates.setChecked(self.preferences['checkForUpdates']) + self.checkRememberTextContent.setChecked(self.preferences['rememberTextContent']) + self.checkAllowEditing.setChecked(self.preferences['allowEditing']) + self.checkLoadRecentFile.setChecked(self.preferences['loadRecentFile']) + self.checkStripReadings.setChecked(self.preferences['stripReadings']) + self.spinScanLength.setValue(self.preferences['scanLength']) + self.checkEnableAnkiConnect.setChecked(self.preferences['enableAnkiConnect']) + + self.updateSampleText() + font = self.textSample.font() + self.comboFontFamily.setCurrentFont(font) + self.spinFontSize.setValue(font.pointSize()) + + if self.anki is not None: + self.tabAnki.setEnabled(True) + self.profiles = copy.deepcopy(self.preferences['profiles']) + self.profileToDialog() + + + def dialogToData(self): + self.preferences['checkForUpdates'] = self.checkCheckForUpdates.isChecked() + self.preferences['rememberTextContent'] = self.checkRememberTextContent.isChecked() + self.preferences['allowEditing'] = self.checkAllowEditing.isChecked() + self.preferences['loadRecentFile'] = self.checkLoadRecentFile.isChecked() + self.preferences['scanLength'] = self.spinScanLength.value() + self.preferences['stripReadings'] = self.checkStripReadings.isChecked() + self.preferences['enableAnkiConnect'] = self.checkEnableAnkiConnect.isChecked() + self.preferences['firstRun'] = False + + if self.anki is not None: + self.dialogToProfile() + self.preferences['profiles'] = self.profiles + + + def dialogToProfile(self): + self.setActiveProfile({ + 'deck': unicode(self.comboBoxDeck.currentText()), + 'model': unicode(self.comboBoxModel.currentText()), + 'fields': self.ankiFields() + }) + + + def profileToDialog(self): + profile, name = self.activeProfile() + + deck = u'' if profile is None else profile['deck'] + model = u'' if profile is None else profile['model'] + + self.comboBoxDeck.blockSignals(True) + self.comboBoxDeck.clear() + self.comboBoxDeck.addItems(self.anki.deckNames()) + self.comboBoxDeck.setCurrentIndex(self.comboBoxDeck.findText(deck)) + self.comboBoxDeck.blockSignals(False) + + self.comboBoxModel.blockSignals(True) + self.comboBoxModel.clear() + self.comboBoxModel.addItems(self.anki.modelNames()) + self.comboBoxModel.setCurrentIndex(self.comboBoxModel.findText(model)) + self.comboBoxModel.blockSignals(False) + + allowedTags = { + 'vocab': ['expression', 'reading', 'glossary', 'sentence'], + 'kanji': ['character', 'onyomi', 'kunyomi', 'glossary'], + }[name] + + allowedTags = map(lambda t: '{' + t + '}', allowedTags) + self.labelTags.setText('Allowed tags are {0}'.format(', '.join(allowedTags))) + + self.updateAnkiFields() + + + def updateSampleText(self): + palette = self.textSample.palette() + palette.setColor(QtGui.QPalette.Base, QtGui.QColor(self.preferences['bgColor'])) + palette.setColor(QtGui.QPalette.Text, QtGui.QColor(self.preferences['fgColor'])) + self.textSample.setPalette(palette) + + font = self.textSample.font() + font.setFamily(self.preferences['fontFamily']) + font.setPointSize(self.preferences['fontSize']) + self.textSample.setFont(font) + + + def setAnkiFields(self, fields, fieldsPrefs): + if fields is None: + fields = [] + + self.tableFields.blockSignals(True) + self.tableFields.setRowCount(len(fields)) + + for i, name in enumerate(fields): + columns = [] + + itemName = QtGui.QTableWidgetItem(name) + itemName.setFlags(QtCore.Qt.ItemIsSelectable) + columns.append(itemName) + + itemValue = QtGui.QTableWidgetItem(fieldsPrefs.get(name, u'')) + columns.append(itemValue) + + for j, column in enumerate(columns): + self.tableFields.setItem(i, j, column) + + self.tableFields.blockSignals(False) + + + def ankiFields(self): + result = {} + + for i in range(0, self.tableFields.rowCount()): + itemName = unicode(self.tableFields.item(i, 0).text()) + itemValue = unicode(self.tableFields.item(i, 1).text()) + result[itemName] = itemValue + + return result + + + def onAccept(self): + self.dialogToData() + + + def onButtonColorFgClicked(self): + color, ok = QtGui.QColorDialog.getRgba(self.preferences['fgColor'], self) + if ok: + self.preferences['fgColor'] = color + self.updateSampleText() + + + def onButtonColorBgClicked(self): + color, ok = QtGui.QColorDialog.getRgba(self.preferences['bgColor'], self) + if ok: + self.preferences['bgColor'] = color + self.updateSampleText() + + + def onFontFamilyChanged(self, font): + self.preferences['fontFamily'] = unicode(font.family()) + self.updateSampleText() + + + def onFontSizeChanged(self, size): + self.preferences['fontSize'] = size + self.updateSampleText() + + + def onModelChanged(self, index): + self.updateAnkiFields() + self.dialogToProfile() + + + def onDeckChanged(self, index): + self.dialogToProfile() + + + def onFieldsChanged(self, item): + self.dialogToProfile() + + + def onProfileChanged(self, data): + self.profileToDialog() + + + def updateAnkiFields(self): + modelName = self.comboBoxModel.currentText() + fieldNames = self.anki.modelFieldNames(modelName) or [] + + profile, name = self.activeProfile() + fields = {} if profile is None else profile['fields'] + + self.setAnkiFields(fieldNames, fields) + + + def activeProfile(self): + name = 'vocab' if self.radioButtonVocab.isChecked() else 'kanji' + return self.profiles.get(name), name + + + def setActiveProfile(self, profile): + name = 'vocab' if self.radioButtonVocab.isChecked() else 'kanji' + self.profiles[name] = profile diff --git a/yomi_base/reader.py b/yomi_base/reader.py new file mode 100644 index 0000000..36fdd10 --- /dev/null +++ b/yomi_base/reader.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +from PyQt4 import QtGui, QtCore +import about +import constants +import gen.reader_ui +import os +import preferences +import reader_util +import updates + + +class MainWindowReader(QtGui.QMainWindow, gen.reader_ui.Ui_MainWindowReader): + class State: + def __init__(self): + self.filename = u'' + self.searchText = u'' + self.kanjiDefs = [] + self.vocabDefs = [] + self.scanPosition = 0 + self.searchPosition = 0 + + + def __init__(self, parent, preferences, language, filename=None, anki=None, closed=None): + QtGui.QMainWindow.__init__(self, parent) + self.setupUi(self) + + self.textContent.mouseMoveEvent = self.onContentMouseMove + self.textContent.mousePressEvent = self.onContentMousePress + self.dockAnki.setEnabled(anki is not None) + + self.facts = [] + self.anki = anki + self.closed = closed + self.language = language + self.preferences = preferences + self.state = self.State() + self.updates = updates.UpdateFinder() + self.zoom = 0 + + self.applyPreferences() + self.updateRecentFiles() + self.updateVocabDefs() + self.updateKanjiDefs() + + if filename is not None: + self.openFile(filename) + elif self.preferences['rememberTextContent']: + self.textContent.setPlainText(self.preferences['textContent']) + elif self.preferences['loadRecentFile']: + filenames = self.preferences.recentFiles() + if len(filenames) > 0 and os.path.isfile(filenames[0]): + self.openFile(filenames[0]) + + self.actionAbout.triggered.connect(self.onActionAbout) + self.actionFind.triggered.connect(self.onActionFind) + self.actionFindNext.triggered.connect(self.onActionFindNext) + self.actionHomepage.triggered.connect(self.onActionHomepage) + self.actionKindleDeck.triggered.connect(self.onActionKindleDeck) + self.actionWordList.triggered.connect(self.onActionWordList) + self.actionOpen.triggered.connect(self.onActionOpen) + self.actionPreferences.triggered.connect(self.onActionPreferences) + self.actionToggleWrap.toggled.connect(self.onActionToggleWrap) + self.actionZoomIn.triggered.connect(self.onActionZoomIn) + self.actionZoomOut.triggered.connect(self.onActionZoomOut) + self.actionZoomReset.triggered.connect(self.onActionZoomReset) + self.dockAnki.visibilityChanged.connect(self.onVisibilityChanged) + self.dockKanji.visibilityChanged.connect(self.onVisibilityChanged) + self.dockVocab.visibilityChanged.connect(self.onVisibilityChanged) + self.listDefinitions.itemDoubleClicked.connect(self.onDefinitionDoubleClicked) + self.textKanjiDefs.anchorClicked.connect(self.onKanjiDefsAnchorClicked) + self.textKanjiSearch.returnPressed.connect(self.onKanjiDefSearchReturn) + self.textVocabDefs.anchorClicked.connect(self.onVocabDefsAnchorClicked) + self.textVocabSearch.returnPressed.connect(self.onVocabDefSearchReturn) + self.updates.updateResult.connect(self.onUpdaterSearchResult) + + if self.preferences['checkForUpdates']: + self.updates.start() + + + def applyPreferences(self): + if self.preferences['windowState'] is not None: + self.restoreState(QtCore.QByteArray.fromBase64(self.preferences['windowState'])) + if self.preferences['windowPosition'] is not None: + self.move(QtCore.QPoint(*self.preferences['windowPosition'])) + if self.preferences['windowSize'] is not None: + self.resize(QtCore.QSize(*self.preferences['windowSize'])) + + self.comboTags.addItems(self.preferences['tags']) + self.applyPreferencesContent() + + if self.preferences['firstRun']: + QtGui.QMessageBox.information( + self, + 'Yomichan', + 'This may be the first time you are running Yomichan.\n' \ + 'Please take some time to configure this extension.' + ) + + self.onActionPreferences() + + + def applyPreferencesContent(self): + palette = self.textContent.palette() + palette.setColor(QtGui.QPalette.Base, QtGui.QColor(self.preferences['bgColor'])) + palette.setColor(QtGui.QPalette.Text, QtGui.QColor(self.preferences['fgColor'])) + self.textContent.setPalette(palette) + + self.textContent.setReadOnly(not self.preferences['allowEditing']) + self.textContent.setAttribute(QtCore.Qt.WA_InputMethodEnabled) + + font = self.textContent.font() + font.setFamily(self.preferences['fontFamily']) + font.setPointSize(self.preferences['fontSize'] + self.zoom) + self.textContent.setLineWrapMode(QtGui.QPlainTextEdit.WidgetWidth if self.preferences['wordWrap'] else QtGui.QPlainTextEdit.NoWrap) + self.textContent.setFont(font) + + self.actionToggleWrap.setChecked(self.preferences['wordWrap']) + + + def closeEvent(self, event): + self.closeFile() + self.preferences['windowState'] = str(self.saveState().toBase64()) + self.preferences.save() + + if self.anki is not None: + self.anki.stopEditing() + + if self.closed is not None: + self.closed() + + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Shift: + self.updateSampleFromPosition() + elif ord('0') <= event.key() <= ord('9'): + index = event.key() - ord('0') - 1 + if index < 0: + index = 9 + if event.modifiers() & QtCore.Qt.ShiftModifier: + if event.modifiers() & QtCore.Qt.ControlModifier: + self.executeKanjiCommand('addKanji', index) + else: + if event.modifiers() & QtCore.Qt.ControlModifier: + self.executeVocabCommand('addVocabExp', index) + if event.modifiers() & QtCore.Qt.AltModifier: + self.executeVocabCommand('addVocabReading', index) + elif event.key() == ord('[') and self.state.scanPosition > 0: + self.state.scanPosition -= 1 + self.updateSampleFromPosition() + elif event.key() == ord(']') and self.state.scanPosition < len(self.textContent.toPlainText()) - 1: + self.state.scanPosition += 1 + self.updateSampleFromPosition() + + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + + def dropEvent(self, event): + url = event.mimeData().urls()[0] + self.openFile(url.toLocalFile()) + + + def moveEvent(self, event): + self.preferences['windowPosition'] = event.pos().x(), event.pos().y() + + + def resizeEvent(self, event): + self.preferences['windowSize'] = event.size().width(), event.size().height() + + + def onActionOpen(self): + filename = QtGui.QFileDialog.getOpenFileName( + parent=self, + caption='Select a file to open', + filter='Text files (*.txt);;All files (*.*)' + ) + if filename: + self.openFile(filename) + + + def onActionKindleDeck(self): + filename = QtGui.QFileDialog.getOpenFileName( + parent=self, + caption='Select a Kindle deck to import', + filter='Deck files (*.db)' + ) + if filename: + words = reader_util.extractKindleDeck(filename) + self.importWordList(words) + + + def onActionWordList(self): + filename = QtGui.QFileDialog.getOpenFileName( + parent=self, + caption='Select a word list file to import', + filter='Text files (*.txt);;All files (*.*)' + ) + if filename: + words = reader_util.extractWordList(filename) + self.importWordList(words) + + + def onActionPreferences(self): + dialog = preferences.DialogPreferences(self, self.preferences, self.anki) + if dialog.exec_() == QtGui.QDialog.Accepted: + self.applyPreferencesContent() + + + def onActionAbout(self): + dialog = about.DialogAbout(self) + dialog.exec_() + + + def onActionZoomIn(self): + font = self.textContent.font() + if font.pointSize() < 72: + font.setPointSize(font.pointSize() + 1) + self.textContent.setFont(font) + self.zoom += 1 + + + def onActionZoomOut(self): + font = self.textContent.font() + if font.pointSize() > 1: + font.setPointSize(font.pointSize() - 1) + self.textContent.setFont(font) + self.zoom -= 1 + + + def onActionZoomReset(self): + if self.zoom: + font = self.textContent.font() + font.setPointSize(font.pointSize() - self.zoom) + self.textContent.setFont(font) + self.zoom = 0 + + + def onActionFind(self): + searchText = self.state.searchText + + cursor = self.textContent.textCursor() + if cursor.hasSelection(): + searchText = cursor.selectedText() + + searchText, ok = QtGui.QInputDialog.getText(self, 'Find', 'Search text:', text=searchText) + if searchText and ok: + self.findText(searchText) + + + def onActionFindNext(self): + if self.state.searchText: + self.findText(self.state.searchText) + + + def onActionToggleWrap(self, wrap): + self.preferences['wordWrap'] = wrap + mode = QtGui.QPlainTextEdit.WidgetWidth if self.preferences['wordWrap'] else QtGui.QPlainTextEdit.NoWrap + self.textContent.setLineWrapMode(mode) + + + def onActionHomepage(self): + url = QtCore.QUrl('https://foosoft.net/projects/yomichan') + QtGui.QDesktopServices().openUrl(url) + + + def onVocabDefsAnchorClicked(self, url): + command, index = unicode(url.toString()).split(':') + self.executeVocabCommand(command, int(index)) + + + def onKanjiDefsAnchorClicked(self, url): + command, index = unicode(url.toString()).split(':') + self.executeKanjiCommand(command, int(index)) + + + def onVocabDefSearchReturn(self): + text = unicode(self.textVocabSearch.text()) + self.state.vocabDefs, length = self.language.findTerm(text, True) + self.updateVocabDefs() + if self.dockKanji.isVisible(): + self.state.kanjiDefs = self.language.findKanji(text) + self.updateKanjiDefs() + + + def onKanjiDefSearchReturn(self): + text = unicode(self.textKanjiSearch.text()) + self.state.kanjiDefs = self.language.findKanji(text) + self.updateKanjiDefs() + + + def onDefinitionDoubleClicked(self, item): + if self.anki is not None: + row = self.listDefinitions.row(item) + self.anki.browseNote(self.facts[row]) + + + def onVisibilityChanged(self, visible): + self.actionToggleAnki.setChecked(self.dockAnki.isVisible()) + self.actionToggleVocab.setChecked(self.dockVocab.isVisible()) + self.actionToggleKanji.setChecked(self.dockKanji.isVisible()) + + + def onUpdaterSearchResult(self, versions): + if versions['latest'] > constants.c['appVersion']: + dialog = updates.DialogUpdates(self, versions) + dialog.exec_() + + + def onContentMouseMove(self, event): + QtGui.QPlainTextEdit.mouseMoveEvent(self.textContent, event) + self.updateSampleMouseEvent(event) + + + def onContentMousePress(self, event): + QtGui.QPlainTextEdit.mousePressEvent(self.textContent, event) + self.updateSampleMouseEvent(event) + + + def openFile(self, filename): + try: + filename = unicode(filename) + with open(filename) as fp: + content = fp.read() + except IOError: + self.setStatus(u'Failed to load file {0}'.format(filename)) + QtGui.QMessageBox.critical(self, 'Yomichan', 'Cannot open file for read') + return + + self.closeFile() + + self.state.filename = filename + self.state.scanPosition = self.preferences.filePosition(filename) + if self.state.scanPosition > len(content): + self.state.scanPosition = 0 + + self.updateRecentFile() + self.updateRecentFiles() + + content, encoding = reader_util.decodeContent(content) + if self.preferences['stripReadings']: + content = reader_util.stripReadings(content) + + self.textContent.setPlainText(content) + if self.state.scanPosition > 0: + cursor = self.textContent.textCursor() + cursor.setPosition(self.state.scanPosition) + self.textContent.setTextCursor(cursor) + self.textContent.centerCursor() + + self.setStatus(u'Loaded file {0}'.format(filename)) + self.setWindowTitle(u'Yomichan - {0} ({1})'.format(os.path.basename(filename), encoding)) + + + def closeFile(self): + if self.preferences['rememberTextContent']: + self.preferences['textContent'] = unicode(self.textContent.toPlainText()) + + self.setWindowTitle('Yomichan') + self.textContent.setPlainText(u'') + self.updateRecentFile(False) + self.state = self.State() + + + def findText(self, text): + content = unicode(self.textContent.toPlainText()) + index = content.find(unicode(text), self.state.searchPosition) + + if index == -1: + wrap = self.state.searchPosition != 0 + self.state.searchPosition = 0 + if wrap: + self.findText(text) + else: + QtGui.QMessageBox.information(self, 'Yomichan', 'Search text not found') + else: + self.state.searchPosition = index + len(text) + cursor = self.textContent.textCursor() + cursor.setPosition(index, QtGui.QTextCursor.MoveAnchor) + cursor.setPosition(self.state.searchPosition, QtGui.QTextCursor.KeepAnchor) + self.textContent.setTextCursor(cursor) + + self.state.searchText = text + + + def ankiAddFact(self, profile, markup): + if markup is None: + return False + + if self.anki is None: + return False + + profile = self.preferences['profiles'].get(profile) + if profile is None: + return False + + fields = reader_util.formatFields(profile['fields'], markup) + tagsSplit = reader_util.splitTags(unicode(self.comboTags.currentText())) + tagsJoined = ' '.join(tagsSplit) + + tagIndex = self.comboTags.findText(tagsJoined) + if tagIndex > 0: + self.comboTags.removeItem(tagIndex) + if tagIndex != 0: + self.comboTags.insertItem(0, tagsJoined) + self.preferences.updateFactTags(tagsJoined) + + factId = self.anki.addNote(profile['deck'], profile['model'], fields, tagsSplit, None) + if factId is None: + return False + + self.facts.append(factId) + self.listDefinitions.addItem(markup['summary']) + self.listDefinitions.setCurrentRow(self.listDefinitions.count() - 1) + self.setStatus(u'Added fact {0}; {1} new fact(s) total'.format(markup['summary'], len(self.facts))) + + self.updateVocabDefs(scroll=True) + self.updateKanjiDefs(scroll=True) + return True + + + def ankiIsFactValid(self, profile, markup): + if markup is None: + return False + + if self.anki is None: + return False + + profile = self.preferences['profiles'].get(profile) + if profile is None: + return False + + fields = reader_util.formatFields(profile['fields'], markup) + return self.anki.canAddNote(profile['deck'], profile['model'], fields) + + + def executeVocabCommand(self, command, index): + if index >= len(self.state.vocabDefs): + return + + definition = self.state.vocabDefs[index] + if command == 'addVocabExp': + markup = reader_util.markupVocabExp(definition) + self.ankiAddFact('vocab', markup) + if command == 'addVocabReading': + markup = reader_util.markupVocabReading(definition) + self.ankiAddFact('vocab', markup) + elif command == 'copyVocabDef': + reader_util.copyVocabDef(definition) + + + def executeKanjiCommand(self, command, index): + if index >= len(self.state.kanjiDefs): + return + + definition = self.state.kanjiDefs[index] + if command == 'addKanji': + markup = reader_util.markupKanji(definition) + self.ankiAddFact('kanji', markup) + elif command == 'copyKanjiDef': + reader_util.copyKanjiDef(definition) + + + def updateSampleMouseEvent(self, event): + cursor = self.textContent.cursorForPosition(event.pos()) + self.state.scanPosition = cursor.position() + if event.buttons() & QtCore.Qt.MidButton or event.modifiers() & QtCore.Qt.ShiftModifier: + self.updateSampleFromPosition() + + + def updateSampleFromPosition(self): + samplePosStart = self.state.scanPosition + samplePosEnd = self.state.scanPosition + self.preferences['scanLength'] + + content = unicode(self.textContent.toPlainText()) + contentSample = content[samplePosStart:samplePosEnd] + contentSampleFlat = contentSample.replace(u'\n', u'') + + cursor = self.textContent.textCursor() + + if len(contentSampleFlat) == 0: + cursor.clearSelection() + self.textContent.setTextCursor(cursor) + return + + lengthMatched = 0 + if self.dockVocab.isVisible(): + self.state.vocabDefs, lengthMatched = self.language.findTerm(contentSampleFlat) + sentence = reader_util.findSentence(content, samplePosStart) + for definition in self.state.vocabDefs: + definition['sentence'] = sentence + self.updateVocabDefs() + + if self.dockKanji.isVisible(): + if lengthMatched == 0: + self.state.kanjiDefs = self.language.findKanji(contentSampleFlat[0]) + if len(self.state.kanjiDefs) > 0: + lengthMatched = 1 + else: + self.state.kanjiDefs = self.language.findKanji(contentSampleFlat[:lengthMatched]) + self.updateKanjiDefs() + + lengthSelect = 0 + for c in contentSample: + if lengthMatched <= 0: + break + lengthSelect += 1 + if c != u'\n': + lengthMatched -= 1 + + cursor.setPosition(samplePosStart, QtGui.QTextCursor.MoveAnchor) + cursor.setPosition(samplePosStart + lengthSelect, QtGui.QTextCursor.KeepAnchor) + self.textContent.setTextCursor(cursor) + + + def clearRecentFiles(self): + self.preferences.clearRecentFiles() + self.updateRecentFiles() + + + def updateRecentFiles(self): + self.menuOpenRecent.clear() + + filenames = self.preferences.recentFiles() + if len(filenames) == 0: + return + + for filename in filenames: + self.menuOpenRecent.addAction(filename, lambda f=filename: self.openFile(f)) + + self.menuOpenRecent.addSeparator() + self.menuOpenRecent.addAction('Clear file history', self.clearRecentFiles) + + + def updateRecentFile(self, addIfNeeded=True): + if self.state.filename: + if addIfNeeded or self.state.filename in self.preferences.recentFiles(): + self.preferences.updateRecentFile(self.state.filename, self.state.scanPosition) + + + def updateDefs(self, defs, builder, control, **options): + scrollbar = control.verticalScrollBar() + position = scrollbar.sliderPosition() + + html = builder(defs, self.ankiIsFactValid) + control.setHtml(html) + + if options.get('scroll', False): + scrollbar.setSliderPosition(position) + + + def updateVocabDefs(self, **options): + self.updateDefs( + self.state.vocabDefs, + reader_util.buildVocabDefs, + self.textVocabDefs, + **options + ) + + + def updateKanjiDefs(self, **options): + self.updateDefs( + self.state.kanjiDefs, + reader_util.buildKanjiDefs, + self.textKanjiDefs, + **options + ) + + + def importWordList(self, words): + self.state.vocabDefs = [] + self.state.kanjiDefs = [] + + for word in words: + if self.dockVocab.isVisible(): + self.state.vocabDefs += self.language.dictionary.findTerm(word) + + if self.dockKanji.isVisible(): + self.state.kanjiDefs += self.language.findKanji(word) + + self.updateVocabDefs(scroll=True) + self.updateKanjiDefs(scroll=True) + + + def setStatus(self, status): + self.statusBar.showMessage(status) diff --git a/yomi_base/reader_util.py b/yomi_base/reader_util.py new file mode 100644 index 0000000..0ff4fd9 --- /dev/null +++ b/yomi_base/reader_util.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +from PyQt4 import QtGui +import re +import codecs +import sqlite3 + + +def decodeContent(content): + encodings = ['utf-8', 'shift_jis', 'euc-jp', 'utf-16'] + errors = {} + + for encoding in encodings: + try: + return content.decode(encoding), encoding + except UnicodeDecodeError, e: + errors[encoding] = e[2] + + encoding = sorted(errors, key=errors.get, reverse=True)[0] + return content.decode(encoding, 'replace'), encoding + + +def stripReadings(content): + return re.sub(u'《[^》]+》', u'', content) + + +def findSentence(content, position): + quotesFwd = {u'「': u'」', u'『': u'』', u"'": u"'", u'"': u'"'} + quotesBwd = {u'」': u'「', u'』': u'『', u"'": u"'", u'"': u'"'} + terminators = u'。..??!!' + + quoteStack = [] + + start = 0 + for i in xrange(position, start, -1): + c = content[i] + + if not quoteStack and (c in terminators or c in quotesFwd or c == '\n'): + start = i + 1 + break + + if quoteStack and c == quoteStack[0]: + quoteStack.pop() + elif c in quotesBwd: + quoteStack.insert(0, quotesBwd[c]) + + quoteStack = [] + + end = len(content) + for i in xrange(position, end): + c = content[i] + + if not quoteStack: + if c in terminators: + end = i + 1 + break + elif c in quotesBwd: + end = i + break + + if quoteStack and c == quoteStack[0]: + quoteStack.pop() + elif c in quotesFwd: + quoteStack.insert(0, quotesFwd[c]) + + return content[start:end].strip() + + +def formatFields(fields, markup): + result = {} + for field, value in fields.items(): + try: + result[field] = value.format(**markup) + except KeyError: + pass + except ValueError: + pass + + return result + + +def splitTags(tags): + return filter(lambda tag: tag.strip(), re.split('[;,\s]', tags)) + + +def markupVocabExp(definition): + if definition['reading']: + summary = u'{expression} [{reading}]'.format(**definition) + else: + summary = u'{expression}'.format(**definition) + + return { + 'expression': definition['expression'], + 'reading': definition['reading'] or u'', + 'glossary': '; '.join(definition['glossary']), + 'sentence': definition.get('sentence'), + 'summary': summary + } + + +def markupVocabReading(definition): + if definition['reading']: + return { + 'expression': definition['reading'], + 'reading': u'', + 'glossary': '; '.join(definition['glossary']), + 'sentence': definition.get('sentence'), + 'summary': definition['reading'] + } + + +def copyVocabDef(definition): + glossary = '; '.join(definition['glossary']) + if definition['reading']: + result = u'{0}\t{1}\t{2}\n'.format(definition['expression'], definition['reading'], glossary) + else: + result = u'{0}\t{1}\n'.format(definition['expression'], glossary) + + QtGui.QApplication.clipboard().setText(result) + + +def markupKanji(definition): + return { + 'character': definition['character'], + 'onyomi': ', '.join(definition['onyomi']), + 'kunyomi': ', '.join(definition['kunyomi']), + 'glossary': ', '.join(definition['glossary']), + 'summary': definition['character'] + } + + +def copyKanjiDef(definition): + result = u'{0}\t{1}\t{2}\t{3}'.format( + definition['character'], + ', '.join(definition['kunyomi']), + ', '.join(definition['onyomi']), + ', '.join(definition['glossary']) + ) + + QtGui.QApplication.clipboard().setText(result) + + +def buildDefHeader(): + palette = QtGui.QApplication.palette() + toolTipBg = palette.color(QtGui.QPalette.Window).name() + toolTipFg = palette.color(QtGui.QPalette.WindowText).name() + + return u''' + '''.format(toolTipBg, toolTipFg) + + +def buildDefFooter(): + return '' + + +def buildEmpty(): + return u''' +

No definitions to display.

+

Mouse over text with the middle mouse button or shift key pressed to search.

+

You can also also input terms in the search box below.''' + + +def buildVocabDef(definition, index, query): + reading = u'' + if definition['reading']: + reading = u'[{0}]
'.format(definition['reading']) + + rules = u'' + if definition.get('rules'): + rules = ' < '.join(definition['rules']) + rules = '({0})
'.format(rules) + + links = ''.format(index) + if query is not None: + if query('vocab', markupVocabExp(definition)): + links += ''.format(index) + if query('vocab', markupVocabReading(definition)): + links += ''.format(index) + + glossary = u'

    ' + for g in definition['glossary']: + glossary += u'
  1. {0}
  2. '.format(g) + glossary += u'
' + + expression = u'' + if 'P' in definition['tags']: + expression += u'{}'.format(definition['expression']) + else: + expression += definition['expression'] + expression += u'' + + html = u''' + {links} + {expression} + {reading} + {rules} + {glossary}
+
'''.format( + links = links, + expression = expression, + reading = reading, + glossary = glossary, + rules = rules + ) + + return html + + +def buildVocabDefs(definitions, query): + html = buildDefHeader() + if len(definitions) > 0: + for i, definition in enumerate(definitions): + html += buildVocabDef(definition, i, query) + else: + html += buildEmpty() + + return html + buildDefFooter() + + +def buildKanjiDef(definition, index, query): + links = ''.format(index) + if query is not None and query('kanji', markupKanji(definition)): + links += ''.format(index) + + readings = ', '.join(definition['kunyomi'] + definition['onyomi']) + glossary = ', '.join(definition['glossary']) + + html = u''' + {links} + {expression}
+ [{reading}]
+ {glossary}
+
'''.format( + links = links, + expression = definition['character'], + reading = readings, + glossary = glossary + ) + + return html + + +def buildKanjiDefs(definitions, query): + html = buildDefHeader() + + if len(definitions) > 0: + for i, definition in enumerate(definitions): + html += buildKanjiDef(definition, i, query) + else: + html += buildEmpty() + + return html + buildDefFooter() + + +def extractKindleDeck(filename): + words = [] + + try: + with sqlite3.connect(unicode(filename)) as db: + for row in db.execute('select word from WORDS'): + words.append(row[0]) + except sqlite3.OperationalError: + pass + + return words + + +def extractWordList(filename): + words = [] + + with codecs.open(unicode(filename), 'rb', 'utf-8') as fp: + words = re.split('[;,\s]', fp.read()) + + return filter(None, words) diff --git a/yomi_base/updates.py b/yomi_base/updates.py new file mode 100644 index 0000000..57a6970 --- /dev/null +++ b/yomi_base/updates.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +from PyQt4 import QtCore, QtGui +import constants +import gen.updates_ui +import json +import urllib2 + + +class DialogUpdates(QtGui.QDialog, gen.updates_ui.Ui_DialogUpdates): + def __init__(self, parent, versions): + QtGui.QDialog.__init__(self, parent) + self.setupUi(self) + + self.updateHtml(versions) + self.labelUpdates.setText( + unicode(self.labelUpdates.text()).format( + constants.c['appVersion'], + versions['latest'] + ) + ) + + + def updateHtml(self, versions): + html = '' + + for update in versions['updates']: + version = update.get('version') + if version > constants.c['appVersion']: + html += 'Version {0}'.format(version) + html += '
    ' + for feature in update['features']: + html += '
  • {0}
  • '.format(feature) + html += '
' + + self.textBrowser.setHtml(html) + + +class UpdateFinder(QtCore.QThread): + updateResult = QtCore.pyqtSignal(dict) + + def run(self): + latest = constants.c['appVersion'] + updates = [] + + try: + fp = urllib2.urlopen('https://foosoft.net/projects/yomichan/dl/updates.json') + updates = json.loads(fp.read()) + fp.close() + + for update in updates: + latest = max(latest, update.get('version')) + except: + pass + finally: + self.updateResult.emit({'latest': latest, 'updates': updates}) diff --git a/yomichan.py b/yomichan.py new file mode 100755 index 0000000..8df5bbd --- /dev/null +++ b/yomichan.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Alex Yatskov +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 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. If not, see . + + +from PyQt4 import QtGui +from yomi_base import japanese +from yomi_base.anki_connect import AnkiConnect +from yomi_base.preference_data import Preferences +from yomi_base.reader import MainWindowReader +import sys + + +class Yomichan: + def __init__(self): + self.language = japanese.initLanguage() + + self.preferences = Preferences() + self.preferences.load() + + +class YomichanPlugin(Yomichan): + def __init__(self): + Yomichan.__init__(self) + + self.toolIconVisible = False + self.window = None + self.anki = anki_bridge.Anki() + self.parent = self.anki.window() + self.ankiConnect = AnkiConnect(self.anki, self.preferences) + + separator = QtGui.QAction(self.parent) + separator.setSeparator(True) + self.anki.addUiAction(separator) + + action = QtGui.QAction(QtGui.QIcon(':/img/img/icon_logo_32.png'), '&Yomichan...', self.parent) + action.setIconVisibleInMenu(True) + action.setShortcut('Ctrl+Y') + action.triggered.connect(self.onShowRequest) + self.anki.addUiAction(action) + + + def onShowRequest(self): + if self.window: + self.window.setVisible(True) + self.window.activateWindow() + else: + self.window = MainWindowReader( + self.parent, + self.preferences, + self.language, + None, + self.anki, + self.onWindowClose + ) + self.window.show() + + + def onWindowClose(self): + self.window = None + + +class YomichanStandalone(Yomichan): + def __init__(self): + Yomichan.__init__(self) + + self.application = QtGui.QApplication(sys.argv) + self.window = MainWindowReader( + None, + self.preferences, + self.language, + filename=sys.argv[1] if len(sys.argv) >= 2 else None + ) + + self.window.show() + self.application.exec_() + + +if __name__ == '__main__': + instance = YomichanStandalone() +else: + from yomi_base import anki_bridge + instance = YomichanPlugin()