From 38cfa411469fe3e7fe49b07c9b80736037a0766a Mon Sep 17 00:00:00 2001 From: Ben Woodward Date: Wed, 7 Oct 2020 09:51:29 +1300 Subject: [PATCH 1/2] chore: Remove trailing whitespace characters --- importer.py | 304 ++++++++++++++++++++++---------------------- memrise.py | 210 +++++++++++++++--------------- memrise_markdown.py | 16 +-- 3 files changed, 265 insertions(+), 265 deletions(-) diff --git a/importer.py b/importer.py index a49be56..b048089 100644 --- a/importer.py +++ b/importer.py @@ -19,16 +19,16 @@ class MemriseCourseLoader(QObject): levelsLoadedChanged = pyqtSignal(int) thingCountChanged = pyqtSignal(int) thingsLoadedChanged = pyqtSignal(int) - + finished = pyqtSignal() - + class RunnableWrapper(QRunnable): def __init__(self, task): super(MemriseCourseLoader.RunnableWrapper, self).__init__() self.task = task def run(self): self.task.run() - + class Observer(object): def __init__(self, sender): self.sender = sender @@ -36,13 +36,13 @@ def __init__(self, sender): self.totalLoaded = 0 self.thingsLoaded = 0 self.levelsLoaded = 0 - + def levelLoaded(self, levelIndex, level=None): self.levelsLoaded += 1 self.sender.levelsLoadedChanged.emit(self.levelsLoaded) self.totalLoaded += 1 self.sender.totalLoadedChanged.emit(self.totalLoaded) - + def downloadMedia(self, thing): for colName in thing.pool.getImageColumnNames(): for image in [f for f in thing.getImageFiles(colName) if not f.isDownloaded()]: @@ -50,14 +50,14 @@ def downloadMedia(self, thing): for colName in thing.pool.getAudioColumnNames(): for audio in [f for f in thing.getAudioFiles(colName) if not f.isDownloaded()]: audio.localUrl = self.sender.download(audio.remoteUrl) - + def downloadMems(self, thing): for mem in list(thing.pool.mems.getMems(thing).values()): for image in [f for f in mem.images if not f.isDownloaded()]: image.localUrl = self.sender.download(image.remoteUrl) if image.isDownloaded(): mem.text = mem.text.replace(image.remoteUrl, image.localUrl) - + if self.sender.embedMemsOnlineMedia: soup = bs4.BeautifulSoup(mem.text, 'html.parser') for link in soup.find_all("a", {"class": "embed"}): @@ -65,7 +65,7 @@ def downloadMems(self, thing): if embedCode: link.replaceWith(bs4.BeautifulSoup(embedCode, "html.parser")) mem.text = str(soup) - + def thingLoaded(self, thing): if thing and self.sender.downloadMedia: self.downloadMedia(thing) @@ -75,23 +75,23 @@ def thingLoaded(self, thing): self.sender.thingsLoadedChanged.emit(self.thingsLoaded) self.totalLoaded += 1 self.sender.totalLoadedChanged.emit(self.totalLoaded) - + def levelCountChanged(self, levelCount): self.sender.levelCountChanged.emit(levelCount) self.totalCount += levelCount self.sender.totalCountChanged.emit(self.totalCount) - + def thingCountChanged(self, thingCount): self.sender.thingCountChanged.emit(thingCount) self.totalCount += thingCount self.sender.totalCountChanged.emit(self.totalCount) - + def __getattr__(self, attr): if hasattr(self.sender, attr): signal = getattr(self.sender, attr) if hasattr(signal, 'emit'): return getattr(signal, 'emit') - + def __init__(self, memriseService): super(MemriseCourseLoader, self).__init__() self.memriseService = memriseService @@ -104,7 +104,7 @@ def __init__(self, memriseService): self.embedMemsOnlineMedia = False self.askerFunction = None self.ignoreDownloadErrors = False - + def download(self, url): import urllib.request, urllib.error, urllib.parse while True: @@ -121,28 +121,28 @@ def download(self, url): raise e else: raise e - + def load(self, url): self.url = url self.run() - + def start(self, url): self.url = url self.runnable = MemriseCourseLoader.RunnableWrapper(self) QThreadPool.globalInstance().start(self.runnable) - + def getResult(self): return self.result - + def getException(self): return self.self.exc_info[1] - + def getExceptionInfo(self): return self.exc_info - + def isException(self): return isinstance(self.exc_info[1], Exception) - + def run(self): self.result = None self.exc_info = (None,None,None) @@ -156,17 +156,17 @@ def run(self): class DownloadFailedBox(QMessageBox): def __init__(self): super(DownloadFailedBox, self).__init__() - + self.setWindowTitle("Download failed") self.setIcon(QMessageBox.Warning) - + self.addButton(QMessageBox.Retry) self.addButton(QMessageBox.Ignore) self.addButton(QMessageBox.Abort) - + self.setEscapeButton(QMessageBox.Ignore) self.setDefaultButton(QMessageBox.Retry) - + @pyqtSlot(str, str, str, result=str) def askRetry(self, url, message, info): self.setText(message) @@ -185,31 +185,31 @@ class MemriseLoginDialog(QDialog): def __init__(self, memriseService): super(MemriseLoginDialog, self).__init__() self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint) - + self.memriseService = memriseService - + self.setWindowTitle("Memrise Login") - + layout = QVBoxLayout(self) - + innerLayout = QGridLayout() - + innerLayout.addWidget(QLabel("Username:"),0,0) self.usernameLineEdit = QLineEdit() innerLayout.addWidget(self.usernameLineEdit,0,1) - + innerLayout.addWidget(QLabel("Password:"),1,0) self.passwordLineEdit = QLineEdit() self.passwordLineEdit.setEchoMode(QLineEdit.Password) innerLayout.addWidget(self.passwordLineEdit,1,1) - + layout.addLayout(innerLayout) - + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) - + def accept(self): if self.memriseService.login(self.usernameLineEdit.text(),self.passwordLineEdit.text()): super(MemriseLoginDialog, self).accept() @@ -218,11 +218,11 @@ def accept(self): msgBox.setWindowTitle("Login") msgBox.setText("Invalid credentials") msgBox.exec_(); - + def reject(self): super(MemriseLoginDialog, self).reject() - + @staticmethod def login(memriseService): dialog = MemriseLoginDialog(memriseService) @@ -232,82 +232,82 @@ class ModelMappingDialog(QDialog): def __init__(self, col): super(ModelMappingDialog, self).__init__() self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint) - + self.col = col self.models = {} - + self.setWindowTitle("Note Type") layout = QVBoxLayout(self) - + layout.addWidget(QLabel("Select note type for newly imported notes:")) - + self.modelSelection = QComboBox() layout.addWidget(self.modelSelection) self.modelSelection.setToolTip("Either a new note type will be created or an existing one can be reused.") - + buttons = QDialogButtonBox(QDialogButtonBox.Ok, Qt.Horizontal, self) buttons.accepted.connect(self.accept) layout.addWidget(buttons) - + self.memsEnabled = False - + def setMemsEnabled(self, value): self.memsEnabled = value - + def __fillModelSelection(self): self.modelSelection.clear() self.modelSelection.addItem("--- create new ---") self.modelSelection.insertSeparator(1) for name in sorted(self.col.models.allNames()): self.modelSelection.addItem(name) - + @staticmethod def __createTemplate(t, pool, front, back, withMem): notFrontBack = partial(lambda fieldname, filtered=[]: fieldname not in filtered, filtered=[front,back]) - + t['qfmt'] = "{{"+front+"}}\n" if front in pool.getTextColumnNames(): frontAlternatives = "{} {}".format(front, _("Alternatives")) t['qfmt'] += "{{#"+frontAlternatives+"}}
{{"+frontAlternatives+"}}{{/"+frontAlternatives+"}}\n" - + for colName in filter(notFrontBack, pool.getTextColumnNames()): t['qfmt'] += "
{{"+colName+"}}\n" altColName = "{} {}".format(colName, _("Alternatives")) t['qfmt'] += "{{#"+altColName+"}}
{{"+altColName+"}}{{/"+altColName+"}}\n" - + for attrName in filter(notFrontBack, pool.getAttributeNames()): t['qfmt'] += "{{#"+attrName+"}}
({{"+attrName+"}}){{/"+attrName+"}}\n" - + t['afmt'] = "{{FrontSide}}\n\n
\n\n"+"{{"+back+"}}\n" if back in pool.getTextColumnNames(): backAlternatives = "{} {}".format(back, _("Alternatives")) t['afmt'] += "{{#"+backAlternatives+"}}
{{"+backAlternatives+"}}{{/"+backAlternatives+"}}\n" - + if front == pool.getTextColumnName(0): imageside = 'afmt' audioside = 'qfmt' else: imageside = 'qfmt' audioside = 'afmt' - + for colName in filter(notFrontBack, pool.getImageColumnNames()): t[imageside] += "{{#"+colName+"}}
{{"+colName+"}}{{/"+colName+"}}\n" - + for colName in filter(notFrontBack, pool.getAudioColumnNames()): t[audioside] += "{{#"+colName+"}}
{{"+colName+"}}
{{/"+colName+"}}\n" - + if withMem: memField = "{} -> {} {}".format(front, back, _("Mem")) t['afmt'] += "{{#"+memField+"}}
{{"+memField+"}}{{/"+memField+"}}\n" - + return t - + def __createMemriseModel(self, course, pool): mm = self.col.models - + name = "Memrise - {} - {}".format(course.title, pool.name) m = mm.new(name) - + for colName in pool.getTextColumnNames(): dfm = mm.newField(colName) mm.addField(m, dfm) @@ -317,132 +317,132 @@ def __createMemriseModel(self, course, pool): mm.addField(m, hafm) tcfm = mm.newField("{} {}".format(colName, _("Typing Corrects"))) mm.addField(m, tcfm) - + for attrName in pool.getAttributeNames(): fm = mm.newField(attrName) mm.addField(m, fm) - + for colName in pool.getImageColumnNames(): fm = mm.newField(colName) mm.addField(m, fm) - + for colName in pool.getAudioColumnNames(): fm = mm.newField(colName) mm.addField(m, fm) - + if self.memsEnabled: for direction in pool.mems.getDirections(): fm = mm.newField("{} -> {} {}".format(direction.front, direction.back, _("Mem"))) mm.addField(m, fm) - + fm = mm.newField(_("Level")) mm.addField(m, fm) - + fm = mm.newField(_("Thing")) mm.addField(m, fm) - + m['css'] += "\n.alts {\n font-size: 14px;\n}" m['css'] += "\n.attrs {\n font-style: italic;\n font-size: 14px;\n}" - + for direction in pool.directions: t = mm.newTemplate(str(direction)) self.__createTemplate(t, pool, direction.front, direction.back, self.memsEnabled and direction in pool.mems.getDirections()) mm.addTemplate(m, t) - + return m - + def __loadModel(self, thing, deck=None): model = self.__createMemriseModel(thing.pool.course, thing.pool) - + modelStored = self.col.models.byName(model['name']) if modelStored: if self.col.models.scmhash(modelStored) == self.col.models.scmhash(model): model = modelStored else: model['name'] += " ({})".format(str(uuid.uuid4())) - + if deck and 'mid' in deck: deckModel = self.col.models.get(deck['mid']) if deckModel and self.col.models.scmhash(deckModel) == self.col.models.scmhash(model): model = deckModel - + if model and not model['id']: self.col.models.add(model) return model - + def reject(self): # prevent close on ESC pass - + def getModel(self, thing, deck): if thing.pool.id in self.models: return self.models[thing.pool.id] - + self.__fillModelSelection() self.exec_() - + if self.modelSelection.currentIndex() == 0: self.models[thing.pool.id] = self.__loadModel(thing, deck) else: modelName = self.modelSelection.currentText() self.models[thing.pool.id] = self.col.models.byName(modelName) - + return self.models[thing.pool.id] class TemplateMappingDialog(QDialog): def __init__(self, col): super(TemplateMappingDialog, self).__init__() self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint) - + self.col = col self.templates = {} - + self.setWindowTitle("Assign Template Direction") layout = QVBoxLayout(self) - + self.grid = QGridLayout() layout.addLayout(self.grid) - + self.grid.addWidget(QLabel("Front:"), 0, 0) self.frontName = QLabel() self.grid.addWidget(self.frontName, 0, 1) self.frontExample = QLabel() self.grid.addWidget(self.frontExample, 0, 2) - + self.grid.addWidget(QLabel("Back:"), 1, 0) self.backName = QLabel() self.grid.addWidget(self.backName, 1, 1) self.backExample = QLabel() self.grid.addWidget(self.backExample, 1, 2) - + layout.addWidget(QLabel("Select template:")) self.templateSelection = QComboBox() layout.addWidget(self.templateSelection) self.templateSelection.setToolTip("Select the corresponding template for this direction.") - + buttons = QDialogButtonBox(QDialogButtonBox.Ok, Qt.Horizontal, self) buttons.accepted.connect(self.accept) layout.addWidget(buttons) - + def __fillTemplateSelection(self, model): self.templateSelection.clear() for template in model['tmpls']: self.templateSelection.addItem(template['name'], template) - + @staticmethod def getFirst(values): return values[0] if 0 < len(values) else '' - + def reject(self): # prevent close on ESC pass - + def getTemplate(self, thing, note, direction): model = note.model() if direction in self.templates.get(model['id'], {}): return self.templates[model['id']][direction] - + for template in model['tmpls']: if template['name'] == str(direction): self.templates.setdefault(model['id'], {})[direction] = template @@ -461,7 +461,7 @@ def getTemplate(self, thing, note, direction): template = self.templateSelection.itemData(self.templateSelection.currentIndex()) self.templates.setdefault(model['id'], {})[direction] = template - + return template class FieldHelper(object): @@ -496,13 +496,13 @@ class FieldMappingDialog(QDialog): def __init__(self, col): super(FieldMappingDialog, self).__init__() self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint) - + self.col = col self.mappings = {} - + self.setWindowTitle("Assign Memrise Fields") layout = QVBoxLayout() - + self.label = QLabel("Define the field mapping for the selected note type.") layout.addWidget(self.label) @@ -516,11 +516,11 @@ def __init__(self, col): scrollArea.setWidget(viewport) layout.addWidget(scrollArea) - + buttons = QDialogButtonBox(QDialogButtonBox.Ok, Qt.Horizontal, self) buttons.accepted.connect(self.accept) layout.addWidget(buttons) - + self.setLayout(layout) self.memsEnabled = False @@ -577,7 +577,7 @@ def __createMemriseFieldSelection(self, pool): for direction in pool.mems.getDirections(): fieldSelection.addItem("Mem: {} -> {}".format(direction.front, direction.back), FieldHelper(memrise.Field(memrise.Field.Mem, None, None), lambda thing, fieldname, direction=direction: pool.mems.get(direction, thing), "{} -> {} {}".format(direction.front, direction.back, _("Mem")))) - + return fieldSelection def __buildGrid(self, pool, model): @@ -589,12 +589,12 @@ def __buildGrid(self, pool, model): label2.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.grid.addWidget(label1, 0, 0) self.grid.addWidget(label2, 0, 1) - + fieldNames = [fieldName for fieldName in self.col.models.fieldNames(model) if not fieldName in [_('Thing'), _('Level')]] poolFieldCount = pool.countTextColumns()*4 + pool.countImageColumns() + pool.countAudioColumns() + pool.countAttributes() if self.memsEnabled: poolFieldCount += pool.mems.countDirections() - + mapping = [] for index in range(0, max(len(fieldNames), poolFieldCount)): modelFieldSelection = self.__createModelFieldSelection(fieldNames) @@ -602,46 +602,46 @@ def __buildGrid(self, pool, model): memriseFieldSelection = self.__createMemriseFieldSelection(pool) self.grid.addWidget(memriseFieldSelection, index+1, 1) - + if index < len(fieldNames): modelFieldSelection.setCurrentIndex(index+2) - + fieldIndex = self.__findIndexWithData(memriseFieldSelection, modelFieldSelection.currentText()) if fieldIndex >= 2: memriseFieldSelection.setCurrentIndex(fieldIndex) - + mapping.append((modelFieldSelection, memriseFieldSelection)) - + return mapping - + def reject(self): # prevent close on ESC pass - + def getFieldMappings(self, pool, model): if pool.id in self.mappings: if model['id'] in self.mappings[pool.id]: return self.mappings[pool.id][model['id']] - + self.label.setText('Define the field mapping for the note type "{}".'.format(model["name"])) selectionMapping = self.__buildGrid(pool, model) self.exec_() - + mapping = {} for modelFieldSelection, memriseFieldSelection in selectionMapping: fieldName = None if modelFieldSelection.currentIndex() >= 2: fieldName = modelFieldSelection.currentText() - + data = None if memriseFieldSelection.currentIndex() >= 2: data = memriseFieldSelection.itemData(memriseFieldSelection.currentIndex()) - + if fieldName and data: mapping.setdefault(fieldName, []).append(data) - + self.mappings.setdefault(pool.id, {})[model['id']] = mapping - + return mapping class MemriseImportDialog(QDialog): @@ -652,7 +652,7 @@ def __init__(self, memriseService): # set up the UI, basically self.setWindowTitle("Import Memrise Course") layout = QVBoxLayout(self) - + self.deckSelection = QComboBox() self.deckSelection.addItem("--- create new ---") self.deckSelection.insertSeparator(1) @@ -665,7 +665,7 @@ def __init__(self, memriseService): layout.addWidget(label) layout.addWidget(self.deckSelection) self.deckSelection.currentIndexChanged.connect(self.loadDeckUrl) - + label = QLabel("Enter the home URL of the Memrise course to import:") self.courseUrlLineEdit = QLineEdit() courseUrlTooltip = "e.g. http://app.memrise.com/course/77958/memrise-intro-french/" @@ -673,7 +673,7 @@ def __init__(self, memriseService): self.courseUrlLineEdit.setToolTip(courseUrlTooltip) layout.addWidget(label) layout.addWidget(self.courseUrlLineEdit) - + label = QLabel("Minimal number of digits in the level tag:") self.minimalLevelTagWidthSpinBox = QSpinBox() self.minimalLevelTagWidthSpinBox.setMinimum(1) @@ -697,7 +697,7 @@ def setScheduler(checkbox, predicate, index): importMemsTooltip = "activate \"Download media files\" in order to download image mems" self.importMemsCheckBox.setToolTip(importMemsTooltip) layout.addWidget(self.importMemsCheckBox) - + self.embedMemsOnlineMediaCheckBox = QCheckBox("Embed online media in mems (experimental)") embedMemsOnlineMediaTooltip = "Warning: Embedding online media is not officially supported by Anki,\n it may or may not work depending on your platform." self.embedMemsOnlineMediaCheckBox.setToolTip(embedMemsOnlineMediaTooltip) @@ -709,10 +709,10 @@ def setScheduler(checkbox, predicate, index): self.downloadMediaCheckBox = QCheckBox("Download media files") layout.addWidget(self.downloadMediaCheckBox) - + self.skipExistingMediaCheckBox = QCheckBox("Skip download of existing media files") layout.addWidget(self.skipExistingMediaCheckBox) - + self.downloadMediaCheckBox.stateChanged.connect(self.skipExistingMediaCheckBox.setEnabled) self.downloadMediaCheckBox.setChecked(True) self.skipExistingMediaCheckBox.setChecked(True) @@ -721,22 +721,22 @@ def setScheduler(checkbox, predicate, index): layout.addWidget(self.ignoreDownloadErrorsCheckBox) layout.addWidget(QLabel("Keep in mind that it can take a substantial amount of time to download \nand import your course. Good things come to those who wait!")) - + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self) self.buttons.accepted.connect(self.loadCourse) self.buttons.rejected.connect(self.reject) okButton = self.buttons.button(QDialogButtonBox.Ok) okButton.setEnabled(False) layout.addWidget(self.buttons) - + def checkUrl(button, predicate, url): button.setEnabled(predicate(url)) self.courseUrlLineEdit.textChanged.connect(partial(checkUrl,okButton,memriseService.checkCourseUrl)) - + self.progressBar = QProgressBar() self.progressBar.hide() layout.addWidget(self.progressBar) - + def setTotalCount(progressBar, totalCount): progressBar.setRange(0, totalCount) progressBar.setFormat("Downloading: %p% (%v/%m)") @@ -746,40 +746,40 @@ def setTotalCount(progressBar, totalCount): self.loader.thingsLoadedChanged.connect(self.progressBar.setValue) self.loader.finished.connect(self.importCourse) self.loader.askerFunction = DownloadFailedBox().askRetry - + self.modelMapper = ModelMappingDialog(mw.col) self.fieldMapper = FieldMappingDialog(mw.col) self.templateMapper = TemplateMappingDialog(mw.col) - + def prepareTitleTag(self, tag): value = ''.join(x for x in tag.title() if x.isalnum()) if value.isdigit(): return '' return value - + def prepareLevelTag(self, levelNum, width): formatstr = "Level{:0"+str(width)+"d}" return formatstr.format(levelNum) - + def getLevelTags(self, levelCount, level): tags = [self.prepareLevelTag(level.index, max(self.minimalLevelTagWidthSpinBox.value(), len(str(levelCount))))] titleTag = self.prepareTitleTag(level.title) if titleTag: tags.append(titleTag) return tags - + @staticmethod def prepareText(content): return '{:s}'.format(content.strip()) - + @staticmethod def prepareAudio(content): return '[sound:{:s}]'.format(content) - + @staticmethod def prepareImage(content): return ''.format(content) - + def selectDeck(self, name, merge=False): did = mw.col.decks.id(name, create=False) if not merge: @@ -787,10 +787,10 @@ def selectDeck(self, name, merge=False): did = mw.col.decks.id("{}-{}".format(name, str(uuid.uuid4()))) else: did = mw.col.decks.id(name, create=True) - + mw.col.decks.select(did) return mw.col.decks.get(did) - + def loadDeckUrl(self, index): did = mw.col.decks.id(self.deckSelection.currentText(), create=False) if did: @@ -798,23 +798,23 @@ def loadDeckUrl(self, index): url = deck.get("addons", {}).get("memrise", {}).get("url", "") if url: self.courseUrlLineEdit.setText(url) - + def saveDeckUrl(self, deck, url): deck.setdefault('addons', {}).setdefault('memrise', {})["url"] = url mw.col.decks.save(deck) - + def saveDeckModelRelation(self, deck, model): deck['mid'] = model['id'] mw.col.decks.save(deck) - + model["did"] = deck["id"] mw.col.models.save(model) - + def findExistingNote(self, deckName, course, thing): notes = mw.col.findNotes('deck:"{}" {}:"{}"'.format(deckName, 'Thing', thing.id)) if notes: return mw.col.getNote(notes[0]) - + return None def getWithSpec(self, thing, spec): @@ -827,9 +827,9 @@ def getWithSpec(self, thing, spec): return list(map(self.prepareAudio, list(filter(bool, values)))) elif spec.field.type == memrise.Field.Mem: return [self.prepareText(values.get())] - + return None - + @staticmethod def toList(values): if isinstance(values, str): @@ -840,32 +840,32 @@ def toList(values): return [values] else: return [] - + def importCourse(self): if self.loader.isException(): self.buttons.show() self.progressBar.hide() exc_info = self.loader.getExceptionInfo() raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) - + try: self.progressBar.setValue(0) self.progressBar.setFormat("Importing: %p% (%v/%m)") - + course = self.loader.getResult() - + self.modelMapper.setMemsEnabled(self.importMemsCheckBox.isEnabled()) self.fieldMapper.setMemsEnabled(self.importMemsCheckBox.isEnabled()) - + noteCache = {} - + deck = None if self.deckSelection.currentIndex() != 0: deck = self.selectDeck(self.deckSelection.currentText(), merge=True) else: deck = self.selectDeck(course.title, merge=False) self.saveDeckUrl(deck, self.courseUrlLineEdit.text()) - + for level in course: tags = self.getLevelTags(len(course), level) for thing in level: @@ -877,30 +877,30 @@ def importCourse(self): model = self.modelMapper.getModel(thing, deck) self.saveDeckModelRelation(deck, model) ankiNote = mw.col.newNote() - + mapping = self.fieldMapper.getFieldMappings(thing.pool, ankiNote.model()) for field, data in list(mapping.items()): values = [] for spec in data: values.extend(self.toList(self.getWithSpec(thing, spec))) ankiNote[field] = ", ".join(values) - + if _('Level') in list(ankiNote.keys()): levels = set(filter(bool, list(map(str.strip, ankiNote[_('Level')].split(','))))) levels.add(str(level.index)) ankiNote[_('Level')] = ', '.join(sorted(levels)) - + if _('Thing') in list(ankiNote.keys()): ankiNote[_('Thing')] = str(thing.id) - + for tag in tags: ankiNote.addTag(tag) - + if not ankiNote.cards(): mw.col.addNote(ankiNote) ankiNote.flush() noteCache[thing.id] = ankiNote - + scheduleInfo = thing.pool.schedule.get(level.direction, thing) if scheduleInfo: template = self.templateMapper.getTemplate(thing, ankiNote, scheduleInfo.direction) @@ -935,19 +935,19 @@ def importCourse(self): self.progressBar.setValue(self.progressBar.value()+1) QApplication.processEvents() - + except Exception: self.buttons.show() self.progressBar.hide() exc_info = sys.exc_info() raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) - + mw.col.reset() mw.reset() - + # refresh deck browser so user can see the newly imported deck mw.deckBrowser.refresh() - + self.accept() def reject(self): @@ -959,7 +959,7 @@ def loadCourse(self): self.buttons.hide() self.progressBar.show() self.progressBar.setValue(0) - + courseUrl = self.courseUrlLineEdit.text() self.loader.downloadMedia = self.downloadMediaCheckBox.isChecked() self.loader.skipExistingMedia = self.skipExistingMediaCheckBox.isChecked() diff --git a/memrise.py b/memrise.py index 7f2dd34..8c7f835 100644 --- a/memrise.py +++ b/memrise.py @@ -31,7 +31,7 @@ def __init__(self, courseId): def __iter__(self): for level in self.levels: yield level - + def __len__(self): return len(self.levels) @@ -44,16 +44,16 @@ class Direction(object): def __init__(self, front=None, back=None): self.front = front self.back = back - + def isValid(self): return self.front != None and self.back != None - + def __hash__(self): return hash((self.front, self.back)) - + def __eq__(self, other): return (self.front, self.back) == (other.front, other.back) - + def __ne__(self, other): return not self.__eq__(other) @@ -64,21 +64,21 @@ class Schedule(object): def __init__(self): self.directionThing = {} self.thingDirection = {} - + def add(self, info): self.directionThing.setdefault(info.direction, {})[info.thingId] = info self.thingDirection.setdefault(info.thingId, {})[info.direction] = info - + def get(self, direction, thing): if not isinstance(thing, Thing): thing = Thing(thing) return self.directionThing.get(direction, {}).get(thing.id) - + def getScheduleInfos(self, thing): if not isinstance(thing, Thing): thing = Thing(thing) return self.thingDirection.get(thing.id, {}) - + def getDirections(self): return list(self.directionThing.keys()) @@ -99,11 +99,11 @@ class MemCollection(object): def __init__(self): self.directionThing = {} self.thingDirection = {} - + def add(self, mem): self.directionThing.setdefault(mem.direction, {})[mem.thingId] = mem self.thingDirection.setdefault(mem.thingId, {})[mem.direction] = mem - + def has(self, direction, thing): return thing.id in self.directionThing.get(direction, {}) @@ -112,10 +112,10 @@ def get(self, direction, thing): def getMems(self, thing): return self.thingDirection.get(thing.id, {}) - + def getDirections(self): return list(self.directionThing.keys()) - + def countDirections(self): return len(self.directionThing.keys()) @@ -126,7 +126,7 @@ def __init__(self, memId=None): self.thingId = None self.text = "" self.images = [] - + def get(self): return self.text @@ -139,11 +139,11 @@ def __init__(self, levelId): self.course = None self.pool = None self.direction = Direction() - + def __iter__(self): for thing in self.things: yield thing - + def __len__(self): return len(self.things) @@ -165,7 +165,7 @@ class Field(object): Image = 'image' Video = 'video' Mem = 'mem' - + def __init__(self, fieldType, name, index): self.type = fieldType self.name = name @@ -173,13 +173,13 @@ def __init__(self, fieldType, name, index): class Column(Field): Types = [Field.Text, Field.Audio, Field.Image, Field.Video] - + def __init__(self, colType, name, index): super(Column, self).__init__(colType, name, index) class Attribute(Field): Types = [Field.Text] - + def __init__(self, attrType, name, index): super(Attribute, self).__init__(attrType, name, index) @@ -188,7 +188,7 @@ def __init__(self, poolId=None): self.id = poolId self.name = '' self.course = None - + self.columns = collections.OrderedDict() self.attributes = collections.OrderedDict() @@ -196,9 +196,9 @@ def __init__(self, poolId=None): for colType in Column.Types: self.columnsByType[colType] = collections.OrderedDict() self.columnsByIndex = collections.OrderedDict() - + self.uniquifyName = NameUniquifier() - + self.things = {} self.schedule = Schedule() self.mems = MemCollection() @@ -206,7 +206,7 @@ def __init__(self, poolId=None): def addThing(self, thing): self.things[thing.id] = thing - + def getThing(self, thingId): return self.things.get(thingId, None) @@ -216,7 +216,7 @@ def hasThing(self, thingId): def addColumn(self, colType, name, index): if not colType in Column.Types: return - + column = Column(colType, self.uniquifyName(sanitizeName(name, "Column")), int(index)) self.columns[column.name] = column self.columnsByType[column.type][column.name] = column @@ -225,16 +225,16 @@ def addColumn(self, colType, name, index): def addAttribute(self, attrType, name, index): if not attrType in Attribute.Types: return - + attribute = Attribute(attrType, self.uniquifyName(sanitizeName(name, "Attribute")), int(index)) self.attributes[attribute.name] = attribute - + def getColumn(self, name): return self.columns.get(name) - + def getAttribute(self, name): return self.columns.get(name) - + def getColumnNames(self): return list(self.columns.keys()) @@ -243,7 +243,7 @@ def getTextColumnNames(self): def getImageColumnNames(self): return list(self.columnsByType[Field.Image].keys()) - + def getAudioColumnNames(self): return list(self.columnsByType[Field.Audio].keys()) @@ -252,19 +252,19 @@ def getVideoColumnNames(self): def getAttributeNames(self): return list(self.attributes.keys()) - + def getColumns(self): return list(self.columns.values()) - + def getTextColumns(self): return list(self.columnsByType[Field.Text].values()) def getImageColumns(self): return list(self.columnsByType[Field.Image].values()) - + def getAudioColumns(self): return list(self.columnsByType[Field.Audio].values()) - + def getVideoColumns(self): return list(self.columnsByType[Field.Video].values()) @@ -276,19 +276,19 @@ def __getKeyFromIndex(keys, index): if not isinstance(index, int): return index return keys[index] - + def getColumnName(self, memriseIndex): column = self.columnsByIndex.get(int(memriseIndex)) if column: return column.name return None - + def getTextColumnName(self, nameOrIndex): return self.__getKeyFromIndex(self.getTextColumnNames(), nameOrIndex) def getImageColumnName(self, nameOrIndex): return self.__getKeyFromIndex(self.getImageColumnNames(), nameOrIndex) - + def getAudioColumnName(self, nameOrIndex): return self.__getKeyFromIndex(self.getAudioColumnNames(), nameOrIndex) @@ -300,13 +300,13 @@ def getAttributeName(self, nameOrIndex): def hasColumnName(self, name): return name in self.columns - + def hasTextColumnName(self, name): return name in self.getTextColumnNames() def hasImageColumnName(self, name): return name in self.getImageColumnNames() - + def hasAudioColumnName(self, name): return name in self.getAudioColumnNames() @@ -318,13 +318,13 @@ def hasAttributeName(self, name): def countColumns(self): return len(self.columns) - + def countTextColumns(self): return len(self.columnsByType[Field.Text]) - + def countImageColumns(self): return len(self.columnsByType[Field.Image]) - + def countAudioColumns(self): return len(self.columnsByType[Field.Audio]) @@ -345,26 +345,26 @@ class DownloadableFile(object): def __init__(self, remoteUrl=None): self.remoteUrl = remoteUrl self.localUrl = None - + def isDownloaded(self): return bool(self.localUrl) class MediaColumnData(object): def __init__(self, files=[]): self.files = files - + def getFiles(self): return self.files - + def setFile(self, files): self.files = files - + def getRemoteUrls(self): return [f.remoteUrl for f in self.files] - + def getLocalUrls(self): return [f.localUrl for f in self.files] - + def setRemoteUrls(self, urls): self.files = list(map(DownloadableFile, urls)) @@ -383,21 +383,21 @@ class Thing(object): def __init__(self, thingId): self.id = thingId self.pool = None - + self.columnData = collections.OrderedDict() self.columnDataByType = collections.OrderedDict() for colType in Column.Types: self.columnDataByType[colType] = collections.OrderedDict() - + self.attributeData = collections.OrderedDict() - + def getColumnData(self, name): return self.columnData[name] - + def getTextColumnData(self, nameOrIndex): name = self.pool.getTextColumnName(nameOrIndex) return self.columnDataByType[Field.Text][name] - + def getAudioColumnData(self, nameOrIndex): name = self.pool.getAudioColumnName(nameOrIndex) return self.columnDataByType[Field.Audio][name] @@ -405,20 +405,20 @@ def getAudioColumnData(self, nameOrIndex): def getVideoColumnData(self, nameOrIndex): name = self.pool.getVideoColumnName(nameOrIndex) return self.columnDataByType[Field.Video][name] - + def getImageColumnData(self, nameOrIndex): name = self.pool.getImageColumnName(nameOrIndex) return self.columnDataByType[Field.Image][name] - + def getAttributeData(self, nameOrIndex): name = self.pool.getAttributeName(nameOrIndex) return self.attributeData[name] - + def setTextColumnData(self, nameOrIndex, data): name = self.pool.getTextColumnName(nameOrIndex) self.columnDataByType[Field.Text][name] = data self.columnData[name] = data - + def setAudioColumnData(self, nameOrIndex, data): name = self.pool.getTextColumnName(nameOrIndex) self.columnDataByType[Field.Audio][name] = data @@ -428,25 +428,25 @@ def setVideoColumnData(self, nameOrIndex, data): name = self.pool.getTextColumnName(nameOrIndex) self.columnDataByType[Field.Video][name] = data self.columnData[name] = data - + def setImageColumnData(self, nameOrIndex, data): name = self.pool.getTextColumnName(nameOrIndex) self.columnDataByType[Field.Image][name] = data self.columnData[name] = data - + def setAttributeData(self, nameOrIndex, data): name = self.pool.getAttributeName(nameOrIndex) self.attributeData[name] = data - + def getDefinitions(self, nameOrIndex): return self.getTextColumnData(nameOrIndex).values - + def getAlternatives(self, nameOrIndex): return self.getTextColumnData(nameOrIndex).alternatives - + def getHiddenAlternatives(self, nameOrIndex): return self.getTextColumnData(nameOrIndex).hiddenAlternatives - + def getTypingCorrects(self, nameOrIndex): return self.getTextColumnData(nameOrIndex).typingCorrects @@ -482,7 +482,7 @@ def getImageUrls(self, nameOrIndex): def setLocalAudioUrls(self, nameOrIndex, urls): self.getAudioColumnData(nameOrIndex).setLocalUrls(urls) - + def getLocalAudioUrls(self, nameOrIndex): return self.getAudioColumnData(nameOrIndex).getLocalUrls() @@ -501,11 +501,11 @@ def getLocalImageUrls(self, nameOrIndex): class ThingLoader(object): def __init__(self, pool): self.pool = pool - - def loadThing(self, row, fixUrl=lambda url: url): + + def loadThing(self, row, fixUrl=lambda url: url): thing = Thing(row['id']) thing.pool = self.pool - + for column in self.pool.getTextColumns(): cell = row['columns'].get(str(column.index), {}) data = TextColumnData() @@ -514,7 +514,7 @@ def loadThing(self, row, fixUrl=lambda url: url): data.hiddenAlternatives = self.__getHiddenAlternatives(cell) data.typingCorrects = self.__getTypingCorrects(cell) thing.setTextColumnData(column.name, data) - + for column in self.pool.getAudioColumns(): cell = row['columns'].get(str(column.index), {}) data = MediaColumnData() @@ -526,7 +526,7 @@ def loadThing(self, row, fixUrl=lambda url: url): data = MediaColumnData() data.setRemoteUrls(list(map(fixUrl, self.__getUrls(cell)))) thing.setVideoColumnData(column.name, data) - + for column in self.pool.getImageColumns(): cell = row['columns'].get(str(column.index), {}) data = MediaColumnData() @@ -544,7 +544,7 @@ def loadThing(self, row, fixUrl=lambda url: url): @staticmethod def __getDefinitions(cell): return list(map(str.strip, cell.get("val", "").split(","))) - + @staticmethod def __getAlternatives(cell): data = [] @@ -553,7 +553,7 @@ def __getAlternatives(cell): if value and not value.startswith("_"): data.append(value) return data - + @staticmethod def __getHiddenAlternatives(cell): data = [] @@ -562,7 +562,7 @@ def __getHiddenAlternatives(cell): if value and value.startswith("_"): data.append(value.lstrip("_")) return data - + @staticmethod def __getTypingCorrects(cell): data = [] @@ -571,7 +571,7 @@ def __getTypingCorrects(cell): if value: data.append(value) return data - + @staticmethod def __getUrls(cell): data = [] @@ -593,18 +593,18 @@ def __init__(self, service): self.thingCount = 0 self.directionThing = {} self.uniquifyPoolName = NameUniquifier() - + def registerObserver(self, observer): self.observers.append(observer) - + def notify(self, signal, *attrs, **kwargs): for observer in self.observers: if hasattr(observer, signal): getattr(observer, signal)(*attrs, **kwargs) - + def loadCourse(self, courseId): course = Course(courseId) - + courseData = self.service.loadCourseData(course.id) course.title = sanitizeName(courseData["session"]["course"]["name"], "Course") @@ -613,10 +613,10 @@ def loadCourse(self, courseId): course.target = courseData["session"]["course"]["target"]["name"] self.levelCount = courseData["session"]["course"]["num_levels"] self.thingCount = courseData["session"]["course"]["num_things"] - + self.notify('levelCountChanged', self.levelCount) self.notify('thingCountChanged', self.thingCount) - + for levelIndex in range(1,self.levelCount+1): try: level = self.loadLevel(course, levelIndex) @@ -625,21 +625,21 @@ def loadCourse(self, courseId): except LevelNotFoundError: level = {} self.notify('levelLoaded', levelIndex, level) - + return course - + def loadPool(self, data): pool = Pool(data["id"]) pool.name = self.uniquifyPoolName(sanitizeName(data["name"], "Pool")) - + for index, column in sorted(data["columns"].items()): pool.addColumn(column['kind'], column['label'], index) for index, attribute in sorted(data["attributes"].items()): pool.addAttribute(attribute['kind'], attribute['label'], index) - + return pool - + @staticmethod def loadScheduleInfo(data, pool): direction = Direction() @@ -660,7 +660,7 @@ def loadScheduleInfo(data, pool): scheduleInfo.streak = data['current_streak'] scheduleInfo.due = utcToLocal(datetime.datetime.strptime(data['next_date'], "%Y-%m-%dT%H:%M:%SZ")) return scheduleInfo - + @staticmethod def loadMem(data, memData, pool, fixUrl=lambda url: url): mem = Mem(memData['id']) @@ -676,10 +676,10 @@ def loadMem(data, memData, pool, fixUrl=lambda url: url): if after != before: mem.text = mem.text.replace(before, after) return mem - + def loadLevel(self, course, levelIndex): levelData = self.service.loadLevelData(course.id, levelIndex) - + level = Level(levelData["session"]["level"]["id"]) level.index = levelData["session"]["level"]["index"] level.title = sanitizeName(levelData["session"]["level"]["title"]) @@ -739,23 +739,23 @@ def loadLevel(self, course, levelIndex): self.thingCount += 1 self.notify('thingCountChanged', self.thingCount) self.directionThing.setdefault(level.direction, {})[thing.id] = thing - + self.notify('thingLoaded', thing) - + return level class IncompleteReadHttpAndHttpsHandler(urllib.request.HTTPHandler, urllib.request.HTTPSHandler): def __init__(self, debuglevel=0): urllib.request.HTTPHandler.__init__(self, debuglevel) urllib.request.HTTPSHandler.__init__(self, debuglevel) - + @staticmethod def makeHttp10(http_class, *args, **kwargs): h = http_class(*args, **kwargs) h._http_vsn = 10 h._http_vsn_str = "HTTP/1.0" return h - + @staticmethod def read(response, reopen10, amt=None): if hasattr(response, "response10"): @@ -766,17 +766,17 @@ def read(response, reopen10, amt=None): except http.client.IncompleteRead: response.response10 = reopen10() return response.response10.read(amt) - + def do_open_wrapped(self, http_class, req, **http_conn_args): response = self.do_open(http_class, req, **http_conn_args) response.read_savedoriginal = response.read reopen10 = functools.partial(self.do_open, functools.partial(self.makeHttp10, http_class, **http_conn_args), req) response.read = functools.partial(self.read, response, reopen10) return response - + def http_open(self, req): return self.do_open_wrapped(http.client.HTTPConnection, req) - + def https_open(self, req): return self.do_open_wrapped(http.client.HTTPSConnection, req, context=self._context, check_hostname=self._check_hostname) @@ -796,7 +796,7 @@ def __init__(self, downloadDirectory=None, cookiejar=None): cookiejar = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener(IncompleteReadHttpAndHttpsHandler, urllib.request.HTTPCookieProcessor(cookiejar)) self.opener.addheaders = [('User-Agent', 'Mozilla/5.0')] - + def openWithRetry(self, url, tryCount=3): try: return self.opener.open(url) @@ -808,12 +808,12 @@ def openWithRetry(self, url, tryCount=3): return self.openWithRetry(url, tryCount-1) else: raise - + def isLoggedIn(self): request = urllib.request.Request('https://www.memrise.com/login/', None, {'Referer': 'https://www.memrise.com/'}) response = self.openWithRetry(request) return bool(re.match('https://app.memrise.com/home/', response.geturl())) - + def login(self, username, password): request1 = urllib.request.Request('https://www.memrise.com/login/', None, {'Referer': 'https://www.memrise.com/'}) response1 = self.openWithRetry(request1) @@ -837,13 +837,13 @@ def login(self, username, password): else: raise return bool(re.match('https://app.memrise.com/home/', response2.geturl())) - + def loadCourse(self, url, observer=None): courseLoader = CourseLoader(self) if not observer is None: courseLoader.registerObserver(observer) return courseLoader.loadCourse(self.getCourseIdFromUrl(url)) - + def loadCourseData(self, courseId): courseUrl = self.getHtmlCourseUrl(courseId) response = self.openWithRetry(courseUrl) @@ -876,7 +876,7 @@ def loadLevelData(self, courseId, levelIndex): raise LevelNotFoundError("Level not found: {}".format(levelIndex)) else: raise - + def loadPoolData(self, poolId): poolUrl = self.getJsonPoolUrl(poolId) response = self.openWithRetry(poolUrl) @@ -922,11 +922,11 @@ def checkCourseUrl(url): @staticmethod def getHtmlCourseUrl(courseId): return 'https://www.memrise.com/course/{:d}/'.format(courseId) - + @staticmethod def getJsonLevelUrl(courseId, levelIndex): return "https://www.memrise.com/ajax/session/?course_id={:d}&level_index={:d}&session_slug=preview".format(courseId, levelIndex) - + @staticmethod def getJsonPoolUrl(poolId): return "https://www.memrise.com/api/pool/get/?pool_id={:d}".format(poolId) @@ -950,21 +950,21 @@ def toAbsoluteMediaUrl(url): # fix wrong urls: /static/xyz should map to https://static.memrise.com/xyz url = re.sub("^\/static\/", "/", url) return urllib.parse.urljoin("http://static.memrise.com/", url) - + def downloadMedia(self, url, skipExisting=False): if not self.downloadDirectory: return url - + # Replace links to images and audio on the Memrise servers # by downloading the content to the user's media dir memrisePath = urllib.parse.urlparse(url).path contentExtension = os.path.splitext(memrisePath)[1] localName = "{:s}{:s}".format(str(uuid.uuid5(uuid.NAMESPACE_URL, url)), contentExtension) fullMediaPath = os.path.join(self.downloadDirectory, localName) - + if skipExisting and os.path.isfile(fullMediaPath) and os.path.getsize(fullMediaPath) > 0: return localName - + data = self.openWithRetry(url).read() with open(fullMediaPath, "wb") as mediaFile: mediaFile.write(data) diff --git a/memrise_markdown.py b/memrise_markdown.py index 9b3122a..c6240a2 100644 --- a/memrise_markdown.py +++ b/memrise_markdown.py @@ -5,12 +5,12 @@ class MemriseRenderer(mistune.Renderer): def __init__(self, capture_images=None, *args, **kwargs): super(MemriseRenderer, self).__init__(*args, **kwargs) self.capture_images = capture_images - + def image(self, src, *args, **kwargs): if not self.capture_images is None: self.capture_images.append(src) return super(MemriseRenderer, self).image(src, *args, **kwargs) - + def embed(self, link, title, text): if link.startswith('javascript:'): link = '' @@ -23,32 +23,32 @@ class MemriseInlineGrammar(mistune.InlineGrammar): memrise_image = re.compile(r'^img:(([\s]+(https?:\/\/[^\s]+))|([^\s]+))') memrise_embed = re.compile(r'^embed:[\s]*(https?:\/\/[^\s]+)') text = re.compile(r'^[\s\S]+?(?=[\\ Date: Wed, 7 Oct 2020 10:49:21 +1300 Subject: [PATCH 2/2] fix: Change www.memrise URLs to app.memrise --- memrise.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/memrise.py b/memrise.py index 8c7f835..e57c5c2 100644 --- a/memrise.py +++ b/memrise.py @@ -810,12 +810,12 @@ def openWithRetry(self, url, tryCount=3): raise def isLoggedIn(self): - request = urllib.request.Request('https://www.memrise.com/login/', None, {'Referer': 'https://www.memrise.com/'}) + request = urllib.request.Request('https://app.memrise.com/login/', None, {'Referer': 'https://app.memrise.com/'}) response = self.openWithRetry(request) return bool(re.match('https://app.memrise.com/home/', response.geturl())) def login(self, username, password): - request1 = urllib.request.Request('https://www.memrise.com/login/', None, {'Referer': 'https://www.memrise.com/'}) + request1 = urllib.request.Request('https://app.memrise.com/login/', None, {'Referer': 'https://app.memrise.com/'}) response1 = self.openWithRetry(request1) soup = bs4.BeautifulSoup(response1.read(), 'html.parser') form = soup.find("form", attrs={"action": '/login/'}) @@ -921,27 +921,27 @@ def checkCourseUrl(url): @staticmethod def getHtmlCourseUrl(courseId): - return 'https://www.memrise.com/course/{:d}/'.format(courseId) + return 'https://app.memrise.com/course/{:d}/'.format(courseId) @staticmethod def getJsonLevelUrl(courseId, levelIndex): - return "https://www.memrise.com/ajax/session/?course_id={:d}&level_index={:d}&session_slug=preview".format(courseId, levelIndex) + return "https://app.memrise.com/ajax/session/?course_id={:d}&level_index={:d}&session_slug=preview".format(courseId, levelIndex) @staticmethod def getJsonPoolUrl(poolId): - return "https://www.memrise.com/api/pool/get/?pool_id={:d}".format(poolId) + return "https://app.memrise.com/api/pool/get/?pool_id={:d}".format(poolId) @staticmethod def getJsonThingUrl(thingId): - return "https://www.memrise.com/api/thing/get/?thing_id={:d}".format(thingId) + return "https://app.memrise.com/api/thing/get/?thing_id={:d}".format(thingId) @staticmethod def getJsonMemUrl(memId, thingId, colA, colB): - return "https://www.memrise.com/api/mem/get/?mem_id={:d}&thing_id={:d}&column_a={:d}&column_b={:d}".format(memId, thingId, colA, colB) + return "https://app.memrise.com/api/mem/get/?mem_id={:d}&thing_id={:d}&column_a={:d}&column_b={:d}".format(memId, thingId, colA, colB) @staticmethod def getJsonManyMemUrl(thingId, learnableId): - return "https://www.memrise.com/api/mem/get_many_for_thing/?thing_id={:d}&learnable_id={:d}".format(thingId, learnableId) + return "https://app.memrise.com/api/mem/get_many_for_thing/?thing_id={:d}&learnable_id={:d}".format(thingId, learnableId) @staticmethod def toAbsoluteMediaUrl(url):